Skip to content

Commit

Permalink
[FEAT] 커뮤니티 검색 기능 구현 (#186)
Browse files Browse the repository at this point in the history
  • Loading branch information
CodyMan0 committed Mar 6, 2024
1 parent 3bf9d9c commit 769fc8d
Show file tree
Hide file tree
Showing 13 changed files with 320 additions and 61 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"immer": "^10.0.3",
"lodash.clonedeep": "^4.5.0",
"lodash.compact": "^3.0.1",
"lodash.debounce": "^4.0.8",
"modern-screenshot": "^4.4.38",
"next": "14.1.0",
"react": "^18",
Expand Down Expand Up @@ -55,6 +56,7 @@
"@types/github-label-sync": "^2",
"@types/lodash.clonedeep": "^4",
"@types/lodash.compact": "^3",
"@types/lodash.debounce": "^4.0.9",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
Expand Down
182 changes: 129 additions & 53 deletions src/app/vote/_component/VoteContents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,81 +4,157 @@ import Link from 'next/link';
import { useSearchParams } from 'next/navigation';

import { Button } from '@/components/common/button';
import { Input } from '@/components/common/input';
import { Spinner } from '@/components/common/spinner';
import { VoteCard, VoteItem } from '@/components/features/vote';
import { EmptyVote } from '@/components/shared';
import { CATEGORY_TAB } from '@/constants/category';
import { useGetAllVotes } from '@/hooks/vote';
import { EmptyVote, EndObserverList } from '@/components/shared';
import { useGetAllVotes, useGetVoteBySearch } from '@/hooks/vote';

import { SearchInput, SearchResults } from './search';
import VoteHeader from './VoteHeader';
import VoteLayout from './VoteLayout';

const VoteContents = () => {
const searchParams = useSearchParams();
const tab = searchParams.get('tab') as string;
const findCategoryNameByParam = CATEGORY_TAB.find((category) => category.params === tab);
const { data: voteList, isLoading } = useGetAllVotes(
findCategoryNameByParam?.name ?? ('전체' as string),
);
const searchQueryStringValue = searchParams.get('q') ?? ('' as string);
const { status: allVoteStatus, data: voteList, isLoading } = useGetAllVotes();
const {
status: searchedVoteStatus,
hasNextPage,
fetchNextPage,
data: searchedVoteList,
isLoading: isSearchLoading,
} = useGetVoteBySearch({
keyword: searchQueryStringValue,
});

return (
<VoteLayout
header={<VoteHeader />}
contents={
<>
<div className="flex w-full flex-col">
<div className="w-full p-3xs">
<div className="py-4xs">
<Input
placeholder="무엇이 고민이신가요?"
icon="search"
iconSide="left"
borderRadius="large"
bgcolor="lightGray"
className="text-[14px] placeholder:text-gray-500"
/>
</div>
</div>
{/* TODO: Select*/}
<SearchInput />
<SearchResults searchQueryStringValue={searchQueryStringValue} />

<ul className="flex flex-col gap-3xs p-3xs">
<div className="flex flex-col gap-3xs p-3xs">
{/* TODO : Suspense로 선언적으로 리팩토링 */}
{isLoading && (
{(isLoading || isSearchLoading) && (
<div className="flex w-full items-center justify-center">
<Spinner />
</div>
)}
{voteList?.length === 0 && <EmptyVote />}
{voteList?.map(
({ id, category, closeDate, title, content, selections, likes, voters, views }) => {
return (
<VoteCard className="shadow-vote-card" key={id}>
<VoteCard.Header categories={category} closeDate={closeDate} />
<VoteCard.Description title={title} content={content} />
<VoteCard.VoteItemGroup withBlur>
<VoteItem readOnly>
<VoteItem.Radio disabled />
<VoteItem.Text>{selections[0].content}</VoteItem.Text>
</VoteItem>
<VoteItem readOnly>
<VoteItem.Radio disabled />
<VoteItem.Text>{selections[1].content}</VoteItem.Text>
</VoteItem>
</VoteCard.VoteItemGroup>
<VoteCard.SubmitButton>
<Link href={`/vote/${id}`}>
<Button variant="primary" width="full">
투표 참여하기
</Button>
</Link>
</VoteCard.SubmitButton>
<VoteCard.Footer likes={likes} views={views} voters={voters} />
</VoteCard>
);
},

{searchQueryStringValue.length > 0 ? (
<>
{searchedVoteStatus === 'success' && (
<>
{searchedVoteList.length > 0 ? (
<EndObserverList
className="flex flex-col gap-3xs"
onScrollEnd={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
>
{searchedVoteList.map(
({
id,
category,
closeDate,
title,
content,
selections,
likes,
voters,
views,
}) => {
return (
<VoteCard className="shadow-vote-card" key={id}>
<VoteCard.Header categories={category} closeDate={closeDate} />
<VoteCard.Description title={title} content={content} />
<VoteCard.VoteItemGroup withBlur>
<VoteItem readOnly>
<VoteItem.Radio disabled />
<VoteItem.Text>{selections[0].content}</VoteItem.Text>
</VoteItem>
<VoteItem readOnly>
<VoteItem.Radio disabled />
<VoteItem.Text>{selections[1].content}</VoteItem.Text>
</VoteItem>
</VoteCard.VoteItemGroup>
<VoteCard.SubmitButton>
<Link href={`/vote/${id}`}>
<Button variant="primary" width="full">
투표 참여하기
</Button>
</Link>
</VoteCard.SubmitButton>
<VoteCard.Footer likes={likes} views={views} voters={voters} />
</VoteCard>
);
},
)}
</EndObserverList>
) : (
<EmptyVote />
)}
</>
)}
</>
) : (
<>
{allVoteStatus === 'success' && (
<>
{voteList.length > 0 ? (
<>
{voteList.map(
({
id,
category,
closeDate,
title,
content,
selections,
likes,
voters,
views,
}) => {
return (
<VoteCard className="shadow-vote-card" key={id}>
<VoteCard.Header categories={category} closeDate={closeDate} />
<VoteCard.Description title={title} content={content} />
<VoteCard.VoteItemGroup withBlur>
<VoteItem readOnly>
<VoteItem.Radio disabled />
<VoteItem.Text>{selections[0].content}</VoteItem.Text>
</VoteItem>
<VoteItem readOnly>
<VoteItem.Radio disabled />
<VoteItem.Text>{selections[1].content}</VoteItem.Text>
</VoteItem>
</VoteCard.VoteItemGroup>
<VoteCard.SubmitButton>
<Link href={`/vote/${id}`}>
<Button variant="primary" width="full">
투표 참여하기
</Button>
</Link>
</VoteCard.SubmitButton>
<VoteCard.Footer likes={likes} views={views} voters={voters} />
</VoteCard>
);
},
)}
</>
) : (
<EmptyVote />
)}
</>
)}
</>
)}
</ul>
</div>
</div>
</>
}
Expand Down
47 changes: 47 additions & 0 deletions src/app/vote/_component/search/SearchInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use client';

import debounce from 'lodash.debounce';
import { useRouter } from 'next/navigation';
import { useMemo, useState } from 'react';

import { Input } from '@/components/common/input';

const SearchInput = () => {
const router = useRouter();
const [searchValueState, setSearchValueState] = useState('');

const onKeyUpHandler = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
router.push(`?q=${searchValueState}`);
}
};

const delayedSearchHandler = useMemo(
() => debounce((targetValue) => router.push(`?q=${targetValue}`), 500),
[router],
);

return (
<div className="w-full p-3xs">
<div className="py-4xs">
<Input
type="search"
placeholder="무엇이 고민이신가요?"
onChange={(e) => {
setSearchValueState(e.target.value);
delayedSearchHandler(e.target.value);
}}
onKeyUp={onKeyUpHandler}
icon="search"
borderRadius="large"
bgcolor="lightGray"
height="large"
value={searchValueState}
className=" placeholder:text-gray-500"
/>
</div>
</div>
);
};

export default SearchInput;
25 changes: 25 additions & 0 deletions src/app/vote/_component/search/SearchResults.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
'use client';

import { Typography } from '@/foundations/typography';
import { cn } from '@/lib/core';

import { searchResultVariants } from './variant';

const SearchResults = ({ searchQueryStringValue }: { searchQueryStringValue: string }) => {
const isSearchTyped = !!searchQueryStringValue;

return (
<div className="flex w-full justify-between p-6xs px-3xs">
<Typography type="title1" className={cn(searchResultVariants({ isSearchTyped }))}>
<span className="text-[#5382FF]">{searchQueryStringValue}</span> 검색 결과
</Typography>

<select defaultValue="최신순" className="text-gray-400">
<option value="최신순">최신순</option>
<option value="인기순">인기순</option>
</select>
</div>
);
};

export default SearchResults;
3 changes: 3 additions & 0 deletions src/app/vote/_component/search/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default as SearchInput } from './SearchInput';
export { default as SearchResults } from './SearchResults';

10 changes: 10 additions & 0 deletions src/app/vote/_component/search/variant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { cva } from "class-variance-authority";

export const searchResultVariants = cva('', {
variants: {
isSearchTyped: {
true: 'visible',
false: 'invisible',
},
},
});
12 changes: 10 additions & 2 deletions src/components/common/input/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
iconColor = 'gray-400',
iconSide = 'left',
onSubmit = () => {},
height,
...props
}: InputProps,
ref,
Expand All @@ -35,9 +36,16 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(

const handleFocus = useCallback(() => setIsFocused(true), []);
const handleBlur = useCallback(() => setIsFocused(false), []);

return (
<div className={inputContainerVariants({ isFocused, borderRadius, bgcolor, variant })}>
<div
className={inputContainerVariants({
isFocused,
borderRadius,
bgcolor,
variant,
height,
})}
>
{icon && iconSide === 'left' && <Icon icon={icon} size={20} color={iconColor} />}
<input
className={cn(inputVariants(), className)}
Expand Down
2 changes: 1 addition & 1 deletion src/components/common/input/variant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,5 @@ export const inputContainerVariants = cva('flex w-full items-center gap-5xs bord
});

export const inputVariants = cva(
' w-full bg-transparent placeholder:text-gray-300 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ',
'w-full bg-transparent text-[16px] placeholder:text-gray-900 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
);
12 changes: 12 additions & 0 deletions src/hooks/useDebounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useEffect, useState } from 'react';

export const useDebounce = (value: string, delay: number = 500) => {
const [debounced, setDebounced] = useState(value);

useEffect(() => {
const handler = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(handler);
}, [value, delay]);

return debounced;
}
3 changes: 2 additions & 1 deletion src/hooks/vote/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ export { default as useCreateVoteReplyMutation } from './useCreateVoteReplyMutat
export { default as useDeleteVoteReplyMutation } from './useDeleteVoteReplyMutation';
export { useGetAllVotes } from './useGetAllVotes';
export { useGetVoteById } from './useGetVoteById';
export { default as useGetVotePaginatedReplies } from './useGetVotePaginatedReplies';
export { useGetVoteBySearch } from './useGetVoteBySearch';
export { default as useGetVoteReplies } from './useGetVoteReplies';
export { default as useLikeVoteMutation } from './useLikeVoteMutation';
export { default as useUpdateVoteReplyMutation } from './useUpdateVoteReplyMutation';
export { default as useVotingMutation } from './useVotingMutation';

Loading

0 comments on commit 769fc8d

Please sign in to comment.