From b731a50cc908fd4c04e1e5ad9cf799ee56f710d9 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 12 Apr 2025 21:35:17 -0400 Subject: [PATCH] nested sidebar --- src/app/[orgId]/page.tsx | 24 +-- .../settings/access/invitations/page.tsx | 17 ++- src/app/[orgId]/settings/layout.tsx | 20 ++- src/app/globals.css | 2 +- src/app/navigation.tsx | 52 +++++++ src/app/page.tsx | 25 ++-- src/components/Layout.tsx | 137 ++++++++++-------- src/components/SidebarNav.tsx | 122 ++++++++++------ 8 files changed, 255 insertions(+), 144 deletions(-) create mode 100644 src/app/navigation.tsx diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index eaff09d1..b009da1c 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -1,4 +1,3 @@ -import ProfileIcon from "@app/components/ProfileIcon"; import { verifySession } from "@app/lib/auth/verifySession"; import UserProvider from "@app/providers/UserProvider"; import { cache } from "react"; @@ -8,6 +7,8 @@ import { internal } from "@app/lib/api"; import { AxiosResponse } from "axios"; import { authCookieHeader } from "@app/lib/api/cookies"; import { redirect } from "next/navigation"; +import { Layout } from "@app/components/Layout"; +import { orgNavItems } from "../navigation"; type OrgPageProps = { params: Promise<{ orgId: string }>; @@ -20,6 +21,10 @@ export default async function OrgPage(props: OrgPageProps) { const getUser = cache(verifySession); const user = await getUser(); + if (!user) { + redirect("/"); + } + let redirectToSettings = false; let overview: GetOrgOverviewResponse | undefined; try { @@ -39,14 +44,11 @@ export default async function OrgPage(props: OrgPageProps) { } return ( - <> -
- {user && ( - - - - )} - + + {overview && (
)} -
- + + ); } diff --git a/src/app/[orgId]/settings/access/invitations/page.tsx b/src/app/[orgId]/settings/access/invitations/page.tsx index b26ed551..9c8b5e11 100644 --- a/src/app/[orgId]/settings/access/invitations/page.tsx +++ b/src/app/[orgId]/settings/access/invitations/page.tsx @@ -8,6 +8,7 @@ import OrgProvider from "@app/providers/OrgProvider"; import UserProvider from "@app/providers/UserProvider"; import { verifySession } from "@app/lib/auth/verifySession"; import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; type InvitationsPageProps = { params: Promise<{ orgId: string }>; @@ -72,13 +73,15 @@ export default async function InvitationsPage(props: InvitationsPageProps) { return ( <> - - - - - - - + + + + + + ); } diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index a780d472..3ec16be4 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -17,6 +17,7 @@ import { cache } from "react"; import { GetOrgUserResponse } from "@server/routers/user"; import UserProvider from "@app/providers/UserProvider"; import { Layout } from "@app/components/Layout"; +import { SidebarNavItem, SidebarNavProps } from "@app/components/SidebarNav"; export const dynamic = "force-dynamic"; @@ -25,25 +26,32 @@ export const metadata: Metadata = { description: "" }; -const navItems = [ +const navItems: SidebarNavItem[] = [ { title: "Sites", - href: "/{orgId}/settings/sites", + href: "/{orgId}/settings/sites" // icon: }, { title: "Resources", - href: "/{orgId}/settings/resources", + href: "/{orgId}/settings/resources" // icon: }, { title: "Access Control", href: "/{orgId}/settings/access", // icon: , + autoExpand: true, children: [ { title: "Users", - href: "/{orgId}/settings/access/users" + href: "/{orgId}/settings/access/users", + children: [ + { + title: "Invitations", + href: "/{orgId}/settings/access/invitations" + } + ] }, { title: "Roles", @@ -53,12 +61,12 @@ const navItems = [ }, { title: "Shareable Links", - href: "/{orgId}/settings/share-links", + href: "/{orgId}/settings/share-links" // icon: }, { title: "General", - href: "/{orgId}/settings/general", + href: "/{orgId}/settings/general" // icon: } ]; diff --git a/src/app/globals.css b/src/app/globals.css index 5bb4f3f0..6d701a49 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -19,7 +19,7 @@ --accent-foreground: hsl(24 9.8% 10%); --destructive: hsl(0 84.2% 60.2%); --destructive-foreground: hsl(60 9.1% 97.8%); - --border: hsl(20 5.9% 80%); + --border: hsl(20 5.9% 90%); --input: hsl(20 5.9% 75%); --ring: hsl(24.6 95% 53.1%); --radius: 0.50rem; diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx new file mode 100644 index 00000000..c9824d63 --- /dev/null +++ b/src/app/navigation.tsx @@ -0,0 +1,52 @@ +import { Home, Settings, Users, Link as LinkIcon, Waypoints, Combine } from "lucide-react"; + +export const rootNavItems = [ + { + title: "Home", + href: "/", + icon: + } +]; + +export const orgNavItems = [ + { + title: "Overview", + href: "/{orgId}", + icon: + }, + { + title: "Sites", + href: "/{orgId}/settings/sites", + icon: + }, + { + title: "Resources", + href: "/{orgId}/settings/resources", + icon: + }, + { + title: "Access Control", + href: "/{orgId}/settings/access", + icon: , + children: [ + { + title: "Users", + href: "/{orgId}/settings/access/users" + }, + { + title: "Roles", + href: "/{orgId}/settings/access/roles" + } + ] + }, + { + title: "Shareable Links", + href: "/{orgId}/settings/share-links", + icon: + }, + { + title: "Settings", + href: "/{orgId}/settings/general", + icon: + } +]; \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index 8e9c044d..e2f06431 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,17 +1,16 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; -import ProfileIcon from "@app/components/ProfileIcon"; import { verifySession } from "@app/lib/auth/verifySession"; import UserProvider from "@app/providers/UserProvider"; import { ListOrgsResponse } from "@server/routers/org"; import { AxiosResponse } from "axios"; -import { ArrowUpRight } from "lucide-react"; -import Link from "next/link"; import { redirect } from "next/navigation"; import { cache } from "react"; import OrganizationLanding from "./components/OrganizationLanding"; import { pullEnv } from "@app/lib/pullEnv"; import { cleanRedirect } from "@app/lib/cleanRedirect"; +import { Layout } from "@app/components/Layout"; +import { rootNavItems } from "./navigation"; export const dynamic = "force-dynamic"; @@ -71,16 +70,12 @@ export default async function Page(props: { } return ( - <> -
- {user && ( - -
- -
-
- )} - + +
-
- + + ); } diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 48dff959..a63ec2e1 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -25,7 +25,7 @@ interface LayoutProps { children: React.ReactNode; orgId?: string; orgs?: ListOrgsResponse["orgs"]; - navItems: Array<{ + navItems?: Array<{ title: string; href: string; icon?: React.ReactNode; @@ -35,84 +35,107 @@ interface LayoutProps { icon?: React.ReactNode; }>; }>; + showSidebar?: boolean; + showBreadcrumbs?: boolean; + showHeader?: boolean; + showTopBar?: boolean; } -export function Layout({ children, orgId, orgs, navItems }: LayoutProps) { +export function Layout({ + children, + orgId, + orgs, + navItems = [], + showSidebar = true, + showBreadcrumbs = true, + showHeader = true, + showTopBar = true +}: LayoutProps) { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const { env } = useEnvContext(); return (
{/* Mobile Menu Button */} -
- - - - - - - Navigation Menu - - - Main navigation menu for the application - + {showSidebar && ( +
+ + + + + + + Navigation Menu + + + Main navigation menu for the application + + {showHeader && ( +
+
+
+ )} +
+ +
+
+ + + {env?.app?.version && ( +
+ v{env.app.version} +
+ )} +
+
+
+
+ )} + + {/* Desktop Sidebar */} + {showSidebar && ( +
+ {showHeader && (
-
- -
-
- - + )} +
+ +
+
+ + +
+
+ Open Source +
{env?.app?.version && (
v{env.app.version}
)}
- - -
- - {/* Desktop Sidebar */} -
-
-
-
-
- -
-
- - -
-
- Open Source -
- {env?.app?.version && ( -
- v{env.app.version} -
- )}
-
+ )} {/* Main content */} -
-
-
- +
+ {showTopBar && ( +
+
+ +
-
- -
+ )} + {showBreadcrumbs && } +
{children}
diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index 0851499a..cf6876ed 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -1,19 +1,20 @@ "use client"; -import React from "react"; +import React, { useState, useEffect } from "react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; import { cn } from "@app/lib/cn"; -import { CornerDownRight } from "lucide-react"; +import { ChevronDown, ChevronRight } from "lucide-react"; -interface SidebarNavItem { +export interface SidebarNavItem { href: string; title: string; icon?: React.ReactNode; children?: SidebarNavItem[]; + autoExpand?: boolean; } -interface SidebarNavProps extends React.HTMLAttributes { +export interface SidebarNavProps extends React.HTMLAttributes { items: SidebarNavItem[]; disabled?: boolean; } @@ -30,6 +31,27 @@ export function SidebarNav({ const niceId = params.niceId as string; const resourceId = params.resourceId as string; const userId = params.userId as string; + const [expandedItems, setExpandedItems] = useState>(new Set()); + + // Initialize expanded items based on autoExpand property + useEffect(() => { + const autoExpanded = new Set(); + + function findAutoExpanded(items: SidebarNavItem[]) { + items.forEach(item => { + const hydratedHref = hydrateHref(item.href); + if (item.autoExpand) { + autoExpanded.add(hydratedHref); + } + if (item.children) { + findAutoExpanded(item.children); + } + }); + } + + findAutoExpanded(items); + setExpandedItems(autoExpanded); + }, [items]); function hydrateHref(val: string): string { return val @@ -39,56 +61,62 @@ export function SidebarNav({ .replace("{userId}", userId); } - function renderItems(items: SidebarNavItem[]) { + function toggleItem(href: string) { + setExpandedItems(prev => { + const newSet = new Set(prev); + if (newSet.has(href)) { + newSet.delete(href); + } else { + newSet.add(href); + } + return newSet; + }); + } + + function renderItems(items: SidebarNavItem[], level = 0) { return items.map((item) => { const hydratedHref = hydrateHref(item.href); const isActive = pathname.startsWith(hydratedHref); + const hasChildren = item.children && item.children.length > 0; + const isExpanded = expandedItems.has(hydratedHref); + const indent = level * 16; // Base indent for each level return (
- + e.preventDefault() : undefined} + tabIndex={disabled ? -1 : undefined} + aria-disabled={disabled} + > + {item.icon && {item.icon}} + {item.title} + + {hasChildren && ( + )} - onClick={disabled ? (e) => e.preventDefault() : undefined} - tabIndex={disabled ? -1 : undefined} - aria-disabled={disabled} - > - {item.icon && {item.icon}} - {item.title} - - {item.children && ( -
- {item.children.map((child) => { - const hydratedChildHref = hydrateHref(child.href); - const isChildActive = pathname.startsWith(hydratedChildHref) && !pathname.includes("create"); - - return ( - e.preventDefault() : undefined} - tabIndex={disabled ? -1 : undefined} - aria-disabled={disabled} - > - - {child.icon && {child.icon}} - {child.title} - - ); - })} +
+ {hasChildren && isExpanded && ( +
+ {renderItems(item.children || [], level + 1)}
)}