nested sidebar

This commit is contained in:
miloschwartz 2025-04-12 21:35:17 -04:00
parent 2398931cc1
commit b731a50cc9
No known key found for this signature in database
8 changed files with 255 additions and 144 deletions

View file

@ -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 (
<>
<div className="p-3">
{user && (
<UserProvider user={user}>
<ProfileIcon />
</UserProvider>
)}
<Layout
orgId={orgId}
navItems={orgNavItems}
>
{overview && (
<div className="w-full max-w-4xl mx-auto md:mt-32 mt-4">
<OrganizationLandingCard
@ -65,7 +67,7 @@ export default async function OrgPage(props: OrgPageProps) {
/>
</div>
)}
</div>
</>
</Layout>
</UserProvider>
);
}

View file

@ -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 (
<>
<AccessPageHeaderAndNav hasInvitations={hasInvitations}>
<SettingsSectionTitle
title="Open Invitations"
description="Manage your invitations to other users"
/>
<UserProvider user={user!}>
<OrgProvider org={org}>
<InvitationsTable invitations={invitationRows} />
</OrgProvider>
</UserProvider>
</AccessPageHeaderAndNav>
</>
);
}

View file

@ -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: <Combine className="h-4 w-4" />
},
{
title: "Resources",
href: "/{orgId}/settings/resources",
href: "/{orgId}/settings/resources"
// icon: <Waypoints className="h-4 w-4" />
},
{
title: "Access Control",
href: "/{orgId}/settings/access",
// icon: <Users className="h-4 w-4" />,
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: <LinkIcon className="h-4 w-4" />
},
{
title: "General",
href: "/{orgId}/settings/general",
href: "/{orgId}/settings/general"
// icon: <Settings className="h-4 w-4" />
}
];

View file

@ -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;

52
src/app/navigation.tsx Normal file
View file

@ -0,0 +1,52 @@
import { Home, Settings, Users, Link as LinkIcon, Waypoints, Combine } from "lucide-react";
export const rootNavItems = [
{
title: "Home",
href: "/",
icon: <Home className="h-4 w-4" />
}
];
export const orgNavItems = [
{
title: "Overview",
href: "/{orgId}",
icon: <Home className="h-4 w-4" />
},
{
title: "Sites",
href: "/{orgId}/settings/sites",
icon: <Combine className="h-4 w-4" />
},
{
title: "Resources",
href: "/{orgId}/settings/resources",
icon: <Waypoints className="h-4 w-4" />
},
{
title: "Access Control",
href: "/{orgId}/settings/access",
icon: <Users className="h-4 w-4" />,
children: [
{
title: "Users",
href: "/{orgId}/settings/access/users"
},
{
title: "Roles",
href: "/{orgId}/settings/access/roles"
}
]
},
{
title: "Shareable Links",
href: "/{orgId}/settings/share-links",
icon: <LinkIcon className="h-4 w-4" />
},
{
title: "Settings",
href: "/{orgId}/settings/general",
icon: <Settings className="h-4 w-4" />
}
];

View file

@ -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 (
<>
<div className="p-3">
{user && (
<UserProvider user={user}>
<div>
<ProfileIcon />
</div>
</UserProvider>
)}
<Layout
orgs={orgs}
navItems={rootNavItems}
showBreadcrumbs={false}
>
<div className="w-full max-w-md mx-auto md:mt-32 mt-4">
<OrganizationLanding
disableCreateOrg={env.flags.disableUserCreateOrg && !user.serverAdmin}
@ -90,7 +85,7 @@ export default async function Page(props: {
}))}
/>
</div>
</div>
</>
</Layout>
</UserProvider>
);
}

View file

@ -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,15 +35,29 @@ 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 (
<div className="flex h-screen overflow-hidden">
{/* Mobile Menu Button */}
{showSidebar && (
<div className="md:hidden fixed top-4 left-4 z-50">
<Sheet
open={isMobileMenuOpen}
@ -61,9 +75,11 @@ export function Layout({ children, orgId, orgs, navItems }: LayoutProps) {
<SheetDescription className="sr-only">
Main navigation menu for the application
</SheetDescription>
{showHeader && (
<div className="flex h-16 items-center border-b px-4 shrink-0">
<Header orgId={orgId} orgs={orgs} />
</div>
)}
<div className="flex-1 overflow-y-auto p-4">
<SidebarNav items={navItems} />
</div>
@ -79,12 +95,16 @@ export function Layout({ children, orgId, orgs, navItems }: LayoutProps) {
</SheetContent>
</Sheet>
</div>
)}
{/* Desktop Sidebar */}
{showSidebar && (
<div className="hidden md:flex w-64 border-r bg-card flex-col h-full shrink-0">
{showHeader && (
<div className="flex h-16 items-center border-b px-4 shrink-0">
<Header orgId={orgId} orgs={orgs} />
</div>
)}
<div className="flex-1 overflow-y-auto p-4">
<SidebarNav items={navItems} />
</div>
@ -103,16 +123,19 @@ export function Layout({ children, orgId, orgs, navItems }: LayoutProps) {
</div>
</div>
</div>
)}
{/* Main content */}
<div className="flex-1 flex flex-col h-full min-w-0">
<div className={cn("flex-1 flex flex-col h-full min-w-0", !showSidebar && "w-full")}>
{showTopBar && (
<div className="h-16 border-b shrink-0 bg-card">
<div className="flex h-full items-center justify-end px-4">
<TopBar orgId={orgId} orgs={orgs} />
</div>
</div>
<Breadcrumbs />
<main className="flex-1 overflow-y-auto p-4 w-full">
)}
{showBreadcrumbs && <Breadcrumbs />}
<main className="flex-1 overflow-y-auto p-6 w-full">
<div className="container mx-auto max-w-12xl">
{children}
</div>

View file

@ -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<HTMLElement> {
export interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
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<Set<string>>(new Set());
// Initialize expanded items based on autoExpand property
useEffect(() => {
const autoExpanded = new Set<string>();
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,13 +61,29 @@ 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 (
<div key={hydratedHref}>
<div className="flex items-center group" style={{ marginLeft: `${indent}px` }}>
<Link
href={hydratedHref}
className={cn(
@ -62,33 +100,23 @@ export function SidebarNav({
{item.icon && <span className="mr-2">{item.icon}</span>}
{item.title}
</Link>
{item.children && (
<div className="ml-4 space-y-1 mt-1">
{item.children.map((child) => {
const hydratedChildHref = hydrateHref(child.href);
const isChildActive = pathname.startsWith(hydratedChildHref) && !pathname.includes("create");
return (
<Link
key={hydratedChildHref}
href={hydratedChildHref}
className={cn(
"flex items-center text-sm py-2 px-3 w-full transition-colors",
isChildActive
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground",
disabled && "cursor-not-allowed opacity-60"
)}
onClick={disabled ? (e) => e.preventDefault() : undefined}
tabIndex={disabled ? -1 : undefined}
aria-disabled={disabled}
{hasChildren && (
<button
onClick={() => toggleItem(hydratedHref)}
className="p-2 hover:bg-muted rounded-md ml-auto"
disabled={disabled}
>
<CornerDownRight className="h-4 w-4 text-muted-foreground/70 mr-2" />
{child.icon && <span className="mr-2">{child.icon}</span>}
{child.title}
</Link>
);
})}
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
)}
</div>
{hasChildren && isExpanded && (
<div className="space-y-1 mt-1">
{renderItems(item.children || [], level + 1)}
</div>
)}
</div>