diff --git a/app/components/assets/assets-index/asset-index-pagination.tsx b/app/components/assets/assets-index/asset-index-pagination.tsx index 3583c096e..abf869ea8 100644 --- a/app/components/assets/assets-index/asset-index-pagination.tsx +++ b/app/components/assets/assets-index/asset-index-pagination.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; -import { useFetcher, useRouteLoaderData } from "@remix-run/react"; +import { useFetcher } from "@remix-run/react"; import { AlertIcon, ChevronRight } from "~/components/icons/library"; +import { useSidebar } from "~/components/layout/sidebar/sidebar"; import { AlertDialog, AlertDialogCancel, @@ -16,7 +17,6 @@ import { useAssetIndexViewState } from "~/hooks/use-asset-index-view-state"; import { useViewportHeight } from "~/hooks/use-viewport-height"; import { useUserRoleHelper } from "~/hooks/user-user-role-helper"; -import type { loader as layoutLoader } from "~/routes/_layout+/_layout"; import { PermissionAction, PermissionEntity, @@ -29,11 +29,9 @@ import { ButtonGroup } from "../../shared/button-group"; export function AssetIndexPagination() { const { roles } = useUserRoleHelper(); - let minimizedSidebar = useRouteLoaderData( - "routes/_layout+/_layout" - )?.minimizedSidebar; const fetcher = useFetcher({ key: "asset-index-settings-mode" }); const { isMd } = useViewportHeight(); + const { state } = useSidebar(); const { modeIsSimple, modeIsAdvanced } = useAssetIndexViewState(); const disabledButtonStyles = @@ -57,9 +55,9 @@ export function AssetIndexPagination() { return (
diff --git a/app/components/errors/error-404-handler.tsx b/app/components/errors/error-404-handler.tsx index 854f9f3aa..732e275e9 100644 --- a/app/components/errors/error-404-handler.tsx +++ b/app/components/errors/error-404-handler.tsx @@ -32,6 +32,7 @@ export default function Error404Handler({ case "asset": case "kit": case "location": + case "booking": case "customField": { const modelLabel = getModelLabelForEnumValue(additionalData.model); diff --git a/app/components/errors/utils.ts b/app/components/errors/utils.ts index a20f2c791..2d5b47062 100644 --- a/app/components/errors/utils.ts +++ b/app/components/errors/utils.ts @@ -1,41 +1,70 @@ import { z } from "zod"; import { isRouteError } from "~/utils/http"; +/** + * Base schema for additional error data. + * Contains common fields used across different error types. + */ const baseAdditionalDataSchema = z.object({ + /** Unique identifier for the error instance */ id: z.string(), + /** Optional URL to redirect the user to after error handling */ redirectTo: z.string().optional(), }); +/** + * Schema defining organization structure used in error data + */ const organizationSchema = z.object({ organization: z.object({ + /** Organization's unique identifier */ id: z.string(), + /** Organization's display name */ name: z.string(), }), }); +/** + * Schema for 404 error additional data. + * Uses a discriminated union to handle different model types with specific requirements. + */ export const error404AdditionalDataSchema = z.discriminatedUnion("model", [ /* For common and general use case */ baseAdditionalDataSchema.extend({ + /** Type of resource that wasn't found */ model: z.enum(["asset", "kit", "location", "booking", "customField"]), + /** Organization context where the resource wasn't found */ organization: organizationSchema, }), /* A team member (user) can be in multiple organization's of user so we do this. */ baseAdditionalDataSchema.extend({ + /** Specific case for team member not found errors */ model: z.literal("teamMember"), + /** List of organizations the team member could belong to */ organizations: organizationSchema.array(), }), ]); +/** + * Type definition for the 404 error additional data structure + */ export type Error404AdditionalData = z.infer< typeof error404AdditionalDataSchema >; -export function parse404ErrorData(response: unknown): +/** + * Parses and validates the structure of a 404 error response. + * + * @param response - The unknown response to be parsed + * @returns An object indicating whether it's a valid 404 error and its additional data + * If it's not a valid 404 error or parsing fails, returns {isError404: false, additionalData: null} + * If it's a valid 404 error, returns {isError404: true, additionalData: Error404AdditionalData} + */ +export function parse404ErrorData( + response: unknown +): | { isError404: false; additionalData: null } - | { - isError404: true; - additionalData: Error404AdditionalData; - } { + | { isError404: true; additionalData: Error404AdditionalData } { if (!isRouteError(response)) { return { isError404: false, additionalData: null }; } @@ -51,12 +80,17 @@ export function parse404ErrorData(response: unknown): return { isError404: true, additionalData: parsedDataResponse.data }; } +/** + * Converts a model enum value to a human-readable label. + * + * @param model - The model type from Error404AdditionalData + * @returns A string representing the human-readable label for the model + */ export function getModelLabelForEnumValue( model: Error404AdditionalData["model"] -) { +): string { if (model === "customField") { return "Custom field"; } - return model; } diff --git a/app/components/icons/icon.tsx b/app/components/icons/icon.tsx index 5922f7a70..5ace388f6 100644 --- a/app/components/icons/icon.tsx +++ b/app/components/icons/icon.tsx @@ -4,11 +4,13 @@ import type { IconType } from "../shared/icons-map"; import iconsMap from "../shared/icons-map"; export interface IconProps { + className?: string; icon: IconType; disableWrap?: true; + size?: React.ComponentProps["size"]; } const Icon = React.forwardRef(function Icon( - { icon, disableWrap }: IconProps, + { className, icon, disableWrap, size = "sm" }: IconProps, _ref ) { return ( @@ -16,7 +18,9 @@ const Icon = React.forwardRef(function Icon( (disableWrap ? (
{iconsMap[icon]}
) : ( - {iconsMap[icon]} + + {iconsMap[icon]} + )) ); }); diff --git a/app/components/icons/iconHug.tsx b/app/components/icons/iconHug.tsx index b4484ac14..0602b4f61 100644 --- a/app/components/icons/iconHug.tsx +++ b/app/components/icons/iconHug.tsx @@ -2,7 +2,7 @@ import { tw } from "~/utils/tw"; interface Props { /** Size of the hug. Defualt is sm */ - size: "sm" | "md" | "lg" | "xl" | "2xl"; + size: "xs" | "sm" | "md" | "lg" | "xl" | "2xl"; children: JSX.Element | JSX.Element[]; @@ -16,6 +16,8 @@ interface Props { const sizeClasses: { [key in Props["size"]]: string; } = { + /** 16 */ + xs: "w-4 h-4", /** 32px */ sm: "w-5 h-5", /** 40px */ diff --git a/app/components/icons/library.tsx b/app/components/icons/library.tsx index 57ff9901e..c75727890 100644 --- a/app/components/icons/library.tsx +++ b/app/components/icons/library.tsx @@ -84,8 +84,8 @@ export function ItemsIcon(props: SVGProps) { export function SettingsIcon(props: SVGProps) { return ( ) { export const AssetsIcon = (props: SVGProps) => ( ) => ( export const TagsIcon = (props: SVGProps) => ( @@ -923,9 +925,9 @@ export const RemoveTagsIcon = (props: SVGProps) => ( export const LocationMarkerIcon = (props: SVGProps) => ( ) => ( export const BookingsIcon = (props: SVGProps) => ( @@ -1491,8 +1493,8 @@ export const CustomFiedIcon = (props: SVGProps) => ( export const KitIcon = (props: SVGProps) => ( ) => ( ; + +export default function AppSidebar(props: AppSidebarProps) { + const { state } = useSidebar(); + const { topMenuItems, bottomMenuItems } = useSidebarNavItems(); + + return ( + + +
+ +
+ + + + + + + + + + + + + + + + ); +} diff --git a/app/components/layout/sidebar/atoms.ts b/app/components/layout/sidebar/atoms.ts deleted file mode 100644 index 9b93b6bb2..000000000 --- a/app/components/layout/sidebar/atoms.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { atom } from "jotai"; - -export const sidebarCollapseStatusAtom = atom(false); - -export const keepSidebarUncollapsedAtom = atom(true); - -/* Controls the state for whether the sidebar is collapsed or not */ -export const toggleSidebarAtom = atom( - (get) => get(sidebarCollapseStatusAtom), - (get, set) => - !get(keepSidebarUncollapsedAtom) - ? set(sidebarCollapseStatusAtom, !get(sidebarCollapseStatusAtom)) - : null -); - -/* Controls the state for whether the sidebar uncollapsed state will be maintained or not */ -export const maintainUncollapsedAtom = atom( - (get) => get(keepSidebarUncollapsedAtom), - (get, set) => { - set(keepSidebarUncollapsedAtom, !get(keepSidebarUncollapsedAtom)); - set(sidebarCollapseStatusAtom, !get(keepSidebarUncollapsedAtom)); - } -); - -/* Using different atoms for mobile navigation sidebar as they conflict with desktop's sidebar atoms state*/ -export const isMobileNavOpenAtom = atom(false); - -export const toggleMobileNavAtom = atom( - (get) => get(isMobileNavOpenAtom), - (get, set) => set(isMobileNavOpenAtom, !get(isMobileNavOpenAtom)) -); diff --git a/app/components/layout/sidebar/bottom.tsx b/app/components/layout/sidebar/bottom.tsx deleted file mode 100644 index b72e72825..000000000 --- a/app/components/layout/sidebar/bottom.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { useState } from "react"; -import type { User } from "@prisma/client"; -import { useAtom } from "jotai"; -import { switchingWorkspaceAtom } from "~/atoms/switching-workspace"; -import { Form } from "~/components/custom-form"; -import { ChevronRight, QuestionsIcon } from "~/components/icons/library"; -import { CrispButton } from "~/components/marketing/crisp"; -import { Button } from "~/components/shared/button"; -import { - DropdownMenuItem, - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from "~/components/shared/dropdown"; -import ProfilePicture from "~/components/user/profile-picture"; -import { tw } from "~/utils/tw"; - -interface Props { - user: Pick; -} - -export default function SidebarBottom({ user }: Props) { - const [dropdownOpen, setDropdownOpen] = useState(false); - const [workspaceSwitching] = useAtom(switchingWorkspaceAtom); - - return ( -
- - setDropdownOpen((prev) => !prev)} - className="w-full truncate outline-none focus-visible:border-0" - > -
- -
-
- {user.username} -
-
- {user.email} -
-
-
- - - -
-
-
- - - - - - e.preventDefault()} - className="border-b border-gray-200 p-0" - > - - - - - - - Questions/Feedback - - - - - e.preventDefault()} - > -
- -
-
-
-
-
- ); -} diff --git a/app/components/layout/sidebar/child-nav-item.tsx b/app/components/layout/sidebar/child-nav-item.tsx new file mode 100644 index 000000000..791f67ae5 --- /dev/null +++ b/app/components/layout/sidebar/child-nav-item.tsx @@ -0,0 +1,44 @@ +import { NavLink } from "@remix-run/react"; +import { useIsRouteActive } from "~/hooks/use-is-route-active"; +import type { ChildNavItem } from "~/hooks/use-sidebar-nav-items"; +import { tw } from "~/utils/tw"; +import { SidebarMenuButton, SidebarMenuItem } from "./sidebar"; + +type ChildNavItemProps = { + route: ChildNavItem; + closeIfMobile?: () => void; + tooltip: React.ComponentProps["tooltip"]; +}; + +export default function ChildNavItem({ + route, + closeIfMobile, + tooltip, +}: ChildNavItemProps) { + const isActive = useIsRouteActive(route.to); + + return ( + + + + + {route.title} + + + + ); +} diff --git a/app/components/layout/sidebar/menu-button.tsx b/app/components/layout/sidebar/menu-button.tsx deleted file mode 100644 index b939ccca7..000000000 --- a/app/components/layout/sidebar/menu-button.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { useAtom } from "jotai"; -import { tw } from "~/utils/tw"; -import { toggleMobileNavAtom } from "./atoms"; - -const MenuButton = () => { - const [isMobileNavOpen, toggleMobileNav] = useAtom(toggleMobileNavAtom); - return ( - - ); -}; - -export default MenuButton; diff --git a/app/components/layout/sidebar/menu-items.tsx b/app/components/layout/sidebar/menu-items.tsx deleted file mode 100644 index 22bea70aa..000000000 --- a/app/components/layout/sidebar/menu-items.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import type { FetcherWithComponents } from "@remix-run/react"; -import { - NavLink, - useLoaderData, - useLocation, - useMatches, -} from "@remix-run/react"; -import { motion } from "framer-motion"; -import { useAtom } from "jotai"; -import { switchingWorkspaceAtom } from "~/atoms/switching-workspace"; -import Icon from "~/components/icons/icon"; -import { UpgradeMessage } from "~/components/marketing/upgrade-message"; -import { Button } from "~/components/shared/button"; -import When from "~/components/when/when"; -import { useMainMenuItems } from "~/hooks/use-main-menu-items"; -import type { loader } from "~/routes/_layout+/_layout"; -import { tw } from "~/utils/tw"; -import { toggleMobileNavAtom } from "./atoms"; -import { SidebarNoticeCard } from "./notice-card"; - -const MenuItems = ({ fetcher }: { fetcher: FetcherWithComponents }) => { - const [, toggleMobileNav] = useAtom(toggleMobileNavAtom); - const { isAdmin, minimizedSidebar, canUseBookings, subscription } = - useLoaderData(); - const { menuItemsTop, menuItemsBottom } = useMainMenuItems(); - const location = useLocation(); - const [workspaceSwitching] = useAtom(switchingWorkspaceAtom); - const matches = useMatches(); - const currentRoute = matches.at(-1); - // @ts-expect-error - const handle = currentRoute?.handle?.name; - return ( -
-
-
    - {isAdmin ? ( -
  • - - tw( - "my-1 flex items-center gap-3 rounded px-3 py-2.5 text-[16px] font-semibold text-gray-700 transition-all duration-75 hover:bg-primary-50 hover:text-primary-600", - isActive ? "active bg-primary-50 text-primary-600" : "", - workspaceSwitching ? "pointer-events-none" : "" - ) - } - to={"/admin-dashboard/users"} - onClick={toggleMobileNav} - title={"Admin dashboard"} - > - 🛸 - - Admin dashboard - - -
    -
  • - ) : null} - - {menuItemsTop.map((item) => - item.to === "bookings" || item.to === "calendar" ? ( -
  • - -
  • - ) : ( -
  • - - tw( - "my-1 flex items-center gap-3 rounded px-3 py-2.5 text-[16px] font-semibold text-gray-700 transition-all duration-75 hover:bg-primary-50 hover:text-primary-600", - isActive ? "active bg-primary-50 text-primary-600" : "", - workspaceSwitching ? "pointer-events-none" : "" - ) - } - to={item.to} - data-test-id={`${item.title.toLowerCase()}SidebarMenuItem`} - onClick={toggleMobileNav} - title={item.title} - > - - {item.icon} - - - {item.title} - - -
  • - ) - )} -
- -
- {/* Sidebar notice card component will be visible when uncollapsed sidebar is selected and hidden when minimizing sidebar form is processing */} - {fetcher.state == "idle" ? ( - - - - ) : null} -
    - {menuItemsBottom.map((item) => ( -
  • - - tw( - " my-1 flex items-center gap-3 rounded px-3 py-2.5 text-[16px] font-semibold text-gray-700 transition-all duration-75 hover:bg-primary-50 hover:text-primary-600", - isActive && handle !== "settings.team.users" - ? "active bg-primary-50 text-primary-600" - : "", - workspaceSwitching ? "pointer-events-none" : "" - ) - } - to={item.to} - data-test-id={`${item.title.toLowerCase()}SidebarMenuItem`} - onClick={toggleMobileNav} - title={item.title} - target={item?.target || undefined} - > - - {item.icon} - - - {item.title} - - - - New - - - -
  • - ))} -
  • - - - - -
  • -
-
-
-
- ); -}; - -export default MenuItems; diff --git a/app/components/layout/sidebar/notice-card.tsx b/app/components/layout/sidebar/notice-card.tsx index fed91f4cb..5e06faf79 100644 --- a/app/components/layout/sidebar/notice-card.tsx +++ b/app/components/layout/sidebar/notice-card.tsx @@ -14,7 +14,7 @@ export const SidebarNoticeCard = () => { } return optimisticHideNoticeCard ? null : ( -
+
Install Shelf for Mobile diff --git a/app/components/layout/sidebar/organization-select-form.tsx b/app/components/layout/sidebar/organization-select-form.tsx deleted file mode 100644 index 265f970c3..000000000 --- a/app/components/layout/sidebar/organization-select-form.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useEffect } from "react"; -import { useFetcher } from "@remix-run/react"; -import { useAtom } from "jotai"; -import { switchingWorkspaceAtom } from "~/atoms/switching-workspace"; - -import { SelectSeparator } from "~/components/forms/select"; -import { Button } from "~/components/shared/button"; -import { isFormProcessing } from "~/utils/form"; -import { OrganizationSelect } from "./organization-select"; - -export const OrganizationSelectForm = () => { - const fetcher = useFetcher(); - - /** We track if the fetcher is submitting */ - const isProcessing = isFormProcessing(fetcher.state); - const [_workspaceSwitching, setWorkspaceSwitching] = useAtom( - switchingWorkspaceAtom - ); - useEffect(() => { - setWorkspaceSwitching(isProcessing); - }, [isProcessing, setWorkspaceSwitching]); - - return ( - { - const form = e.currentTarget; - fetcher.submit(form); - }} - > - - - - - ), - }} - /> - - ); -}; diff --git a/app/components/layout/sidebar/organization-selector.tsx b/app/components/layout/sidebar/organization-selector.tsx new file mode 100644 index 000000000..2d5b0e933 --- /dev/null +++ b/app/components/layout/sidebar/organization-selector.tsx @@ -0,0 +1,153 @@ +import { useEffect, useState } from "react"; +import { ChevronDownIcon } from "@radix-ui/react-icons"; +import { useFetcher, useLoaderData } from "@remix-run/react"; +import { useSetAtom } from "jotai"; +import invariant from "tiny-invariant"; +import { switchingWorkspaceAtom } from "~/atoms/switching-workspace"; +import { Button } from "~/components/shared/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "~/components/shared/dropdown"; +import { Image } from "~/components/shared/image"; +import ProfilePicture from "~/components/user/profile-picture"; +import When from "~/components/when/when"; +import type { loader } from "~/routes/_layout+/_layout"; +import { isFormProcessing } from "~/utils/form"; +import { tw } from "~/utils/tw"; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "./sidebar"; + +export default function OrganizationSelector() { + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + const { open, openMobile, isMobile } = useSidebar(); + + const { organizations, currentOrganizationId } = + useLoaderData(); + + const fetcher = useFetcher(); + const isSwitchingOrg = isFormProcessing(fetcher.state); + + const setWorkspaceSwitching = useSetAtom(switchingWorkspaceAtom); + + const currentOrganization = organizations.find( + (org) => org.id === currentOrganizationId + ); + invariant( + typeof currentOrganization !== "undefined", + "Something went wrong. Current organization is not in the list of organizations." + ); + + function handleSwitchOrganization(organizationId: string) { + const formData = new FormData(); + formData.append("organizationId", organizationId); + + fetcher.submit(formData, { + method: "POST", + action: "/api/user/change-current-organization", + }); + } + + function closeDropdown() { + setIsDropdownOpen(false); + } + + useEffect( + function setSwitchingWorkspaceAfterFetch() { + setWorkspaceSwitching(isSwitchingOrg); + }, + [isSwitchingOrg, setWorkspaceSwitching] + ); + + return ( + + + + + + {currentOrganization.type === "PERSONAL" ? ( + + ) : ( + img + )} + + + <> +
+ + {currentOrganization.name} + +
+ + +
+
+
+ + + {organizations.map((organization) => ( + { + if (organization.id !== currentOrganizationId) { + handleSwitchOrganization(organization.id); + } + }} + > + {organization.type === "PERSONAL" ? ( + + ) : ( + img + )} + {organization.name} + + ))} + + + +
+
+
+ ); +} diff --git a/app/components/layout/sidebar/overlay.tsx b/app/components/layout/sidebar/overlay.tsx deleted file mode 100644 index 015c654c7..000000000 --- a/app/components/layout/sidebar/overlay.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { useAtom } from "jotai"; -import { tw } from "~/utils/tw"; -import { toggleMobileNavAtom } from "./atoms"; - -const Overlay = () => { - const [isMobileNavOpen, toggleMobileNav] = useAtom(toggleMobileNavAtom); - return ( -
- ); -}; - -export default Overlay; diff --git a/app/components/layout/sidebar/parent-nav-item.tsx b/app/components/layout/sidebar/parent-nav-item.tsx new file mode 100644 index 000000000..c55b663e7 --- /dev/null +++ b/app/components/layout/sidebar/parent-nav-item.tsx @@ -0,0 +1,115 @@ +import { NavLink, useNavigate } from "@remix-run/react"; +import { ChevronDownIcon } from "lucide-react"; +import invariant from "tiny-invariant"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "~/components/shared/collapsible"; +import When from "~/components/when/when"; +import { + useIsAnyRouteActive, + useIsRouteActive, +} from "~/hooks/use-is-route-active"; +import type { + ChildNavItem, + ParentNavItem, +} from "~/hooks/use-sidebar-nav-items"; +import { tw } from "~/utils/tw"; +import { + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, +} from "./sidebar"; + +type ParentNavItemProps = { + route: ParentNavItem; + tooltip: React.ComponentProps["tooltip"]; + closeIfMobile?: () => void; +}; + +export default function ParentNavItem({ + route, + tooltip, + closeIfMobile, +}: ParentNavItemProps) { + const navigate = useNavigate(); + const isAnyChildActive = useIsAnyRouteActive( + route.children.map((child) => child.to) + ); + + const firstChildRoute = route.children[0]; + invariant( + typeof firstChildRoute !== "undefined", + "'parent' nav item should have at leaset one child route" + ); + + function handleClick() { + navigate(firstChildRoute.to); + closeIfMobile && closeIfMobile(); + } + + return ( + + + + + + {route.title} + + + + + + + {route.children.map((child) => ( + + ))} + + + + + + ); +} + +function NestedRouteRenderer({ + nested, + closeIfMobile, +}: { + nested: Omit; + closeIfMobile?: () => void; +}) { + const isChildActive = useIsRouteActive(nested.to); + + return ( + + + + {nested.title} + + + + ); +} diff --git a/app/components/layout/sidebar/sidebar-nav.tsx b/app/components/layout/sidebar/sidebar-nav.tsx new file mode 100644 index 000000000..31576e474 --- /dev/null +++ b/app/components/layout/sidebar/sidebar-nav.tsx @@ -0,0 +1,113 @@ +import { Fragment, useCallback } from "react"; +import type { NavItem } from "~/hooks/use-sidebar-nav-items"; +import ChildNavItem from "./child-nav-item"; +import ParentNavItem from "./parent-nav-item"; +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "./sidebar"; + +type SidebarNavProps = { + className?: string; + style?: React.CSSProperties; + items: NavItem[]; +}; + +export default function SidebarNav({ + className, + style, + items, +}: SidebarNavProps) { + const { isMobile, toggleSidebar } = useSidebar(); + + const renderTooltopContent = useCallback((navItem: NavItem) => { + if (typeof navItem.disabled === "boolean" && navItem.disabled) { + return `${navItem.title} is disabled`; + } + + if (typeof navItem.disabled === "object") { + return { children: navItem.disabled.reason }; + } + + return navItem.title; + }, []); + + const closeIfMobile = useCallback(() => { + if (isMobile) { + toggleSidebar(); + } + }, [isMobile, toggleSidebar]); + + const renderNavItem = useCallback( + (navItem: NavItem) => { + switch (navItem.type) { + case "parent": { + return ( + + ); + } + + case "child": { + return ( + + ); + } + + case "label": { + return ( + + {navItem.title} + + ); + } + + case "button": { + return ( + + + + {navItem.title} + + + ); + } + + default: { + return null; + } + } + }, + [closeIfMobile, renderTooltopContent] + ); + + return ( + + + {items.map((navItem, i) => ( + {renderNavItem(navItem)} + ))} + + + ); +} diff --git a/app/components/layout/sidebar/sidebar-user-menu.tsx b/app/components/layout/sidebar/sidebar-user-menu.tsx new file mode 100644 index 000000000..c74634649 --- /dev/null +++ b/app/components/layout/sidebar/sidebar-user-menu.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { NavLink, useFetcher, useLoaderData } from "@remix-run/react"; +import { LogOutIcon, UserRoundIcon } from "lucide-react"; +import { ChevronRight } from "~/components/icons/library"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "~/components/shared/dropdown"; +import ProfilePicture from "~/components/user/profile-picture"; +import type { loader } from "~/routes/_layout+/_layout"; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "./sidebar"; + +export default function SidebarUserMenu() { + const { user } = useLoaderData(); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const { isMobile } = useSidebar(); + const fetcher = useFetcher(); + + function closeDropdown() { + setIsDropdownOpen(false); + } + + function logOut() { + fetcher.submit(null, { action: "/logout", method: "POST" }); + } + + return ( + + + + + + +
+ {user.username} + {user.email} +
+ +
+
+ + + +
+ +
+ + {user.username} + + {user.email} +
+
+
+ + + + + Account settings + + + + + Log Out + +
+
+
+
+ ); +} diff --git a/app/components/layout/sidebar/sidebar.tsx b/app/components/layout/sidebar/sidebar.tsx index b32d72635..1e215763d 100644 --- a/app/components/layout/sidebar/sidebar.tsx +++ b/app/components/layout/sidebar/sidebar.tsx @@ -1,99 +1,766 @@ -import { useRef } from "react"; -import { Link, NavLink, useFetcher, useLoaderData } from "@remix-run/react"; -import { useAtom } from "jotai"; -import { switchingWorkspaceAtom } from "~/atoms/switching-workspace"; -import { ScanQRIcon } from "~/components/icons/library"; -import type { loader } from "~/routes/_layout+/_layout"; +import { + createContext, + forwardRef, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { useFetcher } from "@remix-run/react"; +import type { VariantProps } from "class-variance-authority"; +import { cva } from "class-variance-authority"; + +import Input from "~/components/forms/input"; +import { SwitchIcon } from "~/components/icons/library"; +import { Button } from "~/components/shared/button"; +import { Separator } from "~/components/shared/separator"; +import { Sheet, SheetContent, SheetTitle } from "~/components/shared/sheet"; +import { Skeleton } from "~/components/shared/skeleton"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "~/components/shared/tooltip"; +import { useIsMobile } from "~/hooks/use-mobile"; +import { isFormProcessing } from "~/utils/form"; import { tw } from "~/utils/tw"; -import { toggleMobileNavAtom } from "./atoms"; -import SidebarBottom from "./bottom"; -import MenuButton from "./menu-button"; -import MenuItems from "./menu-items"; -import { OrganizationSelectForm } from "./organization-select-form"; -import Overlay from "./overlay"; -import { ShelfMobileLogo, ShelfSidebarLogo } from "../../marketing/logos"; - -export default function Sidebar() { - const { user, minimizedSidebar, currentOrganizationId } = - useLoaderData(); - const [isMobileNavOpen, toggleMobileNav] = useAtom(toggleMobileNavAtom); - const mainNavigationRef = useRef(null); - - /** We use optimistic UI for folding of the scanner.tsx sidebar - * As we are making a request to the server to store the cookie, - * we need to use this approach, otherwise the sidebar will close/open - * only once the response is received from the server - */ - const sidebarFetcher = useFetcher(); - let optimisticMinimizedSidebar = minimizedSidebar; - if (sidebarFetcher.formData) { - optimisticMinimizedSidebar = - sidebarFetcher.formData.get("minimizeSidebar") === "open"; +const SIDEBAR_WIDTH = "16rem"; +const SIDEBAR_WIDTH_MOBILE = "18rem"; +const SIDEBAR_WIDTH_ICON = "3rem"; +const SIDEBAR_KEYBOARD_SHORTCUT = "b"; + +type SidebarContext = { + state: "expanded" | "collapsed"; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; + isSidebarToggling: boolean; +}; + +const SidebarContext = createContext(null); + +function useSidebar() { + const context = useContext(SidebarContext); + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider."); } - const [workspaceSwitching] = useAtom(switchingWorkspaceAtom); - return ( - <> - {/* this component is named sidebar as of now but also serves as a mobile navigation header in mobile device */} - - - - - +const SidebarTrigger = forwardRef< + React.ElementRef, + React.ComponentProps & { iconClassName?: string } +>(({ className, onClick, iconClassName, ...props }, ref) => { + const { toggleSidebar } = useSidebar(); + + return ( + ); -} +}); +SidebarTrigger.displayName = "SidebarTrigger"; + +const SidebarRail = forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> +>(({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar(); + + return ( +