[questions][ui] Full UI for questions/answer/comment (#346)

* [questions][ui] Add DiscardDraftModal

* [questions][ui] add question draft dialog form

* [questions][ui] refactor bottom contribute bar

* [questions][ui] landing page

* [questions][ui] add similar question card

* [questions][ui] use TIH dialog for discard

* [questions][ui] add aria-hidden for select label

* [questions][ui] extract useFormRegister hook

* [questions][ui] change landing page to component

* [questions][ui] load filter from query param

* [question][chore] add constants.ts

* [questions][ui] add app logo

* [questions][ui] remove form

* [questions][ui] fix dialog closing

* [questions][chore] minor changes

* [questions][ui] radio button

* [questions][ui] add vertical scrolling

* [questions][ui] Question age url param change

* [questions][chore] refactor and add in todo

* [questions][ui] contribute card clickable

* [questions][ui] landing page github stars

* [questions][ui] edit css for question card

* [question][ui] add question detail page

* [questions][ui] remove navbar import

* [questions][ui] css changes

* [questions][ui] hide sidebar

* [questions][ui] contribute questions form ui

* [questions][ui] question page

* [questions][bug] remove button

* [questions][ui] voting button size

* [questions][chore] add dummy data, refactor

* [questions][ui] answer card

* [questions][chore] add sample data

* [questions][ui] add hover

* [questions][ui] clean up old href

* [questions][ui] add comments & commments page

* [question][feat] cache filter options to localStorage

* [questions][fix] fix index refreshing constantly

* [questions][ui] set fixed sample date

Co-authored-by: Jeff Sieu <jeffsy00@gmail.com>
This commit is contained in:
Ren Weilin
2022-10-10 02:01:38 +08:00
committed by GitHub
parent c252f57bd5
commit cf5af1a5c7
27 changed files with 1595 additions and 373 deletions

View File

@ -175,7 +175,7 @@ export default function AppShell({ children }: Props) {
/>
{/* Content area */}
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex h-screen flex-1 flex-col overflow-hidden">
<header className="w-full">
<div className="relative z-10 flex h-16 flex-shrink-0 border-b border-gray-200 bg-white shadow-sm">
<button

View File

@ -0,0 +1,38 @@
import { format } from 'date-fns';
import VotingButtons from './VotingButtons';
export type CommentListItemProps = {
authorImageUrl: string;
authorName: string;
content: string;
createdAt: Date;
upvoteCount: number;
};
export default function CommentListItem({
authorImageUrl,
authorName,
content,
createdAt,
upvoteCount,
}: CommentListItemProps) {
return (
<div className="flex gap-4 border bg-white p-2 ">
<VotingButtons size="sm" upvoteCount={upvoteCount} />
<div className="mt-1 flex flex-col gap-1">
<div className="flex items-center gap-2">
<img
alt={`${authorName} profile picture`}
className="h-8 w-8 rounded-full"
src={authorImageUrl}></img>
<h1 className="font-bold">{authorName}</h1>
<p className="pt-1 text-xs font-extralight">
Posted on: {format(createdAt, 'Pp')}
</p>
</div>
<p className="pl-1 pt-1">{content}</p>
</div>
</div>
);
}

View File

@ -1,102 +1,74 @@
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 { TextInput } from '@tih/ui';
import ContributeQuestionModal from './ContributeQuestionModal';
import ContributeQuestionDialog from './ContributeQuestionDialog';
export type ContributeQuestionData = {
company: string;
date: Date;
questionContent: string;
questionType: string;
};
export default function ContributeQuestionCard() {
const [showDraftDialog, setShowDraftDialog] = useState(false);
type TextInputProps = ComponentProps<typeof TextInput>;
const handleDraftDialogCancel = () => {
setShowDraftDialog(false);
};
type FormTextInputProps = Omit<TextInputProps, 'onChange'> &
Pick<UseFormRegisterReturn<never>, 'onChange'>;
const handleOpenContribute = () => {
setShowDraftDialog(true);
};
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
<div>
<button
className="flex flex-col items-stretch justify-center gap-2 rounded-md border border-slate-300 bg-white p-4 text-left hover:bg-gray-100"
type="button"
onClick={handleOpenContribute}>
<TextInput
disabled={true}
isLabelHidden={true}
label="Question"
placeholder="Contribute a question"
{...register('questionContent')}
onChange={handleOpenContribute}
/>
<div className="flex items-end justify-center gap-x-2">
<div className="min-w-[150px] flex-1">
<FormTextInput
<TextInput
disabled={true}
label="Company"
startAddOn={BuildingOffice2Icon}
startAddOnType="icon"
{...register('company')}
onChange={handleOpenContribute}
/>
</div>
<div className="min-w-[150px] flex-1">
<FormTextInput
<TextInput
disabled={true}
label="Question type"
startAddOn={QuestionMarkCircleIcon}
startAddOnType="icon"
{...register('questionType')}
onChange={handleOpenContribute}
/>
</div>
<div className="min-w-[150px] flex-1">
<FormTextInput
<TextInput
disabled={true}
label="Date"
startAddOn={CalendarDaysIcon}
startAddOnType="icon"
{...register('date')}
onChange={handleOpenContribute}
/>
</div>
<Button
label="Contribute"
type="submit"
variant="primary"
onClick={() => setOpen(true)}
/>
<h1 className="bg-primary-600 hover:bg-primary-700 rounded-full px-3 py-2 text-white">
Contribute
</h1>
</div>
</form>
<ContributeQuestionModal
contributeState={isOpen}
setContributeState={setOpen}></ContributeQuestionModal>
</>
</button>
<ContributeQuestionDialog
show={showDraftDialog}
onCancel={handleDraftDialogCancel}
/>
</div>
);
}

View File

@ -0,0 +1,96 @@
import { Fragment, useState } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { HorizontalDivider } from '~/../../../packages/ui/dist';
import ContributeQuestionForm from './ContributeQuestionForm';
import DiscardDraftDialog from './DiscardDraftDialog';
export type ContributeQuestionDialogProps = {
onCancel: () => void;
show: boolean;
};
export default function ContributeQuestionDialog({
show,
onCancel,
}: ContributeQuestionDialogProps) {
const [showDiscardDialog, setShowDiscardDialog] = useState(false);
const handleDraftDiscard = () => {
setShowDiscardDialog(false);
onCancel();
};
const handleDiscardCancel = () => {
setShowDiscardDialog(false);
};
return (
<div>
<Transition.Root as={Fragment} show={show}>
<Dialog
as="div"
className="relative z-10"
onClose={() => {
// Todo: save state
onCancel();
}}>
<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 max-w-5xl transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full">
<div className="bg-white p-6 pt-5 sm:pb-4">
<div className="flex flex-1 items-stretch">
<div className="mt-3 w-full 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="w-full">
<HorizontalDivider />
</div>
<div className="mt-2">
<ContributeQuestionForm
onDiscard={() => setShowDiscardDialog(true)}
onSubmit={(data) => {
// eslint-disable-next-line no-console
console.log(data);
onCancel();
}}
/>
</div>
</div>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
<DiscardDraftDialog
show={showDiscardDialog}
onCancel={handleDiscardCancel}
onDiscard={handleDraftDiscard}></DiscardDraftDialog>
</div>
);
}

View File

@ -0,0 +1,157 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import {
BuildingOffice2Icon,
CalendarDaysIcon,
// UserIcon,
} from '@heroicons/react/24/outline';
import {
Button,
Collapsible,
Select,
// HorizontalDivider,
TextArea,
TextInput,
} from '@tih/ui';
import { QUESTION_TYPES } from '~/utils/questions/constants';
import {
useFormRegister,
useSelectRegister,
} from '~/utils/questions/useFormRegister';
// Import SimilarQuestionCard from './card/SimilarQuestionCard';
import Checkbox from './ui-patch/Checkbox';
export type ContributeQuestionData = {
company: string;
date: Date;
location: string;
position: string;
questionContent: string;
questionType: string;
};
export type ContributeQuestionFormProps = {
onDiscard: () => void;
onSubmit: (data: ContributeQuestionData) => void;
};
export default function ContributeQuestionForm({
onSubmit,
onDiscard,
}: ContributeQuestionFormProps) {
const { register: formRegister, handleSubmit } =
useForm<ContributeQuestionData>();
const register = useFormRegister(formRegister);
const selectRegister = useSelectRegister(formRegister);
const [canSubmit, setCanSubmit] = useState<boolean>(false);
const handleCheckSimilarQuestions = (checked: boolean) => {
setCanSubmit(checked);
};
return (
<form
className=" flex flex-1 flex-col items-stretch justify-center pb-[50px]"
onSubmit={handleSubmit(onSubmit)}>
<TextArea
label="Question Prompt"
placeholder="Contribute a question"
required={true}
rows={5}
{...register('questionContent')}
/>
<div className="mt-3 mb-1 flex flex-wrap items-end gap-2">
<div className="mr-2 min-w-[113px] max-w-[113px] flex-1">
<Select
defaultValue="coding"
label="Type"
options={QUESTION_TYPES}
required={true}
{...selectRegister('questionType')}
/>
</div>
<div className="min-w-[150px] max-w-[300px] flex-1">
<TextInput
label="Company"
required={true}
startAddOn={BuildingOffice2Icon}
startAddOnType="icon"
{...register('company')}
/>
</div>
<div className="min-w-[150px] max-w-[300px] flex-1">
<TextInput
label="Date"
required={true}
startAddOn={CalendarDaysIcon}
startAddOnType="icon"
{...register('date')}
/>
</div>
</div>
<Collapsible defaultOpen={true} label="Additional info">
<div className="justify-left flex flex-wrap items-end gap-2">
<div className="min-w-[150px] max-w-[300px] flex-1">
<TextInput
label="Location"
startAddOn={CalendarDaysIcon}
startAddOnType="icon"
{...register('location')}
/>
</div>
{/* <div className="min-w-[150px] max-w-[200px] flex-1">
<TextInput
label="Position <TODO>"
startAddOn={UserIcon}
startAddOnType="icon"
{...register('position')}
/>
</div> */}
</div>
</Collapsible>
{/* <div className="w-full">
<HorizontalDivider />
</div>
<h1 className="mb-3">
Are these questions the same as yours? TODO:Change to list
</h1>
<div>
<SimilarQuestionCard
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"
location="Menlo Park, CA"
receivedCount={0}
role="Senior Engineering Manager"
timestamp="Today"
onSimilarQuestionClick={() => {
// eslint-disable-next-line no-console
console.log('hi!');
}}
/>
</div> */}
<div className="bg-primary-50 fixed bottom-0 left-0 w-full 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={onDiscard}>
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}
label="Contribute"
type="submit"
variant="primary"></Button>
</div>
</div>
</form>
);
}

