From 49a5ce86de8d4a01790503f8478a719844649933 Mon Sep 17 00:00:00 2001 From: andless2004 Date: Sat, 6 Dec 2025 07:51:46 +0000 Subject: [PATCH 01/17] Create Search component --- .../src/components/project/search/Search.tsx | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 frontend/src/components/project/search/Search.tsx diff --git a/frontend/src/components/project/search/Search.tsx b/frontend/src/components/project/search/Search.tsx new file mode 100644 index 00000000..503b3a0b --- /dev/null +++ b/frontend/src/components/project/search/Search.tsx @@ -0,0 +1,46 @@ +import { useState } from "react" + +import styled, { useTheme } from "styled-components" + +import useScreenType, { ifMobile } from "@utils/useScreenType" + +import FeatherIcon from "feather-icons-react" + +const Search = () => { + const [isEditMode, setIsEditMode] = useState(false) + + return ( + <> + + setIsEditMode(prev => !prev)} + /> + + + {isEditMode && + + just a test + + } + + ) +} + +const SearchIcon = styled.div` + margin-left: 0.8em; + padding-bottom: 0.8em; + cursor: pointer; + + min-width: 3em; + + & svg { + width: 16px; + height: 16px; + top: 0; + } +` + +const SearchBox = styled.div`` + +export default Search \ No newline at end of file From 8ce43398af79107922fe391ce39e31dd8fb2aa63 Mon Sep 17 00:00:00 2001 From: andless2004 Date: Sat, 6 Dec 2025 07:52:16 +0000 Subject: [PATCH 02/17] Add Search at ProjectListPage --- frontend/src/pages/ProjectListPage.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/pages/ProjectListPage.tsx b/frontend/src/pages/ProjectListPage.tsx index 5e63eab6..0b806d59 100644 --- a/frontend/src/pages/ProjectListPage.tsx +++ b/frontend/src/pages/ProjectListPage.tsx @@ -7,6 +7,7 @@ import PageTitle from "@components/common/PageTitle" import ErrorProjectList from "@components/errors/ErrorProjectList" import ProjectName from "@components/project/ProjectName" import ProjectEdit from "@components/project/edit/ProjectEdit" +import Search from "@components/project/search/Search" import SkeletonProjectList from "@components/project/skeletons/SkeletonProjectList" import { @@ -105,6 +106,7 @@ const ProjectListPage = () => { )} + {isPending && } From 37378e44901a7f42a12d2aff4fad95d6f414a21e Mon Sep 17 00:00:00 2001 From: andless2004 Date: Sat, 6 Dec 2025 11:33:44 +0000 Subject: [PATCH 03/17] Add InputBox w/ handling blur & debounce --- .../src/components/project/search/Search.tsx | 97 ++++++++++++++++--- 1 file changed, 85 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/project/search/Search.tsx b/frontend/src/components/project/search/Search.tsx index 503b3a0b..9c118c63 100644 --- a/frontend/src/components/project/search/Search.tsx +++ b/frontend/src/components/project/search/Search.tsx @@ -1,4 +1,4 @@ -import { useState } from "react" +import { ChangeEvent, useEffect, useRef, useState } from "react" import styled, { useTheme } from "styled-components" @@ -7,33 +7,101 @@ import useScreenType, { ifMobile } from "@utils/useScreenType" import FeatherIcon from "feather-icons-react" const Search = () => { - const [isEditMode, setIsEditMode] = useState(false) + const [searchQuery, setSearchQuery] = useState("") + + const inputRef = useRef(null) + const debounceTimerRef = useRef(null) + const lastSearchRef = useRef<{ q: string; ts: number } | null>(null) + + // temporary stub + const handleExecuteSearch = () => { + const trimmed = searchQuery.trim() + + const now = Date.now() + + // 최근 300ms 내에 같은 쿼리로 서칭되면 무시 (중복 방지) + if ( + lastSearchRef.current && + lastSearchRef.current.q === trimmed && + now - lastSearchRef.current.ts < 300 + ) { + return + } + + lastSearchRef.current = { q: trimmed, ts: now } + + console.log(trimmed) + setSearchQuery(trimmed) + } + + // 입력창 시작 + const handleClick = () => { + inputRef.current?.focus() + } + + // 입력창 처리 + const handleChange = (e: ChangeEvent) => { + console.log("value: ", e.target.value) + setSearchQuery(e.target.value) + + + if (debounceTimerRef.current !== null) { + window.clearTimeout(debounceTimerRef.current) + } + + debounceTimerRef.current = window.setTimeout(() => { + console.log("debounce: ", searchQuery) + handleExecuteSearch() + }, 1500) + } + + // 입력창 끝 + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key == "Enter") { + handleExecuteSearch() + } + } + + const handleBlur = () => { + handleExecuteSearch() + } return ( - <> + setIsEditMode(prev => !prev)} + onClick={handleClick} /> - {isEditMode && - - just a test - + { + } - + ) } +const SearchWrapper = styled.div` + flex: 1; + + display: flex; + align-items: flex-start; +` + const SearchIcon = styled.div` margin-left: 0.8em; padding-bottom: 0.8em; cursor: pointer; - min-width: 3em; - & svg { width: 16px; height: 16px; @@ -41,6 +109,11 @@ const SearchIcon = styled.div` } ` -const SearchBox = styled.div`` +const InputBox = styled.input` + flex: 1; + + min-width: 0; + font-size: 1em; +` export default Search \ No newline at end of file From 21c6fe2f4f6cbae0b4ffc32f9f6a1977cb557e17 Mon Sep 17 00:00:00 2001 From: andless2004 Date: Sat, 6 Dec 2025 11:51:40 +0000 Subject: [PATCH 04/17] Fix issue where the search function uses a stale query --- .../src/components/project/search/Search.tsx | 44 +++++++++---------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/project/search/Search.tsx b/frontend/src/components/project/search/Search.tsx index 9c118c63..b385bbbc 100644 --- a/frontend/src/components/project/search/Search.tsx +++ b/frontend/src/components/project/search/Search.tsx @@ -14,56 +14,54 @@ const Search = () => { const lastSearchRef = useRef<{ q: string; ts: number } | null>(null) // temporary stub - const handleExecuteSearch = () => { - const trimmed = searchQuery.trim() - + const handleExecuteSearch = (query: string) => { const now = Date.now() - // 최근 300ms 내에 같은 쿼리로 서칭되면 무시 (중복 방지) + // 최근 같은 쿼리로 서칭되면 무시 (중복 방지) + // TODO: ref 대신 실 search 천에 퀴리만 이전과 비교하며, 같은 쿼리에 새로운 결과를 얻고 싶어하는 경우를 고려할 것. if ( lastSearchRef.current && - lastSearchRef.current.q === trimmed && - now - lastSearchRef.current.ts < 300 + lastSearchRef.current.q === query ) { return } + lastSearchRef.current = { q: query, ts: now } - lastSearchRef.current = { q: trimmed, ts: now } - - console.log(trimmed) - setSearchQuery(trimmed) + console.log(query) + setSearchQuery(query) } - // 입력창 시작 + // Start get input process const handleClick = () => { inputRef.current?.focus() } - // 입력창 처리 + // Edit input process const handleChange = (e: ChangeEvent) => { - console.log("value: ", e.target.value) - setSearchQuery(e.target.value) + const trimmed = e.target.value.trim() + setSearchQuery(trimmed) - + // Debounce if (debounceTimerRef.current !== null) { window.clearTimeout(debounceTimerRef.current) } debounceTimerRef.current = window.setTimeout(() => { - console.log("debounce: ", searchQuery) - handleExecuteSearch() + handleExecuteSearch(trimmed) }, 1500) } - // 입력창 끝 + // End and search const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key == "Enter") { - handleExecuteSearch() + if (e.key == "Enter") { + const trimed = searchQuery.trim() + handleExecuteSearch(trimed) } } - const handleBlur = () => { - handleExecuteSearch() + const handleBlur = (e: React.FocusEvent) => { + const trimed = e.target.value.trim() + handleExecuteSearch(trimed) } return ( @@ -92,7 +90,7 @@ const Search = () => { const SearchWrapper = styled.div` flex: 1; - + display: flex; align-items: flex-start; ` From f03271147719989a0e32563c8214b10207327e09 Mon Sep 17 00:00:00 2001 From: andless2004 Date: Sat, 6 Dec 2025 12:00:55 +0000 Subject: [PATCH 05/17] Add translations for placeholder --- frontend/src/assets/locales/en/translation.json | 3 +++ frontend/src/assets/locales/ko/translation.json | 3 +++ frontend/src/components/project/search/Search.tsx | 9 ++++++--- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/frontend/src/assets/locales/en/translation.json b/frontend/src/assets/locales/en/translation.json index e9a3e6fc..7922d978 100644 --- a/frontend/src/assets/locales/en/translation.json +++ b/frontend/src/assets/locales/en/translation.json @@ -427,6 +427,9 @@ "results_title": "Search results for ‘{{query}}’" } }, + "search": { + "placeholder": "Search your task" + }, "update": { "message": "New update available.", "update": "Update", diff --git a/frontend/src/assets/locales/ko/translation.json b/frontend/src/assets/locales/ko/translation.json index bc9b097b..406cae5c 100644 --- a/frontend/src/assets/locales/ko/translation.json +++ b/frontend/src/assets/locales/ko/translation.json @@ -427,6 +427,9 @@ "results_title": "‘{{query}}’에 대한 검색 결과" } }, + "search": { + "placeholder": "할 일을 검색해 보세요" + }, "update": { "message": "새로운 업데이트가 있습니다.", "update": "업데이트", diff --git a/frontend/src/components/project/search/Search.tsx b/frontend/src/components/project/search/Search.tsx index b385bbbc..dc7651e4 100644 --- a/frontend/src/components/project/search/Search.tsx +++ b/frontend/src/components/project/search/Search.tsx @@ -5,8 +5,11 @@ import styled, { useTheme } from "styled-components" import useScreenType, { ifMobile } from "@utils/useScreenType" import FeatherIcon from "feather-icons-react" +import { useTranslation } from "react-i18next" const Search = () => { + const { t } = useTranslation("translation", { keyPrefix: "search" }) + const [searchQuery, setSearchQuery] = useState("") const inputRef = useRef(null) @@ -31,12 +34,12 @@ const Search = () => { setSearchQuery(query) } - // Start get input process + // Start input process const handleClick = () => { inputRef.current?.focus() } - // Edit input process + // Edit query const handleChange = (e: ChangeEvent) => { const trimmed = e.target.value.trim() setSearchQuery(trimmed) @@ -77,7 +80,7 @@ const Search = () => { Date: Mon, 8 Dec 2025 11:01:50 +0000 Subject: [PATCH 06/17] Add API for project name search using useInfiniteQuery --- backend/projects/serializers.py | 6 +- backend/projects/urls.py | 3 +- backend/projects/views.py | 30 ++++++++- frontend/src/api/search.api.ts | 27 ++++++++ .../src/components/project/search/Search.tsx | 63 ++++++++++++++----- 5 files changed, 110 insertions(+), 19 deletions(-) create mode 100644 frontend/src/api/search.api.ts diff --git a/backend/projects/serializers.py b/backend/projects/serializers.py index 41e6dedc..14edbdf0 100644 --- a/backend/projects/serializers.py +++ b/backend/projects/serializers.py @@ -3,7 +3,6 @@ from .models import Project from users.serializers import UserSerializer - class ProjectSerializer(serializers.ModelSerializer): user = UserSerializer( default=serializers.CurrentUserDefault(), @@ -49,3 +48,8 @@ class ProjectSerializerForUserProjectList(serializers.ModelSerializer): class Meta: # pyright: ignore [reportIncompatibleVariableOverride] -- ModelSerializer.Meta model = Project exclude = () + +class ProjectSearchSerializer(serializers.ModelSerializer): + class Meta: + model = Project + exclude = () diff --git a/backend/projects/urls.py b/backend/projects/urls.py index 2e603dac..f2ed9cab 100644 --- a/backend/projects/urls.py +++ b/backend/projects/urls.py @@ -5,10 +5,11 @@ from . import views urlpatterns = [ + path("search/", views.ProjectSearchView.as_view()), path("reorder/", views.ProjectReorderView.as_view()), path("", views.ProjectList.as_view()), path("inbox/", views.InboxProjectDetail.as_view()), - path("/", views.ProjectDetail.as_view()), + path("/", views.ProjectDetail.as_view()) ] urlpatterns = format_suffix_patterns(urlpatterns) diff --git a/backend/projects/views.py b/backend/projects/views.py index 34c845a9..25c48dee 100644 --- a/backend/projects/views.py +++ b/backend/projects/views.py @@ -2,9 +2,12 @@ from rest_framework.response import Response from rest_framework.exceptions import ValidationError from rest_framework.pagination import PageNumberPagination +from rest_framework.filters import SearchFilter + +from django.db.models import (Q) from .models import Project -from .serializers import ProjectSerializer, ProjectSerializerForUserProjectList +from .serializers import ProjectSerializer, ProjectSerializerForUserProjectList, ProjectSearchSerializer from .exceptions import ProjectNameDuplicate from api.permissions import IsUserOwner @@ -109,3 +112,28 @@ def patch(self, request, *args, **kwargs): Project.objects.bulk_update(projects, ["order"]) return Response(status=status.HTTP_200_OK) + +class ProjectSearchPagination(PageNumberPagination): + page_size = 20 + +# 나중에 검색 결과 창에서 수정도 할 거면 mixins.ListModelMixin, generics.GenericAPIView 로 변경 +class ProjectSearchView(generics.ListAPIView): + serializer_class = ProjectSearchSerializer + pagination_class = ProjectSearchPagination + + search_fields = ["name"] + + def get_queryset(self, **kwargs): + qs = Project.objects.all() + query = self.request.query_params.get("query") + + print(query) + + if query: + qs = qs.filter( + Q(name__icontains=query) + ) + + return qs + + # return Response(serializer.data, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/frontend/src/api/search.api.ts b/frontend/src/api/search.api.ts new file mode 100644 index 00000000..a0cf6bc1 --- /dev/null +++ b/frontend/src/api/search.api.ts @@ -0,0 +1,27 @@ +import client from "@api/client" +import type { Base, PaginationData, Privacy } from "@api/common" +import type { User } from "@api/users.api" + +import type { PaletteColorName } from "@assets/palettes" + +export interface Project extends Base { + name: string + user: User + order: number + privacy: Privacy | null + color: PaletteColorName + type: ProjectType + completed_task_count: number + uncompleted_task_count: number +} + +export type ProjectType = "inbox" | "regular" | "goal" + +export const getSearchResults = async (query: string, page: string) => { + const search = query + const res = await client.get>(`projects/search/`, { + params: { search, query, page }, + }) + + return res.data +} \ No newline at end of file diff --git a/frontend/src/components/project/search/Search.tsx b/frontend/src/components/project/search/Search.tsx index dc7651e4..314d6319 100644 --- a/frontend/src/components/project/search/Search.tsx +++ b/frontend/src/components/project/search/Search.tsx @@ -1,7 +1,16 @@ import { ChangeEvent, useEffect, useRef, useState } from "react" +import { useInfiniteQuery } from "@tanstack/react-query" import styled, { useTheme } from "styled-components" +import PageTitle from "@components/common/PageTitle" + +import { + type Project, + getSearchResults +} from "@api/search.api" + +import { getPageFromURL } from "@utils/pagination" import useScreenType, { ifMobile } from "@utils/useScreenType" import FeatherIcon from "feather-icons-react" @@ -10,15 +19,38 @@ import { useTranslation } from "react-i18next" const Search = () => { const { t } = useTranslation("translation", { keyPrefix: "search" }) + const [searchInput, setSearchInput] = useState("") const [searchQuery, setSearchQuery] = useState("") const inputRef = useRef(null) const debounceTimerRef = useRef(null) const lastSearchRef = useRef<{ q: string; ts: number } | null>(null) + const { + data, + isPending, + isError, + refetch, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ + queryKey: ["search", searchQuery], + // enabled: false, + enabled: searchQuery.length > 0, // TODO: searchQuery 타입에 따라 enable 조건이 변경되어야 함. + queryFn: ({pageParam, queryKey}) => { + const [, q] = queryKey + return getSearchResults(q, pageParam) + }, + initialPageParam: "1", + getNextPageParam: (lastPage) => getPageFromURL(lastPage.next), + }) + // temporary stub const handleExecuteSearch = (query: string) => { const now = Date.now() + + setSearchInput(query) // 최근 같은 쿼리로 서칭되면 무시 (중복 방지) // TODO: ref 대신 실 search 천에 퀴리만 이전과 비교하며, 같은 쿼리에 새로운 결과를 얻고 싶어하는 경우를 고려할 것. @@ -30,8 +62,9 @@ const Search = () => { } lastSearchRef.current = { q: query, ts: now } - console.log(query) setSearchQuery(query) + + console.log(data) } // Start input process @@ -41,8 +74,7 @@ const Search = () => { // Edit query const handleChange = (e: ChangeEvent) => { - const trimmed = e.target.value.trim() - setSearchQuery(trimmed) + setSearchInput(e.target.value) // Debounce if (debounceTimerRef.current !== null) { @@ -50,14 +82,15 @@ const Search = () => { } debounceTimerRef.current = window.setTimeout(() => { + const trimmed = e.target.value.trim() handleExecuteSearch(trimmed) - }, 1500) + }, 1000) } // End and search const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key == "Enter") { - const trimed = searchQuery.trim() + const trimed = searchInput.trim() handleExecuteSearch(trimed) } } @@ -76,17 +109,15 @@ const Search = () => { /> - { - - } + ) } From 51016c3b650d9d4a5c2a9f1623466c41053ed30f Mon Sep 17 00:00:00 2001 From: andless2004 Date: Mon, 8 Dec 2025 11:58:43 +0000 Subject: [PATCH 07/17] Fix InfiniteQuery to display search results as plain text --- .../src/components/project/search/Search.tsx | 44 ++++------------- frontend/src/pages/ProjectListPage.tsx | 48 ++++++++++++++++++- 2 files changed, 55 insertions(+), 37 deletions(-) diff --git a/frontend/src/components/project/search/Search.tsx b/frontend/src/components/project/search/Search.tsx index 314d6319..826b4efb 100644 --- a/frontend/src/components/project/search/Search.tsx +++ b/frontend/src/components/project/search/Search.tsx @@ -1,51 +1,25 @@ -import { ChangeEvent, useEffect, useRef, useState } from "react" +import { ChangeEvent, useRef, useState } from "react" -import { useInfiniteQuery } from "@tanstack/react-query" import styled, { useTheme } from "styled-components" -import PageTitle from "@components/common/PageTitle" - -import { - type Project, - getSearchResults -} from "@api/search.api" - -import { getPageFromURL } from "@utils/pagination" -import useScreenType, { ifMobile } from "@utils/useScreenType" - import FeatherIcon from "feather-icons-react" import { useTranslation } from "react-i18next" -const Search = () => { +interface SearchProps { + searchQuery: string + setSearchQuery: React.Dispatch> +} + +const Search = ({searchQuery, setSearchQuery}: SearchProps) => { const { t } = useTranslation("translation", { keyPrefix: "search" }) const [searchInput, setSearchInput] = useState("") - const [searchQuery, setSearchQuery] = useState("") + const inputRef = useRef(null) const debounceTimerRef = useRef(null) const lastSearchRef = useRef<{ q: string; ts: number } | null>(null) - const { - data, - isPending, - isError, - refetch, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - } = useInfiniteQuery({ - queryKey: ["search", searchQuery], - // enabled: false, - enabled: searchQuery.length > 0, // TODO: searchQuery 타입에 따라 enable 조건이 변경되어야 함. - queryFn: ({pageParam, queryKey}) => { - const [, q] = queryKey - return getSearchResults(q, pageParam) - }, - initialPageParam: "1", - getNextPageParam: (lastPage) => getPageFromURL(lastPage.next), - }) - // temporary stub const handleExecuteSearch = (query: string) => { const now = Date.now() @@ -63,8 +37,6 @@ const Search = () => { lastSearchRef.current = { q: query, ts: now } setSearchQuery(query) - - console.log(data) } // Start input process diff --git a/frontend/src/pages/ProjectListPage.tsx b/frontend/src/pages/ProjectListPage.tsx index 0b806d59..4c0d65c5 100644 --- a/frontend/src/pages/ProjectListPage.tsx +++ b/frontend/src/pages/ProjectListPage.tsx @@ -10,6 +10,7 @@ import ProjectEdit from "@components/project/edit/ProjectEdit" import Search from "@components/project/search/Search" import SkeletonProjectList from "@components/project/skeletons/SkeletonProjectList" +import { getSearchResults } from "@api/search.api" import { type Project, getProjectList, @@ -94,6 +95,31 @@ const ProjectListPage = () => { setTempProjectOrder([]) }, [projects, displayProjects, mutateAsync]) + // Search + const [searchQuery, setSearchQuery] = useState("") + + const { + data: searchData, + // isSearchPending, + // isSearchError, + // refetchSearch, + // fetchSearchNextPage, + // hasSearchNextPage, + // isSearchFetchingNextPage, + } = useInfiniteQuery({ + queryKey: ["search", searchQuery], + // enabled: false, + enabled: searchQuery.length > 0, // TODO: searchQuery 타입에 따라 enable 조건이 변경되어야 함. + queryFn: ({pageParam, queryKey}) => { + const [, q] = queryKey + return getSearchResults(q, pageParam) + }, + initialPageParam: "1", + getNextPageParam: (lastPage) => getPageFromURL(lastPage.next), + }) + + const searchResults = searchData?.pages.flatMap((page) => page.results) ?? []; + return ( <> @@ -106,12 +132,22 @@ const ProjectListPage = () => { )} - + {isPending && } {isError && refetch()} />} + {/* Search 관련 부분 */} + + {searchResults.map((project) => + + {project.name} + " " + {project.type} + + )} + + {/* Search 관련 부분 끝 */} + {displayProjects.map((project) => ( Date: Wed, 10 Dec 2025 10:52:12 +0000 Subject: [PATCH 08/17] Fix search API to include drawers and tasks --- backend/projects/serializers.py | 13 ++++++ backend/projects/views.py | 65 ++++++++++++++++---------- frontend/src/api/search.api.ts | 7 +-- frontend/src/pages/ProjectListPage.tsx | 32 +++++++++++-- 4 files changed, 86 insertions(+), 31 deletions(-) diff --git a/backend/projects/serializers.py b/backend/projects/serializers.py index 14edbdf0..377163a3 100644 --- a/backend/projects/serializers.py +++ b/backend/projects/serializers.py @@ -1,6 +1,8 @@ from rest_framework import serializers from .models import Project +from drawers.models import Drawer +from tasks.models import Task from users.serializers import UserSerializer class ProjectSerializer(serializers.ModelSerializer): @@ -53,3 +55,14 @@ class ProjectSearchSerializer(serializers.ModelSerializer): class Meta: model = Project exclude = () + +class DrawerSearchSerializer(serializers.ModelSerializer): + class Meta: + model = Drawer + exclude = () + +class TaskSearchSerializer(serializers.ModelSerializer): + class Meta: + model = Task + exclude = () + diff --git a/backend/projects/views.py b/backend/projects/views.py index 25c48dee..c70774d3 100644 --- a/backend/projects/views.py +++ b/backend/projects/views.py @@ -2,12 +2,14 @@ from rest_framework.response import Response from rest_framework.exceptions import ValidationError from rest_framework.pagination import PageNumberPagination -from rest_framework.filters import SearchFilter +from rest_framework.views import APIView -from django.db.models import (Q) +from django.db.models import Q, F, Value from .models import Project -from .serializers import ProjectSerializer, ProjectSerializerForUserProjectList, ProjectSearchSerializer +from drawers.models import Drawer +from tasks.models import Task +from .serializers import ProjectSerializer, ProjectSerializerForUserProjectList, ProjectSearchSerializer, DrawerSearchSerializer, TaskSearchSerializer from .exceptions import ProjectNameDuplicate from api.permissions import IsUserOwner @@ -116,24 +118,39 @@ def patch(self, request, *args, **kwargs): class ProjectSearchPagination(PageNumberPagination): page_size = 20 -# 나중에 검색 결과 창에서 수정도 할 거면 mixins.ListModelMixin, generics.GenericAPIView 로 변경 -class ProjectSearchView(generics.ListAPIView): - serializer_class = ProjectSearchSerializer - pagination_class = ProjectSearchPagination - - search_fields = ["name"] - - def get_queryset(self, **kwargs): - qs = Project.objects.all() - query = self.request.query_params.get("query") - - print(query) - - if query: - qs = qs.filter( - Q(name__icontains=query) - ) - - return qs - - # return Response(serializer.data, status=status.HTTP_200_OK) \ No newline at end of file +# 하나의 view에서 여러 serializer를 부를 거라 복잡해도 그냥 APIView가 적합할 거 같음 +# 파라미터 종류가 많아지면 django-filter를 적극적으로 고려해 보자... +class ProjectSearchView(APIView): + def get(self, request, *args, **kwargs): + q = request.query_params.get("query", "").strip() + + # query가 비어있으면 빈 결과 반환 + # TODO: 나중에 프로젝트 페이지와 합치게 되면 전부 보이는 걸로 바뀌어야 할 지도? + if not q: + return Response({ + "projects": [], + "drawers": [], + "tasks": [], + }) + + project_qs = Project.objects.filter( + Q(name__icontains=q) + ) + + drawer_qs = Drawer.objects.filter( + Q(name__icontains=q) + ) + + task_qs = Task.objects.filter( + Q(name__icontains=q) + ) + + project_data = ProjectSearchSerializer(project_qs, many=True).data + drawer_data = DrawerSearchSerializer(drawer_qs, many=True).data + task_data = TaskSearchSerializer(task_qs, many=True).data + + return Response({ + "projects": project_data, + "drawers": drawer_data, + "tasks": task_data, + }) \ No newline at end of file diff --git a/frontend/src/api/search.api.ts b/frontend/src/api/search.api.ts index a0cf6bc1..2e0b456e 100644 --- a/frontend/src/api/search.api.ts +++ b/frontend/src/api/search.api.ts @@ -17,10 +17,11 @@ export interface Project extends Base { export type ProjectType = "inbox" | "regular" | "goal" -export const getSearchResults = async (query: string, page: string) => { +//export const getSearchResults = async (query: string, page: string) => { +export const getSearchResults = async (query: string) => { const search = query - const res = await client.get>(`projects/search/`, { - params: { search, query, page }, + const res = await client.get(`projects/search/`, { + params: { search, query}, }) return res.data diff --git a/frontend/src/pages/ProjectListPage.tsx b/frontend/src/pages/ProjectListPage.tsx index 4c0d65c5..a5729367 100644 --- a/frontend/src/pages/ProjectListPage.tsx +++ b/frontend/src/pages/ProjectListPage.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo, useState } from "react" -import { useInfiniteQuery, useMutation } from "@tanstack/react-query" +import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query" import styled from "styled-components" import PageTitle from "@components/common/PageTitle" @@ -98,6 +98,7 @@ const ProjectListPage = () => { // Search const [searchQuery, setSearchQuery] = useState("") + /* const { data: searchData, // isSearchPending, @@ -108,8 +109,8 @@ const ProjectListPage = () => { // isSearchFetchingNextPage, } = useInfiniteQuery({ queryKey: ["search", searchQuery], - // enabled: false, - enabled: searchQuery.length > 0, // TODO: searchQuery 타입에 따라 enable 조건이 변경되어야 함. + enabled: false,// + // enabled: searchQuery.length > 0, // TODO: searchQuery 타입에 따라 enable 조건이 변경되어야 함. queryFn: ({pageParam, queryKey}) => { const [, q] = queryKey return getSearchResults(q, pageParam) @@ -117,8 +118,22 @@ const ProjectListPage = () => { initialPageParam: "1", getNextPageParam: (lastPage) => getPageFromURL(lastPage.next), }) +*/ + const { + data: searchData + } = useQuery({ + queryKey: ["search", searchQuery], + // enabled: false, + enabled: searchQuery.length > 0, + queryFn: ({queryKey}) => { + const [, q] = queryKey + return getSearchResults(q) + } + }) - const searchResults = searchData?.pages.flatMap((page) => page.results) ?? []; + // const searchResults = searchData?.pages.flatMap((page) => page.results) ?? []; + const searchResults = searchData + console.log(searchResults) return ( <> @@ -140,11 +155,20 @@ const ProjectListPage = () => { {/* Search 관련 부분 */} + {/* {searchResults.map((project) => {project.name} + " " + {project.type} )} + */} + {searchResults && Object.entries(searchResults).map(([type, resultsArray]) => ( + resultsArray && resultsArray.map((result) => + + {type + ": " + result.name} + + ) + ))} {/* Search 관련 부분 끝 */} From 957d6ab432ea272cbff3e76e9281dacdf542c608 Mon Sep 17 00:00:00 2001 From: andless2004 Date: Wed, 10 Dec 2025 11:42:08 +0000 Subject: [PATCH 09/17] Refactor structure and add SearchPage for better component maintainability --- frontend/src/components/sidebar/Middle.tsx | 8 + frontend/src/pages/ProjectListPage.tsx | 76 +----- frontend/src/pages/SearchPage.tsx | 275 +++++++++++++++++++++ frontend/src/routers/mainRouter.tsx | 5 + 4 files changed, 290 insertions(+), 74 deletions(-) create mode 100644 frontend/src/pages/SearchPage.tsx diff --git a/frontend/src/components/sidebar/Middle.tsx b/frontend/src/components/sidebar/Middle.tsx index 34f417c2..3a25b407 100644 --- a/frontend/src/components/sidebar/Middle.tsx +++ b/frontend/src/components/sidebar/Middle.tsx @@ -72,6 +72,14 @@ const Middle = () => { + + + + {isCollapsed ? null : t("sidebar.projects")} + + + + diff --git a/frontend/src/pages/ProjectListPage.tsx b/frontend/src/pages/ProjectListPage.tsx index a5729367..c7f7eadc 100644 --- a/frontend/src/pages/ProjectListPage.tsx +++ b/frontend/src/pages/ProjectListPage.tsx @@ -1,16 +1,14 @@ import { useCallback, useMemo, useState } from "react" -import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query" +import { useInfiniteQuery, useMutation } from "@tanstack/react-query" import styled from "styled-components" import PageTitle from "@components/common/PageTitle" import ErrorProjectList from "@components/errors/ErrorProjectList" import ProjectName from "@components/project/ProjectName" import ProjectEdit from "@components/project/edit/ProjectEdit" -import Search from "@components/project/search/Search" import SkeletonProjectList from "@components/project/skeletons/SkeletonProjectList" -import { getSearchResults } from "@api/search.api" import { type Project, getProjectList, @@ -95,46 +93,6 @@ const ProjectListPage = () => { setTempProjectOrder([]) }, [projects, displayProjects, mutateAsync]) - // Search - const [searchQuery, setSearchQuery] = useState("") - - /* - const { - data: searchData, - // isSearchPending, - // isSearchError, - // refetchSearch, - // fetchSearchNextPage, - // hasSearchNextPage, - // isSearchFetchingNextPage, - } = useInfiniteQuery({ - queryKey: ["search", searchQuery], - enabled: false,// - // enabled: searchQuery.length > 0, // TODO: searchQuery 타입에 따라 enable 조건이 변경되어야 함. - queryFn: ({pageParam, queryKey}) => { - const [, q] = queryKey - return getSearchResults(q, pageParam) - }, - initialPageParam: "1", - getNextPageParam: (lastPage) => getPageFromURL(lastPage.next), - }) -*/ - const { - data: searchData - } = useQuery({ - queryKey: ["search", searchQuery], - // enabled: false, - enabled: searchQuery.length > 0, - queryFn: ({queryKey}) => { - const [, q] = queryKey - return getSearchResults(q) - } - }) - - // const searchResults = searchData?.pages.flatMap((page) => page.results) ?? []; - const searchResults = searchData - console.log(searchResults) - return ( <> @@ -147,31 +105,11 @@ const ProjectListPage = () => { )} - {isPending && } {isError && refetch()} />} - {/* Search 관련 부분 */} - - {/* - {searchResults.map((project) => - - {project.name} + " " + {project.type} - - )} - */} - {searchResults && Object.entries(searchResults).map(([type, resultsArray]) => ( - resultsArray && resultsArray.map((result) => - - {type + ": " + result.name} - - ) - ))} - - {/* Search 관련 부분 끝 */} - {displayProjects.map((project) => ( { + const { t } = useTranslation("translation") + + const modal = useModal() + + const { + data, + isPending, + isError, + refetch, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ + queryKey: ["projects"], + queryFn: (context) => getProjectList(context.pageParam), + initialPageParam: "1", + getNextPageParam: (lastPage) => getPageFromURL(lastPage.next), + }) + + const projects = useMemo(() => { + if (!data) return [] + return data.pages.flatMap((page) => page.results ?? []) || [] + }, [data]) + + // Drag and drop state + const [tempProjectOrder, setTempProjectOrder] = useState([]) + const displayProjects = + tempProjectOrder.length > 0 ? tempProjectOrder : projects + + const { mutateAsync } = useMutation({ + mutationFn: (data: Partial[]) => { + return patchReorderProject(data) + }, + }) + + const moveProject = useCallback( + (dragIndex: number, hoverIndex: number) => { + const updatedOrder = [...displayProjects] + const [moved] = updatedOrder.splice(dragIndex, 1) + updatedOrder.splice(hoverIndex, 0, moved) + setTempProjectOrder(updatedOrder) + }, + [displayProjects], + ) + + const dropProject = useCallback(async () => { + const changedProjects = displayProjects + .map((project, index) => ({ id: project.id, order: index })) + .filter((project, index) => { + const originalIndex = projects.findIndex( + (p) => p.id === project.id, + ) + return originalIndex !== index + }) + + if (changedProjects.length === 0) return + + await mutateAsync(changedProjects) + await queryClient.invalidateQueries({ + queryKey: ["projects"], + }) + // Reset temp order after successful API update + setTempProjectOrder([]) + }, [projects, displayProjects, mutateAsync]) + + // Search + const [searchQuery, setSearchQuery] = useState("") + + /* + const { + data: searchData, + // isSearchPending, + // isSearchError, + // refetchSearch, + // fetchSearchNextPage, + // hasSearchNextPage, + // isSearchFetchingNextPage, + } = useInfiniteQuery({ + queryKey: ["search", searchQuery], + enabled: false,// + // enabled: searchQuery.length > 0, // TODO: searchQuery 타입에 따라 enable 조건이 변경되어야 함. + queryFn: ({pageParam, queryKey}) => { + const [, q] = queryKey + return getSearchResults(q, pageParam) + }, + initialPageParam: "1", + getNextPageParam: (lastPage) => getPageFromURL(lastPage.next), + }) +*/ + const { + data: searchData + } = useQuery({ + queryKey: ["search", searchQuery], + // enabled: false, + enabled: searchQuery.length > 0, + queryFn: ({queryKey}) => { + const [, q] = queryKey + return getSearchResults(q) + } + }) + + // const searchResults = searchData?.pages.flatMap((page) => page.results) ?? []; + const searchResults = searchData + console.log(searchResults) + + return ( + <> + + {t("project_list.title")} + {isPending || ( + { + modal.openModal() + }}> + + + )} + + + + {isPending && } + {isError && refetch()} />} + + {/* Search 관련 부분 */} + + {/* + {searchResults.map((project) => + + {project.name} + " " + {project.type} + + )} + */} + {searchResults && Object.entries(searchResults).map(([type, resultsArray]) => ( + resultsArray && resultsArray.map((result) => + + {type + ": " + result.name} + + ) + ))} + + {/* Search 관련 부분 끝 */} + + + {displayProjects.map((project) => ( + + ))} + + + { + if (hasNextPage) fetchNextPage() + }} + timeThreshold={200}> + {isFetchingNextPage && t("common.loading")} + + + {isPending || ( + { + modal.openModal() + }}> + + + {t("project_list.button_add_project")} + + + )} + + + + + ) +} + +const PageTitleBox = styled.div` + display: flex; + align-items: center; +` + +const PlusBox = styled.div` + margin-left: 0.8em; + padding-bottom: 0.8em; + cursor: pointer; + + & svg { + width: 16px; + height: 16px; + top: 0; + } +` + +const ProjectCreateButton = styled.div` + display: flex; + align-items: center; + padding: 1em 0em; + margin-left: 0.8em; + cursor: pointer; + + & svg { + width: 1.1em; + height: 1.1em; + top: 0; + } + + ${ifMobile} { + padding: 0.5em 0em; + margin-left: 0em; + } +` + +const StyledImpressionArea = styled(ImpressionArea)` + min-height: 24px; + min-width: 1px; + + display: flex; + align-items: center; + justify-content: center; +` + +const ProjectCreateText = styled.div` + font-size: 1em; + font-weight: medium; + color: ${(p) => p.theme.textColor}; + margin-top: 0em; +` + +const SearchResultContainer = styled.div` + border: solid black; + + display: flex; + flex-direction: column; +` + +const SearchResultBox = styled.div` +` + +export default SearchPage diff --git a/frontend/src/routers/mainRouter.tsx b/frontend/src/routers/mainRouter.tsx index 073b0355..e9c4e896 100644 --- a/frontend/src/routers/mainRouter.tsx +++ b/frontend/src/routers/mainRouter.tsx @@ -21,6 +21,7 @@ import { lazily } from "react-lazily" const HomePage = lazy(() => import("@pages/HomePage")) const TodayPage = lazy(() => import("@pages/TodayPage")) +const SearchPage = lazy(() => import("@pages/SearchPage")) const InboxPage = lazy(() => import("@pages/InboxPage")) const ProjectPage = lazy(() => import("@pages/ProjectPage")) const ProjectListPage = lazy(() => import("@pages/ProjectListPage")) @@ -168,6 +169,10 @@ const routes: RouteObject[] = [ }, ], }, + { + path: "search", + element: , + }, { path: "projects", element: , From 1ca8603d13c224b201fdbb654b49d68a66e1be95 Mon Sep 17 00:00:00 2001 From: andless2004 Date: Fri, 12 Dec 2025 08:59:29 +0000 Subject: [PATCH 10/17] Refactor backend structure and add search app to align with RESTful APIs --- backend/django_peak/urls.py | 1 + backend/projects/urls.py | 1 - backend/projects/views.py | 40 ------------ backend/search/__init__.py | 0 backend/search/admin.py | 3 + backend/search/apps.py | 6 ++ backend/search/migrations/__init__.py | 0 backend/search/models.py | 3 + backend/search/serializers.py | 21 +++++++ backend/search/tests.py | 3 + backend/search/urls.py | 11 ++++ backend/search/views.py | 62 +++++++++++++++++++ frontend/src/api/search.api.ts | 2 +- .../{project => }/search/Search.tsx | 0 frontend/src/pages/SearchPage.tsx | 2 +- 15 files changed, 112 insertions(+), 43 deletions(-) create mode 100644 backend/search/__init__.py create mode 100644 backend/search/admin.py create mode 100644 backend/search/apps.py create mode 100644 backend/search/migrations/__init__.py create mode 100644 backend/search/models.py create mode 100644 backend/search/serializers.py create mode 100644 backend/search/tests.py create mode 100644 backend/search/urls.py create mode 100644 backend/search/views.py rename frontend/src/components/{project => }/search/Search.tsx (100%) diff --git a/backend/django_peak/urls.py b/backend/django_peak/urls.py index 218d473f..ee57a2a4 100644 --- a/backend/django_peak/urls.py +++ b/backend/django_peak/urls.py @@ -29,4 +29,5 @@ path("tasks/", include("tasks.urls")), path("today/", include("today.urls")), path("social/", include("social.urls")), + path("search/", include("search.urls")), ] diff --git a/backend/projects/urls.py b/backend/projects/urls.py index f2ed9cab..a7cc1419 100644 --- a/backend/projects/urls.py +++ b/backend/projects/urls.py @@ -5,7 +5,6 @@ from . import views urlpatterns = [ - path("search/", views.ProjectSearchView.as_view()), path("reorder/", views.ProjectReorderView.as_view()), path("", views.ProjectList.as_view()), path("inbox/", views.InboxProjectDetail.as_view()), diff --git a/backend/projects/views.py b/backend/projects/views.py index c70774d3..3c5786dc 100644 --- a/backend/projects/views.py +++ b/backend/projects/views.py @@ -114,43 +114,3 @@ def patch(self, request, *args, **kwargs): Project.objects.bulk_update(projects, ["order"]) return Response(status=status.HTTP_200_OK) - -class ProjectSearchPagination(PageNumberPagination): - page_size = 20 - -# 하나의 view에서 여러 serializer를 부를 거라 복잡해도 그냥 APIView가 적합할 거 같음 -# 파라미터 종류가 많아지면 django-filter를 적극적으로 고려해 보자... -class ProjectSearchView(APIView): - def get(self, request, *args, **kwargs): - q = request.query_params.get("query", "").strip() - - # query가 비어있으면 빈 결과 반환 - # TODO: 나중에 프로젝트 페이지와 합치게 되면 전부 보이는 걸로 바뀌어야 할 지도? - if not q: - return Response({ - "projects": [], - "drawers": [], - "tasks": [], - }) - - project_qs = Project.objects.filter( - Q(name__icontains=q) - ) - - drawer_qs = Drawer.objects.filter( - Q(name__icontains=q) - ) - - task_qs = Task.objects.filter( - Q(name__icontains=q) - ) - - project_data = ProjectSearchSerializer(project_qs, many=True).data - drawer_data = DrawerSearchSerializer(drawer_qs, many=True).data - task_data = TaskSearchSerializer(task_qs, many=True).data - - return Response({ - "projects": project_data, - "drawers": drawer_data, - "tasks": task_data, - }) \ No newline at end of file diff --git a/backend/search/__init__.py b/backend/search/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/search/admin.py b/backend/search/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/backend/search/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/search/apps.py b/backend/search/apps.py new file mode 100644 index 00000000..3afccb09 --- /dev/null +++ b/backend/search/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SearchConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'search' diff --git a/backend/search/migrations/__init__.py b/backend/search/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/search/models.py b/backend/search/models.py new file mode 100644 index 00000000..71a83623 --- /dev/null +++ b/backend/search/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/backend/search/serializers.py b/backend/search/serializers.py new file mode 100644 index 00000000..08a9e56b --- /dev/null +++ b/backend/search/serializers.py @@ -0,0 +1,21 @@ +from rest_framework import serializers + +from projects.models import Project +from drawers.models import Drawer +from tasks.models import Task + +class ProjectSearchSerializer(serializers.ModelSerializer): + class Meta: + model = Project + exclude = () + +class DrawerSearchSerializer(serializers.ModelSerializer): + class Meta: + model = Drawer + exclude = () + +class TaskSearchSerializer(serializers.ModelSerializer): + class Meta: + model = Task + exclude = () + diff --git a/backend/search/tests.py b/backend/search/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/backend/search/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/search/urls.py b/backend/search/urls.py new file mode 100644 index 00000000..4fbbedce --- /dev/null +++ b/backend/search/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from rest_framework.urlpatterns import format_suffix_patterns + +from . import views + +urlpatterns = [ + path("", views.ProjectSearchView.as_view()), +] + +urlpatterns = format_suffix_patterns(urlpatterns) diff --git a/backend/search/views.py b/backend/search/views.py new file mode 100644 index 00000000..18bc6d38 --- /dev/null +++ b/backend/search/views.py @@ -0,0 +1,62 @@ +from rest_framework import mixins, generics, status +from rest_framework.response import Response +from rest_framework.exceptions import ValidationError +from rest_framework.pagination import PageNumberPagination +from rest_framework.views import APIView + +from django.db.models import Q, F, Value + +from projects.models import Project +from drawers.models import Drawer +from tasks.models import Task +from .serializers import ProjectSearchSerializer, DrawerSearchSerializer, TaskSearchSerializer +from projects.exceptions import ProjectNameDuplicate + +from api.permissions import IsUserOwner +from api.exceptions import UnknownError +from api.serializers import ReorderSerializer + +class SearchPagination(PageNumberPagination): + page_size = 20 + + # page size 조절 가능하게 + page_size_query_param = "page_size" + max_page_size = 100 + +# 하나의 view에서 여러 serializer를 부를 거라 복잡해도 그냥 APIView가 적합할 거 같음 +# 파라미터 종류가 많아지면 django-filter를 적극적으로 고려해 보자... +class ProjectSearchView(APIView): + def get(self, request, *args, **kwargs): + q = request.query_params.get("query", "").strip() + + print(q) + # query가 비어있으면 빈 결과 반환 + # TODO: 나중에 프로젝트 페이지와 합치게 되면 전부 보이는 걸로 바뀌어야 할 지도? + if not q: + return Response({ + "projects": [], + "drawers": [], + "tasks": [], + }) + + project_qs = Project.objects.filter( + Q(name__icontains=q) + ) + + drawer_qs = Drawer.objects.filter( + Q(name__icontains=q) + ) + + task_qs = Task.objects.filter( + Q(name__icontains=q) + ) + + project_data = ProjectSearchSerializer(project_qs, many=True).data + drawer_data = DrawerSearchSerializer(drawer_qs, many=True).data + task_data = TaskSearchSerializer(task_qs, many=True).data + + return Response({ + "projects": project_data, + "drawers": drawer_data, + "tasks": task_data, + }) \ No newline at end of file diff --git a/frontend/src/api/search.api.ts b/frontend/src/api/search.api.ts index 2e0b456e..0693660a 100644 --- a/frontend/src/api/search.api.ts +++ b/frontend/src/api/search.api.ts @@ -20,7 +20,7 @@ export type ProjectType = "inbox" | "regular" | "goal" //export const getSearchResults = async (query: string, page: string) => { export const getSearchResults = async (query: string) => { const search = query - const res = await client.get(`projects/search/`, { + const res = await client.get(`search/`, { params: { search, query}, }) diff --git a/frontend/src/components/project/search/Search.tsx b/frontend/src/components/search/Search.tsx similarity index 100% rename from frontend/src/components/project/search/Search.tsx rename to frontend/src/components/search/Search.tsx diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx index 321d500c..a55dd1b9 100644 --- a/frontend/src/pages/SearchPage.tsx +++ b/frontend/src/pages/SearchPage.tsx @@ -3,11 +3,11 @@ import { useCallback, useMemo, useState } from "react" import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query" import styled from "styled-components" +import Search from "@components/search/Search" import PageTitle from "@components/common/PageTitle" import ErrorProjectList from "@components/errors/ErrorProjectList" import ProjectName from "@components/project/ProjectName" import ProjectEdit from "@components/project/edit/ProjectEdit" -import Search from "@components/project/search/Search" import SkeletonProjectList from "@components/project/skeletons/SkeletonProjectList" import { getSearchResults } from "@api/search.api" From fb223d086273455bc6c354e8ebade80f33d8d36b Mon Sep 17 00:00:00 2001 From: andless2004 Date: Fri, 12 Dec 2025 10:11:10 +0000 Subject: [PATCH 11/17] Introduce service layer for better maintainability --- backend/search/service.py | 29 +++++++++++++++++++++++++++++ backend/search/urls.py | 2 +- backend/search/views.py | 33 ++++++++++++++++++++++++++++++--- frontend/src/api/search.api.ts | 2 +- 4 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 backend/search/service.py diff --git a/backend/search/service.py b/backend/search/service.py new file mode 100644 index 00000000..d046f70b --- /dev/null +++ b/backend/search/service.py @@ -0,0 +1,29 @@ +from django.db.models import Q, F, Value + +from projects.models import Project +from drawers.models import Drawer +from tasks.models import Task +from .serializers import ProjectSearchSerializer, DrawerSearchSerializer, TaskSearchSerializer + +SCOPE_BITMASK = {"task": 1, "drawer": 2, "project": 4} + +def global_search(query, scope): + results = dict() + + targets = { + "task": {"model": Task, "serializer": TaskSearchSerializer}, + "drawer": {"model": Drawer, "serializer": DrawerSearchSerializer}, + "project": {"model": Project, "serializer": ProjectSearchSerializer} + } + + for scope, value in targets.items(): + if scope & SCOPE_BITMASK[scope]: + query_set = value["model"].objects.filter( + Q(name__icontains=query) + ) + data = value["serializer"](query_set, many=True).data + results[scope] = data + else: + results[scope] = [] + + return results \ No newline at end of file diff --git a/backend/search/urls.py b/backend/search/urls.py index 4fbbedce..9293416f 100644 --- a/backend/search/urls.py +++ b/backend/search/urls.py @@ -5,7 +5,7 @@ from . import views urlpatterns = [ - path("", views.ProjectSearchView.as_view()), + path("", views.GlobalSearchView.as_view()), ] urlpatterns = format_suffix_patterns(urlpatterns) diff --git a/backend/search/views.py b/backend/search/views.py index 18bc6d38..b9fc2de0 100644 --- a/backend/search/views.py +++ b/backend/search/views.py @@ -10,6 +10,7 @@ from drawers.models import Drawer from tasks.models import Task from .serializers import ProjectSearchSerializer, DrawerSearchSerializer, TaskSearchSerializer +from .service import global_search from projects.exceptions import ProjectNameDuplicate from api.permissions import IsUserOwner @@ -29,7 +30,6 @@ class ProjectSearchView(APIView): def get(self, request, *args, **kwargs): q = request.query_params.get("query", "").strip() - print(q) # query가 비어있으면 빈 결과 반환 # TODO: 나중에 프로젝트 페이지와 합치게 되면 전부 보이는 걸로 바뀌어야 할 지도? if not q: @@ -50,7 +50,7 @@ def get(self, request, *args, **kwargs): task_qs = Task.objects.filter( Q(name__icontains=q) ) - + project_data = ProjectSearchSerializer(project_qs, many=True).data drawer_data = DrawerSearchSerializer(drawer_qs, many=True).data task_data = TaskSearchSerializer(task_qs, many=True).data @@ -59,4 +59,31 @@ def get(self, request, *args, **kwargs): "projects": project_data, "drawers": drawer_data, "tasks": task_data, - }) \ No newline at end of file + }) + +# GlobalSearchView는 pagination X +class GlobalSearchView(APIView): + def get(self, request, *args, **kwargs): + query = request.query_params.get("query", "").strip() + # bitmask: project / drawer / task + scope = request.query_params.get("scope", "7").strip() + + try: + scope = int(scope) + except ValueError: + return Response( + {"detail": "search_range must be an integer"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # query가 비어있으면 빈 결과 반환 + if not query: + return Response({ + "projects": [], + "drawers": [], + "tasks": [], + }) + + results = global_search(query, scope) + + return Response(results) \ No newline at end of file diff --git a/frontend/src/api/search.api.ts b/frontend/src/api/search.api.ts index 0693660a..c8bf73fa 100644 --- a/frontend/src/api/search.api.ts +++ b/frontend/src/api/search.api.ts @@ -21,7 +21,7 @@ export type ProjectType = "inbox" | "regular" | "goal" export const getSearchResults = async (query: string) => { const search = query const res = await client.get(`search/`, { - params: { search, query}, + params: { search }, }) return res.data From 5cf9386d0e63744f21871ad957afc4d24f93ecf0 Mon Sep 17 00:00:00 2001 From: andless2004 Date: Sun, 21 Dec 2025 06:57:46 +0000 Subject: [PATCH 12/17] Add GlobalSearchResults to display ordered task, drawer, and project results --- backend/search/service.py | 18 ++++--- backend/search/views.py | 9 ++-- frontend/src/api/search.api.ts | 29 +++++----- .../components/search/GlobalSearchResults.tsx | 54 +++++++++++++++++++ frontend/src/components/search/ResultBox.tsx | 5 ++ frontend/src/pages/SearchPage.tsx | 25 +++------ 6 files changed, 97 insertions(+), 43 deletions(-) create mode 100644 frontend/src/components/search/GlobalSearchResults.tsx create mode 100644 frontend/src/components/search/ResultBox.tsx diff --git a/backend/search/service.py b/backend/search/service.py index d046f70b..575fecaf 100644 --- a/backend/search/service.py +++ b/backend/search/service.py @@ -6,6 +6,7 @@ from .serializers import ProjectSearchSerializer, DrawerSearchSerializer, TaskSearchSerializer SCOPE_BITMASK = {"task": 1, "drawer": 2, "project": 4} +SEARCH_PREVIEW_LIMIT = 4 def global_search(query, scope): results = dict() @@ -13,17 +14,22 @@ def global_search(query, scope): targets = { "task": {"model": Task, "serializer": TaskSearchSerializer}, "drawer": {"model": Drawer, "serializer": DrawerSearchSerializer}, - "project": {"model": Project, "serializer": ProjectSearchSerializer} + "project": {"model": Project, "serializer": ProjectSearchSerializer}, } - for scope, value in targets.items(): - if scope & SCOPE_BITMASK[scope]: + for key, value in targets.items(): + if scope & SCOPE_BITMASK[key]: query_set = value["model"].objects.filter( Q(name__icontains=query) ) + count = query_set.count() + + if count > SEARCH_PREVIEW_LIMIT: + query_set = query_set[:SEARCH_PREVIEW_LIMIT] + data = value["serializer"](query_set, many=True).data - results[scope] = data + results[key] = {"data": data, "count": count} else: - results[scope] = [] - + results[key] = {"data": [], "count": 0} + return results \ No newline at end of file diff --git a/backend/search/views.py b/backend/search/views.py index b9fc2de0..818cb54b 100644 --- a/backend/search/views.py +++ b/backend/search/views.py @@ -64,7 +64,7 @@ def get(self, request, *args, **kwargs): # GlobalSearchView는 pagination X class GlobalSearchView(APIView): def get(self, request, *args, **kwargs): - query = request.query_params.get("query", "").strip() + query = request.query_params.get("keyword", "").strip() # bitmask: project / drawer / task scope = request.query_params.get("scope", "7").strip() @@ -79,11 +79,10 @@ def get(self, request, *args, **kwargs): # query가 비어있으면 빈 결과 반환 if not query: return Response({ - "projects": [], - "drawers": [], - "tasks": [], + "projects": {"data": [], "count": 0}, + "drawers": {"data": [], "count": 0}, + "tasks": {"data": [], "count": 0}, }) - results = global_search(query, scope) return Response(results) \ No newline at end of file diff --git a/frontend/src/api/search.api.ts b/frontend/src/api/search.api.ts index c8bf73fa..0254c2b7 100644 --- a/frontend/src/api/search.api.ts +++ b/frontend/src/api/search.api.ts @@ -3,25 +3,28 @@ import type { Base, PaginationData, Privacy } from "@api/common" import type { User } from "@api/users.api" import type { PaletteColorName } from "@assets/palettes" +import { Project } from "@api/projects.api" +import { Drawer } from "@api/drawers.api" +import { Task } from "@api/tasks.api" -export interface Project extends Base { - name: string - user: User - order: number - privacy: Privacy | null - color: PaletteColorName - type: ProjectType - completed_task_count: number - uncompleted_task_count: number +type ResultBlock = { + data: T[] + count: number +} + +export interface SearchResponse { + project: ResultBlock + drawer: ResultBlock + task: ResultBlock } export type ProjectType = "inbox" | "regular" | "goal" -//export const getSearchResults = async (query: string, page: string) => { -export const getSearchResults = async (query: string) => { - const search = query +//export const getGlobalSearchResults = async (query: string, page: string) => { +export const getGlobalSearchResults = async (query: string) => { + const keyword = query const res = await client.get(`search/`, { - params: { search }, + params: { keyword }, }) return res.data diff --git a/frontend/src/components/search/GlobalSearchResults.tsx b/frontend/src/components/search/GlobalSearchResults.tsx new file mode 100644 index 00000000..7bd1d0d0 --- /dev/null +++ b/frontend/src/components/search/GlobalSearchResults.tsx @@ -0,0 +1,54 @@ +import { useCallback, useMemo, useState } from "react" + +import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query" +import styled from "styled-components" +import { SearchResponse } from "@api/search.api" + +type InfoKey = "project" | "drawer" | "task" +const sectionOrder: InfoKey[] = ["project", "drawer", "task"] + +const GlobalSearchResults = ({ searchResults }: {searchResults: SearchResponse}) => { + const totalCount = searchResults ? Object.values(searchResults).reduce( + (sum, section) => sum + section.count, + 0 + ) : 0 + + if(!searchResults?.project) return + + return ( + totalCount === 0 ? ( + "검색 결과가 없습니다." + ):( + + {sectionOrder.map((key) => { + const section = searchResults[key] + if (section.count === 0) return + return ( + section.data.map((value, index: number) => ( + + {key + ": " + value.name} + + )) + ) + })} + + ) + ) +} + +const ResultsContainer = styled.div` + display: flex; + flex-direction: column; +` + +const ResultBlockBox = styled.div` + display: flex; + flex-direction: column; +` + +const ResultBox = styled.div` + display: flex; + flex-direction: column; +` + +export default GlobalSearchResults \ No newline at end of file diff --git a/frontend/src/components/search/ResultBox.tsx b/frontend/src/components/search/ResultBox.tsx new file mode 100644 index 00000000..3243b1d0 --- /dev/null +++ b/frontend/src/components/search/ResultBox.tsx @@ -0,0 +1,5 @@ +const ResultBox = () => { + +} + +export default ResultBox \ No newline at end of file diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx index a55dd1b9..36d631b1 100644 --- a/frontend/src/pages/SearchPage.tsx +++ b/frontend/src/pages/SearchPage.tsx @@ -10,7 +10,7 @@ import ProjectName from "@components/project/ProjectName" import ProjectEdit from "@components/project/edit/ProjectEdit" import SkeletonProjectList from "@components/project/skeletons/SkeletonProjectList" -import { getSearchResults } from "@api/search.api" +import GlobalSearchResults from "@components/search/GlobalSearchResults" import { type Project, getProjectList, @@ -28,6 +28,7 @@ import { ImpressionArea } from "@toss/impression-area" import FeatherIcon from "feather-icons-react" import { DndProvider } from "react-dnd-multi-backend" import { useTranslation } from "react-i18next" +import { getGlobalSearchResults, SearchResponse } from "@api/search.api" const SearchPage = () => { const { t } = useTranslation("translation") @@ -127,13 +128,12 @@ const SearchPage = () => { enabled: searchQuery.length > 0, queryFn: ({queryKey}) => { const [, q] = queryKey - return getSearchResults(q) + return getGlobalSearchResults(q) } }) - + console.log(searchData) // const searchResults = searchData?.pages.flatMap((page) => page.results) ?? []; - const searchResults = searchData - console.log(searchResults) + const searchResults: SearchResponse = searchData return ( <> @@ -155,20 +155,7 @@ const SearchPage = () => { {/* Search 관련 부분 */} - {/* - {searchResults.map((project) => - - {project.name} + " " + {project.type} - - )} - */} - {searchResults && Object.entries(searchResults).map(([type, resultsArray]) => ( - resultsArray && resultsArray.map((result) => - - {type + ": " + result.name} - - ) - ))} + {/* Search 관련 부분 끝 */} From bbd3e0d09fd784179722bbfb709680ac558abed7 Mon Sep 17 00:00:00 2001 From: andless2004 Date: Sun, 21 Dec 2025 08:19:44 +0000 Subject: [PATCH 13/17] Fix project result display to use designed blocks instead of plain text --- .../components/search/GlobalSearchResults.tsx | 16 ++++++ frontend/src/components/search/ResultBox.tsx | 49 +++++++++++++++++++ frontend/src/pages/SearchPage.tsx | 3 +- 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/search/GlobalSearchResults.tsx b/frontend/src/components/search/GlobalSearchResults.tsx index 7bd1d0d0..b349beff 100644 --- a/frontend/src/components/search/GlobalSearchResults.tsx +++ b/frontend/src/components/search/GlobalSearchResults.tsx @@ -3,6 +3,7 @@ import { useCallback, useMemo, useState } from "react" import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query" import styled from "styled-components" import { SearchResponse } from "@api/search.api" +import { ProjectResultBox } from "@components/search/ResultBox" type InfoKey = "project" | "drawer" | "task" const sectionOrder: InfoKey[] = ["project", "drawer", "task"] @@ -15,6 +16,10 @@ const GlobalSearchResults = ({ searchResults }: {searchResults: SearchResponse}) if(!searchResults?.project) return + const projectSection = searchResults["project"] + const drawerSection = searchResults["drawer"] + const taskSection = searchResults["task"] + return ( totalCount === 0 ? ( "검색 결과가 없습니다." @@ -31,6 +36,12 @@ const GlobalSearchResults = ({ searchResults }: {searchResults: SearchResponse}) )) ) })} + + + {projectSection?.data.map((project, index: number) => ( + + ))} + ) ) @@ -41,6 +52,11 @@ const ResultsContainer = styled.div` flex-direction: column; ` +const SectionContainer = styled.div` + display: flex; + flex-direction: column; +` + const ResultBlockBox = styled.div` display: flex; flex-direction: column; diff --git a/frontend/src/components/search/ResultBox.tsx b/frontend/src/components/search/ResultBox.tsx index 3243b1d0..ff7034a7 100644 --- a/frontend/src/components/search/ResultBox.tsx +++ b/frontend/src/components/search/ResultBox.tsx @@ -1,5 +1,54 @@ +import { useEffect, useRef } from "react" +import { useNavigate } from "react-router-dom" + +import styled from "styled-components" + +import ProjectNameBox, { + NameBox, + NameText, + TypeText, +} from "@components/project/ProjectNameBox" + +import { type Project } from "@api/projects.api" + +import { usePaletteColor } from "@assets/palettes" + +import FeatherIcon from "feather-icons-react" +import { useTranslation } from "react-i18next" + const ResultBox = () => { } +export const ProjectResultBox = ({ project } : { project: Project }) => { + const { t } = useTranslation("translation", { keyPrefix: "project_list" }) + const color = usePaletteColor(project.color) + + const isInbox = project.type === "inbox" + const navigate = useNavigate() + + const projectLink = + project.type === "inbox" + ? "/app/projects/inbox" + : `/app/projects/${project.id}` + + const name = project.type === "inbox" ? t("inbox") : project.name + + return ( + + + +
navigate(projectLink)} role="link"> + {name} +
+ + {project.type === "regular" && t("type_regular")} + {project.type === "goal" && t("type_goal")} + +
+
+ ) +} + export default ResultBox \ No newline at end of file diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx index 36d631b1..c23179d1 100644 --- a/frontend/src/pages/SearchPage.tsx +++ b/frontend/src/pages/SearchPage.tsx @@ -119,6 +119,8 @@ const SearchPage = () => { initialPageParam: "1", getNextPageParam: (lastPage) => getPageFromURL(lastPage.next), }) + + // const searchResults = searchData?.pages.flatMap((page) => page.results) ?? []; */ const { data: searchData @@ -132,7 +134,6 @@ const SearchPage = () => { } }) console.log(searchData) - // const searchResults = searchData?.pages.flatMap((page) => page.results) ?? []; const searchResults: SearchResponse = searchData return ( From 8e08ef3cae24e0f9eb3197356ab1b5fcdb45adb0 Mon Sep 17 00:00:00 2001 From: andless2004 Date: Sun, 8 Feb 2026 08:29:07 +0000 Subject: [PATCH 14/17] Fix drawer result display to use disigned block instead plain text --- backend/search/serializers.py | 2 ++ backend/search/views.py | 2 +- frontend/src/api/search.api.ts | 6 +++- .../components/search/GlobalSearchResults.tsx | 8 ++++- frontend/src/components/search/ResultBox.tsx | 30 +++++++++++++++++-- 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/backend/search/serializers.py b/backend/search/serializers.py index 08a9e56b..2a1ee8b8 100644 --- a/backend/search/serializers.py +++ b/backend/search/serializers.py @@ -10,6 +10,8 @@ class Meta: exclude = () class DrawerSearchSerializer(serializers.ModelSerializer): + color = serializers.CharField(source="project.color", read_only=True) + class Meta: model = Drawer exclude = () diff --git a/backend/search/views.py b/backend/search/views.py index 818cb54b..31f501d3 100644 --- a/backend/search/views.py +++ b/backend/search/views.py @@ -43,7 +43,7 @@ def get(self, request, *args, **kwargs): Q(name__icontains=q) ) - drawer_qs = Drawer.objects.filter( + drawer_qs = Drawer.objects.select_related("project").filter( Q(name__icontains=q) ) diff --git a/frontend/src/api/search.api.ts b/frontend/src/api/search.api.ts index 0254c2b7..8f6b3764 100644 --- a/frontend/src/api/search.api.ts +++ b/frontend/src/api/search.api.ts @@ -7,6 +7,10 @@ import { Project } from "@api/projects.api" import { Drawer } from "@api/drawers.api" import { Task } from "@api/tasks.api" +export interface DrawerSearchResult extends Project { + color: PaletteColorName +} + type ResultBlock = { data: T[] count: number @@ -14,7 +18,7 @@ type ResultBlock = { export interface SearchResponse { project: ResultBlock - drawer: ResultBlock + drawer: ResultBlock task: ResultBlock } diff --git a/frontend/src/components/search/GlobalSearchResults.tsx b/frontend/src/components/search/GlobalSearchResults.tsx index b349beff..9c91a2de 100644 --- a/frontend/src/components/search/GlobalSearchResults.tsx +++ b/frontend/src/components/search/GlobalSearchResults.tsx @@ -3,7 +3,7 @@ import { useCallback, useMemo, useState } from "react" import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query" import styled from "styled-components" import { SearchResponse } from "@api/search.api" -import { ProjectResultBox } from "@components/search/ResultBox" +import { ProjectResultBox, DrawerResultBox } from "@components/search/ResultBox" type InfoKey = "project" | "drawer" | "task" const sectionOrder: InfoKey[] = ["project", "drawer", "task"] @@ -42,6 +42,12 @@ const GlobalSearchResults = ({ searchResults }: {searchResults: SearchResponse}) ))} + + + {drawerSection?.data.map((drawer, index: number) => ( + + ))} + ) ) diff --git a/frontend/src/components/search/ResultBox.tsx b/frontend/src/components/search/ResultBox.tsx index ff7034a7..6fa2c6ef 100644 --- a/frontend/src/components/search/ResultBox.tsx +++ b/frontend/src/components/search/ResultBox.tsx @@ -1,4 +1,3 @@ -import { useEffect, useRef } from "react" import { useNavigate } from "react-router-dom" import styled from "styled-components" @@ -8,8 +7,11 @@ import ProjectNameBox, { NameText, TypeText, } from "@components/project/ProjectNameBox" +import DrawerBox, { DrawerName } from "@components/drawers/DrawerBox" +import PrivacyIcon from "@components/project/common/PrivacyIcon" -import { type Project } from "@api/projects.api" +import { Project } from "@api/projects.api" +import { DrawerSearchResult } from "@api/search.api" import { usePaletteColor } from "@assets/palettes" @@ -51,4 +53,28 @@ export const ProjectResultBox = ({ project } : { project: Project }) => { ) } +export const DrawerResultBox = ({ drawer } : { drawer: DrawerSearchResult }) => { + const color = usePaletteColor(drawer.color) + + return ( + + + {drawer.name} + + + {/* DrawerIcons */} + + ) +} + +// TODO: components/drawers/DrawerBlock.tsx와 중복됨 +// -> DrawerBlock에서 export하는 방식 괜찮을까? 문제는 없는데 사소하단 느낌이 듦... +const DrawerTitleBox = styled.div` + display: flex; + align-items: center; +` + export default ResultBox \ No newline at end of file From a41ef2ce19abf6803b36d3cf349c4307dbdd7950 Mon Sep 17 00:00:00 2001 From: andless2004 Date: Sun, 8 Feb 2026 10:57:36 +0000 Subject: [PATCH 15/17] Fix task result display to use designed block instead of plain text --- backend/search/serializers.py | 2 + backend/search/service.py | 11 +- backend/search/views.py | 2 +- frontend/src/api/search.api.ts | 10 +- .../components/search/GlobalSearchResults.tsx | 23 ++-- frontend/src/components/search/ResultBox.tsx | 100 +++++++++++++++++- 6 files changed, 122 insertions(+), 26 deletions(-) diff --git a/backend/search/serializers.py b/backend/search/serializers.py index 2a1ee8b8..02c8f0d9 100644 --- a/backend/search/serializers.py +++ b/backend/search/serializers.py @@ -17,6 +17,8 @@ class Meta: exclude = () class TaskSearchSerializer(serializers.ModelSerializer): + color = serializers.CharField(source="drawer.project.color", read_only=True) + class Meta: model = Task exclude = () diff --git a/backend/search/service.py b/backend/search/service.py index 575fecaf..67331a29 100644 --- a/backend/search/service.py +++ b/backend/search/service.py @@ -11,15 +11,18 @@ def global_search(query, scope): results = dict() + # TODO: global한 결과를 내놓기에 각 결과가 FK object 전체를 들고 오는 것보다 필요한 color만 쓰는 것이 낫다 판단함. + # 검색 옵션에 따라 project, drawer object를 같이 반환하도록 하는 것을 고려 + # drawer serializer 참고 targets = { - "task": {"model": Task, "serializer": TaskSearchSerializer}, - "drawer": {"model": Drawer, "serializer": DrawerSearchSerializer}, - "project": {"model": Project, "serializer": ProjectSearchSerializer}, + "task": {"objects": Task.objects.select_related("drawer__project"), "serializer": TaskSearchSerializer}, + "drawer": {"objects": Drawer.objects.select_related("project"), "serializer": DrawerSearchSerializer}, + "project": {"objects": Project.objects, "serializer": ProjectSearchSerializer}, } for key, value in targets.items(): if scope & SCOPE_BITMASK[key]: - query_set = value["model"].objects.filter( + query_set = value["objects"].filter( Q(name__icontains=query) ) count = query_set.count() diff --git a/backend/search/views.py b/backend/search/views.py index 31f501d3..3612383a 100644 --- a/backend/search/views.py +++ b/backend/search/views.py @@ -47,7 +47,7 @@ def get(self, request, *args, **kwargs): Q(name__icontains=q) ) - task_qs = Task.objects.filter( + task_qs = Task.objects.select_related("drawer__project").filter( Q(name__icontains=q) ) diff --git a/frontend/src/api/search.api.ts b/frontend/src/api/search.api.ts index 8f6b3764..620fe595 100644 --- a/frontend/src/api/search.api.ts +++ b/frontend/src/api/search.api.ts @@ -7,7 +7,11 @@ import { Project } from "@api/projects.api" import { Drawer } from "@api/drawers.api" import { Task } from "@api/tasks.api" -export interface DrawerSearchResult extends Project { +export interface DrawerSearchResult extends Drawer { + color: PaletteColorName +} + +export type TaskSearchResult = Task & { color: PaletteColorName } @@ -19,7 +23,7 @@ type ResultBlock = { export interface SearchResponse { project: ResultBlock drawer: ResultBlock - task: ResultBlock + task: ResultBlock } export type ProjectType = "inbox" | "regular" | "goal" @@ -27,7 +31,7 @@ export type ProjectType = "inbox" | "regular" | "goal" //export const getGlobalSearchResults = async (query: string, page: string) => { export const getGlobalSearchResults = async (query: string) => { const keyword = query - const res = await client.get(`search/`, { + const res = await client.get(`search/`, { params: { keyword }, }) diff --git a/frontend/src/components/search/GlobalSearchResults.tsx b/frontend/src/components/search/GlobalSearchResults.tsx index 9c91a2de..5890a10a 100644 --- a/frontend/src/components/search/GlobalSearchResults.tsx +++ b/frontend/src/components/search/GlobalSearchResults.tsx @@ -3,10 +3,7 @@ import { useCallback, useMemo, useState } from "react" import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query" import styled from "styled-components" import { SearchResponse } from "@api/search.api" -import { ProjectResultBox, DrawerResultBox } from "@components/search/ResultBox" - -type InfoKey = "project" | "drawer" | "task" -const sectionOrder: InfoKey[] = ["project", "drawer", "task"] +import { ProjectResultBox, DrawerResultBox, TaskResultBox } from "@components/search/ResultBox" const GlobalSearchResults = ({ searchResults }: {searchResults: SearchResponse}) => { const totalCount = searchResults ? Object.values(searchResults).reduce( @@ -25,18 +22,6 @@ const GlobalSearchResults = ({ searchResults }: {searchResults: SearchResponse}) "검색 결과가 없습니다." ):( - {sectionOrder.map((key) => { - const section = searchResults[key] - if (section.count === 0) return - return ( - section.data.map((value, index: number) => ( - - {key + ": " + value.name} - - )) - ) - })} - {projectSection?.data.map((project, index: number) => ( @@ -48,6 +33,12 @@ const GlobalSearchResults = ({ searchResults }: {searchResults: SearchResponse}) ))} + + + {taskSection?.data.map((task, index: number) => ( + + ))} + ) ) diff --git a/frontend/src/components/search/ResultBox.tsx b/frontend/src/components/search/ResultBox.tsx index 6fa2c6ef..91e73774 100644 --- a/frontend/src/components/search/ResultBox.tsx +++ b/frontend/src/components/search/ResultBox.tsx @@ -9,9 +9,13 @@ import ProjectNameBox, { } from "@components/project/ProjectNameBox" import DrawerBox, { DrawerName } from "@components/drawers/DrawerBox" import PrivacyIcon from "@components/project/common/PrivacyIcon" +import Priority from "@components/tasks/Priority" +import TaskCircle from "@components/tasks/TaskCircle" import { Project } from "@api/projects.api" -import { DrawerSearchResult } from "@api/search.api" +import { DrawerSearchResult, TaskSearchResult } from "@api/search.api" + +import { ifMobile } from "@utils/useScreenType" import { usePaletteColor } from "@assets/palettes" @@ -60,7 +64,8 @@ export const DrawerResultBox = ({ drawer } : { drawer: DrawerSearchResult }) => + $isDraggable={true} + > {drawer.name} @@ -72,9 +77,100 @@ export const DrawerResultBox = ({ drawer } : { drawer: DrawerSearchResult }) => // TODO: components/drawers/DrawerBlock.tsx와 중복됨 // -> DrawerBlock에서 export하는 방식 괜찮을까? 문제는 없는데 사소하단 느낌이 듦... +// DrawerBlock 시작 const DrawerTitleBox = styled.div` display: flex; align-items: center; ` +// DrawerBlock 끝 + +export const TaskResultBox = ({ task } : { task : TaskSearchResult }) => { + const isSocial = false + const isCompleted = task.completed_at !== null + const hasDate = task.due_type !== null || task.assigned_at !== null + + return ( + + + + + + + + + {task.name} + + + + + ) +} + +// TODO: 하하 정말 이러고 싶지 않은데... +// 나중에 patch/mutation 적용하면서 좀 더 정돈되게 해야지.. +// TaskFrame 시작 +const Box = styled.div` + display: flex; + align-items: center; + margin-top: 0.9em; + margin-bottom: 0.9em; + width: 100%; + + min-width: 0; +` + +const Content = styled.div` + min-width: 0; + width: 100%; +` + +const CircleName = styled.div` + display: flex; + width: 100%; +` + +const TaskNameBox = styled.div<{ $isCompleted: boolean; $isSocial?: boolean }>` + display: inline-block; + font-size: 1.1em; + font-style: normal; + color: ${(p) => { + if (p.$isCompleted && !p.$isSocial) return p.theme.grey + return p.theme.textColor + }}; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + line-height: 1.3em; + width: 100%; + min-width: 0; + + ${ifMobile} { + white-space: normal; + word-wrap: normal; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } +` + +const Icons = styled.div` + display: flex; + align-items: center; +` +// TaskFrame 끝 + export default ResultBox \ No newline at end of file From 7a74d43eb51be56b8e09a094a5e30bf2129b135a Mon Sep 17 00:00:00 2001 From: andless2004 Date: Sun, 15 Feb 2026 07:18:57 +0000 Subject: [PATCH 16/17] Add FilterBox with fix SearchPage title layout --- frontend/src/components/search/Search.tsx | 64 ++++++++++++++++------- frontend/src/pages/SearchPage.tsx | 4 +- 2 files changed, 48 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/search/Search.tsx b/frontend/src/components/search/Search.tsx index 826b4efb..b7adf4d1 100644 --- a/frontend/src/components/search/Search.tsx +++ b/frontend/src/components/search/Search.tsx @@ -73,28 +73,44 @@ const Search = ({searchQuery, setSearchQuery}: SearchProps) => { } return ( - - - + + + + + + - - - - + + + 123 + 123 + 123 + 123 + + ) } -const SearchWrapper = styled.div` +const SearchContainer = styled.div` + flex: 1; + margin-top: 0.3em; + + display: flex; + flex-direction: column; +` + +const SearchBox = styled.div` flex: 1; display: flex; @@ -120,4 +136,16 @@ const InputBox = styled.input` font-size: 1em; ` +const FiltersContainer = styled.div` + margin-left: 0.5em; + + display: flex; +` + +const FilterBox = styled.div` + border: 1.5px solid ${(p) => p.theme.textColor}; + border-radius: 16px; + padding: 0.5em 0.75em; +` + export default Search \ No newline at end of file diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx index c23179d1..b67f8378 100644 --- a/frontend/src/pages/SearchPage.tsx +++ b/frontend/src/pages/SearchPage.tsx @@ -133,7 +133,6 @@ const SearchPage = () => { return getGlobalSearchResults(q) } }) - console.log(searchData) const searchResults: SearchResponse = searchData return ( @@ -200,10 +199,11 @@ const SearchPage = () => { const PageTitleBox = styled.div` display: flex; - align-items: center; + align-items: flex-start; ` const PlusBox = styled.div` + margin-top: 0.3em; margin-left: 0.8em; padding-bottom: 0.8em; cursor: pointer; From be7e77169f789527b00b613d0473298c8d93a37c Mon Sep 17 00:00:00 2001 From: andless2004 Date: Mon, 16 Feb 2026 08:42:40 +0000 Subject: [PATCH 17/17] Add frontend-only FilterButton --- .../src/assets/locales/en/translation.json | 7 +- .../src/assets/locales/ko/translation.json | 7 +- frontend/src/components/search/FilterBox.tsx | 101 ++++++++++++++++++ .../src/components/search/FilterInput.tsx | 81 ++++++++++++++ frontend/src/components/search/Filters.tsx | 84 +++++++++++++++ frontend/src/components/search/Search.tsx | 36 +++---- 6 files changed, 296 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/search/FilterBox.tsx create mode 100644 frontend/src/components/search/FilterInput.tsx create mode 100644 frontend/src/components/search/Filters.tsx diff --git a/frontend/src/assets/locales/en/translation.json b/frontend/src/assets/locales/en/translation.json index 7922d978..360d3428 100644 --- a/frontend/src/assets/locales/en/translation.json +++ b/frontend/src/assets/locales/en/translation.json @@ -428,7 +428,12 @@ } }, "search": { - "placeholder": "Search your task" + "placeholder": "Search your task", + "filter": { + "project": "project", + "drawer": "drawer", + "date": "date" + } }, "update": { "message": "New update available.", diff --git a/frontend/src/assets/locales/ko/translation.json b/frontend/src/assets/locales/ko/translation.json index 406cae5c..05baa138 100644 --- a/frontend/src/assets/locales/ko/translation.json +++ b/frontend/src/assets/locales/ko/translation.json @@ -428,7 +428,12 @@ } }, "search": { - "placeholder": "할 일을 검색해 보세요" + "placeholder": "할 일을 검색해 보세요", + "filter": { + "project": "프로젝트", + "drawer": "서랍", + "date": "날짜" + } }, "update": { "message": "새로운 업데이트가 있습니다.", diff --git a/frontend/src/components/search/FilterBox.tsx b/frontend/src/components/search/FilterBox.tsx new file mode 100644 index 00000000..29117497 --- /dev/null +++ b/frontend/src/components/search/FilterBox.tsx @@ -0,0 +1,101 @@ +import { useState, useRef, useEffect } from "react" +import styled from "styled-components" +import { FilterValues, FilterLabel } from "@components/search/Filters" +import FilterInput from "@components/search/FilterInput" +import MildButton from "@components/common/MildButton" + +import { ifMobile } from "@utils/useScreenType" + +import FeatherIcon from "feather-icons-react" + +interface FilterBoxProps { + filterDisplay: string + filterValue: FilterValues[K] + setFilterValue: (value: FilterValues[K]) => void +} + +type Position = { + top: number + left: number +} + +// TODO: 버튼 누르면 깜빡거리는 이유는 알겠지만 고치는 건 모르겠다... +const FilterBox = ({ filterDisplay, filterValue, setFilterValue }: FilterBoxProps) => { + const [isEditing, setIsEditing] = useState(false) + + const boxRef = useRef(null) + + // TODO: Calendar 사용 위함 + const [inputPosition, setInputPosition] = useState({ top: 0, left: 0 }) + + const handleInputState = () => { + setIsEditing(true) + + if (boxRef.current) { + const rect = boxRef.current.getBoundingClientRect() + setInputPosition({ + top: window.scrollY + rect.top + rect.height, + left: rect.left, + }) + } + } + + const handleClear = (e: React.MouseEvent) => { + e.stopPropagation() + setIsEditing(false) + setFilterValue("") + } + + return + {filterDisplay + (isEditing || !!filterValue ? ": " : "")} + {isEditing ? + + : + filterValue + } + {!!filterValue && + + + + } + +} + +const Box = styled.div` + border: 1.5px solid ${(p) => p.theme.textColor}; + border-radius: 16px; + padding: 0.5em 0.75em; + + display: flex; + align-items: center; +` + +const ClearButton = styled(MildButton)` + margin-right: -0.6em; + + display: flex; + justify-content: center; + align-items: center; + font-size: 0.9em; + + transition: all 0.2s ease; + + &:hover { + opacity: 100%; + } + + & svg { + top: unset; + margin-right: unset; + + border-radius: 100%; + padding: 0.1em; + } + + ${ifMobile} { + opacity: 100%; + } +` + + +export default FilterBox \ No newline at end of file diff --git a/frontend/src/components/search/FilterInput.tsx b/frontend/src/components/search/FilterInput.tsx new file mode 100644 index 00000000..a530e704 --- /dev/null +++ b/frontend/src/components/search/FilterInput.tsx @@ -0,0 +1,81 @@ +import { useState, useRef, useEffect } from "react" +import styled from "styled-components" +import { FilterValues, FilterLabel } from "@components/search/Filters" + +interface FilterInputProps { + setIsEditing: (value: boolean) => void + filterValue: FilterValues[K] + setFilterValue: (value: string) => void +} + +const FilterInput = ({ setIsEditing, filterValue, setFilterValue }: FilterInputProps) => { + const [inputValue, setInputValue] = useState(filterValue || "") + + const ghostSpanRef = useRef(null) + const [inputWidth, setInputWidth] = useState(0) + + useEffect(() => { + if (ghostSpanRef.current) { + const width = ghostSpanRef.current.getBoundingClientRect().width + setInputWidth(width) + } + }, [inputValue]) + + const handleChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key == "Enter") { + setIsEditing(false) + const trimmedText = inputValue.trim() + setInputValue(trimmedText) + setFilterValue(trimmedText) + } + } + + const handleBlur = () => { + setIsEditing(false) + const trimmedText = inputValue.trim() + setInputValue(trimmedText) + setFilterValue(trimmedText) + } + + return ( + <> + {inputValue} + + + ) +} + +const GhostSpan = styled.span` + position: absolute; + + opacity: 0%; + + white-space: pre-wrap; + font-size: 1em; +` + +const TextForm = styled.input<{ $length: number }>` + margin-left: 0.25em; + height: 1em; + width: ${(props) => props.$length}px; + + padding: 0; + overflow-y: visible; + + font-size: 1em; + line-height: 1em; +` + +export default FilterInput \ No newline at end of file diff --git a/frontend/src/components/search/Filters.tsx b/frontend/src/components/search/Filters.tsx new file mode 100644 index 00000000..668b97f1 --- /dev/null +++ b/frontend/src/components/search/Filters.tsx @@ -0,0 +1,84 @@ +import { useMemo } from "react" + +import styled from "styled-components" + +import type { TFunction } from "i18next" +import { useTranslation } from "react-i18next" +import FilterBox from "@components/search/FilterBox" + +export type FilterValues = { + project: string + drawer: string + date: string +} +export type FilterLabel = keyof FilterValues + +type FilterType = "text" | "tags" | "select" | "date" +type Filter = { + label: FilterLabel + display: string + type: FilterType +} + +interface FilterProps { + filterValues: FilterValues + setFilterValues: React.Dispatch> +} + +const Filters = ({ filterValues, setFilterValues }: FilterProps) => { + const { t } = useTranslation("translation", { keyPrefix: "search.filter"}) + + const filterItems = useMemo(() => getFilterItems(t), [t]) + + const setFilterValue = (label: K) => + (value: FilterValues[K]) => { + setFilterValues((prev) => { + if (prev[label] === value) return prev + console.log(label + ": " + value) + return { ...prev, [label]: value,} + }) + } + + return ( + + {filterItems.map((filterItem) => { + return ( + + ) + })} + + ) +} + +const getFilterItems = (t: TFunction<"translation", "search.filter">) => [ + { + label: "project", + display: t("project"), + type: "text", + }, + { + label: "drawer", + display: t("drawer"), + type: "text" + }, + { + label: "date", + display: t("date"), + type: "date" + }, + ] as Filter[] + + +const FiltersContainer = styled.div` + margin-left: 0.5em; + + display: flex; + gap: 0.25em; +` + +export default Filters \ No newline at end of file diff --git a/frontend/src/components/search/Search.tsx b/frontend/src/components/search/Search.tsx index b7adf4d1..f48106a5 100644 --- a/frontend/src/components/search/Search.tsx +++ b/frontend/src/components/search/Search.tsx @@ -4,6 +4,7 @@ import styled, { useTheme } from "styled-components" import FeatherIcon from "feather-icons-react" import { useTranslation } from "react-i18next" +import Filters, { type FilterValues } from "@components/search/Filters" interface SearchProps { searchQuery: string @@ -20,6 +21,22 @@ const Search = ({searchQuery, setSearchQuery}: SearchProps) => { const debounceTimerRef = useRef(null) const lastSearchRef = useRef<{ q: string; ts: number } | null>(null) + const [filterValues, setFilterValues] = useState({ project: "", drawer: "", date: "" }) + +// const queryString = useMemo(() => { +// const params = new URLSearchParams() + +// if (filters.keyword) { +// params.append("keyword", filters.keyword) +// } + +// filters.tags.forEach(tag => { +// params.append("tag", tag) +// }) + +// return params.toString() +// }, [filters]) + // temporary stub const handleExecuteSearch = (query: string) => { const now = Date.now() @@ -92,12 +109,7 @@ const Search = ({searchQuery, setSearchQuery}: SearchProps) => { onBlur={handleBlur} /> - - 123 - 123 - 123 - 123 - + ) } @@ -136,16 +148,4 @@ const InputBox = styled.input` font-size: 1em; ` -const FiltersContainer = styled.div` - margin-left: 0.5em; - - display: flex; -` - -const FilterBox = styled.div` - border: 1.5px solid ${(p) => p.theme.textColor}; - border-radius: 16px; - padding: 0.5em 0.75em; -` - export default Search \ No newline at end of file