diff --git a/backend/app/controllers/ProjectController.scala b/backend/app/controllers/ProjectController.scala index 3ff8413..c826079 100644 --- a/backend/app/controllers/ProjectController.scala +++ b/backend/app/controllers/ProjectController.scala @@ -5,12 +5,7 @@ import dto.response.ApiResponse import play.api.i18n.I18nSupport.RequestWithMessagesApi import play.api.i18n.Messages import play.api.libs.json.{JsValue, Json} -import play.api.mvc.{ - Action, - AnyContent, - MessagesAbstractController, - MessagesControllerComponents -} +import play.api.mvc.{Action, AnyContent, MessagesAbstractController, MessagesControllerComponents} import services.ProjectService import utils.WritesExtras.unitWrites import validations.ValidationHandler @@ -124,4 +119,14 @@ class ProjectController @Inject()( Ok(Json.toJson(apiResponse)) } } + + def getProjectsByUser: Action[AnyContent] = + authenticatedActionWithUser.async { request => + val userId = request.userToken.userId + projectService.getProjectsByUserId(userId).map { projects => + val apiResponse = + ApiResponse.success("Projects retrieved", projects) + Ok(Json.toJson(apiResponse)) + } + } } diff --git a/backend/app/dto/response/task/TaskSearchResponse.scala b/backend/app/dto/response/task/TaskSearchResponse.scala index 55a644b..348dbda 100644 --- a/backend/app/dto/response/task/TaskSearchResponse.scala +++ b/backend/app/dto/response/task/TaskSearchResponse.scala @@ -1,5 +1,6 @@ package dto.response.task +import models.Enums.TaskStatus.TaskStatus import play.api.libs.json.{Format, Json} import java.time.Instant @@ -7,6 +8,7 @@ import java.time.Instant case class TaskSearchResponse(taskId: Int, taskName: String, taskDescription: Option[String], + taskStatus: TaskStatus, projectId: Int, projectName: String, columnName: String, diff --git a/backend/app/mappers/ProjectMapper.scala b/backend/app/mappers/ProjectMapper.scala new file mode 100644 index 0000000..df85416 --- /dev/null +++ b/backend/app/mappers/ProjectMapper.scala @@ -0,0 +1,12 @@ +package mappers + +object ProjectMapper { + + def toProjectResponse(entity: models.entities.Project): dto.response.project.ProjectResponse = + dto.response.project.ProjectResponse( + id = entity.id.getOrElse(0), + name = entity.name, + status = entity.status + ) + +} diff --git a/backend/app/repositories/ProjectRepository.scala b/backend/app/repositories/ProjectRepository.scala index 098e8a1..2879ca0 100644 --- a/backend/app/repositories/ProjectRepository.scala +++ b/backend/app/repositories/ProjectRepository.scala @@ -135,4 +135,15 @@ class ProjectRepository @Inject()( val action = insertQuery ++= entries db.run(action) } + + def getProjectsByUser(userId: Int): DBIO[Seq[Project]] = { + val q = for { + up <- userProjects + if up.userId === userId && (up.role === UserProjectRole.owner || up.role === UserProjectRole.member) + p <- projects if p.id === up.projectId && p.status =!= ProjectStatus.deleted + w <- workspaces if w.id === p.workspaceId && !w.isDeleted + } yield p + + q.result + } } diff --git a/backend/app/repositories/TaskRepository.scala b/backend/app/repositories/TaskRepository.scala index d89a405..127771d 100644 --- a/backend/app/repositories/TaskRepository.scala +++ b/backend/app/repositories/TaskRepository.scala @@ -2,6 +2,9 @@ package repositories import db.MyPostgresProfile.api.{columnStatusTypeMapper, projectStatusTypeMapper, taskStatusTypeMapper} import dto.response.task.{AssignMemberToTaskResponse, AssignedMemberResponse, TaskSummaryResponse} +import dto.response.task.AssignMemberToTaskResponse +import dto.response.task.TaskSummaryResponse +import models.Enums.TaskStatus.TaskStatus import models.Enums.{ColumnStatus, ProjectStatus, TaskStatus} import models.entities.{Task, UserTask} import play.api.db.slick.{DatabaseConfigProvider, HasDatabaseConfigProvider} @@ -131,11 +134,12 @@ class TaskRepository@Inject()( ): Query[(Rep[Int], Rep[String], Rep[Option[String]], + Rep[TaskStatus], Rep[Int], Rep[String], Rep[String], Rep[Instant]), - (Int, String, Option[String], Int, String, String, Instant), + (Int, String, Option[String], TaskStatus, Int, String, String, Instant), Seq] = { val baseQuery = @@ -147,7 +151,7 @@ class TaskRepository@Inject()( .on(_._2.projectId === _.id) .join(userProjects) .on(_._2.id === _.projectId) - if up.userId === userId + if up.userId === userId && t.status =!= TaskStatus.deleted && p.status =!= ProjectStatus.deleted && c.status =!= ColumnStatus.deleted } yield (t, c, p) val filtered = baseQuery @@ -164,7 +168,7 @@ class TaskRepository@Inject()( .sortBy { case (t, _, _) => t.updatedAt.desc } .map { case (t, c, p) => - (t.id, t.name, t.description, p.id, p.name, c.name, t.updatedAt) + (t.id, t.name, t.description, t.status, p.id, p.name, c.name, t.updatedAt) } } diff --git a/backend/app/services/ProjectService.scala b/backend/app/services/ProjectService.scala index 5d171c3..9bbcdf5 100644 --- a/backend/app/services/ProjectService.scala +++ b/backend/app/services/ProjectService.scala @@ -4,6 +4,7 @@ import dto.request.project.CreateProjectRequest import dto.response.project.{ProjectResponse, ProjectSummariesResponse} import dto.response.user.UserInProjectResponse import exception.AppException +import mappers.ProjectMapper import models.Enums.ProjectStatus.ProjectStatus import models.Enums.{ProjectStatus, ProjectVisibility} import models.entities.Project @@ -207,4 +208,10 @@ class ProjectService @Inject()( db.run(projectRepository.isUserInActiveProject(userId, projectId)) } + def getProjectsByUserId(userId: Int): Future[Seq[ProjectResponse]] = { + db.run(projectRepository.getProjectsByUser(userId)).map { + _.map(ProjectMapper.toProjectResponse) + } + } + } diff --git a/backend/app/services/TaskService.scala b/backend/app/services/TaskService.scala index eaba1d7..eaffbdc 100644 --- a/backend/app/services/TaskService.scala +++ b/backend/app/services/TaskService.scala @@ -279,8 +279,8 @@ class TaskService @Inject()(taskRepository: TaskRepository, .take(size) db.run(query.result).map(_.map { - case (taskId, taskName, taskDesc, projectId, projectName, columnName, updatedAt) => - TaskSearchResponse(taskId, taskName, taskDesc, projectId, projectName, columnName, updatedAt) + case (taskId, taskName, taskDesc, taskStatus, projectId, projectName, columnName, updatedAt) => + TaskSearchResponse(taskId, taskName, taskDesc, taskStatus, projectId, projectName, columnName, updatedAt) }) } diff --git a/backend/conf/routes b/backend/conf/routes index 30b1d96..20e9804 100644 --- a/backend/conf/routes +++ b/backend/conf/routes @@ -36,6 +36,7 @@ PATCH /api/projects/:projectId/reopen controllers.ProjectController.reopen GET /api/projects/completed controllers.ProjectController.getCompletedProjectsByUser GET /api/projects/:projectId/members controllers.ProjectController.getAllMembersInProject(projectId: Int) GET /api/projects/:projectId controllers.ProjectController.getProjectById(projectId: Int) +GET /api/projects controllers.ProjectController.getProjectsByUser # Column routes POST /api/projects/:projectId/columns controllers.ColumnController.create(projectId: Int) diff --git a/backend/test/controllers/ProjectControllerSpec.scala b/backend/test/controllers/ProjectControllerSpec.scala index 90641a3..596e380 100644 --- a/backend/test/controllers/ProjectControllerSpec.scala +++ b/backend/test/controllers/ProjectControllerSpec.scala @@ -222,5 +222,14 @@ class ProjectControllerSpec status(result) mustBe OK (contentAsJson(result) \ "message").as[String] mustBe "Project deleted successfully" } + + "get all projects by user successfully" in { + val request = FakeRequest(GET, s"/api/projects") + .withCookies(Cookie(cookieName, fakeToken)) + val result = route(app, request).get + + status(result) mustBe OK + (contentAsJson(result) \ "message").as[String] mustBe "Projects retrieved" + } } } diff --git a/frontend/index.html b/frontend/index.html index 7a4ffd6..51653ab 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - Vite + React + TS + Smart Taskhub
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7778a7b..3f83c6d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,7 +16,9 @@ "@tailwindcss/vite": "^4.1.11", "axios": "^1.11.0", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.536.0", + "qs": "^6.14.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-redux": "^9.2.0", @@ -26,6 +28,7 @@ "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", "tw-animate-css": "^1.3.7", + "use-debounce": "^10.0.6", "vite": "^7.0.6" }, "devDependencies": { @@ -36,6 +39,7 @@ "@testing-library/react": "^14.2.1", "@testing-library/user-event": "^14.5.2", "@types/node": "^24.3.0", + "@types/qs": "^6.14.0", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.7.0", @@ -2208,6 +2212,13 @@ "license": "MIT", "peer": true }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.1.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", @@ -2859,9 +2870,9 @@ } }, "node_modules/axios": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", - "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -2979,7 +2990,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -3217,6 +3227,16 @@ "node": ">=18" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -5468,7 +5488,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5836,6 +5855,21 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -6267,7 +6301,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6287,7 +6320,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6304,7 +6336,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -6323,7 +6354,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -6697,13 +6727,13 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -6713,10 +6743,13 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -6968,6 +7001,18 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-debounce": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.6.tgz", + "integrity": "sha512-C5OtPyhAZgVoteO9heXMTdW7v/IbFI+8bSVKYCJrSmiWWCLsbUxiBSp4t9v0hNBTGY97bT72ydDIDyGSFWfwXg==", + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/use-sync-external-store": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", @@ -6985,17 +7030,17 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.6.tgz", - "integrity": "sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==", + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz", + "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", "license": "MIT", "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.4.6", + "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", - "rollup": "^4.40.0", - "tinyglobby": "^0.2.14" + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" @@ -7082,10 +7127,13 @@ } }, "node_modules/vite/node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, diff --git a/frontend/package.json b/frontend/package.json index 4ec8773..e7c0e68 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,7 +24,9 @@ "@tailwindcss/vite": "^4.1.11", "axios": "^1.11.0", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.536.0", + "qs": "^6.14.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-redux": "^9.2.0", @@ -34,6 +36,7 @@ "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", "tw-animate-css": "^1.3.7", + "use-debounce": "^10.0.6", "vite": "^7.0.6" }, "devDependencies": { @@ -44,6 +47,7 @@ "@testing-library/react": "^14.2.1", "@testing-library/user-event": "^14.5.2", "@types/node": "^24.3.0", + "@types/qs": "^6.14.0", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.7.0", diff --git a/frontend/src/components/board/BoardClosedBanner.tsx b/frontend/src/components/board/BoardClosedBanner.tsx index 5b1fbea..c463bea 100644 --- a/frontend/src/components/board/BoardClosedBanner.tsx +++ b/frontend/src/components/board/BoardClosedBanner.tsx @@ -2,14 +2,14 @@ import { Lock, Unlock } from "lucide-react"; import type React from "react"; interface BoardClosedBannerProps { - status?: string; + isBoardClosed: boolean; handleReopenBoard: () => void; } -const BoardClosedBanner: React.FC = ({ status, handleReopenBoard }) => { +const BoardClosedBanner: React.FC = ({ isBoardClosed, handleReopenBoard }) => { return ( <> - {status && status === 'completed' ? ( + {isBoardClosed ? (
diff --git a/frontend/src/components/board/BoardNavbar.tsx b/frontend/src/components/board/BoardNavbar.tsx index 292fb94..5a28cd6 100644 --- a/frontend/src/components/board/BoardNavbar.tsx +++ b/frontend/src/components/board/BoardNavbar.tsx @@ -36,12 +36,14 @@ interface BoardNavbarProps { id: number; name?: string; isBoardClosed: boolean; + setIsBoardClosed: (closed: boolean) => void; } const BoardNavbar: React.FC = ({ id, name, isBoardClosed, + setIsBoardClosed }) => { const { wsId, boardId } = useParams(); @@ -71,7 +73,8 @@ const BoardNavbar: React.FC = ({ if (!boardId) return; await completedBoard(Number(boardId)); handleCloseMenu(); - navigate(`/workspace/boards/${wsId}`) + // navigate(`/board/${id}`, { replace: true }); + setIsBoardClosed(true); }; const cancelCloseBoard = () => { diff --git a/frontend/src/components/layout/MainLayout.tsx b/frontend/src/components/layout/MainLayout.tsx index a7241a6..108be02 100644 --- a/frontend/src/components/layout/MainLayout.tsx +++ b/frontend/src/components/layout/MainLayout.tsx @@ -1,11 +1,23 @@ import type { LayoutProps } from '@/types/user.types'; import Navbar from './components/Navbar'; +import { createContext, useState } from 'react'; + +type SearchContextType = { + showSearch: boolean; + setShowSearch: React.Dispatch>; +}; + +export const SearchContext = createContext(null); export const MainLayout: React.FC = ({ children }) => { + const [showSearch, setShowSearch] = useState(true); + return ( -
- -
{children}
-
+ +
+ +
{children}
+
+
); }; diff --git a/frontend/src/components/layout/components/Navbar.tsx b/frontend/src/components/layout/components/Navbar.tsx index 5c2a3a4..2c95970 100644 --- a/frontend/src/components/layout/components/Navbar.tsx +++ b/frontend/src/components/layout/components/Navbar.tsx @@ -1,115 +1,29 @@ -import React, { useState } from 'react'; +import React, { useContext, useEffect, useRef, useState } from 'react'; import { useAuth } from '@/hooks/useAuth'; -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import CreateWorkspaceModal from '@/components/shared/CreateModal'; - -interface ProfileDropdownProps { - userName?: string; - email?: string; - handleLogout: () => void; - handleCreateWorkspace: () => void; -} - -const ProfileDropDown: React.FC = ({ - userName, - email, - handleLogout, - handleCreateWorkspace, -}) => { - - return ( -
-
-
- ACCOUNT -
- -
-
- {userName - ?.charAt(0) - .toUpperCase() || 'VT'} -
-
-
- {userName || 'Vu Tran'} -
-
- {email || - 'tranmster5000@gmail.com'} -
-
-
- -
- {/*
- - - - - -
*/} - - -
- -
- -
-
-
- ) -} +import { useDebounce } from "use-debounce"; +import taskService from '@/services/taskService'; +import type { TaskSearchResponse } from '@/types'; +import { StickyNote } from 'lucide-react'; +import { SearchContext } from '../MainLayout'; +import ProfileDropDown from './ProfileDropDown'; const Navbar: React.FC = () => { const { user, logout } = useAuth(); const [isModalOpen, setIsModalOpen] = useState(false); const [isProfileOpen, setIsProfileOpen] = useState(false); + const [keyword, setKeyword] = useState(''); + const [debounceQuery] = useDebounce(keyword, 500); + const [isLoading, setIsLoading] = useState(false); + const [results, setResults] = useState([]); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const searchRef = useRef(null); + const navigate = useNavigate(); + const searchContext = useContext(SearchContext); + + if (!searchContext) return null; + const { showSearch } = searchContext; const handleCreateWorkspace = () => { setIsModalOpen(true); @@ -120,6 +34,49 @@ const Navbar: React.FC = () => { await logout(); }; + const handleSearch = async (keyword: string) => { + try { + if (!keyword.trim()) { + setResults([]); + return; + } + setIsLoading(true); + const res = await taskService.searchTasks(keyword.trim()); + setResults(res.data || []); + setIsDropdownOpen(true); + } catch (error) { + console.error('Search error:', error); + } finally { + setIsLoading(false); + } + } + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + searchRef.current && + !searchRef.current.contains(event.target as Node) + ) { + setIsDropdownOpen(false); + setKeyword(''); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + useEffect(() => { + if (debounceQuery) { + handleSearch(debounceQuery); + } else { + setResults([]); + setIsDropdownOpen(false); + } + }, [debounceQuery]); + return (