View File

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

View File

@ -0,0 +1,30 @@
import { Button, Dialog } from '@tih/ui';
export type DiscardDraftDialogProps = {
onCancel: () => void;
onDiscard: () => void;
show: boolean;
};
export default function DiscardDraftDialog({
show,
onCancel,
onDiscard,
}: DiscardDraftDialogProps) {
return (
<Dialog
isShown={show}
primaryButton={
<Button label="Discard" variant="primary" onClick={onDiscard} />
}
secondaryButton={
<Button label="Cancel" variant="tertiary" onClick={onCancel} />
}
title="Discard draft"
onClose={onCancel}>
<p>
Are you sure you want to discard the current draft? This action cannot
be undone.
</p>
</Dialog>
);
}

View File

@ -0,0 +1,107 @@
import { useState } from 'react';
import { ArrowSmallRightIcon } from '@heroicons/react/24/outline';
import { Button, Select } from '@tih/ui';
import {
COMPANIES,
LOCATIONS,
QUESTION_TYPES,
} from '~/utils/questions/constants';
export type LandingQueryData = {
company: string;
location: string;
questionType: string;
};
export type LandingComponentProps = {
onLanded: (data: LandingQueryData) => void;
};
export default function LandingComponent({
onLanded: handleLandingQuery,
}: LandingComponentProps) {
const [landingQueryData, setLandingQueryData] = useState<LandingQueryData>({
company: 'google',
location: 'singapore',
questionType: 'coding',
});
const handleChangeCompany = (company: string) => {
setLandingQueryData((prev) => ({ ...prev, company }));
};
const handleChangeLocation = (location: string) => {
setLandingQueryData((prev) => ({ ...prev, location }));
};
const handleChangeType = (questionType: string) => {
setLandingQueryData((prev) => ({ ...prev, questionType }));
};
return (
<main className="flex flex-1 flex-col items-stretch overflow-y-auto bg-white">
<div className="pb-4"></div>
<div className="flex flex-1 flex-col justify-center gap-3">
<div className="flex items-center justify-center">
<img alt="app logo" className=" h-20 w-20" src="/logo.svg"></img>
<h1 className="text-primary-600 p-4 text-center text-5xl font-bold">
Tech Interview Question Bank
</h1>
</div>
<p className="mx-auto max-w-lg p-6 text-center text-xl text-black sm:max-w-3xl">
Get to know the latest SWE interview questions asked by top companies
</p>
<div className="mx-auto flex max-w-lg items-baseline gap-3 p-4 text-center text-xl text-black sm:max-w-3xl">
<p>Find</p>
<div className=" space-x-2">
<Select
isLabelHidden={true}
label="Type"
options={QUESTION_TYPES}
value={landingQueryData.questionType}
onChange={handleChangeType}
/>
</div>
<p>questions from</p>
<Select
isLabelHidden={true}
label="Company"
options={COMPANIES}
value={landingQueryData.company}
onChange={handleChangeCompany}
/>
<p>in</p>
<Select
isLabelHidden={true}
label="Location"
options={LOCATIONS}
value={landingQueryData.location}
onChange={handleChangeLocation}
/>
<Button
addonPosition="end"
icon={ArrowSmallRightIcon}
label="Go"
size="md"
variant="primary"
onClick={() => handleLandingQuery(landingQueryData)}></Button>
</div>
<div className="flex justify-center p-4">
<iframe
height={30}
src="https://ghbtns.com/github-btn.html?user=yangshun&amp;repo=tech-interview-handbook&amp;type=star&amp;count=true&amp;size=large"
title="GitHub Stars"
width={160}
/>
</div>
<div>
<p className="py-20 text-center text-white ">
TODO questions Carousel
</p>
</div>
</div>
</main>
);
}

