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
|
* @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";
|
"use client";
|
||||||
|
|
@ -10,23 +10,17 @@ import { useRouter } from "next/navigation";
|
||||||
import Column from "./column/Column";
|
import Column from "./column/Column";
|
||||||
import { COLUMNS, Task, TaskStatus } from "@/types/tasks";
|
import { COLUMNS, Task, TaskStatus } from "@/types/tasks";
|
||||||
import Header from "./header/Header";
|
import Header from "./header/Header";
|
||||||
|
import { mutate } from "swr";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the responsive grid container of columns, coordinating state tracking
|
* 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 {Object} props - The component props.
|
||||||
* @param {Task[]} props.tasks - The array of task items displayed across the board.
|
* @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.
|
* @returns {JSX.Element} The rendered board component.
|
||||||
*/
|
*/
|
||||||
export default function Board({
|
export default function Board({ tasks }: { tasks: Task[] }) {
|
||||||
tasks,
|
|
||||||
trashCount,
|
|
||||||
}: {
|
|
||||||
tasks: Task[];
|
|
||||||
trashCount: number;
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
const [updatingTaskIds, setUpdatingTaskIds] = useState<Set<string>>(
|
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,
|
* 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 {string} taskId - The unique identifier of the task to update.
|
||||||
* @param {TaskStatus} targetStatus - The new target status for the task.
|
* @param {TaskStatus} targetStatus - The new target status for the task.
|
||||||
|
|
@ -54,7 +48,7 @@ export default function Board({
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to update task status");
|
throw new Error("Failed to update task status");
|
||||||
}
|
}
|
||||||
|
mutate("/api/trash/count");
|
||||||
await router.refresh();
|
await router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error during task status update:", 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,
|
* 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.
|
* @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");
|
if (!response.ok) throw new Error("Failed to delete task");
|
||||||
|
mutate("/api/trash/count");
|
||||||
await router.refresh();
|
await router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error during task deletion:", error);
|
console.error("Error during task deletion:", error);
|
||||||
|
|
@ -100,7 +95,7 @@ export default function Board({
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Workspace Header */}
|
{/* Workspace Header */}
|
||||||
<Header onTaskDelete={deleteTask} trashCount={trashCount} />
|
<Header onTaskDelete={deleteTask} />
|
||||||
|
|
||||||
{/* Columns Grid */}
|
{/* Columns Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 items-start">
|
<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({
|
export default function Header({
|
||||||
onTaskDelete,
|
onTaskDelete,
|
||||||
trashCount,
|
|
||||||
}: {
|
}: {
|
||||||
onTaskDelete: (taskId: string) => void;
|
onTaskDelete: (taskId: string) => void;
|
||||||
trashCount: number;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<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} />
|
<DeleteDropZone onTaskDelete={onTaskDelete} />
|
||||||
<div className="flex items-center gap-3 peer-not-empty:hidden">
|
<div className="flex items-center gap-3 peer-not-empty:hidden">
|
||||||
<NewTaskButton />
|
<NewTaskButton />
|
||||||
<TrashLink count={trashCount} />
|
<TrashLink />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,36 @@
|
||||||
/**
|
/**
|
||||||
* @file dasboard/header/TrashLink.tsx
|
* @file dashboard/header/TrashLink.tsx
|
||||||
* @description Client component rendering a navigation link button to the trash view with a dynamic item counter badge.
|
* @description Client component rendering a navigation link button to the trash view with a dynamic item counter badge fetched via SWR.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Trash2 } from "lucide-react";
|
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.
|
* @async
|
||||||
* @param {number} props.count - The number of items currently in the trash.
|
* @param {string} url - The target endpoint URL to fetch.
|
||||||
* @returns {JSX.Element} The rendered trash button component.
|
* @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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href="/trash"
|
href="/trash"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/**
|
/**
|
||||||
* @file dashboard/page.tsx
|
* @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";
|
import { db } from "@/db";
|
||||||
|
|
@ -23,7 +23,7 @@ import { TaskService } from "@/services/task.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the dashboard page component with user session validation,
|
* 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
|
* @async
|
||||||
* @returns {Promise<JSX.Element>} The rendered dashboard page component.
|
* @returns {Promise<JSX.Element>} The rendered dashboard page component.
|
||||||
|
|
@ -69,22 +69,9 @@ export default async function Dashboard() {
|
||||||
commentsCount: 0,
|
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 (
|
return (
|
||||||
<div className="space-y-8 max-w-7xl mx-auto pb-12">
|
<div className="space-y-8 max-w-7xl mx-auto pb-12">
|
||||||
<Board tasks={tasks} trashCount={trashCount} />
|
<Board tasks={tasks} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { RotateCcw, Trash2, Loader2 } from "lucide-react";
|
import { RotateCcw, Trash2, Loader2 } from "lucide-react";
|
||||||
|
import { mutate } from "swr";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders an action button for either restoring or permanently deleting a task,
|
* 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();
|
const data = await response.json();
|
||||||
if (!response.ok)
|
if (!response.ok)
|
||||||
throw new Error(data.error || `Failed to ${action} task`);
|
throw new Error(data.error || `Failed to ${action} task`);
|
||||||
|
mutate("/api/trash/count");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error during task ${action}:`, 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
|
* @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";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
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.
|
* Array of navigation items containing their name, route path, and corresponding icon.
|
||||||
|
|
@ -15,21 +18,30 @@ import { LayoutDashboard, FileText } from "lucide-react";
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: "Summary", href: "/summary", icon: FileText },
|
{ name: "Summary", href: "/summary", icon: FileText },
|
||||||
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
||||||
|
{ name: "Trash", href: "/trash", icon: Trash2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the responsive navigation bar featuring desktop sidebar layout
|
* 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.
|
* @returns {JSX.Element} The rendered navigation component.
|
||||||
*/
|
*/
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const pathname = usePathname();
|
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
|
// Shared function to render navigation links to avoid code duplication
|
||||||
const renderNavLinks = (isMobile = false) =>
|
const renderNavLinks = (isMobile = false) =>
|
||||||
navItems.map((item) => {
|
navItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
|
const isTrash = item.href === "/trash";
|
||||||
const isActive =
|
const isActive =
|
||||||
pathname === item.href ||
|
pathname === item.href ||
|
||||||
(item.href !== "/" && pathname.startsWith(item.href));
|
(item.href !== "/" && pathname.startsWith(item.href));
|
||||||
|
|
@ -53,9 +65,17 @@ export default function Navbar() {
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Icon
|
<div className="relative inline-flex items-center">
|
||||||
className={`${isMobile ? "w-5 h-5" : "w-4 h-4"} ${isActive ? "text-primary" : ""}`}
|
<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" : ""}>
|
<span className={isMobile ? "text-xs font-medium" : ""}>
|
||||||
{item.name}
|
{item.name}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
34
package-lock.json
generated
34
package-lock.json
generated
|
|
@ -15,7 +15,8 @@
|
||||||
"next-auth": "^5.0.0-beta.32",
|
"next-auth": "^5.0.0-beta.32",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"swr": "^2.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|
@ -3943,6 +3944,15 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
|
@ -7560,6 +7570,19 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
||||||
|
|
@ -8424,6 +8447,15 @@
|
||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@
|
||||||
"next-auth": "^5.0.0-beta.32",
|
"next-auth": "^5.0.0-beta.32",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"swr": "^2.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue