nav skeletons

Signed-off-by: Émile Ré <nemile.re@gmail.com>
This commit is contained in:
Émile Ré
2025-03-07 17:01:32 +04:00
parent aa0fca8f7b
commit d077366181
10 changed files with 187 additions and 243 deletions

View File

@@ -13,6 +13,7 @@ import type { AppSidebarQuery as AppSidebarQueryType } from "./__generated__/App
import { OrganizationSwitcher } from "@/components/OrganizationSwitcher"; import { OrganizationSwitcher } from "@/components/OrganizationSwitcher";
import { AppSidebarShell } from "./AppSidebarShell"; import { AppSidebarShell } from "./AppSidebarShell";
import { AppSidebarSkeleton } from "./AppSidebarSkeleton"; import { AppSidebarSkeleton } from "./AppSidebarSkeleton";
import { NavMain } from "./NavMain";
const AppSidebarQuery = graphql` const AppSidebarQuery = graphql`
query AppSidebarQuery { query AppSidebarQuery {
@@ -37,6 +38,7 @@ function AppSidebarContent({
organizationSwitcher={ organizationSwitcher={
<OrganizationSwitcher organizations={data.viewer} /> <OrganizationSwitcher organizations={data.viewer} />
} }
navMain={<NavMain />}
navUser={<NavUser viewer={data.viewer} />} navUser={<NavUser viewer={data.viewer} />}
{...props} {...props}
/> />

View File

@@ -1,5 +1,3 @@
import { useParams } from "react-router";
import { NavMain } from "./NavMain";
import { NavSecondary } from "./NavSecondary"; import { NavSecondary } from "./NavSecondary";
import { import {
Sidebar, Sidebar,
@@ -7,101 +5,28 @@ import {
SidebarFooter, SidebarFooter,
SidebarHeader, SidebarHeader,
} from "./ui/sidebar"; } from "./ui/sidebar";
import {
BookOpen,
Users,
ToyBrick,
LifeBuoy,
Send,
Settings,
Building,
FileText,
} from "lucide-react";
import { ReactNode } from "react"; import { ReactNode } from "react";
interface AppSidebarShellProps extends React.ComponentProps<typeof Sidebar> { interface AppSidebarShellProps extends React.ComponentProps<typeof Sidebar> {
navUser: ReactNode; navUser: ReactNode;
navMain: ReactNode;
organizationSwitcher: ReactNode; organizationSwitcher: ReactNode;
} }
export function AppSidebarShell({ export function AppSidebarShell({
navUser, navUser,
navMain,
organizationSwitcher, organizationSwitcher,
...props ...props
}: AppSidebarShellProps) { }: AppSidebarShellProps) {
const { organizationId } = useParams();
const navItems = getNavItems(organizationId);
return ( return (
<Sidebar variant="inset" {...props}> <Sidebar variant="inset" {...props}>
<SidebarHeader>{organizationSwitcher}</SidebarHeader> <SidebarHeader>{organizationSwitcher}</SidebarHeader>
<SidebarContent> <SidebarContent>
<NavMain items={navItems.navMain} /> {navMain}
<NavSecondary items={navItems.navSecondary} className="mt-auto" /> <NavSecondary className="mt-auto" />
</SidebarContent> </SidebarContent>
<SidebarFooter>{navUser}</SidebarFooter> <SidebarFooter>{navUser}</SidebarFooter>
</Sidebar> </Sidebar>
); );
} }
function getNavItems(organizationId?: string) {
// Always return the same structure, but with or without URLs depending on whether an organization is selected
return {
navMain: [
{
title: "Frameworks",
url: organizationId
? `/organizations/${organizationId}/frameworks`
: undefined,
icon: BookOpen,
},
{
title: "Organizations",
icon: Building,
url: organizationId
? `/organizations/${organizationId}/peoples`
: undefined,
items: organizationId
? [
{
title: "Peoples",
url: `/organizations/${organizationId}/peoples`,
icon: Users,
},
{
title: "Vendors",
url: `/organizations/${organizationId}/vendors`,
icon: ToyBrick,
},
]
: [],
},
{
title: "Policies",
url: organizationId
? `/organizations/${organizationId}/policies`
: undefined,
icon: FileText,
},
{
title: "Settings",
url: organizationId
? `/organizations/${organizationId}/settings`
: undefined,
icon: Settings,
},
],
navSecondary: [
{
title: "Support",
url: "#",
icon: LifeBuoy,
},
{
title: "Feedback",
url: "#",
icon: Send,
},
],
};
}

View File

@@ -1,4 +1,5 @@
import { AppSidebarShell } from "./AppSidebarShell"; import { AppSidebarShell } from "./AppSidebarShell";
import { NavMainSkeleton } from "./NavMainSkeleton";
import { NavUserSkeleton } from "./NavUserSkeleton"; import { NavUserSkeleton } from "./NavUserSkeleton";
import { OrganizationSwitcherSkeleton } from "./OrganizationSwitcherSkeleton"; import { OrganizationSwitcherSkeleton } from "./OrganizationSwitcherSkeleton";
import { Sidebar } from "./ui/sidebar"; import { Sidebar } from "./ui/sidebar";
@@ -9,6 +10,7 @@ export function AppSidebarSkeleton(
return ( return (
<AppSidebarShell <AppSidebarShell
organizationSwitcher={<OrganizationSwitcherSkeleton />} organizationSwitcher={<OrganizationSwitcherSkeleton />}
navMain={<NavMainSkeleton />}
navUser={<NavUserSkeleton />} navUser={<NavUserSkeleton />}
{...props} {...props}
/> />

View File

@@ -19,71 +19,35 @@ import {
SidebarMenuSubButton, SidebarMenuSubButton,
SidebarMenuSubItem, SidebarMenuSubItem,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import {
BookOpen,
Building,
FileText,
Settings,
ToyBrick,
Users,
} from "lucide-react";
import { NavMainSkeleton } from "./NavMainSkeleton";
export function NavMain({ interface NavItem {
items, title: string;
}: { url?: string;
items: { icon: LucideIcon;
isActive?: boolean;
items?: {
title: string; title: string;
url?: string; url: string;
icon: LucideIcon; icon: LucideIcon;
isActive?: boolean;
items?: {
title: string;
url: string;
}[];
}[]; }[];
}) { }
export function NavMain() {
const location = useLocation(); const location = useLocation();
const { organizationId } = useParams(); const { organizationId } = useParams();
const items: NavItem[] = getNavItems(organizationId);
const noOrganizationSelected = organizationId === undefined; if (!organizationId) {
return <NavMainSkeleton />;
if (noOrganizationSelected) {
return (
<SidebarGroup>
<SidebarGroupLabel>
<div className="h-4 w-28 rounded-md bg-gray-200 animate-pulse" />
</SidebarGroupLabel>
<SidebarMenu className="space-y-1.5">
{/* First item - simple item */}
<SidebarMenuItem>
<SidebarMenuButton className="animate-pulse">
<div className="h-4 w-4 rounded-md bg-gray-200" />
<div className="h-4 w-32 rounded-md bg-gray-200 ml-2" />
</SidebarMenuButton>
</SidebarMenuItem>
{/* Second item - with dropdown */}
<SidebarMenuItem>
<SidebarMenuButton className="animate-pulse">
<div className="h-4 w-4 rounded-md bg-gray-200" />
<div className="h-4 w-28 rounded-md bg-gray-200 ml-2" />
<div className="ml-auto">
<div className="h-4 w-4 rounded-md bg-gray-200" />
</div>
</SidebarMenuButton>
<SidebarMenuSub>
{[1, 2].map((i) => (
<SidebarMenuSubItem key={i}>
<SidebarMenuSubButton className="animate-pulse pl-8">
<div className="h-3 w-20 rounded-md bg-gray-200" />
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</SidebarMenuItem>
{/* Third item - simple item */}
<SidebarMenuItem>
<SidebarMenuButton className="animate-pulse">
<div className="h-4 w-4 rounded-md bg-gray-200" />
<div className="h-4 w-24 rounded-md bg-gray-200 ml-2" />
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
);
} }
const isItemActive = (item: { url?: string; items?: { url: string }[] }) => { const isItemActive = (item: { url?: string; items?: { url: string }[] }) => {
@@ -171,3 +135,51 @@ export function NavMain({
</SidebarGroup> </SidebarGroup>
); );
} }
function getNavItems(organizationId?: string): NavItem[] {
// Always return the same structure, but with or without URLs depending on whether an organization is selected
return [
{
title: "Frameworks",
url: organizationId
? `/organizations/${organizationId}/frameworks`
: undefined,
icon: BookOpen,
},
{
title: "Organizations",
icon: Building,
url: organizationId
? `/organizations/${organizationId}/peoples`
: undefined,
items: organizationId
? [
{
title: "Peoples",
url: `/organizations/${organizationId}/peoples`,
icon: Users,
},
{
title: "Vendors",
url: `/organizations/${organizationId}/vendors`,
icon: ToyBrick,
},
]
: [],
},
{
title: "Policies",
url: organizationId
? `/organizations/${organizationId}/policies`
: undefined,
icon: FileText,
},
{
title: "Settings",
url: organizationId
? `/organizations/${organizationId}/settings`
: undefined,
icon: Settings,
},
];
}

View File

@@ -0,0 +1,55 @@
"use client";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
export function NavMainSkeleton() {
return (
<SidebarGroup>
<SidebarGroupLabel className="pl-3">
<div className="py-0.5">
<div className="h-3 w-24 rounded-sm bg-gray-300 animate-pulse" />
</div>
</SidebarGroupLabel>
<SidebarMenu className="space-y-1.5">
<SidebarMenuItem>
<SidebarMenuButton className="animate-pulse">
<div className="h-4 w-4 rounded-md bg-lime-5" />
<div className="py-[3px]">
<div className="h-3.5 w-32 rounded-md bg-gray-300" />
</div>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton className="animate-pulse">
<div className="h-4 w-4 rounded-md bg-lime-6" />
<div className="py-[3px]">
<div className="h-3.5 w-32 rounded-md bg-gray-300" />
</div>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton className="animate-pulse">
<div className="h-4 w-4 rounded-md bg-lime-6" />
<div className="py-[3px]">
<div className="h-3.5 w-32 rounded-md bg-gray-300" />
</div>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton className="animate-pulse">
<div className="h-4 w-4 rounded-md bg-lime-6" />
<div className="py-[3px]">
<div className="h-3.5 w-32 rounded-md bg-gray-300" />
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
);
}

View File

@@ -1,7 +1,6 @@
import * as React from "react"; import * as React from "react";
import { type LucideIcon } from "lucide-react";
import { useLocation } from "react-router";
import { LifeBuoy, Send } from "lucide-react";
import { import {
SidebarGroup, SidebarGroup,
SidebarGroupContent, SidebarGroupContent,
@@ -10,43 +9,27 @@ import {
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
export function NavSecondary({ const navItems = [
items, {
...props title: "Support",
}: { url: "#",
items: { icon: LifeBuoy,
title: string; },
url: string; {
icon: LucideIcon; title: "Feedback",
}[]; url: "#",
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) { icon: Send,
const location = useLocation(); },
const noOrganizationSelected = location.pathname === "/"; ];
if (noOrganizationSelected) {
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{[1, 2].map((i) => (
<SidebarMenuItem key={i}>
<SidebarMenuButton size="sm" className="animate-pulse">
<div className="h-3 w-3 rounded-lg bg-gray-200" />
<div className="h-3 w-16 rounded-lg bg-gray-200 ml-2" />
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
export function NavSecondary(
props: React.ComponentPropsWithoutRef<typeof SidebarGroup>
) {
return ( return (
<SidebarGroup {...props}> <SidebarGroup {...props}>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{items.map((item) => ( {navItems.map((item) => (
<SidebarMenuItem key={item.title}> <SidebarMenuItem key={item.title}>
<SidebarMenuButton <SidebarMenuButton
asChild={item.url !== "#"} asChild={item.url !== "#"}

View File

@@ -29,6 +29,7 @@ import {
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { NavUser_viewer$key } from "./__generated__/NavUser_viewer.graphql"; import { NavUser_viewer$key } from "./__generated__/NavUser_viewer.graphql";
import { NavUserSkeleton } from "./NavUserSkeleton";
export const navUserFragment = graphql` export const navUserFragment = graphql`
fragment NavUser_viewer on User { fragment NavUser_viewer on User {
@@ -44,28 +45,14 @@ export function NavUser({ viewer }: { viewer: NavUser_viewer$key }) {
const { logout } = useAuth(); const { logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { organizationId } = useParams(); const { organizationId } = useParams();
const noOrganizationSelected = !organizationId;
const handleLogout = async () => { const handleLogout = async () => {
await logout(); await logout();
navigate("/login"); navigate("/login");
}; };
if (noOrganizationSelected) { if (!organizationId) {
return ( return <NavUserSkeleton />;
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" className="animate-pulse">
<div className="h-8 w-8 rounded-lg bg-gray-200" />
<div className="flex-1 space-y-1">
<div className="h-4 w-3/4 rounded-lg bg-gray-200" />
<div className="h-3 w-1/2 rounded-lg bg-gray-200" />
</div>
<div className="ml-auto h-4 w-4 rounded-lg bg-gray-200" />
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
);
} }
return ( return (

View File

@@ -1,8 +1,5 @@
"use client"; "use client";
import { ChevronsUpDown } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
@@ -17,20 +14,14 @@ export function NavUserSkeleton() {
isActive isActive
disabled disabled
size="lg" size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground animate-pulse"
> >
<Avatar className="h-9 w-9"> <div className="bg-gray-400 size-9 rounded-full animate-pulse" />
<AvatarFallback className="bg-slate-400 text-gray-100 animate-pulse" /> <div className="flex-1 space-y-[3px] animate-pulse">
</Avatar> <div className="h-3.5 w-20 rounded-sm bg-gray-400" />
<div className="grid flex-1"> <div className="h-3.5 w-30 rounded-sm bg-gray-400" />
<div className="py-[3px]">
<div className="h-3.5 animate-pulse w-20 rounded-sm bg-slate-500" />
</div>
<div className="py-[3px]">
<div className="h-3.5 animate-pulse w-30 rounded-sm bg-slate-500" />
</div>
</div> </div>
<ChevronsUpDown className="ml-auto size-4" /> <div className="ml-auto size-4 rounded-lg bg-gray-400" />
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { ChevronsUpDown, Plus, Building } from "lucide-react"; import { ChevronsUpDown, Plus } from "lucide-react";
import { graphql, useFragment } from "react-relay"; import { graphql, useFragment } from "react-relay";
import { Link, useNavigate, useParams } from "react-router"; import { Link, useNavigate, useParams } from "react-router";
@@ -25,6 +25,7 @@ import {
OrganizationSwitcher_organizations$data, OrganizationSwitcher_organizations$data,
} from "./__generated__/OrganizationSwitcher_organizations.graphql"; } from "./__generated__/OrganizationSwitcher_organizations.graphql";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { OrganizationSwitcherSkeleton } from "./OrganizationSwitcherSkeleton";
export const organizationSwitcherFragment = graphql` export const organizationSwitcherFragment = graphql`
fragment OrganizationSwitcher_organizations on User { fragment OrganizationSwitcher_organizations on User {
@@ -95,20 +96,7 @@ export function OrganizationSwitcher({
}; };
if (!hasOrganizations) { if (!hasOrganizations) {
return ( return <OrganizationSwitcherSkeleton />;
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" className="animate-pulse">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-gray-200" />
<div className="flex-1 space-y-1">
<div className="h-4 w-3/4 rounded-lg bg-gray-200" />
<div className="h-3 w-1/2 rounded-lg bg-gray-200" />
</div>
<div className="ml-auto h-4 w-4 rounded-lg bg-gray-200" />
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
);
} }
return ( return (
@@ -118,32 +106,33 @@ export function OrganizationSwitcher({
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<SidebarMenuButton <SidebarMenuButton
size="lg" size="lg"
className={`data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground gap-2.5 ${ className={cn(
!currentOrganization "data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground gap-2.5",
? "border border-dashed border-gray-400" !currentOrganization && "border border-dashed border-gray-300"
: "" )}
}`}
> >
<div className="flex aspect-square size-8 items-center justify-center rounded-sm bg-slate-400 text-sidebar-primary-foreground overflow-hidden"> <div className="flex aspect-square size-8 items-center justify-center rounded-sm bg-slate-400 text-gray-100">
{currentOrganization ? ( {currentOrganization ? (
<LogoComponent org={currentOrganization} className="size-8" /> <LogoComponent org={currentOrganization} className="size-8" />
) : ( ) : (
<Building className="size-8 text-gray-400" /> <div className="size-8 bg-slate-400 rounded-sm" />
)} )}
</div> </div>
<div className="grid text-left leading-tight"> <div className="grid text-left leading-tight">
<span {currentOrganization ? (
className={`truncate font-medium text-lg leading-5 ${ <>
!currentOrganization ? "text-gray-500" : "text-gray-900" <span className="truncate font-medium text-lg leading-5 text-gray-900">
}`} {currentOrganization.name}
> </span>
{currentOrganization <span className="truncate text-xs text-gray-500 font-medium">
? currentOrganization.name Free
: "Select Organization"} </span>
</span> </>
<span className="truncate text-xs text-gray-500 font-medium"> ) : (
{currentOrganization ? "Free" : "No organization selected"} <span className="truncate text-gray-500 font-medium">
</span> Select Organization
</span>
)}
</div> </div>
<ChevronsUpDown className="ml-auto" /> <ChevronsUpDown className="ml-auto" />
</SidebarMenuButton> </SidebarMenuButton>

View File

@@ -1,7 +1,5 @@
"use client"; "use client";
import { ChevronsUpDown } from "lucide-react";
import { import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
@@ -16,16 +14,16 @@ export function OrganizationSwitcherSkeleton() {
size="lg" size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground gap-2.5" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground gap-2.5"
> >
<div className="flex aspect-square size-8 items-center justify-center rounded-sm bg-slate-300 text-sidebar-primary-foreground animate-pulse" /> <div className="size-8 items-center justify-center rounded-md bg-gray-300 animate-pulse" />
<div className="grid text-left leading-tight"> <div className="flex flex-col items-start">
<div className="py-[1px]"> <div className="py-[1px]">
<div className="h-4.5 animate-pulse w-15 rounded-sm bg-slate-400" /> <div className="h-4.5 animate-pulse w-16 rounded-sm bg-gray-300" />
</div> </div>
<div className="py-0.5"> <div className="py-0.5">
<div className="h-3 animate-pulse w-8 rounded-sm bg-slate-400" /> <div className="h-3 animate-pulse w-8 rounded-sm bg-gray-300" />
</div> </div>
</div> </div>
<ChevronsUpDown className="ml-auto" /> <div className="ml-auto size-4 rounded-lg bg-gray-300 animate-pulse" />
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>