View File

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

View File

@ -30,10 +30,13 @@ export default function QuestionSearchBar<
startAddOnType="icon"
/>
</div>
<span className="pl-3 pr-1 pt-1 text-sm">Sort by:</span>
<span aria-hidden={true} className="pl-3 pr-1 pt-1 text-sm">
Sort by:
</span>
<Select
display="inline"
label=""
isLabelHidden={true}
label="Sort by"
options={sortOptions}
value={sortValue}
onChange={onSortChange}></Select>

View File

@ -2,14 +2,14 @@ import type { ProductNavigationItems } from '~/components/global/ProductNavigati
const navigation: ProductNavigationItems = [
{ href: '/questions', name: 'Home' },
{ href: '#', name: 'My Lists' },
{ href: '#', name: 'My Questions' },
{ href: '#', name: 'History' },
{ href: '/questions', name: 'My Lists' },
{ href: '/questions', name: 'My Questions' },
{ href: '/questions', name: 'History' },
];
const config = {
navigation,
showGlobalNav: true,
showGlobalNav: false,
title: 'Questions Bank',
};

View File

@ -0,0 +1,33 @@
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline';
import type { ButtonSize } from '@tih/ui';
import { Button } from '@tih/ui';
export type VotingButtonsProps = {
size?: ButtonSize;
upvoteCount: number;
};
export default function VotingButtons({
upvoteCount,
size = 'md',
}: VotingButtonsProps) {
return (
<div className="flex flex-col items-center">
<Button
icon={ChevronUpIcon}
isLabelHidden={true}
label="Upvote"
size={size}
variant="tertiary"
/>
<p>{upvoteCount}</p>
<Button
icon={ChevronDownIcon}
isLabelHidden={true}
label="Downvote"
size={size}
variant="tertiary"
/>
</div>
);
}

View File

@ -0,0 +1,48 @@
import { format } from 'date-fns';
import withHref from '~/utils/questions/withHref';
import VotingButtons from '../VotingButtons';
export type AnswerCardProps = {
authorImageUrl: string;
authorName: string;
commentCount: number;
content: string;
createdAt: Date;
upvoteCount: number;
};
function AnswerCardWithoutHref({
authorName,
authorImageUrl,
upvoteCount,
content,
createdAt,
commentCount,
}: AnswerCardProps) {
return (
<div className="flex gap-4 rounded-md border bg-white p-2 hover:bg-slate-50">
<VotingButtons size="sm" upvoteCount={upvoteCount} />
<div className="mt-1 flex flex-col gap-1">
<div className="flex items-center gap-2">
<img
alt={`${authorName} profile picture`}
className="h-8 w-8 rounded-full"
src={authorImageUrl}></img>
<h1 className="font-bold">{authorName}</h1>
<p className="pt-1 text-xs font-extralight">
Posted on: {format(createdAt, 'Pp')}
</p>
</div>
<p className="pl-1 pt-1">{content}</p>
<p className="py-1 pl-3 text-sm font-light underline underline-offset-4">
{commentCount} comment(s)
</p>
</div>
</div>
);
}
const AnswerCard = withHref(AnswerCardWithoutHref);
export default AnswerCard;

