From b386c2b4c522f07017c451a37497cb2685ddf8b0 Mon Sep 17 00:00:00 2001 From: Chneemann Date: Wed, 12 Aug 2026 09:11:51 +0200 Subject: [PATCH] feat(ui): add reactive trash count using SWR to navbar and dashboard trash link --- app/(app)/dashboard/Board.tsx | 23 ++++------ app/(app)/dashboard/header/Header.tsx | 4 +- app/(app)/dashboard/header/TrashLink.tsx | 29 +++++++++--- app/(app)/dashboard/page.tsx | 19 ++------ .../trash/components/TaskActionButton.tsx | 3 +- app/api/trash/count/route.ts | 44 +++++++++++++++++++ app/components/layout/Navbar.tsx | 32 +++++++++++--- package-lock.json | 34 +++++++++++++- package.json | 3 +- 9 files changed, 142 insertions(+), 49 deletions(-) create mode 100644 app/api/trash/count/route.ts diff --git a/app/(app)/dashboard/Board.tsx b/app/(app)/dashboard/Board.tsx index 362a9ba..ca71f44 100644 --- a/app/(app)/dashboard/Board.tsx +++ b/app/(app)/dashboard/Board.tsx @@ -1,6 +1,6 @@ /** * @file dashboard/Board.tsx - * @description Client component wrapping the columns grid, tracking individual task update/deletion states, and handling asynchronous mutations via API. + * @description Client component wrapping the columns grid, tracking individual task update/deletion states, and handling asynchronous mutations via API with cache revalidation. */ "use client"; @@ -10,23 +10,17 @@ import { useRouter } from "next/navigation"; import Column from "./column/Column"; import { COLUMNS, Task, TaskStatus } from "@/types/tasks"; import Header from "./header/Header"; +import { mutate } from "swr"; /** * Renders the responsive grid container of columns, coordinating state tracking - * for active task updates/deletions, trash counts, and triggering mutation API requests. + * for active task updates/deletions, and triggering mutation API requests. * * @param {Object} props - The component props. * @param {Task[]} props.tasks - The array of task items displayed across the board. - * @param {number} props.trashCount - The count of items currently in the trash bin. * @returns {JSX.Element} The rendered board component. */ -export default function Board({ - tasks, - trashCount, -}: { - tasks: Task[]; - trashCount: number; -}) { +export default function Board({ tasks }: { tasks: Task[] }) { const router = useRouter(); const [, startTransition] = useTransition(); const [updatingTaskIds, setUpdatingTaskIds] = useState>( @@ -35,7 +29,7 @@ export default function Board({ /** * Updates the status of a specific task by sending a PATCH request to the API, - * managing loading states, and refreshing the router upon success. + * managing loading states, triggering SWR cache mutations for the trash count, and refreshing the router upon success. * * @param {string} taskId - The unique identifier of the task to update. * @param {TaskStatus} targetStatus - The new target status for the task. @@ -54,7 +48,7 @@ export default function Board({ if (!response.ok) { throw new Error("Failed to update task status"); } - + mutate("/api/trash/count"); await router.refresh(); } catch (error) { console.error("Error during task status update:", error); @@ -70,7 +64,7 @@ export default function Board({ /** * Deletes a specific task by sending a DELETE request to the API, - * managing loading states, and refreshing the router upon success. + * managing loading states, triggering SWR cache mutations for the trash count, and refreshing the router upon success. * * @param {string} taskId - The unique identifier of the task to delete. */ @@ -84,6 +78,7 @@ export default function Board({ }); if (!response.ok) throw new Error("Failed to delete task"); + mutate("/api/trash/count"); await router.refresh(); } catch (error) { console.error("Error during task deletion:", error); @@ -100,7 +95,7 @@ export default function Board({ return (
{/* Workspace Header */} -
+
{/* Columns Grid */}
diff --git a/app/(app)/dashboard/header/Header.tsx b/app/(app)/dashboard/header/Header.tsx index 40d3c1a..9dbccb1 100644 --- a/app/(app)/dashboard/header/Header.tsx +++ b/app/(app)/dashboard/header/Header.tsx @@ -19,10 +19,8 @@ import NewTaskButton from "./NewTaskButton"; */ export default function Header({ onTaskDelete, - trashCount, }: { onTaskDelete: (taskId: string) => void; - trashCount: number; }) { return (
@@ -41,7 +39,7 @@ export default function Header({
- +
diff --git a/app/(app)/dashboard/header/TrashLink.tsx b/app/(app)/dashboard/header/TrashLink.tsx index 3d46907..a0a9c99 100644 --- a/app/(app)/dashboard/header/TrashLink.tsx +++ b/app/(app)/dashboard/header/TrashLink.tsx @@ -1,21 +1,36 @@ /** - * @file dasboard/header/TrashLink.tsx - * @description Client component rendering a navigation link button to the trash view with a dynamic item counter badge. + * @file dashboard/header/TrashLink.tsx + * @description Client component rendering a navigation link button to the trash view with a dynamic item counter badge fetched via SWR. */ "use client"; import Link from "next/link"; import { Trash2 } from "lucide-react"; +import useSWR from "swr"; /** - * Renders an interactive trash icon link featuring hover animations and an optional item count badge. + * Fetches data from a given URL and returns the parsed JSON response. * - * @param {Object} props - The component props. - * @param {number} props.count - The number of items currently in the trash. - * @returns {JSX.Element} The rendered trash button component. + * @async + * @param {string} url - The target endpoint URL to fetch. + * @returns {Promise} The JSON response data. */ -export default function TrashLink({ count }: { count: number }) { +const fetcher = (url: string) => fetch(url).then((res) => res.json()); + +/** + * Renders an interactive trash icon link featuring hover animations and a self-fetched item count badge. + * + * @returns {JSX.Element} The rendered trash link component. + */ +export default function TrashLink() { + const { data } = useSWR("/api/trash/count", fetcher, { + revalidateOnFocus: false, + revalidateIfStale: false, + }); + + const count = data?.count || 0; + return ( } The rendered dashboard page component. @@ -69,22 +69,9 @@ export default async function Dashboard() { commentsCount: 0, })); - // Check the trash counter - const [trashCountResult] = await db - .select({ count: count() }) - .from(tasksTable) - .where( - and( - eq(tasksTable.userId, currentUserId), - isNotNull(tasksTable.deletedAt), - ), - ); - - const trashCount = trashCountResult.count; - return (
- +
); } diff --git a/app/(app)/trash/components/TaskActionButton.tsx b/app/(app)/trash/components/TaskActionButton.tsx index 84e33d3..ddc2512 100644 --- a/app/(app)/trash/components/TaskActionButton.tsx +++ b/app/(app)/trash/components/TaskActionButton.tsx @@ -8,6 +8,7 @@ import { useTransition } from "react"; import { useRouter } from "next/navigation"; import { RotateCcw, Trash2, Loader2 } from "lucide-react"; +import { mutate } from "swr"; /** * Renders an action button for either restoring or permanently deleting a task, @@ -53,7 +54,7 @@ export default function TaskActionButton({ const data = await response.json(); if (!response.ok) throw new Error(data.error || `Failed to ${action} task`); - + mutate("/api/trash/count"); router.refresh(); } catch (error) { console.error(`Error during task ${action}:`, error); diff --git a/app/api/trash/count/route.ts b/app/api/trash/count/route.ts new file mode 100644 index 0000000..39d5a82 --- /dev/null +++ b/app/api/trash/count/route.ts @@ -0,0 +1,44 @@ +/** + * @file app/api/trash/count/route.ts + * @description API route handler to retrieve the count of soft-deleted tasks for the authenticated user. + */ + +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { db } from "@/db"; +import { tasksTable } from "@/db/schema"; +import { eq, and, isNotNull, count } from "drizzle-orm"; + +/** + * Handles GET requests to retrieve the total number of soft-deleted tasks belonging to the authenticated user. + * Verifies the user session, queries the database for tasks with a non-null deletedAt timestamp, and returns the count. + * + * @async + * @returns {Promise} A JSON response containing the trash task count or an error message with the appropriate HTTP status code. + */ +export async function GET() { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const [result] = await db + .select({ count: count() }) + .from(tasksTable) + .where( + and( + eq(tasksTable.userId, session.user.id), + isNotNull(tasksTable.deletedAt), + ), + ); + + return NextResponse.json({ count: result?.count ?? 0 }, { status: 200 }); + } catch (error) { + console.error("Error fetching trash count:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } +} diff --git a/app/components/layout/Navbar.tsx b/app/components/layout/Navbar.tsx index 591bc3b..9d49525 100644 --- a/app/components/layout/Navbar.tsx +++ b/app/components/layout/Navbar.tsx @@ -1,13 +1,16 @@ /** * @file Navbar.tsx - * @description Client component providing responsive navigation for desktop and mobile views with active route indicators. + * @description Client component providing responsive navigation for desktop and mobile views with active route indicators and trash count. */ "use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { LayoutDashboard, FileText } from "lucide-react"; +import { LayoutDashboard, FileText, Trash2 } from "lucide-react"; +import useSWR from "swr"; + +const fetcher = (url: string) => fetch(url).then((res) => res.json()); /** * Array of navigation items containing their name, route path, and corresponding icon. @@ -15,21 +18,30 @@ import { LayoutDashboard, FileText } from "lucide-react"; const navItems = [ { name: "Summary", href: "/summary", icon: FileText }, { name: "Dashboard", href: "/dashboard", icon: LayoutDashboard }, + { name: "Trash", href: "/trash", icon: Trash2 }, ]; /** * Renders the responsive navigation bar featuring desktop sidebar layout - * and mobile bottom bar layout with active state styling. + * and mobile bottom bar layout with active state styling and cached trash counter. * * @returns {JSX.Element} The rendered navigation component. */ export default function Navbar() { const pathname = usePathname(); + const { data } = useSWR("/api/trash/count", fetcher, { + revalidateOnFocus: false, + revalidateIfStale: false, + }); + + const trashCount = data?.count || 0; + // Shared function to render navigation links to avoid code duplication const renderNavLinks = (isMobile = false) => navItems.map((item) => { const Icon = item.icon; + const isTrash = item.href === "/trash"; const isActive = pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href)); @@ -53,9 +65,17 @@ export default function Navbar() { }` } > - +
+ + {isTrash && trashCount > 0 && ( + + {trashCount} + + )} +
+ {item.name} diff --git a/package-lock.json b/package-lock.json index f8fa8f0..570cec7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "next-auth": "^5.0.0-beta.32", "postgres": "^3.4.9", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "swr": "^2.5.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -3943,6 +3944,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -7560,6 +7570,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swr": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.5.0.tgz", + "integrity": "sha512-W0GomadRJe9OfzIoRX0kZHhDSaRRfdiOUeH6uazgR05SibBhDNwfdTbhAxmFtObvkf59fFymo22mooKukosvNA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -8424,6 +8447,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 9fdfcb8..0313af8 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "next-auth": "^5.0.0-beta.32", "postgres": "^3.4.9", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "swr": "^2.5.0" }, "devDependencies": { "@tailwindcss/postcss": "^4",