diff --git a/backend/django_peak/urls.py b/backend/django_peak/urls.py index 218d473f8..ee57a2a4d 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/serializers.py b/backend/projects/serializers.py index 41e6dedc1..377163a33 100644 --- a/backend/projects/serializers.py +++ b/backend/projects/serializers.py @@ -1,9 +1,10 @@ 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): user = UserSerializer( default=serializers.CurrentUserDefault(), @@ -49,3 +50,19 @@ class ProjectSerializerForUserProjectList(serializers.ModelSerializer): class Meta: # pyright: ignore [reportIncompatibleVariableOverride] -- ModelSerializer.Meta model = Project exclude = () + +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/urls.py b/backend/projects/urls.py index 2e603dac6..a7cc1419a 100644 --- a/backend/projects/urls.py +++ b/backend/projects/urls.py @@ -8,7 +8,7 @@ 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 34c845a98..3c5786dc6 100644 --- a/backend/projects/views.py +++ b/backend/projects/views.py @@ -2,9 +2,14 @@ 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 .models import Project -from .serializers import ProjectSerializer, ProjectSerializerForUserProjectList +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 diff --git a/backend/search/__init__.py b/backend/search/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/search/admin.py b/backend/search/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /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 000000000..3afccb094 --- /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 000000000..e69de29bb diff --git a/backend/search/models.py b/backend/search/models.py new file mode 100644 index 000000000..71a836239 --- /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 000000000..02c8f0d90 --- /dev/null +++ b/backend/search/serializers.py @@ -0,0 +1,25 @@ +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): + color = serializers.CharField(source="project.color", read_only=True) + + class Meta: + model = Drawer + 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 new file mode 100644 index 000000000..67331a298 --- /dev/null +++ b/backend/search/service.py @@ -0,0 +1,38 @@ +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} +SEARCH_PREVIEW_LIMIT = 4 + +def global_search(query, scope): + results = dict() + + # TODO: global한 결과를 내놓기에 각 결과가 FK object 전체를 들고 오는 것보다 필요한 color만 쓰는 것이 낫다 판단함. + # 검색 옵션에 따라 project, drawer object를 같이 반환하도록 하는 것을 고려 + # drawer serializer 참고 + targets = { + "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["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[key] = {"data": data, "count": count} + else: + results[key] = {"data": [], "count": 0} + + return results \ No newline at end of file diff --git a/backend/search/tests.py b/backend/search/tests.py new file mode 100644 index 000000000..7ce503c2d --- /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 000000000..9293416f1 --- /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.GlobalSearchView.as_view()), +] + +urlpatterns = format_suffix_patterns(urlpatterns) diff --git a/backend/search/views.py b/backend/search/views.py new file mode 100644 index 000000000..3612383a2 --- /dev/null +++ b/backend/search/views.py @@ -0,0 +1,88 @@ +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 .service import global_search +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() + + # query가 비어있으면 빈 결과 반환 + # TODO: 나중에 프로젝트 페이지와 합치게 되면 전부 보이는 걸로 바뀌어야 할 지도? + if not q: + return Response({ + "projects": [], + "drawers": [], + "tasks": [], + }) + + project_qs = Project.objects.filter( + Q(name__icontains=q) + ) + + drawer_qs = Drawer.objects.select_related("project").filter( + Q(name__icontains=q) + ) + + task_qs = Task.objects.select_related("drawer__project").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, + }) + +# GlobalSearchView는 pagination X +class GlobalSearchView(APIView): + def get(self, request, *args, **kwargs): + query = request.query_params.get("keyword", "").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": {"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 new file mode 100644 index 000000000..620fe595e --- /dev/null +++ b/frontend/src/api/search.api.ts @@ -0,0 +1,39 @@ +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" +import { Project } from "@api/projects.api" +import { Drawer } from "@api/drawers.api" +import { Task } from "@api/tasks.api" + +export interface DrawerSearchResult extends Drawer { + color: PaletteColorName +} + +export type TaskSearchResult = Task & { + color: PaletteColorName +} + +type ResultBlock = { + data: T[] + count: number +} + +export interface SearchResponse { + project: ResultBlock + drawer: ResultBlock + task: ResultBlock +} + +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/`, { + params: { keyword }, + }) + + return res.data +} \ No newline at end of file diff --git a/frontend/src/assets/locales/en/translation.json b/frontend/src/assets/locales/en/translation.json index e9a3e6fcf..360d34287 100644 --- a/frontend/src/assets/locales/en/translation.json +++ b/frontend/src/assets/locales/en/translation.json @@ -427,6 +427,14 @@ "results_title": "Search results for ‘{{query}}’" } }, + "search": { + "placeholder": "Search your task", + "filter": { + "project": "project", + "drawer": "drawer", + "date": "date" + } + }, "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 bc9b097b0..05baa1380 100644 --- a/frontend/src/assets/locales/ko/translation.json +++ b/frontend/src/assets/locales/ko/translation.json @@ -427,6 +427,14 @@ "results_title": "‘{{query}}’에 대한 검색 결과" } }, + "search": { + "placeholder": "할 일을 검색해 보세요", + "filter": { + "project": "프로젝트", + "drawer": "서랍", + "date": "날짜" + } + }, "update": { "message": "새로운 업데이트가 있습니다.", "update": "업데이트", diff --git a/frontend/src/components/search/FilterBox.tsx b/frontend/src/components/search/FilterBox.tsx new file mode 100644 index 000000000..29117497b --- /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 000000000..a530e704b --- /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 000000000..668b97f1b --- /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/GlobalSearchResults.tsx b/frontend/src/components/search/GlobalSearchResults.tsx new file mode 100644 index 000000000..5890a10ad --- /dev/null +++ b/frontend/src/components/search/GlobalSearchResults.tsx @@ -0,0 +1,67 @@ +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, TaskResultBox } from "@components/search/ResultBox" + +const GlobalSearchResults = ({ searchResults }: {searchResults: SearchResponse}) => { + const totalCount = searchResults ? Object.values(searchResults).reduce( + (sum, section) => sum + section.count, + 0 + ) : 0 + + if(!searchResults?.project) return + + const projectSection = searchResults["project"] + const drawerSection = searchResults["drawer"] + const taskSection = searchResults["task"] + + return ( + totalCount === 0 ? ( + "검색 결과가 없습니다." + ):( + + + {projectSection?.data.map((project, index: number) => ( + + ))} + + + + {drawerSection?.data.map((drawer, index: number) => ( + + ))} + + + + {taskSection?.data.map((task, index: number) => ( + + ))} + + + ) + ) +} + +const ResultsContainer = styled.div` + display: flex; + flex-direction: column; +` + +const SectionContainer = 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 000000000..91e737746 --- /dev/null +++ b/frontend/src/components/search/ResultBox.tsx @@ -0,0 +1,176 @@ +import { useNavigate } from "react-router-dom" + +import styled from "styled-components" + +import ProjectNameBox, { + NameBox, + NameText, + TypeText, +} 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, TaskSearchResult } from "@api/search.api" + +import { ifMobile } from "@utils/useScreenType" + +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 const DrawerResultBox = ({ drawer } : { drawer: DrawerSearchResult }) => { + const color = usePaletteColor(drawer.color) + + return ( + + + {drawer.name} + + + {/* DrawerIcons */} + + ) +} + +// 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 diff --git a/frontend/src/components/search/Search.tsx b/frontend/src/components/search/Search.tsx new file mode 100644 index 000000000..f48106a5e --- /dev/null +++ b/frontend/src/components/search/Search.tsx @@ -0,0 +1,151 @@ +import { ChangeEvent, useRef, useState } from "react" + +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 + setSearchQuery: React.Dispatch> +} + +const Search = ({searchQuery, setSearchQuery}: SearchProps) => { + const { t } = useTranslation("translation", { keyPrefix: "search" }) + + const [searchInput, setSearchInput] = useState("") + + + const inputRef = useRef(null) + 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() + + setSearchInput(query) + + // 최근 같은 쿼리로 서칭되면 무시 (중복 방지) + // TODO: ref 대신 실 search 천에 퀴리만 이전과 비교하며, 같은 쿼리에 새로운 결과를 얻고 싶어하는 경우를 고려할 것. + if ( + lastSearchRef.current && + lastSearchRef.current.q === query + ) { + return + } + lastSearchRef.current = { q: query, ts: now } + + setSearchQuery(query) + } + + // Start input process + const handleClick = () => { + inputRef.current?.focus() + } + + // Edit query + const handleChange = (e: ChangeEvent) => { + setSearchInput(e.target.value) + + // Debounce + if (debounceTimerRef.current !== null) { + window.clearTimeout(debounceTimerRef.current) + } + + debounceTimerRef.current = window.setTimeout(() => { + const trimmed = e.target.value.trim() + handleExecuteSearch(trimmed) + }, 1000) + } + + // End and search + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key == "Enter") { + const trimed = searchInput.trim() + handleExecuteSearch(trimed) + } + } + + const handleBlur = (e: React.FocusEvent) => { + const trimed = e.target.value.trim() + handleExecuteSearch(trimed) + } + + return ( + + + + + + + + + + + ) +} + +const SearchContainer = styled.div` + flex: 1; + margin-top: 0.3em; + + display: flex; + flex-direction: column; +` + +const SearchBox = styled.div` + flex: 1; + + display: flex; + align-items: flex-start; +` + +const SearchIcon = styled.div` + margin-left: 0.8em; + padding-bottom: 0.8em; + cursor: pointer; + + & svg { + width: 16px; + height: 16px; + top: 0; + } +` + +const InputBox = styled.input` + flex: 1; + + min-width: 0; + font-size: 1em; +` + +export default Search \ No newline at end of file diff --git a/frontend/src/components/sidebar/Middle.tsx b/frontend/src/components/sidebar/Middle.tsx index 34f417c2e..3a25b4076 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 5e63eab64..c7f7eadcd 100644 --- a/frontend/src/pages/ProjectListPage.tsx +++ b/frontend/src/pages/ProjectListPage.tsx @@ -200,4 +200,4 @@ const ProjectCreateText = styled.div` margin-top: 0em; ` -export default ProjectListPage +export default ProjectListPage \ No newline at end of file diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx new file mode 100644 index 000000000..b67f83787 --- /dev/null +++ b/frontend/src/pages/SearchPage.tsx @@ -0,0 +1,263 @@ +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 SkeletonProjectList from "@components/project/skeletons/SkeletonProjectList" + +import GlobalSearchResults from "@components/search/GlobalSearchResults" +import { + type Project, + getProjectList, + patchReorderProject, +} from "@api/projects.api" + +import HTML5toTouch from "@utils/html5ToTouch" +import { getPageFromURL } from "@utils/pagination" +import useModal, { Portal } from "@utils/useModal" +import { ifMobile } from "@utils/useScreenType" + +import queryClient from "@queries/queryClient" + +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") + + 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 searchResults = searchData?.pages.flatMap((page) => page.results) ?? []; +*/ + const { + data: searchData + } = useQuery({ + queryKey: ["search", searchQuery], + // enabled: false, + enabled: searchQuery.length > 0, + queryFn: ({queryKey}) => { + const [, q] = queryKey + return getGlobalSearchResults(q) + } + }) + const searchResults: SearchResponse = searchData + + return ( + <> + + {t("project_list.title")} + {isPending || ( + { + modal.openModal() + }}> + + + )} + + + + {isPending && } + {isError && refetch()} />} + + {/* Search 관련 부분 */} + + + + {/* 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: flex-start; +` + +const PlusBox = styled.div` + margin-top: 0.3em; + 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 073b03557..e9c4e8963 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: ,