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

pull/327/head
hpkoh 3 years ago
commit fe52d9d55a

@ -0,0 +1,30 @@
/*
Warnings:
- Changed the type of `vote` on the `QuestionsAnswerCommentVote` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
- Changed the type of `vote` on the `QuestionsAnswerVote` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
- Changed the type of `vote` on the `QuestionsQuestionCommentVote` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
- Changed the type of `vote` on the `QuestionsQuestionVote` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
*/
-- CreateEnum
CREATE TYPE "Vote" AS ENUM ('UPVOTE', 'DOWNVOTE');
-- AlterTable
ALTER TABLE "QuestionsAnswerCommentVote" DROP COLUMN "vote",
ADD COLUMN "vote" "Vote" NOT NULL;
-- AlterTable
ALTER TABLE "QuestionsAnswerVote" DROP COLUMN "vote",
ADD COLUMN "vote" "Vote" NOT NULL;
-- AlterTable
ALTER TABLE "QuestionsQuestionCommentVote" DROP COLUMN "vote",
ADD COLUMN "vote" "Vote" NOT NULL;
-- AlterTable
ALTER TABLE "QuestionsQuestionVote" DROP COLUMN "vote",
ADD COLUMN "vote" "Vote" NOT NULL;
-- DropEnum
DROP TYPE "QuestionsVote";

