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 (
-
+
+
+
);
};
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 (