diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 8f8d960..7acc5d1 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -1,8 +1,10 @@ import { redirect } from "next/navigation"; import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; import { SideNavProvider } from "@/components/nav/side-nav-provider"; import { SideNav } from "@/components/nav/side-nav"; +import { ProjectsProvider } from "@/components/projects/projects-context"; export default async function AppLayout({ children }: { children: React.ReactNode }) { const session = await auth(); @@ -10,12 +12,22 @@ export default async function AppLayout({ children }: { children: React.ReactNod // but every protected data boundary should check for itself too. if (!session?.user) redirect("/login"); + // Just the list for the sidebar/`/projects` page -- each project's own + // board (categories/groups/todos) is fetched separately by its own page. + const projects = await prisma.project.findMany({ + where: { ownerId: session.user.id }, + orderBy: { createdAt: "asc" }, + select: { id: true, title: true }, + }); + return ( -
- -
{children}
-
+ +
+ +
{children}
+
+
); } diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index d074dac..484549f 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -1,49 +1,14 @@ import { redirect } from "next/navigation"; import { auth } from "@/auth"; -import { prisma } from "@/lib/db"; +import { getBoard } from "@/lib/board"; import { KanbanBoard } from "@/components/board/kanban-board"; -import type { CategoryDTO } from "@/types/board"; export default async function HomePage() { const session = await auth(); if (!session?.user) redirect("/login"); - const categories = await prisma.category.findMany({ - where: { userId: session.user.id }, - orderBy: { order: "asc" }, - include: { - groups: { - // Archived groups are kept in the database (not deleted) but - // excluded from the Home board. - where: { archivedAt: null }, - orderBy: { order: "asc" }, - include: { todos: { orderBy: { order: "asc" } } }, - }, - }, - }); - - const board: CategoryDTO[] = categories.map((category) => ({ - id: category.id, - name: category.name, - order: category.order, - groups: category.groups.map((group) => ({ - id: group.id, - title: group.title, - color: group.color, - order: group.order, - noteContent: group.noteContent, - categoryId: group.categoryId, - todos: group.todos.map((todo) => ({ - id: todo.id, - title: todo.title, - details: todo.details, - completed: todo.completed, - order: todo.order, - groupId: todo.groupId, - })), - })), - })); + const board = await getBoard({ userId: session.user.id, projectId: null }); return ; } diff --git a/app/(app)/projects/[projectId]/page.tsx b/app/(app)/projects/[projectId]/page.tsx new file mode 100644 index 0000000..015ecae --- /dev/null +++ b/app/(app)/projects/[projectId]/page.tsx @@ -0,0 +1,29 @@ +import { notFound, redirect } from "next/navigation"; + +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; +import { projectAccessFilter } from "@/lib/access"; +import { getBoard } from "@/lib/board"; +import { KanbanBoard } from "@/components/board/kanban-board"; + +export default async function ProjectPage({ + params, +}: { + params: Promise<{ projectId: string }>; +}) { + const session = await auth(); + if (!session?.user) redirect("/login"); + const { projectId } = await params; + + const project = await prisma.project.findFirst({ + where: { id: projectId, ...projectAccessFilter(session.user.id) }, + select: { id: true, title: true }, + }); + // Same response whether the project doesn't exist or just isn't this + // user's -- no need to distinguish "not found" from "not yours". + if (!project) notFound(); + + const board = await getBoard({ projectId: project.id }); + + return ; +} diff --git a/app/(app)/projects/page.tsx b/app/(app)/projects/page.tsx new file mode 100644 index 0000000..55edf61 --- /dev/null +++ b/app/(app)/projects/page.tsx @@ -0,0 +1,5 @@ +import { ProjectsListView } from "@/components/projects/projects-list-view"; + +export default function ProjectsPage() { + return ; +} diff --git a/components/board/board-context.tsx b/components/board/board-context.tsx index 53c66e8..84fb1d6 100644 --- a/components/board/board-context.tsx +++ b/components/board/board-context.tsx @@ -72,9 +72,15 @@ const BoardContext = createContext(null); export function BoardProvider({ initialCategories, + projectId, children, }: { initialCategories: CategoryDTO[]; + // Undefined means Home. The Project-scoped board reuses every bit of + // this provider -- the only calls that need to know which board they're + // on are the two that operate over "all of this board's categories" + // rather than a specific existing category/group/todo id. + projectId?: string; children: React.ReactNode; }) { const [categories, setCategories] = useState(initialCategories); @@ -96,14 +102,17 @@ export function BoardProvider({ [updateCategory] ); - const addCategory = useCallback(async (name: string) => { - try { - const category = await createCategory(name); - setCategories((prev) => [...prev, category]); - } catch { - toast.error("Couldn't create category. Try again."); - } - }, []); + const addCategory = useCallback( + async (name: string) => { + try { + const category = await createCategory(name, projectId); + setCategories((prev) => [...prev, category]); + } catch { + toast.error("Couldn't create category. Try again."); + } + }, + [projectId] + ); const removeCategory = useCallback(async (categoryId: string) => { let prevState: CategoryDTO[] = []; @@ -123,20 +132,23 @@ export function BoardProvider({ } }, []); - const reorderLanes = useCallback(async (orderedIds: string[]) => { - let prevState: CategoryDTO[] = []; - setCategories((prev) => { - prevState = prev; - const byId = new Map(prev.map((c) => [c.id, c])); - return orderedIds.map((id, order) => ({ ...byId.get(id)!, order })); - }); - try { - await reorderCategories(orderedIds); - } catch { - setCategories(prevState); - toast.error("Couldn't reorder categories. Try again."); - } - }, []); + const reorderLanes = useCallback( + async (orderedIds: string[]) => { + let prevState: CategoryDTO[] = []; + setCategories((prev) => { + prevState = prev; + const byId = new Map(prev.map((c) => [c.id, c])); + return orderedIds.map((id, order) => ({ ...byId.get(id)!, order })); + }); + try { + await reorderCategories(orderedIds, projectId); + } catch { + setCategories(prevState); + toast.error("Couldn't reorder categories. Try again."); + } + }, + [projectId] + ); const addGroup = useCallback(async (categoryId: string, title: string, color: string) => { try { diff --git a/components/board/kanban-board.tsx b/components/board/kanban-board.tsx index cf95699..8041251 100644 --- a/components/board/kanban-board.tsx +++ b/components/board/kanban-board.tsx @@ -21,7 +21,7 @@ import { GroupCardOverlay } from "@/components/board/group-card-overlay"; import { EmptyState } from "@/components/board/empty-state"; import type { CategoryDTO, GroupDTO } from "@/types/board"; -function Board() { +function Board({ title }: { title: string }) { const { categories, reorderLanes, reorderGroups, moveGroup } = useBoard(); const [draggedGroup, setDraggedGroup] = useState(null); @@ -115,7 +115,7 @@ function Board() { onDragEnd={handleDragEnd} >
-

Home

+

{title}

{categories.length === 0 ? (
@@ -139,10 +139,21 @@ function Board() { ); } -export function KanbanBoard({ initialCategories }: { initialCategories: CategoryDTO[] }) { +export function KanbanBoard({ + initialCategories, + projectId, + title = "Home", +}: { + initialCategories: CategoryDTO[]; + // Undefined renders the caller's Home board; set it to scope the whole + // board -- creating/reordering categories, and everything nested under + // them -- to that Project instead. + projectId?: string; + title?: string; +}) { return ( - - + + ); } diff --git a/components/nav/projects-nav-section.tsx b/components/nav/projects-nav-section.tsx new file mode 100644 index 0000000..6572b00 --- /dev/null +++ b/components/nav/projects-nav-section.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { ChevronDown, ChevronRight, FolderKanban } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useProjects } from "@/components/projects/projects-context"; + +const STORAGE_KEY = "organize:projects-expanded"; + +/** + * The "Projects" nav entry: a link to /projects (the list + create page) + * plus, underneath it, a live list of the user's projects that expands + * and contracts independently of the link itself -- clicking the label + * navigates, clicking the chevron just toggles the list. + */ +export function ProjectsNavSection({ collapsed }: { collapsed: boolean }) { + const pathname = usePathname(); + const { projects } = useProjects(); + // Defaults to expanded on both server and first client render (same + // reasoning as SideNavProvider's `collapsed`) to avoid a hydration + // mismatch; the persisted value applies right after mount. + const [expanded, setExpanded] = useState(true); + + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored !== null) setExpanded(stored === "true"); + }, []); + + function toggleExpanded() { + setExpanded((prev) => { + const next = !prev; + localStorage.setItem(STORAGE_KEY, String(next)); + return next; + }); + } + + const isListActive = pathname === "/projects"; + + const link = ( + + + {!collapsed && Projects} + + ); + + // No room for an expandable sub-list in the icon rail -- just a link, + // matching how every other collapsed nav item behaves. + if (collapsed) { + return ( + + + Projects + + ); + } + + return ( +
+
+ {link} + +
+ + {expanded && projects.length > 0 && ( +
+ {projects.map((project) => { + const isActive = pathname === `/projects/${project.id}`; + return ( + + {project.title} + + ); + })} +
+ )} +
+ ); +} diff --git a/components/nav/side-nav.tsx b/components/nav/side-nav.tsx index 7c314c1..53a8416 100644 --- a/components/nav/side-nav.tsx +++ b/components/nav/side-nav.tsx @@ -8,6 +8,7 @@ import { Separator } from "@/components/ui/separator"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useSideNav } from "@/components/nav/side-nav-provider"; import { SideNavItem } from "@/components/nav/side-nav-item"; +import { ProjectsNavSection } from "@/components/nav/projects-nav-section"; import { NAV_ITEMS, ADMIN_NAV_ITEM } from "@/components/nav/nav-items"; import { ThemeToggle } from "@/components/theme/theme-toggle"; import { logout } from "@/lib/actions/auth"; @@ -15,7 +16,7 @@ import { Role } from "@/lib/generated/prisma/enums"; export function SideNav({ userEmail, role }: { userEmail: string; role: Role }) { const { collapsed, toggle } = useSideNav(); - const items = role === Role.ADMIN ? [...NAV_ITEMS, ADMIN_NAV_ITEM] : NAV_ITEMS; + const adminItem = role === Role.ADMIN ? [ADMIN_NAV_ITEM] : []; return (