View File

@ -0,0 +1,38 @@
import { format } from 'date-fns';
import VotingButtons from '../VotingButtons';
export type FullAnswerCardProps = {
authorImageUrl: string;
authorName: string;
content: string;
createdAt: Date;
upvoteCount: number;
};
export default function FullAnswerCard({
authorImageUrl,
authorName,
content,
createdAt,
upvoteCount,
}: FullAnswerCardProps) {
return (
<article className="flex gap-4 rounded-md border border-slate-300 bg-white p-4">
<VotingButtons upvoteCount={upvoteCount}></VotingButtons>
<div className="mt-1 flex flex-col gap-1">
<div className="flex items-center gap-2">
<img
alt={`${authorName} profile picture`}
className="h-8 w-8 rounded-full"
src={authorImageUrl}></img>
<h1 className="font-bold">{authorName}</h1>
<p className="pt-1 text-xs font-extralight">
Posted on: {format(createdAt, 'Pp')}
</p>
</div>
<p className="pl-1 pt-1">{content}</p>
</div>
</article>
);
}

View File

@ -0,0 +1,66 @@
import { Badge } from '@tih/ui';
import VotingButtons from '../VotingButtons';
type UpvoteProps =
| {
showVoteButtons: true;
upvoteCount: number;
}
| {
showVoteButtons?: false;
upvoteCount?: never;
};
export type FullQuestionCardProps = UpvoteProps & {
company: string;
content: string;
location: string;
receivedCount: number;
role: string;
timestamp: string;
};
export default function FullQuestionCard({
company,
content,
showVoteButtons,
upvoteCount,
timestamp,
role,
location,
}: FullQuestionCardProps) {
const altText = company + ' logo';
return (
<article className="flex gap-4 rounded-md border border-slate-300 bg-white p-4">
{showVoteButtons && <VotingButtons upvoteCount={upvoteCount} />}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<img alt={altText} src="https://logo.clearbit.com/google.com"></img>
<h2 className="ml-2 text-xl">{company}</h2>
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2 text-slate-500">
<Badge label="Technical" variant="primary" />
<p className="text-xs">
{timestamp} · {location} · {role}
</p>
</div>
</div>
<div className="mx-2 mb-2">
<p>{content}</p>
</div>
</div>
</article>
);
// Return href ? (
// <a
// className="ring-primary-500 rounded-md hover:bg-slate-50 focus:ring-2 focus-visible:outline-none active:bg-slate-100"
// href={href}>
// {mainCard}
// </a>
// ) : (
// mainCard
// );
}

View File

@ -0,0 +1,110 @@
import {
ChatBubbleBottomCenterTextIcon,
// EyeIcon,
} from '@heroicons/react/24/outline';
import { Badge, Button } from '@tih/ui';
import VotingButtons from '../VotingButtons';
type UpvoteProps =
| {
showVoteButtons: true;
upvoteCount: number;
}
| {
showVoteButtons?: false;
upvoteCount?: never;
};
type StatisticsProps =
| {
answerCount: number;
showUserStatistics: true;
}
| {
answerCount?: never;
showUserStatistics?: false;
};
type ActionButtonProps =
| {
actionButtonLabel: string;
onActionButtonClick: () => void;
showActionButton: true;
}
| {
actionButtonLabel?: never;
onActionButtonClick?: never;
showActionButton?: false;
};
export type QuestionCardProps = ActionButtonProps &
StatisticsProps &
UpvoteProps & {
content: string;
href?: string;
location: string;
receivedCount: number;
role: string;
timestamp: string;
};
export default function QuestionCard({
answerCount,
content,
// ReceivedCount,
showVoteButtons,
showUserStatistics,
showActionButton,
actionButtonLabel,
onActionButtonClick,
upvoteCount,
timestamp,
role,
location,
}: QuestionCardProps) {
return (
<article className="flex gap-4 rounded-md border border-slate-300 bg-white p-4 hover:bg-slate-50">
{showVoteButtons && <VotingButtons upvoteCount={upvoteCount} />}
<div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2 text-slate-500">
<Badge label="Technical" variant="primary" />
<p className="text-xs">
{timestamp} · {location} · {role}
</p>
</div>
{showActionButton && (
<Button
label={actionButtonLabel}
size="sm"
variant="tertiary"
onClick={onActionButtonClick}
/>
)}
</div>
<div className="ml-2">
<p className="line-clamp-2 text-ellipsis ">{content}</p>
</div>
{showUserStatistics && (
<div className="flex gap-2">
<Button
addonPosition="start"
icon={ChatBubbleBottomCenterTextIcon}
label={`${answerCount} answers`}
size="sm"
variant="tertiary"
/>
{/* <Button
addonPosition="start"
icon={EyeIcon}
label={`${receivedCount} received this`}
size="sm"
variant="tertiary"
/> */}
</div>
)}
</div>
</article>
);
}

View File

@ -0,0 +1,31 @@
import withHref from '~/utils/questions/withHref';
import type { QuestionCardProps } from './QuestionCard';
import QuestionCard from './QuestionCard';
export type QuestionOverviewCardProps = Omit<
QuestionCardProps & {
showActionButton: false;
showUserStatistics: true;
showVoteButtons: true;
},
| 'actionButtonLabel'
| 'onActionButtonClick'
| 'showActionButton'
| 'showUserStatistics'
| 'showVoteButtons'
>;
function QuestionOverviewCardWithoutHref(props: QuestionOverviewCardProps) {
return (
<QuestionCard
{...props}
showActionButton={false}
showUserStatistics={true}
showVoteButtons={true}
/>
);
}
const QuestionOverviewCard = withHref(QuestionOverviewCardWithoutHref);
export default QuestionOverviewCard;

