mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-29 13:13:54 +08:00
[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:
@ -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
|
||||
|
38
apps/portal/src/components/questions/CommentListItem.tsx
Normal file
38
apps/portal/src/components/questions/CommentListItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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 (
|
||||
<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
|
||||
{...(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
|
||||
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>
|
||||
</button>
|
||||
<ContributeQuestionDialog
|
||||
show={showDraftDialog}
|
||||
onCancel={handleDraftDialogCancel}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ContributeQuestionModal
|
||||
contributeState={isOpen}
|
||||
setContributeState={setOpen}></ContributeQuestionModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
157
apps/portal/src/components/questions/ContributeQuestionForm.tsx
Normal file
157
apps/portal/src/components/questions/ContributeQuestionForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
30
apps/portal/src/components/questions/DiscardDraftDialog.tsx
Normal file
30
apps/portal/src/components/questions/DiscardDraftDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
107
apps/portal/src/components/questions/LandingComponent.tsx
Normal file
107
apps/portal/src/components/questions/LandingComponent.tsx
Normal 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&repo=tech-interview-handbook&type=star&count=true&size=large"
|
||||
title="GitHub Stars"
|
||||
width={160}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="py-20 text-center text-white ">
|
||||
TODO questions Carousel
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
|
@ -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',
|
||||
};
|
||||
|
||||
|
33
apps/portal/src/components/questions/VotingButtons.tsx
Normal file
33
apps/portal/src/components/questions/VotingButtons.tsx
Normal 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>
|
||||
);
|
||||
}
|
48
apps/portal/src/components/questions/card/AnswerCard.tsx
Normal file
48
apps/portal/src/components/questions/card/AnswerCard.tsx
Normal 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;
|
38
apps/portal/src/components/questions/card/FullAnswerCard.tsx
Normal file
38
apps/portal/src/components/questions/card/FullAnswerCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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
|
||||
// );
|
||||
}
|
110
apps/portal/src/components/questions/card/QuestionCard.tsx
Normal file
110
apps/portal/src/components/questions/card/QuestionCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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;
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -2,18 +2,34 @@ 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 = {
|
||||
export type FilterChoices = Array<Omit<FilterOption, 'checked'>>;
|
||||
|
||||
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;
|
||||
onOptionChange: (optionValue: string, checked: boolean) => void;
|
||||
options: Array<FilterOptions>;
|
||||
} & (
|
||||
options: FilterOptions;
|
||||
} & (
|
||||
| {
|
||||
searchPlaceholder: string;
|
||||
showAll?: never;
|
||||
@ -22,15 +38,18 @@ export type FilterSectionProps = {
|
||||
searchPlaceholder?: never;
|
||||
showAll: true;
|
||||
}
|
||||
);
|
||||
);
|
||||
|
||||
export default function FilterSection({
|
||||
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,6 +63,13 @@ export default function FilterSection({
|
||||
startAddOnType="icon"
|
||||
/>
|
||||
)}
|
||||
{isSingleSelect ? (
|
||||
<RadioGroup
|
||||
radioData={options}
|
||||
onChange={(value) => {
|
||||
onOptionChange(value);
|
||||
}}></RadioGroup>
|
||||
) : (
|
||||
<div className="mx-1">
|
||||
{options.map((option) => (
|
||||
<Checkbox
|
||||
@ -55,6 +81,7 @@ export default function FilterSection({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
36
apps/portal/src/components/questions/ui-patch/RadioGroup.tsx
Normal file
36
apps/portal/src/components/questions/ui-patch/RadioGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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]);
|
||||
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
{Array.from({ length: 10 }).map((_, index) => (
|
||||
<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}
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
href="/questions/1/1"
|
||||
{...SAMPLE_QUESTION}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
108
apps/portal/src/utils/questions/constants.ts
Normal file
108
apps/portal/src/utils/questions/constants.ts
Normal 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,
|
||||
};
|
43
apps/portal/src/utils/questions/useFormRegister.ts
Normal file
43
apps/portal/src/utils/questions/useFormRegister.ts
Normal 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;
|
||||
};
|
69
apps/portal/src/utils/questions/useSearchFilter.ts
Normal file
69
apps/portal/src/utils/questions/useSearchFilter.ts
Normal 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;
|
||||
};
|
21
apps/portal/src/utils/questions/withHref.tsx
Normal file
21
apps/portal/src/utils/questions/withHref.tsx
Normal 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;
|
Reference in New Issue
Block a user