@ -60,6 +60,11 @@ model User {
questionsAnswerCommentVotes QuestionsAnswerCommentVote[] questionsAnswerCommentVotes QuestionsAnswerCommentVote[]
} }
enum Vote {
UPVOTE
DOWNVOTE
}
model VerificationToken { model VerificationToken {
identifier String identifier String
token String @unique token String @unique
@ -172,12 +177,6 @@ model ResumesCommentVote {
// use camelCase for field names, and try to name them consistently // use camelCase for field names, and try to name them consistently
// across all models in this file. // across all models in this file.
enum QuestionsVote {
NO_VOTE
UPVOTE
DOWNVOTE
}
enum QuestionsQuestionType { enum QuestionsQuestionType {
CODING CODING
SYSTEM_DESIGN SYSTEM_DESIGN
@ -218,7 +217,7 @@ model QuestionsQuestionVote {
id String @id @default(cuid()) id String @id @default(cuid())
questionId String questionId String
userId String? userId String?
vote QuestionsVote vote Vote
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -245,7 +244,7 @@ model QuestionsQuestionCommentVote {
id String @id @default(cuid()) id String @id @default(cuid())
questionCommentId String questionCommentId String
userId String? userId String?
vote QuestionsVote vote Vote
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -273,7 +272,7 @@ model QuestionsAnswerVote {
id String @id @default(cuid()) id String @id @default(cuid())
answerId String answerId String
userId String? userId String?
vote QuestionsVote vote Vote
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -300,7 +299,7 @@ model QuestionsAnswerCommentVote {
id String @id @default(cuid()) id String @id @default(cuid())
answerCommentId String answerCommentId String
userId String? userId String?
vote QuestionsVote vote Vote
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

@ -0,0 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 582.39 458.49">
<defs>
<style>
.cls-1{fill:#5252ee}.cls-2{fill:#7f7ff0}.cls-3{fill:#fff}.cls-4{fill:#363636}.cls-5{fill:#ff4585}.cls-6{fill:#00e5a0}
</style>
</defs>
<ellipse cx="291.19" cy="428.46" fill="#eef2f5" rx="291.19" ry="30.03"/>
<rect width="346.05" height="180.28" x="197.89" y="168.35" class="cls-1" rx="30" ry="30" transform="rotate(-90 370.92 258.49)"/>
<rect width="346.05" height="180.28" x="189.19" y="168.35" class="cls-2" rx="30" ry="30" transform="rotate(-90 362.22 258.49)"/>
<path d="M257.71 419.94V97.04h152.1a28.18 28.18 0 0128.18 28.18v266.54a28.18 28.18 0 01-28.18 28.18h-152.1z" class="cls-3"/>
<path d="M206.96 431.51v-346h151.11a29.17 29.17 0 0129.17 29.08v287.75a29.17 29.17 0 01-29.17 29.17H206.96z" class="cls-1"/>
<rect width="346.05" height="244.06" x="80.86" y="136.46" class="cls-2" rx="33.94" ry="33.94" transform="rotate(-90 253.89 258.49)"/>
<path fill="#ffcb2c" d="M131.87 397.57V119.41a33.94 33.94 0 0133.94-33.94h5.06v346h-5.07a33.94 33.94 0 01-33.93-33.9z"/>
<g opacity=".2">
<circle cx="148.62" cy="375.73" r="11.84"/>
<circle cx="148.62" cy="329.54" r="11.84"/>
<circle cx="148.62" cy="283.35" r="11.84"/>
<circle cx="148.62" cy="237.16" r="11.84"/>
<circle cx="148.62" cy="190.96" r="11.84"/>
<circle cx="148.62" cy="144.77" r="11.84"/>
</g>
<rect width="11.93" height="27.44" x="129.79" y="362.01" class="cls-4" rx="4.5" ry="4.5" transform="rotate(-90 135.755 375.735)"/>
<rect width="11.93" height="27.44" x="129.79" y="315.82" class="cls-4" rx="4.5" ry="4.5" transform="rotate(-90 135.75 329.54)"/>
<rect width="11.93" height="27.44" x="129.79" y="269.63" class="cls-4" rx="4.5" ry="4.5" transform="rotate(-90 135.75 283.35)"/>
<rect width="11.93" height="27.44" x="129.79" y="223.44" class="cls-4" rx="4.5" ry="4.5" transform="rotate(-90 135.75 237.16)"/>
<rect width="11.93" height="27.44" x="129.79" y="177.24" class="cls-4" rx="4.5" ry="4.5" transform="rotate(-90 135.755 190.965)"/>
<rect width="11.93" height="27.44" x="129.79" y="131.05" class="cls-4" rx="4.5" ry="4.5" transform="rotate(-90 135.75 144.77)"/>
<path d="M396.07 97.04h2.47v189.3h-2.47zm0 233.12h2.47v90.57h-2.47zm15.44-107.97h2.47v197.75h-2.47zm16.06 10.68h-2.47V101.54l2.47 1.79v129.54zm-2.47 58.71h2.47v45.59h-2.47zm-13.59-163h2.47v45.59h-2.47z" opacity=".1"/>
<path d="M237.95 271a5 5 0 01-3.54-1.46L211.9 247a5 5 0 010-7.07l22.51-22.51a5 5 0 017.07 7.07l-19 19 19 19a5 5 0 01-3.54 8.54zm64.82 0a5 5 0 01-3.54-8.54l19-19-19-19a5 5 0 017.07-7.07l22.51 22.51a5 5 0 010 7.07l-22.51 22.51a5 5 0 01-3.53 1.52zm-41.58 14.87a5 5 0 01-4.84-6.27l19.72-74.85a5 5 0 119.67 2.55L266 282.14a5 5 0 01-4.81 3.73z" class="cls-3"/>
<path fill="#ffe400" d="M92.72 186.5l5 13.1A2.32 2.32 0 0099 201l13.1 5a2.32 2.32 0 010 4.34l-13.1 5a2.32 2.32 0 00-1.35 1.35l-5 13.1a2.32 2.32 0 01-4.34 0l-5-13.1a2.32 2.32 0 00-1.35-1.35l-13.1-5a2.32 2.32 0 010-4.34l13.1-5a2.32 2.32 0 001.35-1.35l5-13.1a2.32 2.32 0 014.41-.05zm386.36 79.39l5.38 14.22a2.52 2.52 0 001.47 1.47l14.22 5.42a2.52 2.52 0 010 4.72l-14.22 5.38a2.52 2.52 0 00-1.47 1.47l-5.38 14.22a2.52 2.52 0 01-4.72 0L469 298.52a2.52 2.52 0 00-1.47-1.47l-14.22-5.38a2.52 2.52 0 010-4.72l14.22-5.38a2.52 2.52 0 001.47-1.47l5.38-14.22a2.52 2.52 0 014.7.01zM258.14 18l5.67 15a2.66 2.66 0 001.55 1.55l15 5.67a2.66 2.66 0 010 5l-15 5.67a2.66 2.66 0 00-1.55 1.55l-5.67 15a2.66 2.66 0 01-5 0l-5.67-15a2.66 2.66 0 00-1.55-1.55l-15-5.67a2.66 2.66 0 010-5l15-5.67a2.66 2.66 0 001.55-1.55l5.67-15a2.66 2.66 0 015 0zm-49.69 344.87l5.67 15a2.66 2.66 0 001.55 1.55l15 5.67a2.66 2.66 0 010 5l-15 5.67a2.66 2.66 0 00-1.55 1.55l-5.67 15a2.66 2.66 0 01-5 0l-5.67-15a2.66 2.66 0 00-1.55-1.55l-15-5.67a2.66 2.66 0 010-5l15-5.67a2.66 2.66 0 001.55-1.55l5.67-15a2.66 2.66 0 015 0z"/>
<rect width="90.16" height="59.5" x="480.14" y="173.05" class="cls-5" rx="11.44" ry="11.44" transform="rotate(30.42 525.27 202.83)"/>
<path d="M505.53 242.61a4.27 4.27 0 01-5.83-3.43l-1.49-11.87-1.1-8.77a4.27 4.27 0 016.4-4.21l10.94 6.42 10.94 6.42a4.27 4.27 0 01-.56 7.64l-8.19 3.31z" class="cls-5"/>
<circle cx="507.01" cy="194.63" r="5.78" class="cls-3" transform="rotate(-59.76 507.024 194.63)"/>
<circle cx="524.1" cy="204.6" r="5.78" class="cls-3" transform="rotate(-59.76 524.112 204.598)"/>
<circle cx="541.31" cy="214.63" r="5.78" class="cls-3" transform="rotate(-59.76 541.326 214.627)"/>
<rect width="86.2" height="56.89" x="69.02" y="11.6" class="cls-6" rx="10.94" ry="10.94" transform="rotate(-24.07 112.12 40.037)"/>
<path d="M132.16 77.47a4.08 4.08 0 01-5.9 2.64l-10.06-5.43-7.43-4a4.08 4.08 0 01.27-7.32l11.07-4.95 11.07-4.95a4.08 4.08 0 015.63 4.68l-2 8.21z" class="cls-6"/>
<path d="M115.94 53.34l-17.08-5.28 2.41-7.83 9.67 2.99 7.81-21.18 7.68 2.84-10.49 28.46z" class="cls-3"/>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

@ -0,0 +1,102 @@
import type { ComponentProps, ForwardedRef } from 'react';
import { useState } from 'react';
import { forwardRef } from 'react';
import type { UseFormRegisterReturn } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import {
BuildingOffice2Icon,
CalendarDaysIcon,
QuestionMarkCircleIcon,
} from '@heroicons/react/24/outline';
import { Button, TextInput } from '@tih/ui';
import ContributeQuestionModal from './ContributeQuestionModal';
export type ContributeQuestionData = {
company: string;
date: Date;
questionContent: string;
questionType: string;
};
type TextInputProps = ComponentProps<typeof TextInput>;
type FormTextInputProps = Omit<TextInputProps, 'onChange'> &
Pick<UseFormRegisterReturn<never>, 'onChange'>;
function FormTextInputWithRef(
props: FormTextInputProps,
ref?: ForwardedRef<HTMLInputElement>,
) {
const { onChange, ...rest } = props;
return (
<TextInput
{...(rest as TextInputProps)}
ref={ref}
onChange={(_, event) => onChange(event)}
/>
);
}
const FormTextInput = forwardRef(FormTextInputWithRef);
export type ContributeQuestionCardProps = {
onSubmit: (data: ContributeQuestionData) => void;
};
export default function ContributeQuestionCard({
onSubmit,
}: ContributeQuestionCardProps) {
const { register, handleSubmit } = useForm<ContributeQuestionData>();
const [isOpen, setOpen] = useState<boolean>(false);
return (
<>
<form
className="flex flex-col items-stretch justify-center gap-2 rounded-md border border-slate-300 p-4"
onSubmit={handleSubmit(onSubmit)}>
<FormTextInput
isLabelHidden={true}
label="Question"
placeholder="Contribute a question"
{...register('questionContent')}
/>
<div className="flex items-end justify-center gap-x-2">
<div className="min-w-[150px] flex-1">
<FormTextInput
label="Company"
startAddOn={BuildingOffice2Icon}
startAddOnType="icon"
{...register('company')}
/>
</div>
<div className="min-w-[150px] flex-1">
<FormTextInput
label="Question type"
startAddOn={QuestionMarkCircleIcon}
startAddOnType="icon"
{...register('questionType')}
/>
</div>
<div className="min-w-[150px] flex-1">
<FormTextInput
label="Date"
startAddOn={CalendarDaysIcon}
startAddOnType="icon"
{...register('date')}
/>
</div>
<Button
label="Contribute"
type="submit"
variant="primary"
onClick={() => setOpen(true)}
/>
</div>
</form>
<ContributeQuestionModal
contributeState={isOpen}
setContributeState={setOpen}></ContributeQuestionModal>
</>
);
}

@ -0,0 +1,96 @@
import type { Dispatch, SetStateAction } from 'react';
import { Fragment, useState } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import Checkbox from './ui-patch/Checkbox';
export type ContributeQuestionModalProps = {
contributeState: boolean;
setContributeState: Dispatch<SetStateAction<boolean>>;
};
export default function ContributeQuestionModal({
contributeState,
setContributeState,
}: ContributeQuestionModalProps) {
const [canSubmit, setCanSubmit] = useState<boolean>(false);
const handleCheckSimilarQuestions = (checked: boolean) => {
setCanSubmit(checked);
};
return (
<Transition.Root as={Fragment} show={contributeState}>
<Dialog
as="div"
className="relative z-10"
onClose={() => setContributeState(false)}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-gray-900">
Question Draft
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Question Contribution form
</p>
</div>
</div>
</div>
</div>
<div className="bg-primary-50 px-4 py-3 sm:flex sm:flex-row sm:justify-between sm:px-6">
<div className="mb-1 flex">
<Checkbox
checked={canSubmit}
label="I have checked that my question is new"
onChange={handleCheckSimilarQuestions}></Checkbox>
</div>
<div className=" flex gap-x-2">
<button
className="inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
type="button"
onClick={() => setContributeState(false)}>
Discard
</button>
<button
className="bg-primary-600 hover:bg-primary-700 focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-transparent px-4 py-2 text-base font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:bg-gray-400 sm:ml-3 sm:w-auto sm:text-sm"
disabled={!canSubmit}
type="button"
onClick={() => setContributeState(false)}>
Contribute
</button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}

@ -0,0 +1,56 @@
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,72 @@
import {
ChatBubbleBottomCenterTextIcon,
ChevronDownIcon,
ChevronUpIcon,
EyeIcon,
} from '@heroicons/react/24/outline';
import { Badge, Button } from '@tih/ui';
export type QuestionOverviewCardProps = {
answerCount: number;
content: string;
location: string;
role: string;
similarCount: number;
timestamp: string;
upvoteCount: number;
};
export default function QuestionOverviewCard({
answerCount,
content,
similarCount,
upvoteCount,
timestamp,
role,
location,
}: QuestionOverviewCardProps) {
return (
<article className="flex gap-2 rounded-md border border-slate-300 p-4">
<div className="flex flex-col items-center">
<Button
icon={ChevronUpIcon}
isLabelHidden={true}
label="Upvote"
variant="tertiary"
/>
<p>{upvoteCount}</p>
<Button
icon={ChevronDownIcon}
isLabelHidden={true}
label="Downvote"
variant="tertiary"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-slate-500">
<Badge label="Technical" variant="primary" />
<p className="text-xs">
{timestamp} · {location} · {role}
</p>
</div>
<p className="line-clamp-2 text-ellipsis">{content}</p>
<div className="flex gap-2">
<Button
addonPosition="start"
icon={ChatBubbleBottomCenterTextIcon}
label={`${answerCount} answers`}
size="sm"
variant="tertiary"
/>
<Button
addonPosition="start"
icon={EyeIcon}
label={`${similarCount} received this`}
size="sm"
variant="tertiary"
/>
</div>
</div>
</article>
);
}

@ -0,0 +1,42 @@
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { Select, TextInput } from '@tih/ui';
export type SortOption = {
label: string;
value: string;
};
export type QuestionSearchBarProps<SortOptions extends Array<SortOption>> = {
onSortChange?: (sortValue: SortOptions[number]['value']) => void;
sortOptions: SortOptions;
sortValue: SortOptions[number]['value'];
};
export default function QuestionSearchBar<
SortOptions extends Array<SortOption>,
>({
onSortChange,
sortOptions,
sortValue,
}: QuestionSearchBarProps<SortOptions>) {
return (
<div className="flex items-center gap-2">
<div className="flex-1 pt-1">
<TextInput
isLabelHidden={true}
label="Search by content"
placeholder="Search by content"
startAddOn={MagnifyingGlassIcon}
startAddOnType="icon"
/>
</div>
<span className="pl-3 pr-1 pt-1 text-sm">Sort by:</span>
<Select
display="inline"
label=""
options={sortOptions}
value={sortValue}
onChange={onSortChange}></Select>
</div>
);
}

@ -0,0 +1,62 @@
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { Collapsible, TextInput } from '@tih/ui';
import Checkbox from '../ui-patch/Checkbox';
export type FilterOptions = {
checked: boolean;
label: string;
value: string;
};
export type FilterSectionProps = {
label: string;
onOptionChange: (optionValue: string, checked: boolean) => void;
options: Array<FilterOptions>;
} & (
| {
searchPlaceholder: string;
showAll?: never;
}
| {
searchPlaceholder?: never;
showAll: true;
}
);
export default function FilterSection({
label,
options,
searchPlaceholder,
showAll,
onOptionChange,
}: FilterSectionProps) {
return (
<div className="mx-2">
<Collapsible defaultOpen={true} label={label}>
<div className="-mx-2 flex flex-col items-stretch gap-2">
{!showAll && (
<TextInput
isLabelHidden={true}
label={label}
placeholder={searchPlaceholder}
startAddOn={MagnifyingGlassIcon}
startAddOnType="icon"
/>
)}
<div className="mx-1">
{options.map((option) => (
<Checkbox
key={option.value}
{...option}
onChange={(checked) => {
onOptionChange(option.value, checked);
}}
/>
))}
</div>
</div>
</Collapsible>
</div>
);
}

@ -0,0 +1,25 @@
import { useId } from 'react';
export type CheckboxProps = {
checked: boolean;
label: string;
onChange: (checked: boolean) => void;
};
export default function Checkbox({ label, checked, onChange }: CheckboxProps) {
const id = useId();
return (
<div className="flex items-center">
<input
checked={checked}
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-gray-300"
id={id}
type="checkbox"
onChange={(event) => onChange(event.target.checked)}
/>
<label className="ml-3 min-w-0 flex-1 text-gray-700" htmlFor={id}>
{label}
</label>
</div>
);
}

@ -1,3 +1,4 @@
import { formatDistanceToNow } from 'date-fns';
import Link from 'next/link'; import Link from 'next/link';
import type { UrlObject } from 'url'; import type { UrlObject } from 'url';
import { ChevronRightIcon } from '@heroicons/react/20/solid'; import { ChevronRightIcon } from '@heroicons/react/20/solid';
@ -13,8 +14,8 @@ type Props = Readonly<{
export default function BrowseListItem({ href, resumeInfo }: Props) { export default function BrowseListItem({ href, resumeInfo }: Props) {
return ( return (
<Link href={href}> <Link href={href}>
<div className="flex justify-between border-b border-slate-200 p-4"> <div className="grid grid-cols-8 border-b border-slate-200 p-4">
<div> <div className="col-span-4">
{resumeInfo.title} {resumeInfo.title}
<div className="mt-2 flex items-center justify-start text-xs text-indigo-500"> <div className="mt-2 flex items-center justify-start text-xs text-indigo-500">
{resumeInfo.role} {resumeInfo.role}
@ -33,11 +34,11 @@ export default function BrowseListItem({ href, resumeInfo }: Props) {
</div> </div>
</div> </div>
</div> </div>
<div className="self-center text-sm text-slate-500"> <div className="col-span-3 self-center text-sm text-slate-500">
{/* TODO: Replace hardcoded days ago with calculated days ago*/} Uploaded {formatDistanceToNow(resumeInfo.createdAt)} ago by{' '}
Uploaded 2 days ago by {resumeInfo.user} {resumeInfo.user}
</div> </div>
<ChevronRightIcon className="w-8" /> <ChevronRightIcon className="col-span-1 w-8 self-center justify-self-center" />
</div> </div>
</Link> </Link>
); );

@ -1,3 +1,9 @@
export const BROWSE_TABS_VALUES = {
ALL: 'all',
MY: 'my',
STARRED: 'starred',
};
export const SORT_OPTIONS = [ export const SORT_OPTIONS = [
{ current: true, href: '#', name: 'Latest' }, { current: true, href: '#', name: 'Latest' },
{ current: false, href: '#', name: 'Popular' }, { current: false, href: '#', name: 'Popular' },
@ -17,19 +23,33 @@ export const ROLES = [
label: 'Full-Stack Engineer', label: 'Full-Stack Engineer',
value: 'Full-Stack Engineer', value: 'Full-Stack Engineer',
}, },
{ checked: false, label: 'Frontend Engineer', value: 'frontend-engineer' }, { checked: false, label: 'Frontend Engineer', value: 'Frontend Engineer' },
{ checked: false, label: 'Backend Engineer', value: 'backend-engineer' }, { checked: false, label: 'Backend Engineer', value: 'Backend Engineer' },
{ checked: false, label: 'DevOps Engineer', value: 'devops-engineer' }, { checked: false, label: 'DevOps Engineer', value: 'DevOps Engineer' },
{ checked: false, label: 'iOS Engineer', value: 'ios-engineer' }, { checked: false, label: 'iOS Engineer', value: 'iOS Engineer' },
{ checked: false, label: 'Android Engineer', value: 'android-engineer' }, { checked: false, label: 'Android Engineer', value: 'Android Engineer' },
]; ];
export const EXPERIENCE = [ export const EXPERIENCE = [
{ checked: false, label: 'Freshman', value: 'freshman' }, { checked: false, label: 'Freshman', value: 'Freshman' },
{ checked: false, label: 'Sophomore', value: 'sophomore' }, { checked: false, label: 'Sophomore', value: 'Sophomore' },
{ checked: false, label: 'Junior', value: 'junior' }, { checked: false, label: 'Junior', value: 'Junior' },
{ checked: false, label: 'Senior', value: 'senior' }, { checked: false, label: 'Senior', value: 'Senior' },
{ checked: false, label: 'Fresh Grad (0-1 years)', value: 'freshgrad' }, {
checked: false,
label: 'Fresh Grad (0-1 years)',
value: 'Fresh Grad (0-1 years)',
},
{
checked: false,
label: 'Mid-level (2 - 5 years)',
value: 'Mid-level (2 - 5 years)',
},
{
checked: false,
label: 'Senior (5+ years)',
value: 'Senior (5+ years)',
},
]; ];
export const LOCATION = [ export const LOCATION = [

@ -1,7 +1,7 @@
import { useState } from 'react'; import { 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 { Button, Dialog, TextInput } from '@tih/ui'; import { Button, Dialog, TextArea } from '@tih/ui';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
@ -86,45 +86,39 @@ export default function CommentsForm({
<form <form
className="w-full space-y-8 divide-y divide-gray-200" className="w-full space-y-8 divide-y divide-gray-200"
onSubmit={handleSubmit(onSubmit)}> onSubmit={handleSubmit(onSubmit)}>
{/* TODO: Convert TextInput to TextArea */}
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
<TextInput <TextArea
{...(register('general'), {})} {...(register('general'), {})}
label="General" label="General"
placeholder="General comments about the resume" placeholder="General comments about the resume"
type="text"
onChange={(value) => onValueChange('general', value)} onChange={(value) => onValueChange('general', value)}
/> />
<TextInput <TextArea
{...(register('education'), {})} {...(register('education'), {})}
label="Education" label="Education"
placeholder="Comments about the Education section" placeholder="Comments about the Education section"
type="text"
onChange={(value) => onValueChange('education', value)} onChange={(value) => onValueChange('education', value)}
/> />
<TextInput <TextArea
{...(register('experience'), {})} {...(register('experience'), {})}
label="Experience" label="Experience"
placeholder="Comments about the Experience section" placeholder="Comments about the Experience section"
type="text"
onChange={(value) => onValueChange('experience', value)} onChange={(value) => onValueChange('experience', value)}
/> />
<TextInput <TextArea
{...(register('projects'), {})} {...(register('projects'), {})}
label="Projects" label="Projects"
placeholder="Comments about the Projects section" placeholder="Comments about the Projects section"
type="text"
onChange={(value) => onValueChange('projects', value)} onChange={(value) => onValueChange('projects', value)}
/> />
<TextInput <TextArea
{...(register('skills'), {})} {...(register('skills'), {})}
label="Skills" label="Skills"
placeholder="Comments about the Skills section" placeholder="Comments about the Skills section"
type="text"
onChange={(value) => onValueChange('skills', value)} onChange={(value) => onValueChange('skills', value)}
/> />
</div> </div>

@ -1,10 +1,202 @@
import QuestionBankTitle from '~/components/questions/QuestionBankTitle'; import { useMemo, useState } from 'react';
import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard';
import type { FilterOptions } from '~/components/questions/filter/FilterSection';
import FilterSection from '~/components/questions/filter/FilterSection';
import NavBar from '~/components/questions/NavBar';
import QuestionOverviewCard from '~/components/questions/QuestionOverviewCard';
import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
type FilterChoices = Array<Omit<FilterOptions, 'checked'>>;
const companies: FilterChoices = [
{
label: 'Google',
value: 'Google',
},
{
label: 'Meta',
value: 'meta',
},
];
// Code, design, behavioral
const questionTypes: FilterChoices = [
{
label: 'Code',
value: 'code',
},
{
label: 'Design',
value: 'design',
},
{
label: 'Behavioral',
value: 'behavioral',
},
];
const questionAges: FilterChoices = [
{
label: 'Last month',
value: 'last-month',
},
{
label: 'Last 6 months',
value: 'last-6-months',
},
{
label: 'Last year',
value: 'last-year',
},
];
const locations: FilterChoices = [
{
label: 'Singapore',
value: 'singapore',
},
];
export default function QuestionsHomePage() { export default function QuestionsHomePage() {
const [selectedCompanies, setSelectedCompanies] = useState<Array<string>>([]);
const [selectedQuestionTypes, setSelectedQuestionTypes] = useState<
Array<string>
>([]);
const [selectedQuestionAges, setSelectedQuestionAges] = useState<
Array<string>
>([]);
const [selectedLocations, setSelectedLocations] = useState<Array<string>>([]);
const companyFilterOptions = useMemo(() => {
return companies.map((company) => ({
...company,
checked: selectedCompanies.includes(company.value),
}));
}, [selectedCompanies]);
const questionTypeFilterOptions = useMemo(() => {
return questionTypes.map((questionType) => ({
...questionType,
checked: selectedQuestionTypes.includes(questionType.value),
}));
}, [selectedQuestionTypes]);
const questionAgeFilterOptions = useMemo(() => {
return questionAges.map((questionAge) => ({
...questionAge,
checked: selectedQuestionAges.includes(questionAge.value),
}));
}, [selectedQuestionAges]);
const locationFilterOptions = useMemo(() => {
return locations.map((location) => ({
...location,
checked: selectedLocations.includes(location.value),
}));
}, [selectedLocations]);
return ( return (
<main className="flex-1 overflow-y-auto"> <main className="flex flex-1 flex-col items-stretch overflow-y-auto">
<div className="flex h-full items-center justify-center"> <div className="pb-4">
<QuestionBankTitle /> <NavBar></NavBar>
</div>
<div className="flex">
<section className="w-[300px] border-r px-4">
<h2 className="text-xl font-semibold">Filter by</h2>
<div className="divide-y divide-slate-200">
<FilterSection
label="Company"
options={companyFilterOptions}
searchPlaceholder="Add company filter"
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedCompanies((prev) => [...prev, optionValue]);
} else {
setSelectedCompanies((prev) =>
prev.filter((company) => company !== optionValue),
);
}
}}
/>
<FilterSection
label="Question types"
options={questionTypeFilterOptions}
showAll={true}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedQuestionTypes((prev) => [...prev, optionValue]);
} else {
setSelectedQuestionTypes((prev) =>
prev.filter((questionType) => questionType !== optionValue),
);
}
}}
/>
<FilterSection
label="Question age"
options={questionAgeFilterOptions}
showAll={true}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedQuestionAges((prev) => [...prev, optionValue]);
} else {
setSelectedQuestionAges((prev) =>
prev.filter((questionAge) => questionAge !== optionValue),
);
}
}}
/>
<FilterSection
label="Location"
options={locationFilterOptions}
searchPlaceholder="Add location filter"
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedLocations((prev) => [...prev, optionValue]);
} else {
setSelectedLocations((prev) =>
prev.filter((location) => location !== optionValue),
);
}
}}
/>
</div>
</section>
<div className="flex flex-1 justify-center">
<div className="flex max-w-3xl flex-1 gap-x-4">
<div className="flex flex-1 flex-col items-stretch justify-start gap-4">
<ContributeQuestionCard
onSubmit={(data) => {
// eslint-disable-next-line no-console
console.log(data);
}}
/>
<QuestionSearchBar
sortOptions={[
{
label: 'Most recent',
value: 'most-recent',
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
sortValue="most-recent"
/>
<QuestionOverviewCard
answerCount={0}
content="Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and"
location="Menlo Park, CA"
role="Senior Engineering Manager"
similarCount={0}
timestamp="Last month"
upvoteCount={0}
/>
</div>
</div>
</div>
</div> </div>
</main> </main>
); );

@ -1,5 +1,7 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { Fragment, useState } from 'react'; import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react';
import { Fragment, useEffect, useState } from 'react';
import { Disclosure, Menu, Transition } from '@headlessui/react'; import { Disclosure, Menu, Transition } from '@headlessui/react';
import { import {
ChevronDownIcon, ChevronDownIcon,
@ -11,6 +13,7 @@ import { Tabs, TextInput } from '@tih/ui';
import BrowseListItem from '~/components/resumes/browse/BrowseListItem'; import BrowseListItem from '~/components/resumes/browse/BrowseListItem';
import { import {
BROWSE_TABS_VALUES,
EXPERIENCE, EXPERIENCE,
LOCATION, LOCATION,
ROLES, ROLES,
@ -19,6 +22,8 @@ import {
} 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 type { Resume } from '~/types/resume';
const filters = [ const filters = [
{ {
id: 'roles', id: 'roles',
@ -41,12 +46,57 @@ import ResumeReviewsTitle from '~/components/resumes/ResumeReviewsTitle';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
export default function ResumeHomePage() { export default function ResumeHomePage() {
const [tabsValue, setTabsValue] = useState('all'); const { data } = useSession();
const router = useRouter();
const [tabsValue, setTabsValue] = useState(BROWSE_TABS_VALUES.ALL);
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState('');
const resumesQuery = trpc.useQuery(['resumes.resume.list']); const [resumes, setResumes] = useState(Array<Resume>());
const allResumesQuery = trpc.useQuery(['resumes.resume.all'], {
enabled: tabsValue === BROWSE_TABS_VALUES.ALL,
});
const starredResumesQuery = trpc.useQuery(['resumes.resume.browse.stars'], {
enabled: tabsValue === BROWSE_TABS_VALUES.STARRED,
});
const myResumesQuery = trpc.useQuery(['resumes.resume.browse.my'], {
enabled: tabsValue === BROWSE_TABS_VALUES.MY,
});
useEffect(() => {
switch (tabsValue) {
case BROWSE_TABS_VALUES.ALL: {
setResumes(allResumesQuery.data ?? Array<Resume>());
break;
}
case BROWSE_TABS_VALUES.STARRED: {
setResumes(starredResumesQuery.data ?? Array<Resume>());
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 = () => {
if (data?.user?.id) {
router.push('/resumes/submit');
} else {
// TODO: Handle non-logged in user behaviour
}
};
return ( return (
<main className="h-full flex-1 overflow-y-auto"> <main className="h-[calc(100vh-4rem)] flex-1 overflow-y-scroll">
<div className="ml-4 py-4"> <div className="ml-4 py-4">
<ResumeReviewsTitle /> <ResumeReviewsTitle />
</div> </div>
@ -64,15 +114,15 @@ export default function ResumeHomePage() {
tabs={[ tabs={[
{ {
label: 'All Resumes', label: 'All Resumes',
value: 'all', value: BROWSE_TABS_VALUES.ALL,
}, },
{ {
label: 'Starred Resumes', label: 'Starred Resumes',
value: 'starred', value: BROWSE_TABS_VALUES.STARRED,
}, },
{ {
label: 'My Resumes', label: 'My Resumes',
value: 'my', value: BROWSE_TABS_VALUES.MY,
}, },
]} ]}
value={tabsValue} value={tabsValue}
@ -139,7 +189,8 @@ export default function ResumeHomePage() {
<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}>
New New
</button> </button>
</div> </div>
@ -223,14 +274,19 @@ export default function ResumeHomePage() {
</form> </form>
</div> </div>
</div> </div>
{resumesQuery.isLoading ? ( {allResumesQuery.isLoading ||
starredResumesQuery.isLoading ||
myResumesQuery.isLoading ? (
<div>Loading...</div> <div>Loading...</div>
) : ( ) : (
<div className="col-span-10 pr-8"> <div className="col-span-10 pr-8">
<ul role="list"> <ul role="list">
{resumesQuery.data?.map((resumeObj) => ( {resumes.map((resumeObj) => (
<li key={resumeObj.id}> <li key={resumeObj.id}>
<BrowseListItem href="#" resumeInfo={resumeObj} /> <BrowseListItem
href={`resumes/${resumeObj.id}`}
resumeInfo={resumeObj}
/>
</li> </li>
))} ))}
</ul> </ul>

@ -1,10 +1,17 @@
import clsx from 'clsx';
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useMemo, useState } from 'react'; 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, TextInput } from '@tih/ui'; import { Button, Select, TextArea, TextInput } from '@tih/ui';
import {
EXPERIENCE,
LOCATION,
ROLES,
} from '~/components/resumes/browse/constants';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
@ -13,7 +20,7 @@ const TITLE_PLACEHOLDER =
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 10MB.';
const MAX_FILE_SIZE_LIMIT = 10485760; const MAX_FILE_SIZE_LIMIT = 10000000;
type IFormInput = { type IFormInput = {
additionalInfo?: string; additionalInfo?: string;
@ -25,52 +32,6 @@ type IFormInput = {
}; };
export default function SubmitResumeForm() { export default function SubmitResumeForm() {
// TODO: Use enums instead
const roleItems = [
{
label: 'Frontend Engineer',
value: 'Frontend Engineer',
},
{
label: 'Full-Stack Engineer',
value: 'Full-Stack Engineer',
},
{
label: 'Backend Engineer',
value: 'Backend Engineer',
},
];
const experienceItems = [
{
label: 'Fresh Graduate (0 - 1 years)',
value: 'Fresh Graduate (0 - 1 years)',
},
{
label: 'Mid',
value: 'Mid',
},
{
label: 'Senior',
value: 'Senior',
},
];
const locationItems = [
{
label: 'United States',
value: 'United States',
},
{
label: 'Singapore',
value: 'Singapore',
},
{
label: 'India',
value: 'India',
},
];
const resumeCreateMutation = trpc.useMutation('resumes.resume.user.create'); const resumeCreateMutation = trpc.useMutation('resumes.resume.user.create');
const router = useRouter(); const router = useRouter();
@ -126,7 +87,7 @@ export default function SubmitResumeForm() {
<Head> <Head>
<title>Upload a resume</title> <title>Upload a resume</title>
</Head> </Head>
<main className="flex-1 overflow-y-auto"> <main className="h-[calc(100vh-4rem)] flex-1 overflow-y-scroll">
<section <section
aria-labelledby="primary-heading" aria-labelledby="primary-heading"
className="flex h-full min-w-0 flex-1 flex-col lg:order-last"> className="flex h-full min-w-0 flex-1 flex-col lg:order-last">
@ -139,6 +100,7 @@ export default function SubmitResumeForm() {
errorMessage={errors?.title && 'Title cannot be empty!'} errorMessage={errors?.title && 'Title cannot be empty!'}
label="Title" label="Title"
placeholder={TITLE_PLACEHOLDER} placeholder={TITLE_PLACEHOLDER}
required={true}
onChange={(val) => setValue('title', val)} onChange={(val) => setValue('title', val)}
/> />
</div> </div>
@ -146,7 +108,8 @@ export default function SubmitResumeForm() {
<Select <Select
{...register('role', { required: true })} {...register('role', { required: true })}
label="Role" label="Role"
options={roleItems} options={ROLES}
required={true}
onChange={(val) => setValue('role', val)} onChange={(val) => setValue('role', val)}
/> />
</div> </div>
@ -154,7 +117,8 @@ export default function SubmitResumeForm() {
<Select <Select
{...register('experience', { required: true })} {...register('experience', { required: true })}
label="Experience Level" label="Experience Level"
options={experienceItems} options={EXPERIENCE}
required={true}
onChange={(val) => setValue('experience', val)} onChange={(val) => setValue('experience', val)}
/> />
</div> </div>
@ -163,27 +127,39 @@ export default function SubmitResumeForm() {
{...register('location', { required: true })} {...register('location', { required: true })}
label="Location" label="Location"
name="location" name="location"
options={locationItems} options={LOCATION}
required={true}
onChange={(val) => setValue('location', val)} onChange={(val) => setValue('location', val)}
/> />
</div> </div>
<p className="text-sm font-medium text-slate-700"> <p className="text-sm font-medium text-slate-700">
Upload resume (PDF format) Upload resume (PDF format)
<span aria-hidden="true" className="text-danger-500">
{' '}
*
</span>
</p> </p>
<div className="mb-4"> <div className="mb-4">
<div className="mt-2 flex justify-center rounded-md border-2 border-dashed border-gray-300 px-6 pt-5 pb-6"> <div
className={clsx(
fileUploadError ? 'border-danger-600' : 'border-gray-300',
'mt-2 flex justify-center rounded-md border-2 border-dashed px-6 pt-5 pb-6',
)}>
<div className="space-y-1 text-center"> <div className="space-y-1 text-center">
<div className="flex gap-2"> <div className="flex gap-2">
<PaperClipIcon className="m-auto h-8 w-8 text-gray-600" /> <PaperClipIcon className="m-auto h-8 w-8 text-gray-600" />
{resumeFile && <p>{resumeFile.name}</p>} {resumeFile && <p>{resumeFile.name}</p>}
</div> </div>
<div className="flex text-sm text-gray-600"> <div className="flex justify-center text-sm">
<label <label
className="relative cursor-pointer rounded-md bg-white font-medium text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-500 focus-within:ring-offset-2 hover:text-indigo-500" className="rounded-md focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-500 focus-within:ring-offset-2"
htmlFor="file-upload"> htmlFor="file-upload">
<span>Upload a file</span> <p className="cursor-pointer font-medium text-indigo-600 hover:text-indigo-500">
Upload a file
</p>
<input <input
{...register('file', { required: true })} {...register('file', { required: true })}
accept="application/pdf"
className="sr-only" className="sr-only"
id="file-upload" id="file-upload"
name="file-upload" name="file-upload"
@ -191,7 +167,6 @@ export default function SubmitResumeForm() {
onChange={onUploadFile} onChange={onUploadFile}
/> />
</label> </label>
<p className="pl-1">or drag and drop</p>
</div> </div>
<p className="text-xs text-gray-500">PDF up to 10MB</p> <p className="text-xs text-gray-500">PDF up to 10MB</p>
</div> </div>
@ -201,8 +176,7 @@ export default function SubmitResumeForm() {
)} )}
</div> </div>
<div className="mb-4"> <div className="mb-4">
{/* TODO: Use TextInputArea instead */} <TextArea
<TextInput
{...register('additionalInfo')} {...register('additionalInfo')}
label="Additional Information" label="Additional Information"
placeholder={ADDITIONAL_INFO_PLACEHOLDER} placeholder={ADDITIONAL_INFO_PLACEHOLDER}

@ -4,6 +4,7 @@ import { createRouter } from './context';
import { protectedExampleRouter } from './protected-example-router'; import { protectedExampleRouter } from './protected-example-router';
import { resumesRouter } from './resumes'; import { resumesRouter } from './resumes';
import { resumesDetailsRouter } from './resumes-details-router'; import { resumesDetailsRouter } from './resumes-details-router';
import { resumesResumeProtectedTabsRouter } from './resumes-resume-protected-tabs-router';
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';
@ -21,6 +22,7 @@ export const appRouter = createRouter()
.merge('resumes.resume.', resumesRouter) .merge('resumes.resume.', resumesRouter)
.merge('resumes.details.', resumesDetailsRouter) .merge('resumes.details.', resumesDetailsRouter)
.merge('resumes.resume.user.', resumesResumeUserRouter) .merge('resumes.resume.user.', resumesResumeUserRouter)
.merge('resumes.resume.browse.', resumesResumeProtectedTabsRouter)
.merge('resumes.reviews.', resumeReviewsRouter) .merge('resumes.reviews.', resumeReviewsRouter)
.merge('resumes.reviews.user.', resumesReviewsUserRouter); .merge('resumes.reviews.user.', resumesReviewsUserRouter);

@ -0,0 +1,93 @@
import { createProtectedRouter } from './context';
import type { Resume } from '~/types/resume';
export const resumesResumeProtectedTabsRouter = createProtectedRouter()
.query('stars', {
async resolve({ ctx }) {
const userId = ctx.session?.user?.id;
const resumeStarsData = await ctx.prisma.resumesStar.findMany({
include: {
resume: {
include: {
_count: {
select: {
comments: true,
stars: true,
},
},
},
},
user: {
select: {
name: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
where: {
userId,
},
});
return resumeStarsData.map((rs) => {
const resume: Resume = {
additionalInfo: rs.resume.additionalInfo,
createdAt: rs.resume.createdAt,
experience: rs.resume.experience,
id: rs.id,
location: rs.resume.location,
numComments: rs.resume._count.comments,
numStars: rs.resume._count.stars,
role: rs.resume.role,
title: rs.resume.title,
url: rs.resume.url,
user: rs.user.name!,
};
return resume;
});
},
})
.query('my', {
async resolve({ ctx }) {
const userId = ctx.session?.user?.id;
const resumesData = await ctx.prisma.resumesResume.findMany({
include: {
_count: {
select: {
comments: true,
stars: true,
},
},
user: {
select: {
name: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
where: {
userId,
},
});
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;
});
},
});

@ -2,7 +2,7 @@ import { createRouter } from './context';
import type { Resume } from '~/types/resume'; import type { Resume } from '~/types/resume';
export const resumesRouter = createRouter().query('list', { export const resumesRouter = createRouter().query('all', {
async resolve({ ctx }) { async resolve({ ctx }) {
const resumesData = await ctx.prisma.resumesResume.findMany({ const resumesData = await ctx.prisma.resumesResume.findMany({
include: { include: {

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

@ -0,0 +1,256 @@
import React, { useState } from 'react';
import type { ComponentMeta } from '@storybook/react';
import type { RadioListOrientation } from '@tih/ui';
import { HorizontalDivider } from '@tih/ui';
import { RadioList } from '@tih/ui';
const RadioListOrientations: ReadonlyArray<RadioListOrientation> = [
'horizontal',
'vertical',
];
export default {
argTypes: {
description: {
control: 'text',
},
label: {
control: 'text',
},
orientation: {
control: { type: 'select' },
options: RadioListOrientations,
},
},
component: RadioList,
title: 'RadioList',
} as ComponentMeta<typeof RadioList>;
export function Basic({
description,
label,
orientation,
}: Pick<
React.ComponentProps<typeof RadioList>,
'description' | 'label' | 'orientation'
>) {
const items = [
{
label: 'Apple',
value: 'apple',
},
{
label: 'Banana',
value: 'banana',
},
{
label: 'Orange',
value: 'orange',
},
];
return (
<RadioList
defaultValue="apple"
description={description}
label={label}
name="fruit"
orientation={orientation}>
{items.map(({ label: itemLabel, value }) => (
<RadioList.Item key={itemLabel} label={itemLabel} value={value} />
))}
</RadioList>
);
}
Basic.args = {
description: 'Your favorite fruit',
label: 'Choose a fruit',
orientation: 'vertical',
};
export function Controlled() {
const items = [
{
label: 'Apple',
value: 'apple',
},
{
label: 'Banana',
value: 'banana',
},
{
label: 'Orange',
value: 'orange',
},
];
const [value, setValue] = useState('apple');
return (
<RadioList
description="You will be served it during dinner"
label="Choose a fruit"
value={value}
onChange={(newValue: string) => setValue(newValue)}>
{items.map(({ label: itemLabel, value: itemValue }) => (
<RadioList.Item key={itemLabel} label={itemLabel} value={itemValue} />
))}
</RadioList>
);
}
export function Required() {
const items = [
{
label: 'Apple',
value: 'apple',
},
{
label: 'Banana',
value: 'banana',
},
{
label: 'Orange',
value: 'orange',
},
];
const [value, setValue] = useState('apple');
return (
<RadioList
description="You will be served it during dinner"
label="Choose a fruit"
required={true}
value={value}
onChange={(newValue: string) => setValue(newValue)}>
{items.map(({ label: itemLabel, value: itemValue }) => (
<RadioList.Item key={itemLabel} label={itemLabel} value={itemValue} />
))}
</RadioList>
);
}
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',
},
];
return (
<div className="space-y-4">
<RadioList
defaultValue="apple"
label="Choose a fruit (some fruits disabled)"
name="fruit-5">
{items.map(
({ description, label: itemLabel, value: itemValue, disabled }) => (
<RadioList.Item
key={itemLabel}
description={description}
disabled={disabled}
label={itemLabel}
value={itemValue}
/>
),
)}
</RadioList>
</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 [value, setValue] = useState('apple');
return (
<RadioList
label="Choose a fruit"
value={value}
onChange={(newValue: string) => setValue(newValue)}>
{items.map(({ description, label: itemLabel, value: itemValue }) => (
<RadioList.Item
key={itemValue}
description={description}
label={itemLabel}
value={itemValue}
/>
))}
</RadioList>
);
}
export function Orientation() {
const items = [
{
label: 'Apple',
value: 'apple',
},
{
label: 'Banana',
value: 'banana',
},
{
label: 'Orange',
value: 'orange',
},
];
return (
<div className="space-y-4">
<RadioList
defaultValue="apple"
label="Choose a fruit"
name="fruit-2"
orientation="vertical">
{items.map(({ label: itemLabel, value: itemValue }) => (
<RadioList.Item key={itemLabel} label={itemLabel} value={itemValue} />
))}
</RadioList>
<HorizontalDivider />
<RadioList
defaultValue="banana"
label="Choose a fruit"
name="fruit-3"
orientation="horizontal">
{items.map(({ label: itemLabel, value: itemValue }) => (
<RadioList.Item key={itemLabel} label={itemLabel} value={itemValue} />
))}
</RadioList>
</div>
);
}

@ -177,6 +177,35 @@ export function Disabled() {
); );
} }
export function Required() {
const [value, setValue] = useState('apple');
return (
<div className="space-x-4">
<Select
label="Select a fruit"
options={[
{
label: 'Apple',
value: 'apple',
},
{
label: 'Banana',
value: 'banana',
},
{
label: 'Orange',
value: 'orange',
},
]}
required={true}
value={value}
onChange={setValue}
/>
</div>
);
}
export function Uncontrolled() { export function Uncontrolled() {
return ( return (
<div className="space-x-4"> <div className="space-x-4">

@ -0,0 +1,83 @@
import clsx from 'clsx';
import type { ChangeEvent } from 'react';
import { 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;
}>;
export default function CheckboxInput({
defaultValue,
description,
disabled = false,
label,
name,
value,
onChange,
}: Props) {
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
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>
);
}

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

@ -23,7 +23,7 @@ export default function Collapsible({ children, defaultOpen, label }: Props) {
/> />
<span className="flex-1">{label}</span> <span className="flex-1">{label}</span>
</Disclosure.Button> </Disclosure.Button>
<Disclosure.Panel className="pt-1 pb-2 text-sm text-gray-500"> <Disclosure.Panel className="w-full pt-1 pb-2 text-sm text-gray-500">
{children} {children}
</Disclosure.Panel> </Disclosure.Panel>
</> </>

@ -0,0 +1,72 @@
import clsx from 'clsx';
import type { ChangeEvent } from 'react';
import { useId } from 'react';
import { RadioListContext } from './RadioListContext';
import RadioListItem from './RadioListItem';
export type RadioListOrientation = 'horizontal' | 'vertical';
type Props<T> = Readonly<{
children: ReadonlyArray<React.ReactElement<typeof RadioListItem>>;
defaultValue?: T;
description?: string;
isLabelHidden?: boolean;
label: string;
name?: string;
onChange?: (value: T, event: ChangeEvent<HTMLInputElement>) => void;
orientation?: RadioListOrientation;
required?: boolean;
value?: T;
}>;
RadioList.Item = RadioListItem;
export default function RadioList<T>({
children,
defaultValue,
description,
isLabelHidden,
name,
orientation = 'vertical',
label,
required,
value,
onChange,
}: Props<T>) {
const labelId = useId();
return (
<RadioListContext.Provider
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore TODO: Figure out how to type the onChange.
value={{ defaultValue, name, onChange, value }}>
<div>
<div className={clsx(isLabelHidden ? 'sr-only' : 'mb-2')}>
<label className="text-sm font-medium text-gray-900" id={labelId}>
{label}
{required && (
<span aria-hidden="true" className="text-danger-500">
{' '}
*
</span>
)}
</label>
{description && (
<p className="text-xs leading-5 text-gray-500">{description}</p>
)}
</div>
<div
aria-labelledby={labelId}
aria-required={required != null ? required : undefined}
className={clsx(
'space-y-2',
orientation === 'horizontal' &&
'sm:flex sm:items-center sm:space-y-0 sm:space-x-10',
)}
role="radiogroup">
{children}
</div>
</div>
</RadioListContext.Provider>
);
}

@ -0,0 +1,20 @@
import type { ChangeEvent } from 'react';
import { createContext, useContext } from 'react';
type RadioListContextValue<T = unknown> = {
defaultValue?: T;
name?: string;
onChange?: (
value: T,
event: ChangeEvent<HTMLInputElement>,
) => undefined | void;
value?: T;
};
export const RadioListContext =
createContext<RadioListContextValue<unknown> | null>(null);
export function useRadioListContext<T>(): RadioListContextValue<T> | null {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore TODO: Figure out how to type useContext with generics.
return useContext<T>(RadioListContext);
}

@ -0,0 +1,80 @@
import clsx from 'clsx';
import { useId } from 'react';
import { useRadioListContext } from './RadioListContext';
type Props<T> = Readonly<{
description?: string;
disabled?: boolean;
label: string;
value: T;
}>;
export default function RadioListItem<T>({
description,
disabled = false,
label,
value,
}: Props<T>) {
const id = useId();
const descriptionId = useId();
const context = useRadioListContext();
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
aria-describedby={description != null ? descriptionId : undefined}
checked={
context?.value != null ? value === context?.value : undefined
}
className={clsx(
'text-primary-600 focus:ring-primary-500 h-4 w-4 border-slate-300',
disabled && 'bg-slate-100',
)}
defaultChecked={
context?.defaultValue != null
? value === context?.defaultValue
: undefined
}
disabled={disabled}
id={id}
name={context?.name}
type="radio"
onChange={
context?.onChange != null
? (event) => {
context?.onChange?.(value, 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>
);
}

@ -43,6 +43,7 @@ function Select<T>(
label, label,
isLabelHidden, isLabelHidden,
options, options,
required,
value, value,
onChange, onChange,
...props ...props
@ -58,6 +59,12 @@ function Select<T>(
className={clsx('mb-1 block text-sm font-medium text-slate-700')} className={clsx('mb-1 block text-sm font-medium text-slate-700')}
htmlFor={id ?? undefined}> htmlFor={id ?? undefined}>
{label} {label}
{required && (
<span aria-hidden="true" className="text-danger-500">
{' '}
*
</span>
)}
</label> </label>
)} )}
<select <select
@ -72,6 +79,7 @@ function Select<T>(
defaultValue={defaultValue != null ? String(defaultValue) : undefined} defaultValue={defaultValue != null ? String(defaultValue) : undefined}
disabled={disabled} disabled={disabled}
id={id} id={id}
required={required}
value={value != null ? String(value) : undefined} value={value != null ? String(value) : undefined}
onChange={(event) => { onChange={(event) => {
onChange?.(event.target.value); onChange?.(event.target.value);

@ -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';
@ -22,6 +28,9 @@ export { default as HorizontalDivider } from './HorizontalDivider/HorizontalDivi
// Pagination // Pagination
export * from './Pagination/Pagination'; export * from './Pagination/Pagination';
export { default as Pagination } from './Pagination/Pagination'; export { default as Pagination } from './Pagination/Pagination';
// RadioList
export * from './RadioList/RadioList';
export { default as RadioList } from './RadioList/RadioList';
// Select // Select
export * from './Select/Select'; export * from './Select/Select';
export { default as Select } from './Select/Select'; export { default as Select } from './Select/Select';

Loading…
Cancel
Save