View File

@ -0,0 +1,31 @@
import type { QuestionCardProps } from './QuestionCard';
import QuestionCard from './QuestionCard';
export type SimilarQuestionCardProps = Omit<
QuestionCardProps & {
showActionButton: true;
showUserStatistics: false;
showVoteButtons: false;
},
| 'actionButtonLabel'
| 'answerCount'
| 'onActionButtonClick'
| 'showActionButton'
| 'showUserStatistics'
| 'showVoteButtons'
| 'upvoteCount'
> & {
onSimilarQuestionClick: () => void;
};
export default function SimilarQuestionCard(props: SimilarQuestionCardProps) {
const { onSimilarQuestionClick, ...rest } = props;
return (
<QuestionCard
{...rest}
actionButtonLabel="Yes, this is my question"
showActionButton={true}
onActionButtonClick={onSimilarQuestionClick}
/>
);
}

View File

@ -2,35 +2,54 @@ import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { Collapsible, TextInput } from '@tih/ui';
import Checkbox from '../ui-patch/Checkbox';
import RadioGroup from '../ui-patch/RadioGroup';
export type FilterOptions = {
export type FilterOption = {
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 type FilterChoices = Array<Omit<FilterOption, 'checked'>>;
export default function FilterSection({
type FilterSectionType<FilterOptions extends Array<FilterOption>> =
| {
isSingleSelect: true;
onOptionChange: (optionValue: FilterOptions[number]['value']) => void;
}
| {
isSingleSelect?: false;
onOptionChange: (
optionValue: FilterOptions[number]['value'],
checked: boolean,
) => void;
};
export type FilterSectionProps<FilterOptions extends Array<FilterOption>> =
FilterSectionType<FilterOptions> & {
label: string;
options: FilterOptions;
} & (
| {
searchPlaceholder: string;
showAll?: never;
}
| {
searchPlaceholder?: never;
showAll: true;
}
);
export default function FilterSection<
FilterOptions extends Array<FilterOption>,
>({
label,
options,
searchPlaceholder,
showAll,
onOptionChange,
}: FilterSectionProps) {
isSingleSelect,
}: FilterSectionProps<FilterOptions>) {
return (
<div className="mx-2">
<Collapsible defaultOpen={true} label={label}>
@ -44,17 +63,25 @@ export default function FilterSection({
startAddOnType="icon"
/>
)}
<div className="mx-1">
{options.map((option) => (
<Checkbox
key={option.value}
{...option}
onChange={(checked) => {
onOptionChange(option.value, checked);
}}
/>
))}
</div>
{isSingleSelect ? (
<RadioGroup
radioData={options}
onChange={(value) => {
onOptionChange(value);
}}></RadioGroup>
) : (
<div className="mx-1">
{options.map((option) => (
<Checkbox
key={option.value}
{...option}
onChange={(checked) => {
onOptionChange(option.value, checked);
}}
/>
))}
</div>
)}
</div>
</Collapsible>
</div>

View File

@ -0,0 +1,36 @@
export type RadioProps = {
onChange: (value: string) => void;
radioData: Array<RadioData>;
};
export type RadioData = {
checked: boolean;
label: string;
value: string;
};
export default function RadioGroup({ radioData, onChange }: RadioProps) {
return (
<div className="mx-1 space-y-1">
{radioData.map((radio) => (
<div key={radio.value} className="flex items-center">
<input
checked={radio.checked}
className="text-primary-600 focus:ring-primary-500 h-4 w-4 border-gray-300"
type="radio"
value={radio.value}
onChange={(event) => {
const target = event.target as HTMLInputElement;
onChange(target.value);
}}
/>
<label
className="ml-3 min-w-0 flex-1 text-gray-700"
htmlFor={radio.value}>
{radio.label}
</label>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,116 @@
import { useRouter } from 'next/router';
import { useForm } from 'react-hook-form';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
import { Button, Select, TextArea } from '@tih/ui';
import FullAnswerCard from '~/components/questions/card/FullAnswerCard';
import CommentListItem from '~/components/questions/CommentListItem';
import {
SAMPLE_ANSWER,
SAMPLE_ANSWER_COMMENT,
SAMPLE_QUESTION,
} from '~/utils/questions/constants';
import { useFormRegister } from '~/utils/questions/useFormRegister';
export type AnswerCommentData = {
commentContent: string;
};
export default function QuestionPage() {
const router = useRouter();
const {
register: comRegister,
handleSubmit: handleCommentSubmit,
formState: { isDirty: isCommentDirty, isValid: isCommentValid },
} = useForm<AnswerCommentData>({ mode: 'onChange' });
const commentRegister = useFormRegister(comRegister);
const question = SAMPLE_QUESTION;
const comment = SAMPLE_ANSWER_COMMENT;
const handleBackNavigation = () => {
router.back();
};
const handleSubmitComment = (data: AnswerCommentData) => {
// eslint-disable-next-line no-console
console.log(data);
};
return (
<div className="flex w-full flex-1 items-stretch pb-4">
<div className="flex items-baseline gap-2 py-4 pl-4">
<Button
addonPosition="start"
display="inline"
icon={ArrowSmallLeftIcon}
label="Back"
variant="secondary"
onClick={handleBackNavigation}></Button>
</div>
<div className="flex w-full justify-center overflow-y-auto py-4 px-5">
<div className="flex max-w-7xl flex-1 flex-col gap-2">
<FullAnswerCard {...SAMPLE_ANSWER} />
<div className="mx-2">
<form
className="mb-2"
onSubmit={handleCommentSubmit(handleSubmitComment)}>
<TextArea
{...commentRegister('commentContent', {
minLength: 1,
required: true,
})}
label="Post a comment"
required={true}
resize="vertical"
rows={2}
/>
<div className="my-3 flex justify-between">
<div className="flex items-baseline gap-2">
<span aria-hidden={true} className="text-sm">
Sort by:
</span>
<Select
display="inline"
isLabelHidden={true}
label="Sort by"
options={[
{
label: 'Most recent',
value: 'most-recent',
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
value="most-recent"
onChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}></Select>
</div>
<Button
disabled={!isCommentDirty || !isCommentValid}
label="Post"
type="submit"
variant="primary"
/>
</div>
</form>
{Array.from({ length: question.commentCount }).map((_, index) => (
<CommentListItem
// eslint-disable-next-line react/no-array-index-key
key={index}
{...comment}
/>
))}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,190 @@
import { useRouter } from 'next/router';
import { useForm } from 'react-hook-form';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
import { Button, Collapsible, Select, TextArea } from '@tih/ui';
import AnswerCard from '~/components/questions/card/AnswerCard';
import FullQuestionCard from '~/components/questions/card/FullQuestionCard';
import CommentListItem from '~/components/questions/CommentListItem';
import {
SAMPLE_ANSWER,
SAMPLE_QUESTION,
SAMPLE_QUESTION_COMMENT,
} from '~/utils/questions/constants';
import { useFormRegister } from '~/utils/questions/useFormRegister';
export type AnswerQuestionData = {
answerContent: string;
};
export type QuestionCommentData = {
commentContent: string;
};
export default function QuestionPage() {
const router = useRouter();
const {
register: ansRegister,
handleSubmit,
formState: { isDirty, isValid },
} = useForm<AnswerQuestionData>({ mode: 'onChange' });
const answerRegister = useFormRegister(ansRegister);
const {
register: comRegister,
handleSubmit: handleCommentSubmit,
formState: { isDirty: isCommentDirty, isValid: isCommentValid },
} = useForm<QuestionCommentData>({ mode: 'onChange' });
const commentRegister = useFormRegister(comRegister);
const question = SAMPLE_QUESTION;
const comment = SAMPLE_QUESTION_COMMENT;
const handleBackNavigation = () => {
router.back();
};
const handleSubmitAnswer = (data: AnswerQuestionData) => {
// eslint-disable-next-line no-console
console.log(data);
};
const handleSubmitComment = (data: QuestionCommentData) => {
// eslint-disable-next-line no-console
console.log(data);
};
return (
<div className="flex w-full flex-1 items-stretch pb-4">
<div className="flex items-baseline gap-2 py-4 pl-4">
<Button
addonPosition="start"
display="inline"
icon={ArrowSmallLeftIcon}
label="Back"
variant="secondary"
onClick={handleBackNavigation}></Button>
</div>
<div className="flex w-full justify-center overflow-y-auto py-4 px-5">
<div className="flex max-w-7xl flex-1 flex-col gap-2">
<FullQuestionCard {...question} showVoteButtons={true} />
<div className="mx-2">
<Collapsible label={`${question.commentCount} comment(s)`}>
<form
className="mb-2"
onSubmit={handleCommentSubmit(handleSubmitComment)}>
<TextArea
{...commentRegister('commentContent', {
minLength: 1,
required: true,
})}
label="Post a comment"
required={true}
resize="vertical"
rows={2}
/>
<div className="my-3 flex justify-between">
<div className="flex items-baseline gap-2">
<span aria-hidden={true} className="text-sm">
Sort by:
</span>
<Select
display="inline"
isLabelHidden={true}
label="Sort by"
options={[
{
label: 'Most recent',
value: 'most-recent',
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
value="most-recent"
onChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}></Select>
</div>
<Button
disabled={!isCommentDirty || !isCommentValid}
label="Post"
type="submit"
variant="primary"
/>
</div>
</form>
{Array.from({ length: question.commentCount }).map((_, index) => (
<CommentListItem
// eslint-disable-next-line react/no-array-index-key
key={index}
{...comment}
/>
))}
</Collapsible>
</div>
<form onSubmit={handleSubmit(handleSubmitAnswer)}>
<TextArea
{...answerRegister('answerContent', {
minLength: 1,
required: true,
})}
label="Contribute your answer"
required={true}
resize="vertical"
rows={5}
/>
<div className="mt-3 mb-1 flex justify-between">
<div className="flex items-baseline justify-start gap-2">
<p>{question.answerCount} answers</p>
<div className="flex items-baseline gap-2">
<span aria-hidden={true} className="text-sm">
Sort by:
</span>
<Select
display="inline"
isLabelHidden={true}
label="Sort by"
options={[
{
label: 'Most recent',
value: 'most-recent',
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
value="most-recent"
onChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}></Select>
</div>
</div>
<Button
disabled={!isDirty || !isValid}
label="Contribute"
type="submit"
variant="primary"
/>
</div>
</form>
{Array.from({ length: question.answerCount }).map((_, index) => (
<AnswerCard
// eslint-disable-next-line react/no-array-index-key
key={index}
{...SAMPLE_ANSWER}
href={`${router.asPath}/answer/1/1`}
/>
))}
</div>
</div>
</div>
);
}

View File

@ -1,104 +1,122 @@
import { useMemo, useState } from 'react';
import { useRouter } from 'next/router';
import { useEffect, useMemo, useState } from 'react';
import QuestionOverviewCard from '~/components/questions/card/QuestionOverviewCard';
import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard';
import type { FilterOptions } from '~/components/questions/filter/FilterSection';
import FilterSection from '~/components/questions/filter/FilterSection';
import QuestionOverviewCard from '~/components/questions/QuestionOverviewCard';
import type { LandingQueryData } from '~/components/questions/LandingComponent';
import LandingComponent from '~/components/questions/LandingComponent';
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',
},
];
import {
COMPANIES,
LOCATIONS,
QUESTION_AGES,
QUESTION_TYPES,
SAMPLE_QUESTION,
} from '~/utils/questions/constants';
import {
useSearchFilter,
useSearchFilterSingle,
} from '~/utils/questions/useSearchFilter';
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 router = useRouter();
const [selectedCompanies, setSelectedCompanies, areCompaniesInitialized] =
useSearchFilter('companies');
const [
selectedQuestionTypes,
setSelectedQuestionTypes,
areQuestionTypesInitialized,
] = useSearchFilter('questionTypes');
const [
selectedQuestionAge,
setSelectedQuestionAge,
isQuestionAgeInitialized,
] = useSearchFilterSingle('questionAge', 'all');
const [selectedLocations, setSelectedLocations, areLocationsInitialized] =
useSearchFilter('locations');
const [hasLanded, setHasLanded] = useState(false);
const [loaded, setLoaded] = useState(false);
const companyFilterOptions = useMemo(() => {
return companies.map((company) => ({
return COMPANIES.map((company) => ({
...company,
checked: selectedCompanies.includes(company.value),
}));
}, [selectedCompanies]);
const questionTypeFilterOptions = useMemo(() => {
return questionTypes.map((questionType) => ({
return QUESTION_TYPES.map((questionType) => ({
...questionType,
checked: selectedQuestionTypes.includes(questionType.value),
}));
}, [selectedQuestionTypes]);
const questionAgeFilterOptions = useMemo(() => {
return questionAges.map((questionAge) => ({
return QUESTION_AGES.map((questionAge) => ({
...questionAge,
checked: selectedQuestionAges.includes(questionAge.value),
checked: selectedQuestionAge === questionAge.value,
}));
}, [selectedQuestionAges]);
}, [selectedQuestionAge]);
const locationFilterOptions = useMemo(() => {
return locations.map((location) => ({
return LOCATIONS.map((location) => ({
...location,
checked: selectedLocations.includes(location.value),
}));
}, [selectedLocations]);
return (
const handleLandingQuery = (data: LandingQueryData) => {
const { company, location, questionType } = data;
setSelectedCompanies([company]);
setSelectedLocations([location]);
setSelectedQuestionTypes([questionType]);
setHasLanded(true);
};
const areFiltersInitialized = useMemo(() => {
return (
areCompaniesInitialized &&
areQuestionTypesInitialized &&
isQuestionAgeInitialized &&
areLocationsInitialized
);
}, [
areCompaniesInitialized,
areQuestionTypesInitialized,
isQuestionAgeInitialized,
areLocationsInitialized,
]);
useEffect(() => {
if (areFiltersInitialized) {
const hasFilter =
router.query.companies ||
router.query.questionTypes ||
router.query.questionAge ||
router.query.locations;
if (hasFilter) {
setHasLanded(true);
}
// Console.log('landed', hasLanded);
setLoaded(true);
}
}, [areFiltersInitialized, hasLanded, router.query]);
if (!loaded) {
return null;
}
return !hasLanded ? (
<LandingComponent onLanded={handleLandingQuery}></LandingComponent>
) : (
<main className="flex flex-1 flex-col items-stretch overflow-y-auto">
<div className="flex pt-4">
<section className="w-[300px] border-r px-4">
<aside className="w-[300px] border-r px-4">
<h2 className="text-xl font-semibold">Filter by</h2>
<div className="divide-y divide-slate-200">
<FilterSection
@ -107,10 +125,12 @@ export default function QuestionsHomePage() {
searchPlaceholder="Add company filter"
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedCompanies((prev) => [...prev, optionValue]);
setSelectedCompanies([...selectedCompanies, optionValue]);
} else {
setSelectedCompanies((prev) =>
prev.filter((company) => company !== optionValue),
setSelectedCompanies(
selectedCompanies.filter(
(company) => company !== optionValue,
),
);
}
}}
@ -121,26 +141,26 @@ export default function QuestionsHomePage() {
showAll={true}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedQuestionTypes((prev) => [...prev, optionValue]);
setSelectedQuestionTypes([
...selectedQuestionTypes,
optionValue,
]);
} else {
setSelectedQuestionTypes((prev) =>
prev.filter((questionType) => questionType !== optionValue),
setSelectedQuestionTypes(
selectedQuestionTypes.filter(
(questionType) => questionType !== optionValue,
),
);
}
}}
/>
<FilterSection
isSingleSelect={true}
label="Question age"
options={questionAgeFilterOptions}
showAll={true}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedQuestionAges((prev) => [...prev, optionValue]);
} else {
setSelectedQuestionAges((prev) =>
prev.filter((questionAge) => questionAge !== optionValue),
);
}
onOptionChange={(optionValue) => {
setSelectedQuestionAge(optionValue);
}}
/>
<FilterSection
@ -149,25 +169,22 @@ export default function QuestionsHomePage() {
searchPlaceholder="Add location filter"
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedLocations((prev) => [...prev, optionValue]);
setSelectedLocations([...selectedLocations, optionValue]);
} else {
setSelectedLocations((prev) =>
prev.filter((location) => location !== optionValue),
setSelectedLocations(
selectedLocations.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);
}}
/>
</aside>
<section className="flex min-h-0 flex-1 flex-col items-center overflow-auto pt-4">
<div className="flex min-h-0 max-w-3xl flex-1">
<div className="flex flex-1 flex-col items-stretch justify-start gap-4 pb-4">
<ContributeQuestionCard />
<QuestionSearchBar
sortOptions={[
{
@ -180,19 +197,22 @@ export default function QuestionsHomePage() {
},
]}
sortValue="most-recent"
onSortChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}
/>
<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}
/>
{Array.from({ length: 10 }).map((_, index) => (
<QuestionOverviewCard
// eslint-disable-next-line react/no-array-index-key
key={index}
href="/questions/1/1"
{...SAMPLE_QUESTION}
/>
))}
</div>
</div>
</div>
</section>
</div>
</main>
);

View File

@ -0,0 +1,108 @@
import type { FilterChoices } from '~/components/questions/filter/FilterSection';
export const COMPANIES: FilterChoices = [
{
label: 'Google',
value: 'google',
},
{
label: 'Meta',
value: 'meta',
},
];
// Code, design, behavioral
export const QUESTION_TYPES: FilterChoices = [
{
label: 'Coding',
value: 'coding',
},
{
label: 'Design',
value: 'design',
},
{
label: 'Behavioral',
value: 'behavioral',
},
];
export const QUESTION_AGES: FilterChoices = [
{
label: 'Last month',
value: 'last-month',
},
{
label: 'Last 6 months',
value: 'last-6-months',
},
{
label: 'Last year',
value: 'last-year',
},
{
label: 'All',
value: 'all',
},
];
export const LOCATIONS: FilterChoices = [
{
label: 'Singapore',
value: 'singapore',
},
{
label: 'Menlo Park',
value: 'menlopark',
},
{
label: 'California',
value: 'california',
},
{
label: 'Hong Kong',
value: 'hongkong',
},
{
label: 'Taiwan',
value: 'taiwan',
},
];
export const SAMPLE_QUESTION = {
answerCount: 10,
commentCount: 10,
company: 'Google',
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 andiven 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 andiven 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 andiven 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 andiven 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 andiven 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 andiven 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 andiven 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',
receivedCount: 12,
role: 'Software Engineer',
timestamp: 'Last month',
upvoteCount: 5,
};
export const SAMPLE_ANSWER = {
authorImageUrl: 'https://avatars.githubusercontent.com/u/66356390?v=4',
authorName: 'Jeff Sieu',
commentCount: 10,
content: 'This is a sample answer',
createdAt: new Date(2014, 8, 1, 11, 30, 40),
upvoteCount: 10,
};
export const SAMPLE_QUESTION_COMMENT = {
authorImageUrl: 'https://avatars.githubusercontent.com/u/66356390?v=4',
authorName: 'Jeff Sieu',
content: 'This is a sample question comment',
createdAt: new Date(2014, 8, 1, 11, 30, 40),
upvoteCount: 10,
};
export const SAMPLE_ANSWER_COMMENT = {
authorImageUrl: 'https://avatars.githubusercontent.com/u/66356390?v=4',
authorName: 'Jeff Sieu',
content: 'This is an sample answer comment',
createdAt: new Date(2014, 8, 1, 11, 30, 40),
upvoteCount: 10,
};

View File

@ -0,0 +1,43 @@
import type { ChangeEvent } from 'react';
import { useCallback } from 'react';
import type { FieldValues, UseFormRegister } from 'react-hook-form';
export const useFormRegister = <TFieldValues extends FieldValues>(
register: UseFormRegister<TFieldValues>,
) => {
const formRegister = useCallback(
(...args: Parameters<typeof register>) => {
const { onChange, ...rest } = register(...args);
return {
...rest,
onChange: (value: string, event: ChangeEvent<unknown>) => {
onChange(event);
},
};
},
[register],
);
return formRegister;
};
export const useSelectRegister = <TFieldValues extends FieldValues>(
register: UseFormRegister<TFieldValues>,
) => {
const formRegister = useCallback(
(...args: Parameters<typeof register>) => {
const { onChange, ...rest } = register(...args);
return {
...rest,
onChange: (value: string) => {
onChange({
target: {
value,
},
});
},
};
},
[register],
);
return formRegister;
};

View File

@ -0,0 +1,69 @@
import { useRouter } from 'next/router';
import { useCallback, useEffect, useState } from 'react';
export const useSearchFilter = (
name: string,
defaultValues?: Array<string>,
) => {
const [isInitialized, setIsInitialized] = useState(false);
const router = useRouter();
const [filters, setFilters] = useState<Array<string>>(defaultValues || []);
useEffect(() => {
if (router.isReady && !isInitialized) {
// Initialize from query params
const query = router.query[name];
if (query) {
const queryValues = Array.isArray(query) ? query : [query];
setFilters(queryValues);
} else {
// Try to load from local storage
const localStorageValue = localStorage.getItem(name);
if (localStorageValue !== null) {
const loadedFilters = JSON.parse(localStorageValue);
setFilters(loadedFilters);
router.replace({
pathname: router.pathname,
query: {
...router.query,
[name]: loadedFilters,
},
});
}
}
setIsInitialized(true);
}
}, [isInitialized, name, router]);
const setFiltersCallback = useCallback(
(newFilters: Array<string>) => {
setFilters(newFilters);
localStorage.setItem(name, JSON.stringify(newFilters));
router.replace({
pathname: router.pathname,
query: {
...router.query,
[name]: newFilters,
},
});
},
[name, router],
);
return [filters, setFiltersCallback, isInitialized] as const;
};
export const useSearchFilterSingle = (name: string, defaultValue: string) => {
const [filters, setFilters, isInitialized] = useSearchFilter(name, [
defaultValue,
]);
return [
filters[0],
(value: string) => {
setFilters([value]);
},
isInitialized,
] as const;
};

View File

@ -0,0 +1,21 @@
const withHref = <Props extends Record<string, unknown>>(
Component: React.ComponentType<Props>,
) => {
return (
props: Props & {
href: string;
},
) => {
const { href, ...others } = props;
return (
<a
className="ring-primary-500 rounded-md focus:ring-2 focus-visible:outline-none active:bg-slate-100"
href={href}>
<Component {...(others as unknown as Props)} />
</a>
);
};
};
export default withHref;