Skip to content

Commit

Permalink
FE-74 ✨검색 결과 기능 (#73)
Browse files Browse the repository at this point in the history
* FE-74 fix: 사용하지 않는 lodash 라이브러리 삭제

* FE-74 ✨feat: 테스트 용 api 코드 가져오기

* FE-74 ✨feat: 검색 결과 기능 구현

* FE-74 ✨fix: 중복된 key, href 제거 및 규칙 무시 주석 추가

* FE-74 ✨test: 테스트 데이터 추가

* FE-74 ✨feat: 검색어 하이라이팅 및 순서 기능 추가

* FE-74 ✨fix:  주석 수정 및 api 파일 삭제

* FE-74 ✨styles:  주석 추가

* FE-74 ✨fix: 멘토링  내용 주석으로  추가
  • Loading branch information
imsoohyeok authored Jul 26, 2024
1 parent c3b3536 commit 81d7b90
Show file tree
Hide file tree
Showing 8 changed files with 232 additions and 16 deletions.
1 change: 0 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
"axios": "^1.7.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lodash": "^4.17.21",
"lucide-react": "^0.407.0",
"next": "14.2.4",
"qs": "^6.12.2",
Expand All @@ -41,7 +40,6 @@
},
"devDependencies": {
"@tanstack/eslint-plugin-query": "^5.50.0",
"@types/lodash": "^4.17.7",
"@types/node": "^20.14.10",
"@types/qs": "^6.9.15",
"@types/react": "^18.3.3",
Expand Down
2 changes: 2 additions & 0 deletions src/components/search/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react';
import Image from 'next/image';
import SEARCH_ICON from '../../../public/md.svg';

// TODO react-hook-form 사용

interface SearchBarProps {
onSearch: (search: string) => void;
currentSearch: string;
Expand Down
90 changes: 80 additions & 10 deletions src/components/search/SearchResults.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,87 @@
import React from 'react';
import Link from 'next/link';
import { GetEpigramsResponseType } from '@/schema/epigrams';

function SearchResults() {
// TODO highlightedSections의 key 설정 부분에 더 나은 방법이 생각나면 변경

interface SearchResultsProps {
results: GetEpigramsResponseType | null;
query: string;
}

// 텍스트 하이라이팅 함수
function handleHighlightText(text: string, highlight: string) {
if (!highlight.trim()) {
return text;
}

// 검색어(highlight)기준으로 검색 결과를 배열로 나눔(g: 중복 O, i: 대소문자 구분 X)
const highlightedSections = text.split(new RegExp(`(${highlight})`, 'gi'));

// 검색어와 비교해서 같으면 하이라이팅, 다르면 그냥 반환
return (
<div className='flex flex-col py-4 px-6 lg:p-6 gap-2 lg:gap-[16px] border-b border-gray-100'>
<div className='flex flex-col gap-1 md:gap-2 lg:gap-6'>
<span className='text-black-600 font-iropkeBatang iropke-lg lg:iropke-xl'>오랫동안 꿈을 그리는 사람은 마침내 그 꿈을 닮아 간다.</span>
<span className='text-blue-400 font-iropkeBatang iropke-lg lg:iropke-xl'>- 앙드레 말로 -</span>
</div>
<div className='flex flex-row justify-end gap-3'>
<span className='text-blue-400 font-pretendard iropke-lg lg:iropke-xl'>#동기부여</span>
<span className='text-blue-400 font-pretendard iropke-lg lg:iropke-xl'>#우울할때</span>
<span className='text-blue-400 font-pretendard iropke-lg lg:iropke-xl'>#나아가야할때</span>
<>
{highlightedSections.map((section, index) => {
const key = `${section}-${index}-${section.length}`;
return section.toLowerCase() === highlight.toLowerCase() ? (
<span key={key} className='text-illust-blue'>
{section}
</span>
) : (
section
);
})}
</>
);
}

function SearchResults({ results, query }: SearchResultsProps) {
if (!results) {
return <span>검색 결과를 불러오는 중 문제가 발생했습니다.</span>;
}

// 태그와 내용 순서로 정렬
const sortedResults = results.list.sort((a, b) => {
const aHasTag = a.tags.some((tag) => tag.name.includes(query));
const bHasTag = b.tags.some((tag) => tag.name.includes(query));

if (aHasTag && !bHasTag) return -1;
if (!aHasTag && bHasTag) return 1;
return 0;
});

// TODO useMemo 사용하는게 나을 것 같음(멘토님)
const filteredResults = sortedResults.filter((item) => item.content.includes(query) || item.author.includes(query) || item.tags.some((tag) => tag.name.includes(query)));

if (filteredResults.length === 0) {
return (
<div className='flex flex-col py-4 px-6 lg:p-6 gap-2 lg:gap-[16px]'>
<div className='flex flex-col gap-1 md:gap-2 lg:gap-6'>
<span className='text-black-600 font-iropkeBatang iropke-lg lg:iropke-xl'>해당 검색어에 대한 결과가 없습니다.</span>
</div>
</div>
);
}

return (
<div>
{filteredResults.map((item) => (
<Link href={`/epigrams/${item.id}`} key={item.id}>
<div className='flex flex-col py-4 px-6 lg:p-6 gap-2 lg:gap-[16px] border-b border-gray-100'>
<div className='flex flex-col gap-1 md:gap-2 lg:gap-6'>
<span className='text-black-600 font-iropkeBatang iropke-lg lg:iropke-xl'>{handleHighlightText(item.content, query)}</span>
<span className='text-blue-400 font-iropkeBatang iropke-lg lg:iropke-xl'>- {handleHighlightText(item.author, query)} -</span>
</div>
<div className='flex flex-row justify-end gap-3'>
{item.tags.map((tag) => (
<span key={tag.id} className='text-blue-400 font-pretendard iropke-lg lg:iropke-xl'>
{handleHighlightText(`#${tag.name}`, query)}
</span>
))}
</div>
</div>
</Link>
))}
</div>
);
}
Expand Down
71 changes: 71 additions & 0 deletions src/components/search/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
const testData = {
totalCount: 5,
nextCursor: 5,
list: [
{
id: 1,
likeCount: 10,
tags: [
{ name: '동기부여', id: 101 },
{ name: '우울할때', id: 102 },
{ name: '나아가야할때', id: 103 },
],
writerId: 1001,
referenceUrl: 'https://example.com/epigram/1',
referenceTitle: 'The Power of Dreams',
author: '앙드레 말로',
content: '오랫동안 꿈을 그리는 사람은 마침내 그 꿈을 닮아 간다.',
},
{
id: 2,
likeCount: 20,
tags: [{ name: '새로운영감', id: 201 }],
writerId: 1002,
referenceUrl: 'https://example.com/epigram/2',
referenceTitle: 'Life Lessons',
author: '파우울로 코엘료 테스트',
content: '이 세상에는 위대한 진실이 하나 있어. 무언가를 온 마음을 다해 원한다면, 반드시 그렇게 된다는거야.',
},
{
id: 3,
likeCount: 15,
tags: [
{ name: '짧은명언', id: 301 },
{ name: '우울증', id: 302 },
],
writerId: 1003,
referenceUrl: 'https://example.com/epigram/3',
referenceTitle: 'Path to Success',
author: '클라우스 랑에',
content: '우울증이란 우리를 내적인 나락으로 이끄는 유혹의 손길이다. 테스트',
},
{
id: 4,
likeCount: 5,
tags: [
{ name: 'motivation', id: 401 },
{ name: 'challenge', id: 402 },
],
writerId: 1004,
referenceUrl: 'https://example.com/epigram/4',
referenceTitle: 'Overcoming Challenges',
author: '테스트터티',
content: '우울한 기분은 신나는 기분을 더욱 더 신나게 만들어준다.',
},
{
id: 5,
likeCount: 8,
tags: [
{ name: '테스트', id: 501 },
{ name: 'discipline', id: 502 },
],
writerId: 1005,
referenceUrl: 'https://example.com/epigram/5',
referenceTitle: 'Staying Focused',
author: 'David Wilson',
content: '그렇게 우울은 흘러가고 새로운 기분이 우릴 맞이할 것이다.',
},
],
};

export default testData;
8 changes: 5 additions & 3 deletions src/pageLayout/SearchLayout/SearchLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@ import React, { useState, useEffect } from 'react';
import SearchBar from '@/components/search/SearchBar';
import RecentSearches from '@/components/search/RecentSearches';
import SearchResults from '@/components/search/SearchResults';
import testData from '@/components/search/test';

// TODO 로그인한 사용자에 따라서 최근 검색어를 관리할 수 있도록 추후에 수정
// TODO api, SearchResults 컴포넌트와 연결
// TODO 실제 api와 연동 후 테스트 코드 삭제
// TODO 검색 결과를 URL 에 저장, 새로고침시 데이터 분실에 대응

function SearchLayout() {
const [searches, setSearches] = useState<string[]>([]);
const [currentSearch, setCurrentSearch] = useState<string>('');

// 검색어가 제출될 때 작동
const handleSearch = (search: string) => {
const handleSearch = async (search: string) => {
setSearches((prevSearches) => {
// 중복되지 않는 검색어를 최근 검색어에 추가하고 최대 10개로 제한
const updatedSearches = [search, ...prevSearches.filter((item) => item !== search)].slice(0, 10);
Expand Down Expand Up @@ -39,7 +41,7 @@ function SearchLayout() {
<div className='container mx-auto max-w-screen-sm bg-blue-100'>
<SearchBar onSearch={handleSearch} currentSearch={currentSearch} />
<RecentSearches searches={searches} onSearch={handleSearch} onClear={handleClearAll} />
<SearchResults />
{currentSearch && <SearchResults results={testData} query={currentSearch} />}
</div>
</>
);
Expand Down
41 changes: 41 additions & 0 deletions src/pages/epigram/[id]/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// pages/epigrams/[id].tsx
import React from 'react';
import { useRouter } from 'next/router';
import testData from '@/components/search/test';

function EpigramDetail() {
const router = useRouter();
const { id } = router.query;

// `id`가 숫자인 경우를 대비하여 변환
const epigramId = parseInt(id as string, 10);
const epigram = testData.list.find((e) => e.id === epigramId);

if (!epigram) {
return <div>해당 에피그램을 찾을 수 없습니다.</div>;
}

return (
<div style={{ padding: '20px' }}>
<h1>에피그램 상세 페이지</h1>
<h2>{epigram.content}</h2>
<p>작성자: {epigram.author}</p>
<p>
참조 제목:{' '}
<a href={epigram.referenceUrl} target='_blank' rel='noopener noreferrer'>
{epigram.referenceTitle}
</a>
</p>
<div>
<strong>태그: </strong>
{epigram.tags.map((tag) => (
<span key={tag.id} style={{ marginRight: '5px' }}>
#{tag.name}
</span>
))}
</div>
</div>
);
}

export default EpigramDetail;
33 changes: 33 additions & 0 deletions src/schema/epigrams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import * as z from 'zod';

export const GetEpigramsParams = z.object({
limit: z.number(),
cursor: z.number().optional(),
keyword: z.string().optional(),
writerId: z.number().optional(),
});

export const GetEpigramsResponse = z.object({
totalCount: z.number(),
nextCursor: z.number(),
list: z.array(
z.object({
likeCount: z.number(),
tags: z.array(
z.object({
name: z.string(),
id: z.number(),
}),
),
writerId: z.number(),
referenceUrl: z.string(),
referenceTitle: z.string(),
author: z.string(),
content: z.string(),
id: z.number(),
}),
),
});

export type GetEpigramsParamsType = z.infer<typeof GetEpigramsParams>;
export type GetEpigramsResponseType = z.infer<typeof GetEpigramsResponse>;

0 comments on commit 81d7b90

Please sign in to comment.