feat(dashboard,ui): Streamline spacing and sizing (#6061)
This commit is contained in:
committed by
GitHub
parent
5dacd4ac9f
commit
a2c149e7e5
@@ -1,26 +0,0 @@
|
||||
import { Outlet, useLocation } from "react-router-dom"
|
||||
import { Gutter } from "./gutter"
|
||||
import { MainNav } from "./main-nav"
|
||||
import { SettingsNav } from "./settings-nav"
|
||||
import { Topbar } from "./topbar"
|
||||
|
||||
export const AppLayout = () => {
|
||||
const location = useLocation()
|
||||
|
||||
const isSettings = location.pathname.startsWith("/settings")
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-start overflow-hidden md:flex-row">
|
||||
<MainNav />
|
||||
<div className="flex h-[calc(100vh-57px)] w-full md:h-screen">
|
||||
{isSettings && <SettingsNav />}
|
||||
<div className="flex h-full w-full flex-col items-center overflow-y-auto p-4">
|
||||
<Gutter>
|
||||
<Topbar />
|
||||
<Outlet />
|
||||
</Gutter>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { TriangleRightMini } from "@medusajs/icons";
|
||||
import { clx } from "@medusajs/ui";
|
||||
import { Link, UIMatch, useMatches } from "react-router-dom";
|
||||
|
||||
type BreadcrumbProps = React.ComponentPropsWithoutRef<"ol">;
|
||||
|
||||
export const Breadcrumbs = ({ className, ...props }: BreadcrumbProps) => {
|
||||
const matches = useMatches() as unknown as UIMatch<
|
||||
unknown,
|
||||
{ crumb?: (data?: unknown) => string }
|
||||
>[];
|
||||
|
||||
const crumbs = matches
|
||||
.filter((match) => Boolean(match.handle?.crumb))
|
||||
.map((match) => {
|
||||
const handle = match.handle;
|
||||
|
||||
return {
|
||||
label: handle.crumb!(match.data),
|
||||
path: match.pathname,
|
||||
};
|
||||
});
|
||||
|
||||
if (crumbs.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ol
|
||||
className={clx("flex items-center gap-x-1 text-ui-fg-muted", className)}
|
||||
{...props}
|
||||
>
|
||||
{crumbs.map((crumb, index) => {
|
||||
const isLast = index === crumbs.length - 1;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={index}
|
||||
className="txt-compact-small-plus flex items-center gap-x-1"
|
||||
>
|
||||
{!isLast ? (
|
||||
<Link to={crumb.path}>{crumb.label}</Link>
|
||||
) : (
|
||||
<span key={index}>{crumb.label}</span>
|
||||
)}
|
||||
{!isLast && <TriangleRightMini />}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
export const Gutter = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<div className="w-full max-w-[1200px] flex flex-col gap-y-4">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./app-layout";
|
||||
@@ -1,26 +0,0 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { adminProductKeys } from "medusa-react";
|
||||
import { LoaderFunctionArgs } from "react-router-dom";
|
||||
import { medusa, queryClient } from "../../../lib/medusa";
|
||||
|
||||
const appLoaderQuery = (id: string) => ({
|
||||
queryKey: adminProductKeys.detail(id),
|
||||
queryFn: async () => medusa.admin.products.retrieve(id),
|
||||
});
|
||||
|
||||
export const productLoader = (client: QueryClient) => {
|
||||
return async ({ params }: LoaderFunctionArgs) => {
|
||||
const id = params?.id;
|
||||
|
||||
if (!id) {
|
||||
throw new Error("No id provided");
|
||||
}
|
||||
|
||||
const query = appLoaderQuery(id);
|
||||
|
||||
return (
|
||||
queryClient.getQueryData(query.queryKey) ??
|
||||
(await client.fetchQuery(query))
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -1,380 +0,0 @@
|
||||
import {
|
||||
ArrowRightOnRectangle,
|
||||
BookOpen,
|
||||
BuildingStorefront,
|
||||
Calendar,
|
||||
ChevronDownMini,
|
||||
CircleHalfSolid,
|
||||
CogSixTooth,
|
||||
CurrencyDollar,
|
||||
EllipsisHorizontal,
|
||||
MinusMini,
|
||||
ReceiptPercent,
|
||||
ShoppingCart,
|
||||
Sidebar,
|
||||
SquaresPlus,
|
||||
Tag,
|
||||
Users,
|
||||
} from "@medusajs/icons"
|
||||
import { Avatar, DropdownMenu, IconButton, Text } from "@medusajs/ui"
|
||||
import * as Collapsible from "@radix-ui/react-collapsible"
|
||||
import * as Dialog from "@radix-ui/react-dialog"
|
||||
import { useAdminDeleteSession, useAdminStore } from "medusa-react"
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom"
|
||||
|
||||
import { useAuth } from "../../../providers/auth-provider"
|
||||
import { useTheme } from "../../../providers/theme-provider"
|
||||
|
||||
import { Fragment, useEffect, useState } from "react"
|
||||
import { Breadcrumbs } from "./breadcrumbs"
|
||||
import { NavItem, NavItemProps } from "./nav-item"
|
||||
import { Notifications } from "./notifications"
|
||||
import { SearchToggle } from "./search-toggle"
|
||||
import { Spacer } from "./spacer"
|
||||
|
||||
import extensions from "medusa-admin:routes/links"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
export const MainNav = () => {
|
||||
return (
|
||||
<Fragment>
|
||||
<DesktopNav />
|
||||
<MobileNav />
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
const MobileNav = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
|
||||
// If the user navigates to a new route, we want to close the menu
|
||||
useEffect(() => {
|
||||
setOpen(false)
|
||||
}, [location.pathname])
|
||||
|
||||
return (
|
||||
<div className="bg-ui-bg-base border-ui-border-base flex h-[57px] w-full items-center justify-between border-b px-4 md:hidden">
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Dialog.Trigger asChild>
|
||||
<IconButton variant="transparent">
|
||||
<Sidebar />
|
||||
</IconButton>
|
||||
</Dialog.Trigger>
|
||||
<Breadcrumbs />
|
||||
</div>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="bg-ui-bg-overlay fixed inset-0 lg:hidden" />
|
||||
<Dialog.Content className="bg-ui-bg-subtle fixed inset-y-0 left-0 flex w-full flex-col overflow-y-auto sm:max-w-[240px] lg:hidden">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="sticky top-0">
|
||||
<Header />
|
||||
<Spacer />
|
||||
</div>
|
||||
<CoreRouteSection />
|
||||
<ExtensionRouteSection />
|
||||
</div>
|
||||
<div className="sticky bottom-0 flex w-full flex-col">
|
||||
<SettingsSection />
|
||||
<Spacer />
|
||||
<UserSection />
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<SearchToggle />
|
||||
<Notifications />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DesktopNav = () => {
|
||||
return (
|
||||
<aside className="flex h-full max-h-screen w-full max-w-[240px] flex-col justify-between overflow-y-auto max-md:hidden">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="bg-ui-bg-subtle sticky top-0">
|
||||
<Header />
|
||||
<Spacer />
|
||||
</div>
|
||||
<CoreRouteSection />
|
||||
<ExtensionRouteSection />
|
||||
</div>
|
||||
<div className="bg-ui-bg-subtle sticky bottom-0 flex flex-col">
|
||||
<SettingsSection />
|
||||
<Spacer />
|
||||
<UserSection />
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
const Header = () => {
|
||||
const { store } = useAdminStore()
|
||||
const { setTheme, theme } = useTheme()
|
||||
const { mutateAsync: logoutMutation } = useAdminDeleteSession()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const logout = async () => {
|
||||
await logoutMutation(undefined, {
|
||||
onSuccess: () => {
|
||||
navigate("/login")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!store) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full p-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger className="hover:bg-ui-bg-subtle-hover active:bg-ui-bg-subtle-pressed focus:bg-ui-bg-subtle-pressed transition-fg w-full rounded-md outline-none">
|
||||
<div className="flex items-center justify-between p-1 md:pr-2">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<div className="bg-ui-bg-base shadow-borders-base flex h-8 w-8 items-center justify-center overflow-hidden rounded-md">
|
||||
<div className="bg-ui-bg-component flex h-[28px] w-[28px] items-center justify-center overflow-hidden rounded-[4px]">
|
||||
{store.name[0].toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{store.name}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="text-ui-fg-subtle">
|
||||
<EllipsisHorizontal />
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item>
|
||||
<BuildingStorefront className="text-ui-fg-subtle mr-2" />
|
||||
Store Settings
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<Link to="https://docs.medusajs.com/user-guide" target="_blank">
|
||||
<DropdownMenu.Item>
|
||||
<BookOpen className="text-ui-fg-subtle mr-2" />
|
||||
Documentation
|
||||
</DropdownMenu.Item>
|
||||
</Link>
|
||||
<Link to="https://medusajs.com/changelog/" target="_blank">
|
||||
<DropdownMenu.Item>
|
||||
<Calendar className="text-ui-fg-subtle mr-2" />
|
||||
Changelog
|
||||
</DropdownMenu.Item>
|
||||
</Link>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.SubMenu>
|
||||
<DropdownMenu.SubMenuTrigger className="rounded-md">
|
||||
<CircleHalfSolid className="text-ui-fg-subtle mr-2" />
|
||||
Theme
|
||||
</DropdownMenu.SubMenuTrigger>
|
||||
<DropdownMenu.SubMenuContent>
|
||||
<DropdownMenu.RadioGroup value={theme}>
|
||||
<DropdownMenu.RadioItem
|
||||
value="light"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setTheme("light")
|
||||
}}
|
||||
>
|
||||
Light
|
||||
</DropdownMenu.RadioItem>
|
||||
<DropdownMenu.RadioItem
|
||||
value="dark"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setTheme("dark")
|
||||
}}
|
||||
>
|
||||
Dark
|
||||
</DropdownMenu.RadioItem>
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.SubMenuContent>
|
||||
</DropdownMenu.SubMenu>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onClick={logout}>
|
||||
<ArrowRightOnRectangle className="text-ui-fg-subtle mr-2" />
|
||||
Logout
|
||||
<DropdownMenu.Shortcut>⌥⇧Q</DropdownMenu.Shortcut>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const useCoreRoutes = (): Omit<NavItemProps, "pathname">[] => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return [
|
||||
{
|
||||
icon: <ShoppingCart />,
|
||||
label: t("orders.domain"),
|
||||
to: "/orders",
|
||||
items: [
|
||||
{
|
||||
label: t("draftOrders.domain"),
|
||||
to: "/draft-orders",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <Tag />,
|
||||
label: t("products.domain"),
|
||||
to: "/products",
|
||||
items: [
|
||||
{
|
||||
label: t("collections.domain"),
|
||||
to: "/collections",
|
||||
},
|
||||
{
|
||||
label: t("categories.domain"),
|
||||
to: "/categories",
|
||||
},
|
||||
{
|
||||
label: t("giftCards.domain"),
|
||||
to: "/gift-cards",
|
||||
},
|
||||
{
|
||||
label: t("inventory.domain"),
|
||||
to: "/inventory",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <Users />,
|
||||
label: t("customers.domain"),
|
||||
to: "/customers",
|
||||
items: [
|
||||
{
|
||||
label: t("customerGroups.domain"),
|
||||
to: "/customer-groups",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <ReceiptPercent />,
|
||||
label: t("discounts.domain"),
|
||||
to: "/discounts",
|
||||
},
|
||||
{
|
||||
icon: <CurrencyDollar />,
|
||||
label: t("pricing.domain"),
|
||||
to: "/pricing",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const CoreRouteSection = () => {
|
||||
const coreRoutes = useCoreRoutes()
|
||||
|
||||
return (
|
||||
<nav className="flex flex-col gap-y-1 py-4">
|
||||
{coreRoutes.map((route) => {
|
||||
return <NavItem key={route.to} {...route} />
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
const ExtensionRouteSection = () => {
|
||||
if (!extensions.links || extensions.links.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Spacer />
|
||||
<div className="flex flex-col gap-y-4 py-4">
|
||||
<Collapsible.Root defaultOpen>
|
||||
<div className="px-4">
|
||||
<Collapsible.Trigger asChild className="group/trigger">
|
||||
<button className="text-ui-fg-subtle flex w-full items-center justify-between px-2">
|
||||
<Text size="xsmall" weight="plus" leading="compact">
|
||||
Extensions
|
||||
</Text>
|
||||
<div className="text-ui-fg-muted">
|
||||
<ChevronDownMini className="group-data-[state=open]/trigger:hidden" />
|
||||
<MinusMini className="group-data-[state=closed]/trigger:hidden" />
|
||||
</div>
|
||||
</button>
|
||||
</Collapsible.Trigger>
|
||||
</div>
|
||||
<Collapsible.Content>
|
||||
<div className="flex flex-col gap-y-1 py-1 pb-4">
|
||||
{extensions.links.map((link) => {
|
||||
return (
|
||||
<NavItem
|
||||
key={link.path}
|
||||
to={link.path}
|
||||
label={link.label}
|
||||
icon={link.icon ? <link.icon /> : <SquaresPlus />}
|
||||
type="extension"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SettingsSection = () => {
|
||||
return (
|
||||
<div className="py-4">
|
||||
<NavItem icon={<CogSixTooth />} label="Settings" to="/settings" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const UserSection = () => {
|
||||
const { user } = useAuth()
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fallback =
|
||||
user.first_name && user.last_name
|
||||
? `${user.first_name[0]}${user.last_name[0]}`
|
||||
: user.first_name
|
||||
? user.first_name[0]
|
||||
: user.email[0]
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Link
|
||||
to="/settings/profile"
|
||||
className="hover:bg-ui-bg-subtle-hover transition-fg active:bg-ui-bg-subtle-pressed focus:bg-ui-bg-subtle-pressed flex items-center gap-x-3 rounded-md p-1 outline-none"
|
||||
>
|
||||
<Avatar fallback={fallback.toUpperCase()} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
{(user.first_name || user.last_name) && (
|
||||
<Text
|
||||
size="xsmall"
|
||||
weight="plus"
|
||||
leading="compact"
|
||||
className="max-w-[90%] truncate"
|
||||
>{`${user.first_name && `${user.first_name} `}${
|
||||
user.last_name
|
||||
}`}</Text>
|
||||
)}
|
||||
<Text
|
||||
size="xsmall"
|
||||
leading="compact"
|
||||
className="text-ui-fg-subtle max-w-[90%] truncate"
|
||||
>
|
||||
{user.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import { Text, clx } from "@medusajs/ui";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
|
||||
type ItemType = "core" | "extension";
|
||||
|
||||
type NestedItemProps = {
|
||||
label: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
export type NavItemProps = {
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
to: string;
|
||||
items?: NestedItemProps[];
|
||||
type?: ItemType;
|
||||
};
|
||||
|
||||
export const NavItem = ({
|
||||
icon,
|
||||
label,
|
||||
to,
|
||||
items,
|
||||
type = "core",
|
||||
}: NavItemProps) => {
|
||||
const location = useLocation();
|
||||
|
||||
const [open, setOpen] = useState(
|
||||
[to, ...(items?.map((i) => i.to) ?? [])].some((p) =>
|
||||
location.pathname.startsWith(p)
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(
|
||||
[to, ...(items?.map((i) => i.to) ?? [])].some((p) =>
|
||||
location.pathname.startsWith(p)
|
||||
)
|
||||
);
|
||||
}, [location.pathname, to, items]);
|
||||
|
||||
return (
|
||||
<div className="px-4">
|
||||
<Link
|
||||
to={to}
|
||||
className={clx(
|
||||
"text-ui-fg-subtle hover:text-ui-fg-base px-2 py-2.5 md:py-1.5 outline-none flex items-center gap-x-2 transition-fg rounded-md hover:bg-ui-bg-subtle-hover",
|
||||
{
|
||||
"bg-ui-bg-base shadow-elevation-card-rest":
|
||||
location.pathname.startsWith(to),
|
||||
"max-md:hidden": items && items.length > 0,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Icon icon={icon} type={type} />
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{label}
|
||||
</Text>
|
||||
</Link>
|
||||
{items && items.length > 0 && (
|
||||
<Collapsible.Root open={open} onOpenChange={setOpen}>
|
||||
<Collapsible.Trigger
|
||||
className={clx(
|
||||
"w-full md:hidden text-ui-fg-subtle hover:text-ui-fg-base px-2 py-2.5 md:py-1.5 outline-none flex items-center gap-x-2 transition-fg rounded-md hover:bg-ui-bg-subtle-hover"
|
||||
)}
|
||||
>
|
||||
<Icon icon={icon} type={type} />
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{label}
|
||||
</Text>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content className="flex flex-col gap-y-1 pt-1">
|
||||
<Link
|
||||
to={to}
|
||||
className={clx(
|
||||
"md:hidden text-ui-fg-subtle hover:text-ui-fg-base px-2 py-2.5 md:py-1.5 outline-none flex items-center gap-x-2 transition-fg rounded-md hover:bg-ui-bg-subtle-hover",
|
||||
{
|
||||
"bg-ui-bg-base shadow-elevation-card-rest":
|
||||
location.pathname.startsWith(to),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="w-5 h-5 flex items-center justify-center">
|
||||
<div
|
||||
className={clx(
|
||||
"w-1.5 h-1.5 border-[1.5px] border-ui-fg-muted transition-fg rounded-full",
|
||||
{
|
||||
"border-ui-fg-base border-2": location.pathname === to,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{label}
|
||||
</Text>
|
||||
</Link>
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<Link
|
||||
to={item.to}
|
||||
key={item.to}
|
||||
className={clx(
|
||||
"first-of-type:mt-1 last-of-type:mb-2 text-ui-fg-subtle hover:text-ui-fg-base px-2 py-2.5 md:py-1.5 outline-none flex items-center gap-x-2 transition-fg rounded-md hover:bg-ui-bg-subtle-hover",
|
||||
{
|
||||
"bg-ui-bg-base shadow-elevation-card-rest":
|
||||
location.pathname === item.to,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="w-5 h-5 flex items-center justify-center">
|
||||
<div
|
||||
className={clx(
|
||||
"w-1.5 h-1.5 border-[1.5px] border-ui-fg-muted transition-fg rounded-full",
|
||||
{
|
||||
"border-ui-fg-base border-2":
|
||||
location.pathname === item.to,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{item.label}
|
||||
</Text>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Icon = ({ icon, type }: { icon?: React.ReactNode; type: ItemType }) => {
|
||||
if (!icon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return type === "extension" ? (
|
||||
<div className="rounded-[4px] w-5 h-5 flex items-center justify-center shadow-borders-base bg-ui-bg-base">
|
||||
<div className="w-4 h-4 rounded-sm overflow-hidden">{icon}</div>
|
||||
</div>
|
||||
) : (
|
||||
icon
|
||||
);
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
import { MagnifyingGlass } from "@medusajs/icons"
|
||||
import { IconButton } from "@medusajs/ui"
|
||||
import { useSearch } from "../../../providers/search-provider"
|
||||
|
||||
export const SearchToggle = () => {
|
||||
const { toggleSearch } = useSearch()
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
variant="transparent"
|
||||
onClick={toggleSearch}
|
||||
className="text-ui-fg-muted hover:text-ui-fg-subtle"
|
||||
>
|
||||
<MagnifyingGlass />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { ChevronDownMini, CogSixTooth, MinusMini } from "@medusajs/icons"
|
||||
import { Text } from "@medusajs/ui"
|
||||
import * as Collapsible from "@radix-ui/react-collapsible"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { NavItem, NavItemProps } from "./nav-item"
|
||||
import { Spacer } from "./spacer"
|
||||
|
||||
const useSettingRoutes = (): NavItemProps[] => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t("profile.domain"),
|
||||
to: "/settings/profile",
|
||||
},
|
||||
{
|
||||
label: t("store.domain"),
|
||||
to: "/settings/store",
|
||||
},
|
||||
{
|
||||
label: t("users.domain"),
|
||||
to: "/settings/users",
|
||||
},
|
||||
{
|
||||
label: t("regions.domain"),
|
||||
to: "/settings/regions",
|
||||
},
|
||||
{
|
||||
label: t("currencies.domain"),
|
||||
to: "/settings/currencies",
|
||||
},
|
||||
{
|
||||
label: "Taxes",
|
||||
to: "/settings/taxes",
|
||||
},
|
||||
{
|
||||
label: "Locations",
|
||||
to: "/settings/locations",
|
||||
},
|
||||
{
|
||||
label: t("salesChannels.domain"),
|
||||
to: "/settings/sales-channels",
|
||||
},
|
||||
{
|
||||
label: t("apiKeyManagement.domain"),
|
||||
to: "/settings/api-key-management",
|
||||
},
|
||||
],
|
||||
[t]
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsNav = () => {
|
||||
const routes = useSettingRoutes()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="border-ui-border-base box-content flex h-full max-h-screen w-full max-w-[240px] flex-col overflow-hidden border-x max-md:hidden">
|
||||
<div className="p-4">
|
||||
<div className="flex h-10 items-center gap-x-3 p-1">
|
||||
<CogSixTooth className="text-ui-fg-subtle" />
|
||||
<Text leading="compact" weight="plus" size="small">
|
||||
{t("general.settings")}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<Spacer />
|
||||
<div className="flex flex-1 flex-col gap-y-4 overflow-y-auto py-4">
|
||||
<Collapsible.Root defaultOpen>
|
||||
<div className="px-4">
|
||||
<Collapsible.Trigger asChild className="group/trigger">
|
||||
<button className="text-ui-fg-subtle flex w-full items-center justify-between px-2">
|
||||
<Text size="xsmall" weight="plus" leading="compact">
|
||||
{t("general.general")}
|
||||
</Text>
|
||||
<div className="text-ui-fg-muted">
|
||||
<ChevronDownMini className="group-data-[state=open]/trigger:hidden" />
|
||||
<MinusMini className="group-data-[state=closed]/trigger:hidden" />
|
||||
</div>
|
||||
</button>
|
||||
</Collapsible.Trigger>
|
||||
</div>
|
||||
<Collapsible.Content asChild>
|
||||
<nav className="flex flex-col gap-y-1 py-1">
|
||||
{routes.map((setting) => (
|
||||
<NavItem key={setting.to} {...setting} />
|
||||
))}
|
||||
</nav>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
<Collapsible.Root>
|
||||
<div className="px-4">
|
||||
<Collapsible.Trigger asChild className="group/trigger">
|
||||
<button className="text-ui-fg-subtle flex w-full items-center justify-between px-2">
|
||||
<Text size="xsmall" weight="plus" leading="compact">
|
||||
{t("general.extensions")}
|
||||
</Text>
|
||||
<div className="text-ui-fg-muted">
|
||||
<ChevronDownMini className="group-data-[state=open]/trigger:hidden" />
|
||||
<MinusMini className="group-data-[state=closed]/trigger:hidden" />
|
||||
</div>
|
||||
</button>
|
||||
</Collapsible.Trigger>
|
||||
</div>
|
||||
<Collapsible.Content asChild>
|
||||
<nav className="flex flex-col gap-y-1 py-1">
|
||||
{routes.map((setting) => (
|
||||
<NavItem key={setting.to} {...setting} />
|
||||
))}
|
||||
</nav>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export const Spacer = () => {
|
||||
return (
|
||||
<div className="px-4">
|
||||
<div className="w-full h-px border-b border-dashed border-ui-border-strong" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Sidebar } from "@medusajs/icons"
|
||||
import { Breadcrumbs } from "./breadcrumbs"
|
||||
import { Notifications } from "./notifications"
|
||||
import { SearchToggle } from "./search-toggle"
|
||||
|
||||
export const Topbar = () => {
|
||||
return (
|
||||
<div className="hidden items-center justify-between px-4 py-1 md:flex">
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<Sidebar className="text-ui-fg-muted" />
|
||||
<Breadcrumbs />
|
||||
</div>
|
||||
<div className="text-ui-fg-muted flex items-center gap-x-1">
|
||||
<SearchToggle />
|
||||
<Notifications />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./main-layout"
|
||||
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
ChevronDownMini,
|
||||
CurrencyDollar,
|
||||
MinusMini,
|
||||
ReceiptPercent,
|
||||
ShoppingCart,
|
||||
SquaresPlus,
|
||||
Tag,
|
||||
Users,
|
||||
} from "@medusajs/icons"
|
||||
import { Avatar, Text } from "@medusajs/ui"
|
||||
import * as Collapsible from "@radix-ui/react-collapsible"
|
||||
import { useAdminStore } from "medusa-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { Skeleton } from "../../common/skeleton"
|
||||
import { NavItem, NavItemProps } from "../nav-item"
|
||||
import { Shell } from "../shell"
|
||||
|
||||
import extensions from "medusa-admin:routes/links"
|
||||
|
||||
export const MainLayout = () => {
|
||||
return (
|
||||
<Shell>
|
||||
<MainSidebar />
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
const MainSidebar = () => {
|
||||
return (
|
||||
<aside className="flex flex-1 flex-col justify-between overflow-y-auto">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="bg-ui-bg-subtle sticky top-0">
|
||||
<Header />
|
||||
<div className="px-3">
|
||||
<div className="border-ui-border-strong h-px w-full border-b border-dashed" />
|
||||
</div>
|
||||
</div>
|
||||
<CoreRouteSection />
|
||||
<ExtensionRouteSection />
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
const Header = () => {
|
||||
const { store, isError, error } = useAdminStore()
|
||||
|
||||
const name = store?.name
|
||||
const fallback = store?.name?.slice(0, 1).toUpperCase()
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full px-3 py-2">
|
||||
<div className="flex items-center p-1 md:pr-2">
|
||||
<div className="flex items-center gap-x-3">
|
||||
{fallback ? (
|
||||
<Avatar variant="squared" fallback={fallback} />
|
||||
) : (
|
||||
<Skeleton className="w-8 h-8 rounded-md" />
|
||||
)}
|
||||
{name ? (
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{store.name}
|
||||
</Text>
|
||||
) : (
|
||||
<Skeleton className="w-[120px] h-[9px]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const useCoreRoutes = (): Omit<NavItemProps, "pathname">[] => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return [
|
||||
{
|
||||
icon: <ShoppingCart />,
|
||||
label: t("orders.domain"),
|
||||
to: "/orders",
|
||||
items: [
|
||||
{
|
||||
label: t("draftOrders.domain"),
|
||||
to: "/draft-orders",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <Tag />,
|
||||
label: t("products.domain"),
|
||||
to: "/products",
|
||||
items: [
|
||||
{
|
||||
label: t("collections.domain"),
|
||||
to: "/collections",
|
||||
},
|
||||
{
|
||||
label: t("categories.domain"),
|
||||
to: "/categories",
|
||||
},
|
||||
{
|
||||
label: t("giftCards.domain"),
|
||||
to: "/gift-cards",
|
||||
},
|
||||
{
|
||||
label: t("inventory.domain"),
|
||||
to: "/inventory",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <Users />,
|
||||
label: t("customers.domain"),
|
||||
to: "/customers",
|
||||
items: [
|
||||
{
|
||||
label: t("customerGroups.domain"),
|
||||
to: "/customer-groups",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <ReceiptPercent />,
|
||||
label: t("discounts.domain"),
|
||||
to: "/discounts",
|
||||
},
|
||||
{
|
||||
icon: <CurrencyDollar />,
|
||||
label: t("pricing.domain"),
|
||||
to: "/pricing",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const CoreRouteSection = () => {
|
||||
const coreRoutes = useCoreRoutes()
|
||||
|
||||
return (
|
||||
<nav className="flex flex-col gap-y-1 py-2">
|
||||
{coreRoutes.map((route) => {
|
||||
return <NavItem key={route.to} {...route} />
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
const ExtensionRouteSection = () => {
|
||||
if (!extensions.links || extensions.links.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="px-3">
|
||||
<div className="border-ui-border-strong h-px w-full border-b border-dashed" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-1 py-2">
|
||||
<Collapsible.Root defaultOpen>
|
||||
<div className="px-4">
|
||||
<Collapsible.Trigger asChild className="group/trigger">
|
||||
<button className="text-ui-fg-subtle flex w-full items-center justify-between px-2">
|
||||
<Text size="xsmall" weight="plus" leading="compact">
|
||||
Extensions
|
||||
</Text>
|
||||
<div className="text-ui-fg-muted">
|
||||
<ChevronDownMini className="group-data-[state=open]/trigger:hidden" />
|
||||
<MinusMini className="group-data-[state=closed]/trigger:hidden" />
|
||||
</div>
|
||||
</button>
|
||||
</Collapsible.Trigger>
|
||||
</div>
|
||||
<Collapsible.Content>
|
||||
<div className="flex flex-col gap-y-1 py-1 pb-4">
|
||||
{extensions.links.map((link) => {
|
||||
return (
|
||||
<NavItem
|
||||
key={link.path}
|
||||
to={link.path}
|
||||
label={link.label}
|
||||
icon={link.icon ? <link.icon /> : <SquaresPlus />}
|
||||
type="extension"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./nav-item"
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Text, clx } from "@medusajs/ui"
|
||||
import * as Collapsible from "@radix-ui/react-collapsible"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Link, useLocation } from "react-router-dom"
|
||||
|
||||
type ItemType = "core" | "extension"
|
||||
|
||||
type NestedItemProps = {
|
||||
label: string
|
||||
to: string
|
||||
}
|
||||
|
||||
export type NavItemProps = {
|
||||
icon?: React.ReactNode
|
||||
label: string
|
||||
to: string
|
||||
items?: NestedItemProps[]
|
||||
type?: ItemType
|
||||
from?: string
|
||||
}
|
||||
|
||||
export const NavItem = ({
|
||||
icon,
|
||||
label,
|
||||
to,
|
||||
items,
|
||||
type = "core",
|
||||
from,
|
||||
}: NavItemProps) => {
|
||||
const location = useLocation()
|
||||
|
||||
const [open, setOpen] = useState(
|
||||
[to, ...(items?.map((i) => i.to) ?? [])].some((p) =>
|
||||
location.pathname.startsWith(p)
|
||||
)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(
|
||||
[to, ...(items?.map((i) => i.to) ?? [])].some((p) =>
|
||||
location.pathname.startsWith(p)
|
||||
)
|
||||
)
|
||||
}, [location.pathname, to, items])
|
||||
|
||||
return (
|
||||
<div className="px-3">
|
||||
<Link
|
||||
to={to}
|
||||
state={
|
||||
from
|
||||
? {
|
||||
from,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={clx(
|
||||
"text-ui-fg-subtle hover:text-ui-fg-base transition-fg hover:bg-ui-bg-subtle-hover flex items-center gap-x-2 rounded-md px-2 py-2.5 outline-none md:py-1.5",
|
||||
{
|
||||
"bg-ui-bg-base hover:bg-ui-bg-base-hover shadow-elevation-card-rest":
|
||||
location.pathname.startsWith(to),
|
||||
"max-md:hidden": items && items.length > 0,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Icon icon={icon} type={type} />
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{label}
|
||||
</Text>
|
||||
</Link>
|
||||
{items && items.length > 0 && (
|
||||
<Collapsible.Root open={open} onOpenChange={setOpen}>
|
||||
<Collapsible.Trigger
|
||||
className={clx(
|
||||
"text-ui-fg-subtle hover:text-ui-fg-base transition-fg hover:bg-ui-bg-subtle-hover flex w-full items-center gap-x-2 rounded-md px-2 py-2.5 outline-none md:hidden md:py-1.5"
|
||||
)}
|
||||
>
|
||||
<Icon icon={icon} type={type} />
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{label}
|
||||
</Text>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content className="flex flex-col pt-1">
|
||||
<div className="flex h-[36px] w-full items-center gap-x-1 pl-2 md:hidden">
|
||||
<div
|
||||
role="presentation"
|
||||
className="flex h-full w-5 items-center justify-center"
|
||||
>
|
||||
<div className="bg-ui-border-strong h-full w-px" />
|
||||
</div>
|
||||
<Link
|
||||
to={to}
|
||||
className={clx(
|
||||
"text-ui-fg-subtle hover:text-ui-fg-base transition-fg hover:bg-ui-bg-subtle-hover mb-2 mt-1 flex h-8 flex-1 items-center gap-x-2 rounded-md px-2 py-2.5 outline-none md:py-1.5",
|
||||
{
|
||||
"bg-ui-bg-base hover:bg-ui-bg-base text-ui-fg-base shadow-elevation-card-rest":
|
||||
location.pathname.startsWith(to),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{label}
|
||||
</Text>
|
||||
</Link>
|
||||
</div>
|
||||
<ul>
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<li
|
||||
key={item.to}
|
||||
className="flex h-[36px] items-center gap-x-1 pl-2"
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
className="flex h-full w-5 items-center justify-center"
|
||||
>
|
||||
<div className="bg-ui-border-strong h-full w-px" />
|
||||
</div>
|
||||
<Link
|
||||
to={item.to}
|
||||
className={clx(
|
||||
"text-ui-fg-subtle hover:text-ui-fg-base transition-fg hover:bg-ui-bg-subtle-hover flex h-8 flex-1 items-center gap-x-2 rounded-md px-2 py-2.5 outline-none first-of-type:mt-1 last-of-type:mb-2 md:py-1.5",
|
||||
{
|
||||
"bg-ui-bg-base text-ui-fg-base hover:bg-ui-bg-base shadow-elevation-card-rest":
|
||||
location.pathname === item.to,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{item.label}
|
||||
</Text>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Icon = ({ icon, type }: { icon?: React.ReactNode; type: ItemType }) => {
|
||||
if (!icon) {
|
||||
return null
|
||||
}
|
||||
|
||||
return type === "extension" ? (
|
||||
<div className="shadow-borders-base bg-ui-bg-base flex h-5 w-5 items-center justify-center rounded-[4px]">
|
||||
<div className="h-4 w-4 overflow-hidden rounded-sm">{icon}</div>
|
||||
</div>
|
||||
) : (
|
||||
icon
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./notifications"
|
||||
+16
-13
@@ -1,28 +1,31 @@
|
||||
import { BellAlert } from "@medusajs/icons";
|
||||
import { Drawer, Heading, IconButton } from "@medusajs/ui";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BellAlert } from "@medusajs/icons"
|
||||
import { Drawer, Heading, IconButton } from "@medusajs/ui"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export const Notifications = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "n" && (e.metaKey || e.ctrlKey)) {
|
||||
setOpen((prev) => !prev);
|
||||
setOpen((prev) => !prev)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
document.addEventListener("keydown", onKeyDown)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, []);
|
||||
document.removeEventListener("keydown", onKeyDown)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<Drawer.Trigger asChild>
|
||||
<IconButton variant="transparent" className="text-ui-fg-muted">
|
||||
<IconButton
|
||||
variant="transparent"
|
||||
className="text-ui-fg-muted hover:text-ui-fg-subtle"
|
||||
>
|
||||
<BellAlert />
|
||||
</IconButton>
|
||||
</Drawer.Trigger>
|
||||
@@ -33,5 +36,5 @@ export const Notifications = () => {
|
||||
<Drawer.Body>Notifications will go here</Drawer.Body>
|
||||
</Drawer.Content>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./settings-layout"
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import { ArrowUturnLeft } from "@medusajs/icons"
|
||||
import { IconButton, Text } from "@medusajs/ui"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link, useLocation } from "react-router-dom"
|
||||
|
||||
import { NavItem, NavItemProps } from "../nav-item"
|
||||
import { Shell } from "../shell"
|
||||
|
||||
export const SettingsLayout = () => {
|
||||
return (
|
||||
<Shell>
|
||||
<SettingsSidebar />
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
const useSettingRoutes = (): NavItemProps[] => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t("profile.domain"),
|
||||
to: "/settings/profile",
|
||||
},
|
||||
{
|
||||
label: t("store.domain"),
|
||||
to: "/settings/store",
|
||||
},
|
||||
{
|
||||
label: t("users.domain"),
|
||||
to: "/settings/users",
|
||||
},
|
||||
{
|
||||
label: t("regions.domain"),
|
||||
to: "/settings/regions",
|
||||
},
|
||||
{
|
||||
label: "Taxes",
|
||||
to: "/settings/taxes",
|
||||
},
|
||||
{
|
||||
label: "Locations",
|
||||
to: "/settings/locations",
|
||||
},
|
||||
{
|
||||
label: t("salesChannels.domain"),
|
||||
to: "/settings/sales-channels",
|
||||
},
|
||||
{
|
||||
label: t("apiKeyManagement.domain"),
|
||||
to: "/settings/api-key-management",
|
||||
},
|
||||
],
|
||||
[t]
|
||||
)
|
||||
}
|
||||
|
||||
const SettingsSidebar = () => {
|
||||
const routes = useSettingRoutes()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const location = useLocation()
|
||||
const [from, setFrom] = useState("/orders")
|
||||
|
||||
useEffect(() => {
|
||||
if (location.state?.from) {
|
||||
setFrom(location.state.from)
|
||||
}
|
||||
}, [location])
|
||||
|
||||
return (
|
||||
<aside className="flex flex-1 flex-col justify-between overflow-y-auto">
|
||||
<div className="px-3 py-2">
|
||||
<div className="flex items-center gap-x-3 p-1">
|
||||
<Link to={from} replace className="flex items-center justify-center">
|
||||
<IconButton size="small" variant="transparent">
|
||||
<ArrowUturnLeft />
|
||||
</IconButton>
|
||||
</Link>
|
||||
<Text leading="compact" weight="plus" size="small">
|
||||
{t("general.settings")}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<div className="border-ui-border-strong h-px w-full border-b border-dashed" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-y-4 overflow-y-auto py-2">
|
||||
<nav className="flex flex-col gap-y-1">
|
||||
{routes.map((setting) => (
|
||||
<NavItem key={setting.to} {...setting} />
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./shell"
|
||||
@@ -0,0 +1,378 @@
|
||||
import {
|
||||
ArrowRightOnRectangle,
|
||||
BellAlert,
|
||||
BookOpen,
|
||||
Calendar,
|
||||
CircleHalfSolid,
|
||||
CogSixTooth,
|
||||
MagnifyingGlass,
|
||||
Sidebar,
|
||||
User as UserIcon,
|
||||
} from "@medusajs/icons"
|
||||
import { Avatar, DropdownMenu, IconButton, Kbd, Text, clx } from "@medusajs/ui"
|
||||
import * as Dialog from "@radix-ui/react-dialog"
|
||||
import { useAdminDeleteSession, useAdminGetSession } from "medusa-react"
|
||||
import { PropsWithChildren } from "react"
|
||||
import {
|
||||
Link,
|
||||
Outlet,
|
||||
UIMatch,
|
||||
useLocation,
|
||||
useMatches,
|
||||
useNavigate,
|
||||
} from "react-router-dom"
|
||||
|
||||
import { Skeleton } from "../../common/skeleton"
|
||||
|
||||
import { useSearch } from "../../../providers/search-provider"
|
||||
import { useSidebar } from "../../../providers/sidebar-provider"
|
||||
import { useTheme } from "../../../providers/theme-provider"
|
||||
|
||||
export const Shell = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-start overflow-hidden lg:flex-row">
|
||||
<div>
|
||||
<MobileSidebarContainer>{children}</MobileSidebarContainer>
|
||||
<DesktopSidebarContainer>{children}</DesktopSidebarContainer>
|
||||
</div>
|
||||
<div className="flex flex-col h-screen w-full">
|
||||
<Topbar />
|
||||
<div className="flex h-full w-full flex-col items-center overflow-y-auto">
|
||||
<Gutter>
|
||||
<Outlet />
|
||||
</Gutter>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Gutter = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<div className="flex w-full max-w-[1600px] flex-col gap-y-2 p-3">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Breadcrumbs = () => {
|
||||
const matches = useMatches() as unknown as UIMatch<
|
||||
unknown,
|
||||
{ crumb?: (data?: unknown) => string }
|
||||
>[]
|
||||
|
||||
const crumbs = matches
|
||||
.filter((match) => Boolean(match.handle?.crumb))
|
||||
.map((match) => {
|
||||
const handle = match.handle
|
||||
|
||||
return {
|
||||
label: handle.crumb!(match.data),
|
||||
path: match.pathname,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<ol className={clx("text-ui-fg-muted flex items-center select-none")}>
|
||||
{crumbs.map((crumb, index) => {
|
||||
const isLast = index === crumbs.length - 1
|
||||
|
||||
return (
|
||||
<li
|
||||
key={index}
|
||||
className={clx("txt-compact-small-plus flex items-center", {
|
||||
"text-ui-fg-subtle": isLast,
|
||||
})}
|
||||
>
|
||||
{!isLast ? (
|
||||
<Link
|
||||
className="transition-fg hover:text-ui-fg-subtle"
|
||||
to={crumb.path}
|
||||
>
|
||||
{crumb.label}
|
||||
</Link>
|
||||
) : (
|
||||
<div>
|
||||
<span className="block md:hidden">...</span>
|
||||
<span key={index} className="hidden md:block">
|
||||
{crumb.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* {!isLast && <TriangleRightMini className="-mt-0.5 mx-2" />} */}
|
||||
{!isLast && <span className="-mt-0.5 mx-2">›</span>}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
const UserBadge = () => {
|
||||
const { user, isError, error } = useAdminGetSession()
|
||||
|
||||
const displayName = user
|
||||
? user.first_name && user.last_name
|
||||
? `${user.first_name} ${user.last_name}`
|
||||
: user.first_name
|
||||
? user.first_name
|
||||
: user.email
|
||||
: null
|
||||
|
||||
const fallback = displayName ? displayName[0].toUpperCase() : null
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
disabled={!user}
|
||||
className={clx(
|
||||
"shadow-borders-base flex max-w-[192px] items-center gap-x-2 overflow-hidden text-ellipsis whitespace-nowrap rounded-full py-1 pl-1 pr-2.5 select-none"
|
||||
)}
|
||||
>
|
||||
{fallback ? (
|
||||
<Avatar size="xsmall" fallback={fallback} />
|
||||
) : (
|
||||
<Skeleton className="w-5 h-5 rounded-full" />
|
||||
)}
|
||||
{displayName ? (
|
||||
<Text
|
||||
size="xsmall"
|
||||
weight="plus"
|
||||
leading="compact"
|
||||
className="truncate"
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
) : (
|
||||
<Skeleton className="w-[70px] h-[9px]" />
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
const ThemeToggle = () => {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<DropdownMenu.SubMenu>
|
||||
<DropdownMenu.SubMenuTrigger className="rounded-md">
|
||||
<CircleHalfSolid className="text-ui-fg-subtle mr-2" />
|
||||
Theme
|
||||
</DropdownMenu.SubMenuTrigger>
|
||||
<DropdownMenu.SubMenuContent>
|
||||
<DropdownMenu.RadioGroup value={theme}>
|
||||
<DropdownMenu.RadioItem
|
||||
value="light"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setTheme("light")
|
||||
}}
|
||||
>
|
||||
Light
|
||||
</DropdownMenu.RadioItem>
|
||||
<DropdownMenu.RadioItem
|
||||
value="dark"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setTheme("dark")
|
||||
}}
|
||||
>
|
||||
Dark
|
||||
</DropdownMenu.RadioItem>
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.SubMenuContent>
|
||||
</DropdownMenu.SubMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const Logout = () => {
|
||||
const navigate = useNavigate()
|
||||
const { mutateAsync: logoutMutation } = useAdminDeleteSession()
|
||||
|
||||
const handleLayout = async () => {
|
||||
await logoutMutation(undefined, {
|
||||
onSuccess: () => {
|
||||
navigate("/login")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu.Item onClick={handleLayout}>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<ArrowRightOnRectangle className="text-ui-fg-subtle" />
|
||||
<span>Logout</span>
|
||||
</div>
|
||||
</DropdownMenu.Item>
|
||||
)
|
||||
}
|
||||
|
||||
const Profile = () => {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<Link to="/settings/profile" state={{ from: location.pathname }}>
|
||||
<DropdownMenu.Item>
|
||||
<UserIcon className="text-ui-fg-subtle mr-2" />
|
||||
Profile
|
||||
</DropdownMenu.Item>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const LoggedInUser = () => {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<UserBadge />
|
||||
<DropdownMenu.Content align="center">
|
||||
<Profile />
|
||||
<DropdownMenu.Separator />
|
||||
<Link to="https://docs.medusajs.com/user-guide" target="_blank">
|
||||
<DropdownMenu.Item>
|
||||
<BookOpen className="text-ui-fg-subtle mr-2" />
|
||||
Documentation
|
||||
</DropdownMenu.Item>
|
||||
</Link>
|
||||
<Link to="https://medusajs.com/changelog/" target="_blank">
|
||||
<DropdownMenu.Item>
|
||||
<Calendar className="text-ui-fg-subtle mr-2" />
|
||||
Changelog
|
||||
</DropdownMenu.Item>
|
||||
</Link>
|
||||
<DropdownMenu.Separator />
|
||||
<ThemeToggle />
|
||||
<DropdownMenu.Separator />
|
||||
<Logout />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const SettingsLink = () => {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/settings"
|
||||
className="flex items-center justify-center"
|
||||
state={{ from: location.pathname }}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="transparent"
|
||||
className="text-ui-fg-muted transition-fg hover:text-ui-fg-subtle"
|
||||
>
|
||||
<CogSixTooth />
|
||||
</IconButton>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const ToggleNotifications = () => {
|
||||
return (
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="transparent"
|
||||
className="text-ui-fg-muted transition-fg hover:text-ui-fg-subtle"
|
||||
>
|
||||
<BellAlert />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
|
||||
const Searchbar = () => {
|
||||
const { toggleSearch } = useSearch()
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleSearch}
|
||||
className="shadow-borders-base bg-ui-bg-subtle hover:bg-ui-bg-subtle-hover transition-fg focus-visible:shadow-borders-focus text-ui-fg-muted flex w-full max-w-[280px] items-center gap-x-2 rounded-full py-1.5 pl-2 pr-1.5 outline-none select-none"
|
||||
>
|
||||
<MagnifyingGlass />
|
||||
<div className="flex-1 text-left">
|
||||
<Text size="small" leading="compact">
|
||||
Jump to or search
|
||||
</Text>
|
||||
</div>
|
||||
<Kbd className="rounded-full">⌘K</Kbd>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const ToggleSidebar = () => {
|
||||
const { toggle } = useSidebar()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<IconButton
|
||||
className="hidden lg:flex"
|
||||
variant="transparent"
|
||||
onClick={() => toggle("desktop")}
|
||||
>
|
||||
<Sidebar className="text-ui-fg-muted" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className="hidden max-lg:flex"
|
||||
variant="transparent"
|
||||
onClick={() => toggle("mobile")}
|
||||
>
|
||||
<Sidebar className="text-ui-fg-muted" />
|
||||
</IconButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Topbar = () => {
|
||||
return (
|
||||
<div className="w-full grid-cols-3 border-b p-3 grid">
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<ToggleSidebar />
|
||||
<Breadcrumbs />
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<Searchbar />
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-x-3">
|
||||
<div className="text-ui-fg-muted flex items-center gap-x-1">
|
||||
<ToggleNotifications />
|
||||
<SettingsLink />
|
||||
</div>
|
||||
<LoggedInUser />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DesktopSidebarContainer = ({ children }: PropsWithChildren) => {
|
||||
const { desktop } = useSidebar()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx("hidden h-screen w-[220px] border-r", {
|
||||
"lg:flex": desktop,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MobileSidebarContainer = ({ children }: PropsWithChildren) => {
|
||||
const { mobile, toggle } = useSidebar()
|
||||
|
||||
return (
|
||||
<Dialog.Root open={mobile} onOpenChange={() => toggle("mobile")}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-ui-bg-overlay" />
|
||||
<Dialog.Content className="h-screen fixed left-0 inset-y-0 w-[220px] border-r bg-ui-bg-subtle">
|
||||
{children}
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user