feat(ui): add reactive trash count using SWR to navbar and dashboard trash link
This commit is contained in:
parent
23f898d763
commit
b386c2b4c5
9 changed files with 142 additions and 49 deletions
|
|
@ -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<Set<string>>(
|
||||
|
|
@ -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 (
|
||||
<div className="space-y-8">
|
||||
{/* Workspace Header */}
|
||||
<Header onTaskDelete={deleteTask} trashCount={trashCount} />
|
||||
<Header onTaskDelete={deleteTask} />
|
||||
|
||||
{/* Columns Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 items-start">
|
||||
|
|
|
|||
|
|
@ -19,10 +19,8 @@ import NewTaskButton from "./NewTaskButton";
|
|||
*/
|
||||
export default function Header({
|
||||
onTaskDelete,
|
||||
trashCount,
|
||||
}: {
|
||||
onTaskDelete: (taskId: string) => void;
|
||||
trashCount: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
|
|
@ -41,7 +39,7 @@ export default function Header({
|
|||
<DeleteDropZone onTaskDelete={onTaskDelete} />
|
||||
<div className="flex items-center gap-3 peer-not-empty:hidden">
|
||||
<NewTaskButton />
|
||||
<TrashLink count={trashCount} />
|
||||
<TrashLink />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<any>} 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 (
|
||||
<Link
|
||||
href="/trash"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* @file dashboard/page.tsx
|
||||
* @description Server component rendering the main dashboard page, handling authentication, fetching active tasks with assignees and creators, computing trash counts, and passing data to the board container.
|
||||
* @description Server component rendering the main dashboard page, handling authentication, fetching active tasks with assignees and creators, and passing data to the board container.
|
||||
*/
|
||||
|
||||
import { db } from "@/db";
|
||||
|
|
@ -23,7 +23,7 @@ import { TaskService } from "@/services/task.service";
|
|||
|
||||
/**
|
||||
* Renders the dashboard page component with user session validation,
|
||||
* optimized database queries for active tasks, team assignees, and soft-deleted trash counts.
|
||||
* optimized database queries for active tasks, and team assignees
|
||||
*
|
||||
* @async
|
||||
* @returns {Promise<JSX.Element>} 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 (
|
||||
<div className="space-y-8 max-w-7xl mx-auto pb-12">
|
||||
<Board tasks={tasks} trashCount={trashCount} />
|
||||
<Board tasks={tasks} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
44
app/api/trash/count/route.ts
Normal file
44
app/api/trash/count/route.ts
Normal file
|
|
@ -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<NextResponse>} 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
|||
}`
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
className={`${isMobile ? "w-5 h-5" : "w-4 h-4"} ${isActive ? "text-primary" : ""}`}
|
||||
/>
|
||||
<div className="relative inline-flex items-center">
|
||||
<Icon
|
||||
className={`${isMobile ? "w-5 h-5" : "w-4 h-4"} ${isActive ? "text-primary" : ""}`}
|
||||
/>
|
||||
{isTrash && trashCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-2 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-destructive text-[9px] font-bold text-white shadow-sm ring-1 ring-background">
|
||||
{trashCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className={isMobile ? "text-xs font-medium" : ""}>
|
||||
{item.name}
|
||||
</span>
|
||||
|
|
|
|||
34
package-lock.json
generated
34
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue