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 { verifySession } from "@app/lib/auth/verifySession";
import UserProvider from "@app/providers/UserProvider"; import UserProvider from "@app/providers/UserProvider";
import { cache } from "react"; import { cache } from "react";
@ -8,6 +7,8 @@ import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Layout } from "@app/components/Layout";
import { orgNavItems } from "../navigation";
type OrgPageProps = { type OrgPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
@ -20,6 +21,10 @@ export default async function OrgPage(props: OrgPageProps) {
const getUser = cache(verifySession); const getUser = cache(verifySession);
const user = await getUser(); const user = await getUser();
if (!user) {
redirect("/");
}
let redirectToSettings = false; let redirectToSettings = false;
let overview: GetOrgOverviewResponse | undefined; let overview: GetOrgOverviewResponse | undefined;
try { try {
@ -39,14 +44,11 @@ export default async function OrgPage(props: OrgPageProps) {
} }
return ( return (
<> <UserProvider user={user}>
<div className="p-3"> <Layout
{user && ( orgId={orgId}
<UserProvider user={user}> navItems={orgNavItems}
<ProfileIcon /> >
</UserProvider>
)}
{overview && ( {overview && (
<div className="w-full max-w-4xl mx-auto md:mt-32 mt-4"> <div className="w-full max-w-4xl mx-auto md:mt-32 mt-4">
<OrganizationLandingCard <OrganizationLandingCard
@ -65,7 +67,7 @@ export default async function OrgPage(props: OrgPageProps) {
/> />
</div> </div>
)} )}
</div> </Layout>
</> </UserProvider>
); );
} }

View file

@ -8,6 +8,7 @@ import OrgProvider from "@app/providers/OrgProvider";
import UserProvider from "@app/providers/UserProvider"; import UserProvider from "@app/providers/UserProvider";
import { verifySession } from "@app/lib/auth/verifySession"; import { verifySession } from "@app/lib/auth/verifySession";
import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav"; import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
type InvitationsPageProps = { type InvitationsPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
@ -72,13 +73,15 @@ export default async function InvitationsPage(props: InvitationsPageProps) {
return ( return (
<> <>
<AccessPageHeaderAndNav hasInvitations={hasInvitations}> <SettingsSectionTitle
<UserProvider user={user!}> title="Open Invitations"
<OrgProvider org={org}> description="Manage your invitations to other users"
<InvitationsTable invitations={invitationRows} /> />
</OrgProvider> <UserProvider user={user!}>
</UserProvider> <OrgProvider org={org}>
</AccessPageHeaderAndNav> <InvitationsTable invitations={invitationRows} />
</OrgProvider>
</UserProvider>
</> </>
); );
} }

View file

@ -17,6 +17,7 @@ import { cache } from "react";
import { GetOrgUserResponse } from "@server/routers/user"; import { GetOrgUserResponse } from "@server/routers/user";
import UserProvider from "@app/providers/UserProvider"; import UserProvider from "@app/providers/UserProvider";
import { Layout } from "@app/components/Layout"; import { Layout } from "@app/components/Layout";
import { SidebarNavItem, SidebarNavProps } from "@app/components/SidebarNav";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@ -25,25 +26,32 @@ export const metadata: Metadata = {
description: "" description: ""
}; };
const navItems = [ const navItems: SidebarNavItem[] = [
{ {
title: "Sites", title: "Sites",
href: "/{orgId}/settings/sites", href: "/{orgId}/settings/sites"
// icon: <Combine className="h-4 w-4" /> // icon: <Combine className="h-4 w-4" />
}, },
{ {
title: "Resources", title: "Resources",
href: "/{orgId}/settings/resources", href: "/{orgId}/settings/resources"
// icon: <Waypoints className="h-4 w-4" /> // icon: <Waypoints className="h-4 w-4" />
}, },
{ {
title: "Access Control", title: "Access Control",
href: "/{orgId}/settings/access", href: "/{orgId}/settings/access",
// icon: <Users className="h-4 w-4" />, // icon: <Users className="h-4 w-4" />,
autoExpand: true,
children: [ children: [
{ {
title: "Users", title: "Users",
href: "/{orgId}/settings/access/users" href: "/{orgId}/settings/access/users",
children: [
{
title: "Invitations",
href: "/{orgId}/settings/access/invitations"
}
]
}, },
{ {
title: "Roles", title: "Roles",
@ -53,12 +61,12 @@ const navItems = [
}, },
{ {
title: "Shareable Links", title: "Shareable Links",
href: "/{orgId}/settings/share-links", href: "/{orgId}/settings/share-links"
// icon: <LinkIcon className="h-4 w-4" /> // icon: <LinkIcon className="h-4 w-4" />
}, },
{ {
title: "General", title: "General",
href: "/{orgId}/settings/general", href: "/{orgId}/settings/general"
// icon: <Settings className="h-4 w-4" /> // icon: <Settings className="h-4 w-4" />
} }
]; ];

View file

@ -19,7 +19,7 @@
--accent-foreground: hsl(24 9.8% 10%); --accent-foreground: hsl(24 9.8% 10%);
--destructive: hsl(0 84.2% 60.2%); --destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(60 9.1% 97.8%); --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%); --input: hsl(20 5.9% 75%);
--ring: hsl(24.6 95% 53.1%); --ring: hsl(24.6 95% 53.1%);
--radius: 0.50rem; --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 { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import ProfileIcon from "@app/components/ProfileIcon";
import { verifySession } from "@app/lib/auth/verifySession"; import { verifySession } from "@app/lib/auth/verifySession";
import UserProvider from "@app/providers/UserProvider"; import UserProvider from "@app/providers/UserProvider";
import { ListOrgsResponse } from "@server/routers/org"; import { ListOrgsResponse } from "@server/routers/org";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { ArrowUpRight } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { cache } from "react"; import { cache } from "react";
import OrganizationLanding from "./components/OrganizationLanding"; import OrganizationLanding from "./components/OrganizationLanding";
import { pullEnv } from "@app/lib/pullEnv"; import { pullEnv } from "@app/lib/pullEnv";
import { cleanRedirect } from "@app/lib/cleanRedirect"; import { cleanRedirect } from "@app/lib/cleanRedirect";
import { Layout } from "@app/components/Layout";
import { rootNavItems } from "./navigation";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@ -71,16 +70,12 @@ export default async function Page(props: {
} }
return ( return (
<> <UserProvider user={user}>
<div className="p-3"> <Layout
{user && ( orgs={orgs}
<UserProvider user={user}> navItems={rootNavItems}
<div> showBreadcrumbs={false}
<ProfileIcon /> >
</div>
</UserProvider>
)}
<div className="w-full max-w-md mx-auto md:mt-32 mt-4"> <div className="w-full max-w-md mx-auto md:mt-32 mt-4">
<OrganizationLanding <OrganizationLanding
disableCreateOrg={env.flags.disableUserCreateOrg && !user.serverAdmin} disableCreateOrg={env.flags.disableUserCreateOrg && !user.serverAdmin}
@ -90,7 +85,7 @@ export default async function Page(props: {
}))} }))}
/> />
</div> </div>
</div> </Layout>
</> </UserProvider>
); );
} }

View file

@ -25,7 +25,7 @@ interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
orgId?: string; orgId?: string;
orgs?: ListOrgsResponse["orgs"]; orgs?: ListOrgsResponse["orgs"];
navItems: Array<{ navItems?: Array<{
title: string; title: string;
href: string; href: string;
icon?: React.ReactNode; icon?: React.ReactNode;
@ -35,84 +35,107 @@ interface LayoutProps {
icon?: React.ReactNode; 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 [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const { env } = useEnvContext(); const { env } = useEnvContext();
return ( return (
<div className="flex h-screen overflow-hidden"> <div className="flex h-screen overflow-hidden">
{/* Mobile Menu Button */} {/* Mobile Menu Button */}
<div className="md:hidden fixed top-4 left-4 z-50"> {showSidebar && (
<Sheet <div className="md:hidden fixed top-4 left-4 z-50">
open={isMobileMenuOpen} <Sheet
onOpenChange={setIsMobileMenuOpen} open={isMobileMenuOpen}
> onOpenChange={setIsMobileMenuOpen}
<SheetTrigger asChild> >
<Button variant="ghost" size="icon"> <SheetTrigger asChild>
<Menu className="h-6 w-6" /> <Button variant="ghost" size="icon">
</Button> <Menu className="h-6 w-6" />
</SheetTrigger> </Button>
<SheetContent side="left" className="w-64 p-0 flex flex-col h-full"> </SheetTrigger>
<SheetTitle className="sr-only"> <SheetContent side="left" className="w-64 p-0 flex flex-col h-full">
Navigation Menu <SheetTitle className="sr-only">
</SheetTitle> Navigation Menu
<SheetDescription className="sr-only"> </SheetTitle>
Main navigation menu for the application <SheetDescription className="sr-only">
</SheetDescription> 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>
<div className="p-4 space-y-4 border-t shrink-0">
<SupporterStatus />
<OrgSelector orgId={orgId} orgs={orgs} />
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
v{env.app.version}
</div>
)}
</div>
</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"> <div className="flex h-16 items-center border-b px-4 shrink-0">
<Header orgId={orgId} orgs={orgs} /> <Header orgId={orgId} orgs={orgs} />
</div> </div>
<div className="flex-1 overflow-y-auto p-4"> )}
<SidebarNav items={navItems} /> <div className="flex-1 overflow-y-auto p-4">
</div> <SidebarNav items={navItems} />
<div className="p-4 space-y-4 border-t shrink-0"> </div>
<SupporterStatus /> <div className="p-4 space-y-4 border-t shrink-0">
<OrgSelector orgId={orgId} orgs={orgs} /> <SupporterStatus />
<OrgSelector orgId={orgId} orgs={orgs} />
<div className="space-y-2">
<div className="text-xs text-muted-foreground text-center">
Open Source
</div>
{env?.app?.version && ( {env?.app?.version && (
<div className="text-xs text-muted-foreground text-center"> <div className="text-xs text-muted-foreground text-center">
v{env.app.version} v{env.app.version}
</div> </div>
)} )}
</div> </div>
</SheetContent>
</Sheet>
</div>
{/* Desktop Sidebar */}
<div className="hidden md:flex w-64 border-r bg-card flex-col h-full shrink-0">
<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>
<div className="p-4 space-y-4 border-t shrink-0">
<SupporterStatus />
<OrgSelector orgId={orgId} orgs={orgs} />
<div className="space-y-2">
<div className="text-xs text-muted-foreground text-center">
Open Source
</div>
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
v{env.app.version}
</div>
)}
</div> </div>
</div> </div>
</div> )}
{/* Main content */} {/* 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")}>
<div className="h-16 border-b shrink-0 bg-card"> {showTopBar && (
<div className="flex h-full items-center justify-end px-4"> <div className="h-16 border-b shrink-0 bg-card">
<TopBar orgId={orgId} orgs={orgs} /> <div className="flex h-full items-center justify-end px-4">
<TopBar orgId={orgId} orgs={orgs} />
</div>
</div> </div>
</div> )}
<Breadcrumbs /> {showBreadcrumbs && <Breadcrumbs />}
<main className="flex-1 overflow-y-auto p-4 w-full"> <main className="flex-1 overflow-y-auto p-6 w-full">
<div className="container mx-auto max-w-12xl"> <div className="container mx-auto max-w-12xl">
{children} {children}
</div> </div>

View file

@ -1,19 +1,20 @@
"use client"; "use client";
import React from "react"; import React, { useState, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { useParams, usePathname } from "next/navigation"; import { useParams, usePathname } from "next/navigation";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { CornerDownRight } from "lucide-react"; import { ChevronDown, ChevronRight } from "lucide-react";
interface SidebarNavItem { export interface SidebarNavItem {
href: string; href: string;
title: string; title: string;
icon?: React.ReactNode; icon?: React.ReactNode;
children?: SidebarNavItem[]; children?: SidebarNavItem[];
autoExpand?: boolean;
} }
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> { export interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
items: SidebarNavItem[]; items: SidebarNavItem[];
disabled?: boolean; disabled?: boolean;
} }
@ -30,6 +31,27 @@ export function SidebarNav({
const niceId = params.niceId as string; const niceId = params.niceId as string;
const resourceId = params.resourceId as string; const resourceId = params.resourceId as string;
const userId = params.userId 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 { function hydrateHref(val: string): string {
return val return val
@ -39,56 +61,62 @@ export function SidebarNav({
.replace("{userId}", userId); .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) => { return items.map((item) => {
const hydratedHref = hydrateHref(item.href); const hydratedHref = hydrateHref(item.href);
const isActive = pathname.startsWith(hydratedHref); 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 ( return (
<div key={hydratedHref}> <div key={hydratedHref}>
<Link <div className="flex items-center group" style={{ marginLeft: `${indent}px` }}>
href={hydratedHref} <Link
className={cn( href={hydratedHref}
"flex items-center py-2 px-3 w-full transition-colors", className={cn(
isActive "flex items-center py-2 px-3 w-full transition-colors",
? "text-primary font-medium" isActive
: "text-muted-foreground hover:text-foreground", ? "text-primary font-medium"
disabled && "cursor-not-allowed opacity-60" : "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}
>
{item.icon && <span className="mr-2">{item.icon}</span>}
{item.title}
</Link>
{hasChildren && (
<button
onClick={() => toggleItem(hydratedHref)}
className="p-2 hover:bg-muted rounded-md ml-auto"
disabled={disabled}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
)} )}
onClick={disabled ? (e) => e.preventDefault() : undefined} </div>
tabIndex={disabled ? -1 : undefined} {hasChildren && isExpanded && (
aria-disabled={disabled} <div className="space-y-1 mt-1">
> {renderItems(item.children || [], level + 1)}
{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}
>
<CornerDownRight className="h-4 w-4 text-muted-foreground/70 mr-2" />
{child.icon && <span className="mr-2">{child.icon}</span>}
{child.title}
</Link>
);
})}
</div> </div>
)} )}
</div> </div>