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 { AppSidebarShell } from "./AppSidebarShell";
import { AppSidebarSkeleton } from "./AppSidebarSkeleton";
import { NavMain } from "./NavMain";
const AppSidebarQuery = graphql`
query AppSidebarQuery {
@@ -37,6 +38,7 @@ function AppSidebarContent({
organizationSwitcher={
<OrganizationSwitcher organizations={data.viewer} />
}
navMain={<NavMain />}
navUser={<NavUser viewer={data.viewer} />}
{...props}
/>

View File

@@ -1,5 +1,3 @@
import { useParams } from "react-router";
import { NavMain } from "./NavMain";
import { NavSecondary } from "./NavSecondary";
import {
Sidebar,
@@ -7,101 +5,28 @@ import {
SidebarFooter,
SidebarHeader,
} from "./ui/sidebar";
import {
BookOpen,
Users,
ToyBrick,
LifeBuoy,
Send,
Settings,
Building,
FileText,
} from "lucide-react";
import { ReactNode } from "react";
interface AppSidebarShellProps extends React.ComponentProps<typeof Sidebar> {
navUser: ReactNode;
navMain: ReactNode;
organizationSwitcher: ReactNode;
}
export function AppSidebarShell({
navUser,
navMain,
organizationSwitcher,
...props
}: AppSidebarShellProps) {
const { organizationId } = useParams();
const navItems = getNavItems(organizationId);
return (
<Sidebar variant="inset" {...props}>
<SidebarHeader>{organizationSwitcher}</SidebarHeader>
<SidebarContent>
<NavMain items={navItems.navMain} />
<NavSecondary items={navItems.navSecondary} className="mt-auto" />
{navMain}
<NavSecondary className="mt-auto" />
</SidebarContent>
<SidebarFooter>{navUser}</SidebarFooter>
</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 { NavMainSkeleton } from "./NavMainSkeleton";
import { NavUserSkeleton } from "./NavUserSkeleton";
import { OrganizationSwitcherSkeleton } from "./OrganizationSwitcherSkeleton";
import { Sidebar } from "./ui/sidebar";
@@ -9,6 +10,7 @@ export function AppSidebarSkeleton(
return (
<AppSidebarShell
organizationSwitcher={<OrganizationSwitcherSkeleton />}
navMain={<NavMainSkeleton />}
navUser={<NavUserSkeleton />}
{...props}
/>

View File

@@ -19,71 +19,35 @@ import {
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@/components/ui/sidebar";
import {
BookOpen,
Building,
FileText,
Settings,
ToyBrick,
Users,
} from "lucide-react";
import { NavMainSkeleton } from "./NavMainSkeleton";
export function NavMain({
items,
}: {
items: {
interface NavItem {
title: string;
url?: string;
icon: LucideIcon;
isActive?: boolean;
items?: {
title: string;
url?: string;
url: string;
icon: LucideIcon;
isActive?: boolean;
items?: {
title: string;
url: string;
}[];
}[];
}) {
}
export function NavMain() {
const location = useLocation();
const { organizationId } = useParams();
const items: NavItem[] = getNavItems(organizationId);
const noOrganizationSelected = organizationId === undefined;
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>
);
if (!organizationId) {
return <NavMainSkeleton />;
}
const isItemActive = (item: { url?: string; items?: { url: string }[] }) => {
@@ -171,3 +135,51 @@ export function NavMain({
</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 { type LucideIcon } from "lucide-react";
import { useLocation } from "react-router";
import { LifeBuoy, Send } from "lucide-react";
import {
SidebarGroup,
SidebarGroupContent,
@@ -10,43 +9,27 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar";
export function NavSecondary({
items,
...props
}: {
items: {
title: string;
url: string;
icon: LucideIcon;
}[];
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
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>
);
}
const navItems = [
{
title: "Support",
url: "#",
icon: LifeBuoy,
},
{
title: "Feedback",
url: "#",
icon: Send,
},
];
export function NavSecondary(
props: React.ComponentPropsWithoutRef<typeof SidebarGroup>
) {
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
{navItems.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild={item.url !== "#"}

View File

@@ -29,6 +29,7 @@ import {
} from "@/components/ui/sidebar";
import { useAuth } from "@/contexts/AuthContext";
import { NavUser_viewer$key } from "./__generated__/NavUser_viewer.graphql";
import { NavUserSkeleton } from "./NavUserSkeleton";
export const navUserFragment = graphql`
fragment NavUser_viewer on User {
@@ -44,28 +45,14 @@ export function NavUser({ viewer }: { viewer: NavUser_viewer$key }) {
const { logout } = useAuth();
const navigate = useNavigate();
const { organizationId } = useParams();
const noOrganizationSelected = !organizationId;
const handleLogout = async () => {
await logout();
navigate("/login");
};
if (noOrganizationSelected) {
return (
<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>
);
if (!organizationId) {
return <NavUserSkeleton />;
}
return (

View File

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

View File

@@ -1,7 +1,7 @@
"use client";
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 { Link, useNavigate, useParams } from "react-router";
@@ -25,6 +25,7 @@ import {
OrganizationSwitcher_organizations$data,
} from "./__generated__/OrganizationSwitcher_organizations.graphql";
import { cn } from "@/lib/utils";
import { OrganizationSwitcherSkeleton } from "./OrganizationSwitcherSkeleton";
export const organizationSwitcherFragment = graphql`
fragment OrganizationSwitcher_organizations on User {
@@ -95,20 +96,7 @@ export function OrganizationSwitcher({
};
if (!hasOrganizations) {
return (
<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 <OrganizationSwitcherSkeleton />;
}
return (
@@ -118,32 +106,33 @@ export function OrganizationSwitcher({
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className={`data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground gap-2.5 ${
!currentOrganization
? "border border-dashed border-gray-400"
: ""
}`}
className={cn(
"data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground gap-2.5",
!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 ? (
<LogoComponent org={currentOrganization} className="size-8" />
) : (
<Building className="size-8 text-gray-400" />
<div className="size-8 bg-slate-400 rounded-sm" />
)}
</div>
<div className="grid text-left leading-tight">
<span
className={`truncate font-medium text-lg leading-5 ${
!currentOrganization ? "text-gray-500" : "text-gray-900"
}`}
>
{currentOrganization
? currentOrganization.name
: "Select Organization"}
</span>
<span className="truncate text-xs text-gray-500 font-medium">
{currentOrganization ? "Free" : "No organization selected"}
</span>
{currentOrganization ? (
<>
<span className="truncate font-medium text-lg leading-5 text-gray-900">
{currentOrganization.name}
</span>
<span className="truncate text-xs text-gray-500 font-medium">
Free
</span>
</>
) : (
<span className="truncate text-gray-500 font-medium">
Select Organization
</span>
)}
</div>
<ChevronsUpDown className="ml-auto" />
</SidebarMenuButton>

View File

@@ -1,7 +1,5 @@
"use client";
import { ChevronsUpDown } from "lucide-react";
import {
SidebarMenu,
SidebarMenuButton,
@@ -16,16 +14,16 @@ export function OrganizationSwitcherSkeleton() {
size="lg"
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="grid text-left leading-tight">
<div className="size-8 items-center justify-center rounded-md bg-gray-300 animate-pulse" />
<div className="flex flex-col items-start">
<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 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>
<ChevronsUpDown className="ml-auto" />
<div className="ml-auto size-4 rounded-lg bg-gray-300 animate-pulse" />
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>