diff --git a/src/features/document/components/document-type-icon.tsx b/src/features/document/components/document-type-icon.tsx index bb865344..25caf170 100644 --- a/src/features/document/components/document-type-icon.tsx +++ b/src/features/document/components/document-type-icon.tsx @@ -47,6 +47,18 @@ const DocumentTypeIcon = ({ type, containerClassName, iconClassName }: Props) => ) } + + // default + return ( +
+ +
+ ) } export default DocumentTypeIcon diff --git a/src/features/search/components/header-in-document.tsx b/src/features/search/components/header-in-document.tsx index 4fb3f922..19f3753b 100644 --- a/src/features/search/components/header-in-document.tsx +++ b/src/features/search/components/header-in-document.tsx @@ -1,34 +1,54 @@ +'use client' + import Icon from '@/shared/components/custom/icon' import { Input } from '@/shared/components/ui/input' import { useRouter } from 'next/navigation' -import { RefObject, useState } from 'react' +import { ChangeEventHandler, RefObject } from 'react' interface Props { - searchHeaderRef: RefObject + inputValue: string + onChangeInputValue: ChangeEventHandler + searchInputRef: RefObject isSearchFocused: boolean setIsSearchFocused: (value: boolean) => void + onDeleteKeyword: () => void + onSubmit: (e: React.FormEvent) => void } -const HeaderInDocument = ({ searchHeaderRef, isSearchFocused, setIsSearchFocused }: Props) => { +const HeaderInDocument = ({ + inputValue, + onChangeInputValue, + searchInputRef, + isSearchFocused, + setIsSearchFocused, + onDeleteKeyword, + onSubmit, +}: Props) => { const router = useRouter() - const [keyword, setKeyword] = useState('') + + const handleCancel = () => { + if (isSearchFocused) { + setIsSearchFocused(false) + return + } else { + router.push('/document') + } + } return ( -
-
+
+
setKeyword(e.target.value)} + ref={searchInputRef} onFocus={() => setIsSearchFocused(true)} + value={inputValue} + onChange={onChangeInputValue} placeholder="노트명, 노트, 퀴즈 검색" className="h-[40px] placeholder:text-text-placeholder-01" variant={'round'} left={} right={ -
-
diff --git a/src/features/search/components/recent-searches.tsx b/src/features/search/components/recent-searches.tsx index 3927bd94..85b7b3fd 100644 --- a/src/features/search/components/recent-searches.tsx +++ b/src/features/search/components/recent-searches.tsx @@ -1,21 +1,66 @@ +'use client' + import Icon from '@/shared/components/custom/icon' import Text from '@/shared/components/ui/text' +import { getLocalStorage, removeLocalStorage, setLocalStorage } from '@/shared/utils/storage' +import { RefObject, useEffect, useState } from 'react' +import { RECENT_SEARCHES } from '../config' + +interface Props { + containerRef: RefObject + onUpdateKeyword: (keyword: string) => void +} + +const RecentSearches = ({ containerRef, onUpdateKeyword }: Props) => { + const [recentSearches, setRecentSearches] = useState([]) + + useEffect(() => { + const storageSearches = getLocalStorage(RECENT_SEARCHES) ?? [] + setRecentSearches(storageSearches) + }, []) + + /** 로컬스토리지에서 특정 검색어 삭제 */ + const deleteRecentSearch = (keyword: string) => { + const newRecentSearches = recentSearches.filter((search) => search !== keyword) + setLocalStorage(RECENT_SEARCHES, newRecentSearches) + setRecentSearches(newRecentSearches) + } + + /** 전체 검색어 삭제 */ + const deleteAllRecentSearches = () => { + removeLocalStorage(RECENT_SEARCHES) + setRecentSearches([]) + } -const RecentSearches = () => { return ( -
-
+
+
최근 검색어 - +
-
- {Array.from({ length: 5 }).map((_, idx) => ( -
- 최근 검색어 {idx} -
diff --git a/src/features/search/components/search-item/index.tsx b/src/features/search/components/search-item/index.tsx index bf7e6594..1db9a393 100644 --- a/src/features/search/components/search-item/index.tsx +++ b/src/features/search/components/search-item/index.tsx @@ -3,17 +3,20 @@ import Tag from '@/shared/components/ui/tag' import Text from '@/shared/components/ui/text' import { cn } from '@/shared/lib/utils' import DocumentTypeIcon from '@/features/document/components/document-type-icon' +import Link from 'next/link' interface Props { + documentId: number | undefined // api 수정되면 undefined 제거 createType: Document.ItemInList['documentType'] - documentTitle: string - matchingSentence: string + documentTitle: React.ReactNode + matchingSentence: React.ReactNode resultType: 'document' | 'quiz' relativeDirectory: string lastItem?: boolean } const SearchItem = ({ + documentId, createType, documentTitle, matchingSentence, @@ -22,7 +25,8 @@ const SearchItem = ({ lastItem, }: Props) => { return ( -
{documentTitle}
- {/* todo: 키워드와 일치하는 부분 색상 accent표시 하는 로직 필요 */} {matchingSentence}
@@ -50,7 +53,7 @@ const SearchItem = ({ {relativeDirectory}
-
+ ) } diff --git a/src/features/search/components/search-list.tsx b/src/features/search/components/search-list.tsx index 4d7bfd31..1940a41f 100644 --- a/src/features/search/components/search-list.tsx +++ b/src/features/search/components/search-list.tsx @@ -3,7 +3,7 @@ import { PropsWithChildren } from 'react' const SearchList = ({ length, children }: PropsWithChildren & { length: number }) => { return ( -
+
퀴즈 노트 {length} diff --git a/src/features/search/config/index.tsx b/src/features/search/config/index.tsx new file mode 100644 index 00000000..fe751f86 --- /dev/null +++ b/src/features/search/config/index.tsx @@ -0,0 +1 @@ +export const RECENT_SEARCHES = 'recentSearches' diff --git a/src/features/search/screen/search-in-document.tsx b/src/features/search/screen/search-in-document.tsx index 2e50fce6..02d3cc7c 100644 --- a/src/features/search/screen/search-in-document.tsx +++ b/src/features/search/screen/search-in-document.tsx @@ -1,20 +1,43 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { ChangeEvent, useEffect, useRef, useState } from 'react' import SearchList from '../components/search-list' import SearchItem from '../components/search-item' import RecentSearches from '../components/recent-searches' import HeaderInDocument from '../components/header-in-document' +import { highlightAndTrimText, MarkdownProcessor } from '../utils' +import Text from '@/shared/components/ui/text' +import { useRouter, useSearchParams } from 'next/navigation' +import { useQuery } from '@tanstack/react-query' +import { queries } from '@/shared/lib/tanstack-query/query-keys' +import Loading from '@/shared/components/custom/loading' +import { RECENT_SEARCHES } from '../config' +import { getLocalStorage, setLocalStorage } from '@/shared/utils/storage' +import usePreviousPath from '@/shared/hooks/use-previous-path' +// 퀴즈노트 탭 내 검색창 화면 const SearchInDocument = () => { + usePreviousPath() + + const router = useRouter() + const searchParams = useSearchParams() + const initialKeyword = searchParams.get('keyword') || '' + + const [keyword, setKeyword] = useState(initialKeyword) const [isSearchFocused, setIsSearchFocused] = useState(false) - const searchHeaderRef = useRef(null) + + const searchInputRef = useRef(null) const searchContainerRef = useRef(null) + const { data, isPending } = useQuery(queries.document.search({ keyword: initialKeyword })) + const searchResults = [...(data?.documents ?? []), ...(data?.quizzes ?? [])] as Partial< + Document.SearchedDocument & Document.SearchedQuiz + >[] + useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if ( - !searchHeaderRef.current?.contains(e.target as Node) && + !searchInputRef.current?.contains(e.target as Node) && !searchContainerRef.current?.contains(e.target as Node) ) { setIsSearchFocused(false) @@ -27,44 +50,114 @@ const SearchInDocument = () => { } }, []) + // 검색 후 반영 + useEffect(() => { + if (!initialKeyword) return + + const storageSearches = getLocalStorage(RECENT_SEARCHES) ?? [] + const newSearches = [ + initialKeyword, + ...storageSearches.filter((search) => search !== initialKeyword), + ].slice(0, 5) + setLocalStorage(RECENT_SEARCHES, newSearches) + }, [initialKeyword]) + + /** 최근 검색어 리스트에서 특정 검색어 클릭 시 검색창에 키워드가 반영되도록하는 함수 */ + const handleUpdateKeyword = (selectedKeyword: string) => { + setKeyword(selectedKeyword) + searchInputRef.current?.focus() + } + + /** 검색창에 입력되어있는 키워드를 삭제하는 함수 */ + const handleDeleteKeyword = () => { + setKeyword('') + router.push('/document/search') + } + + // 검색 + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + + if (!keyword.trim()) { + alert('검색어를 입력해주세요.') + return + } + + router.push(`?keyword=${keyword}`) + searchInputRef.current?.blur() + setIsSearchFocused(false) + } + return ( - <> +
) => setKeyword(e.target.value)} + onDeleteKeyword={handleDeleteKeyword} + onSubmit={handleSubmit} + searchInputRef={searchInputRef} isSearchFocused={isSearchFocused} setIsSearchFocused={setIsSearchFocused} /> -
+
{/* input 클릭 시 나타날 최근 검색어 */} - {isSearchFocused && } - - {/* 검색 결과 O : 검색 결과 리스트 */} - {!isSearchFocused && ( - - {Array.from({ length: 5 }).map((_, idx) => ( - - ))} - + {isSearchFocused && ( + )} - {/* 검색 결과 X : 검색 결과 리스트 */} - {/*
- 검색결과가 없습니다 - - 다른 키워드를 입력해보세요 - -
*/} + {isPending && } + + {!isSearchFocused && + // 검색 결과 X + (!data || searchResults.length === 0 ? ( +
+ 검색결과가 없습니다 + + 다른 키워드를 입력해보세요 + +
+ ) : ( + // 검색 결과 O : 검색 결과 리스트 + data && + searchResults.length > 0 && ( + + {searchResults.map((searchItem, idx) => ( + + ) : ( + highlightAndTrimText( + searchItem.question ?? 'Q...' + searchItem.answer ?? 'A...', + initialKeyword ?? '' + ) + ) + } + resultType={searchItem.question ? 'quiz' : 'document'} + relativeDirectory={ + searchItem.directory + ? searchItem.directory.name + : searchItem.directoryName ?? '' + } + lastItem={idx === searchResults.length - 1} + /> + ))} + + ) + ))}
- +
) } diff --git a/src/features/search/utils/index.tsx b/src/features/search/utils/index.tsx new file mode 100644 index 00000000..cb50dfbf --- /dev/null +++ b/src/features/search/utils/index.tsx @@ -0,0 +1,83 @@ +'use client' + +import Text from '@/shared/components/ui/text' +import React from 'react' +import { marked } from 'marked' + +/** + * 텍스트에서 키워드를 강조하는 함수 + */ +export function highlightAndTrimText(text: string, keyword: string): JSX.Element | string { + if (!text) return text + + const totalLength = 70 + const regex = new RegExp(`(${keyword})`, 'gi') + const match = text.match(regex) + + if (!keyword || !match) { + // 키워드가 없으면 텍스트를 70자로 자르기 + const trimmedText = text.length > totalLength ? text.slice(0, totalLength) + '...' : text + return trimmedText + } + + // 첫 번째 매칭된 키워드의 위치 + const keywordIndex = text.toLowerCase().indexOf(keyword.toLowerCase()) + const keywordLength = keyword.length + + // 키워드 기준 앞뒤로 잘라낼 텍스트 길이 계산 + const surroundingLength = Math.floor((totalLength - keywordLength) / 2) + + const start = Math.max(0, keywordIndex - surroundingLength) + const end = Math.min(text.length, keywordIndex + keywordLength + surroundingLength) + + const trimmedText = text.slice(start, end) + + // 앞뒤가 잘린 경우 생략 표시 추가 + const prefix = start > 0 ? '...' : '' + const suffix = end < text.length ? '...' : '' + + // 텍스트 분리 및 강조 처리 + const parts = trimmedText.split(regex) + + return ( + <> + {prefix} + {parts.map((part, index) => + regex.test(part) ? ( + + {part} + + ) : ( + part + ) + )} + {suffix} + + ) +} + +export function extractPlainText(markdownText: string): string { + // 마크다운 -> HTML 변환 + const html = marked(markdownText, { headerIds: false, mangle: false }) + + // HTML -> 텍스트 추출 + const div = document.createElement('div') + div.innerHTML = html + return div.textContent || '' +} + +/** 마크다운 텍스트를 받아 + * 문법을 제거하고 키워드에 강조를 해서 반환하는 함수 + */ +export const MarkdownProcessor = ({ + markdownText, + keyword, +}: { + markdownText: string + keyword: string +}) => { + const plainText = extractPlainText(markdownText) + const highlightedText = highlightAndTrimText(plainText, keyword) + + return
{highlightedText}
+} diff --git a/src/requests/document/client.tsx b/src/requests/document/client.tsx index c74fe7df..bc0d8ddd 100644 --- a/src/requests/document/client.tsx +++ b/src/requests/document/client.tsx @@ -71,3 +71,19 @@ export const deleteDocument = async (requestBody: Document.Request.DeleteDocumen throw error } } + +export const searchDocument = async (requestBody: Document.Request.SearchDocuments) => { + if (!requestBody.keyword || requestBody.keyword === '') return null + + try { + const { data } = await http.post( + API_ENDPOINTS.DOCUMENT.POST.SEARCH, + requestBody + ) + + return data + } catch (error) { + console.error(error) + throw error + } +} diff --git a/src/requests/document/hooks.ts b/src/requests/document/hooks.ts index 9aed32c6..e267a12b 100644 --- a/src/requests/document/hooks.ts +++ b/src/requests/document/hooks.ts @@ -3,7 +3,7 @@ import { useMutation } from '@tanstack/react-query' import { useSession } from 'next-auth/react' import { createDocument } from './create-document' -import { deleteDocument, getDocumentDetail, moveDocument } from './client' +import { deleteDocument, getDocumentDetail, moveDocument, searchDocument } from './client' import { queries } from '@/shared/lib/tanstack-query/query-keys' import { getQueryClient } from '@/shared/lib/tanstack-query/client' import { updateDocument } from './update-document' @@ -82,3 +82,13 @@ export const useDeleteDocument = (listOption?: { }, }) } + +/** + * 문서 검색 Hook + */ +export const useSearchDocument = () => { + return useMutation({ + mutationFn: async (requestBody: Document.Request.SearchDocuments) => + searchDocument(requestBody), + }) +} diff --git a/src/shared/hooks/use-previous-path.tsx b/src/shared/hooks/use-previous-path.tsx index 6c40663c..a0d6de2f 100644 --- a/src/shared/hooks/use-previous-path.tsx +++ b/src/shared/hooks/use-previous-path.tsx @@ -1,5 +1,5 @@ import { useEffect, useCallback } from 'react' -import { usePathname } from 'next/navigation' +import { usePathname, useSearchParams } from 'next/navigation' const PREVIOUS_PATH_KEY = 'previousPath' @@ -10,6 +10,7 @@ interface UsePreviousPath { const usePreviousPath = ({ getCustomPath } = { getCustomPath: false }): UsePreviousPath => { const pathname = usePathname() + const searchParams = useSearchParams() // 이전 경로 가져오기 const getPreviousPath = useCallback((): string | null => { @@ -25,12 +26,15 @@ const usePreviousPath = ({ getCustomPath } = { getCustomPath: false }): UsePrevi useEffect(() => { const setPreviousPath = () => { if (pathname && !getCustomPath) { - sessionStorage.setItem(PREVIOUS_PATH_KEY, pathname) + const fullPath = searchParams?.toString() + ? `${pathname}?${searchParams.toString()}` + : pathname + sessionStorage.setItem(PREVIOUS_PATH_KEY, fullPath) } } return () => setPreviousPath() - }, [pathname, getCustomPath]) + }, [pathname, getCustomPath, searchParams]) return { getPreviousPath, setPreviousPath } } diff --git a/src/shared/lib/tanstack-query/query-keys.ts b/src/shared/lib/tanstack-query/query-keys.ts index b850e00d..5cc97adb 100644 --- a/src/shared/lib/tanstack-query/query-keys.ts +++ b/src/shared/lib/tanstack-query/query-keys.ts @@ -24,6 +24,12 @@ export const queries = createQueryKeyStore({ queryFn: () => REQUEST.document.getDocumentDetail(documentId), enabled: !!documentId, }), + search: (requestBody: Document.Request.SearchDocuments) => ({ + queryKey: [requestBody], + queryFn: () => REQUEST.document.searchDocument(requestBody), + enabled: requestBody.keyword.trim() !== '', + initialData: { documents: [], quizzes: [] }, + }), }, quiz: { diff --git a/src/types/document.d.ts b/src/types/document.d.ts index 9cdf6763..4f433fef 100644 --- a/src/types/document.d.ts +++ b/src/types/document.d.ts @@ -12,10 +12,16 @@ declare global { type Sort = 'CREATED_AT' | 'UPDATED_AT' type Status = DeepRequired - type Type = DeepRequired + type Type = Exclude< + DeepRequired, + undefined + > type SearchedDocument = DeepRequired + type SearchedDocument = Document.Response.SearchDocuments['documents'][number] + type SearchedQuiz = Document.Response.SearchDocuments['quizzes'][number] + declare namespace Request { /** PATCH /api/v2/documents/{document_id}/update-name * 문서 이름 변경 diff --git a/src/types/schema.d.ts b/src/types/schema.d.ts index e960f72c..30a3fda5 100644 --- a/src/types/schema.d.ts +++ b/src/types/schema.d.ts @@ -893,6 +893,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v2/members/reward": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 초대 링크 보상 확인? */ + get: operations["getInviteLinkMember"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v2/members/info": { parameters: { query?: never; @@ -1423,7 +1440,11 @@ export interface components { id?: number; question?: string; answer?: string; + /** Format: int64 */ + documentId?: number; documentName?: string; + /** @enum {string} */ + documentType?: "FILE" | "TEXT" | "NOTION"; directoryName?: string; }; IntegratedSearchResponse: { @@ -1479,7 +1500,11 @@ export interface components { id?: number; question?: string; answer?: string; + /** Format: int64 */ + documentId?: number; documentName?: string; + /** @enum {string} */ + documentType?: "FILE" | "TEXT" | "NOTION"; directoryName?: string; }; SearchDocumentResponse: { @@ -1706,10 +1731,10 @@ export interface components { parent?: components["schemas"]["ApplicationContext"]; id?: string; displayName?: string; - autowireCapableBeanFactory?: components["schemas"]["AutowireCapableBeanFactory"]; + applicationName?: string; /** Format: int64 */ startupDate?: number; - applicationName?: string; + autowireCapableBeanFactory?: components["schemas"]["AutowireCapableBeanFactory"]; environment?: components["schemas"]["Environment"]; /** Format: int32 */ beanDefinitionCount?: number; @@ -1834,8 +1859,8 @@ export interface components { is3xxRedirection?: boolean; }; JspConfigDescriptor: { - taglibs?: components["schemas"]["TaglibDescriptor"][]; jspPropertyGroups?: components["schemas"]["JspPropertyGroupDescriptor"][]; + taglibs?: components["schemas"]["TaglibDescriptor"][]; }; JspPropertyGroupDescriptor: { defaultContentType?: string; @@ -1844,11 +1869,11 @@ export interface components { errorOnELNotFound?: string; pageEncoding?: string; scriptingInvalid?: string; - isXml?: string; includePreludes?: string[]; includeCodas?: string[]; trimDirectiveWhitespaces?: string; errorOnUndeclaredNamespace?: string; + isXml?: string; buffer?: string; urlPatterns?: string[]; }; @@ -3662,6 +3687,24 @@ export interface operations { }; }; }; + getInviteLinkMember: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; getMemberInfo: { parameters: { query?: never;