Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FE-74 ✨검색 결과 기능 #73

Merged
merged 9 commits into from
Jul 26, 2024
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>;
Loading