feat(dashboard,ui): Streamline spacing and sizing (#6061)

This commit is contained in:
Kasper Fabricius Kristensen
2024-01-15 11:43:16 +01:00
committed by GitHub
parent 5dacd4ac9f
commit a2c149e7e5
266 changed files with 10738 additions and 4646 deletions

View File

@@ -1,25 +1,31 @@
import { Spinner } from "@medusajs/icons";
import { PropsWithChildren } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { Spinner } from "@medusajs/icons"
import { Navigate, Outlet, useLocation } from "react-router-dom"
import { useAuth } from "../../../providers/auth-provider";
import { useAdminGetSession } from "medusa-react"
import { SearchProvider } from "../../../providers/search-provider"
import { SidebarProvider } from "../../../providers/sidebar-provider"
export const RequireAuth = ({ children }: PropsWithChildren) => {
const auth = useAuth();
const location = useLocation();
export const ProtectedRoute = () => {
const { user, isLoading } = useAdminGetSession()
const location = useLocation()
if (auth.isLoading) {
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Spinner className="animate-spin text-ui-fg-interactive" />
<div className="flex min-h-screen items-center justify-center">
<Spinner className="text-ui-fg-interactive animate-spin" />
</div>
);
)
}
if (!auth.user) {
console.log("redirecting");
return <Navigate to="/login" state={{ from: location }} replace />;
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />
}
return children;
};
return (
<SidebarProvider>
<SearchProvider>
<Outlet />
</SearchProvider>
</SidebarProvider>
)
}

View File

@@ -0,0 +1,37 @@
import { clx } from "@medusajs/ui"
import * as Popover from "@radix-ui/react-popover"
type ComboboxOption = {
value: string
label: string
}
type ComboboxProps = {
size?: "base" | "small"
options: ComboboxOption[]
value: string
}
export const Combobox = ({ size = "base" }: ComboboxProps) => {
return (
<Popover.Root>
<Popover.Trigger asChild>
<button
className={clx(
"bg-ui-bg-field shadow-buttons-neutral transition-fg flex w-full select-none items-center justify-between rounded-md outline-none",
"data-[placeholder]:text-ui-fg-muted text-ui-fg-base",
"hover:bg-ui-bg-field-hover",
"focus:shadow-borders-interactive-with-active data-[state=open]:!shadow-borders-interactive-with-active",
"aria-[invalid=true]:border-ui-border-error aria-[invalid=true]:shadow-borders-error",
"invalid::border-ui-border-error invalid:shadow-borders-error",
"disabled:!bg-ui-bg-disabled disabled:!text-ui-fg-disabled",
{
"h-8 px-2 py-1.5 txt-compact-small": size === "base",
"h-7 px-2 py-1 txt-compact-small": size === "small",
}
)}
></button>
</Popover.Trigger>
</Popover.Root>
)
}

View File

@@ -0,0 +1,54 @@
import { forwardRef } from "react"
import { TrianglesMini } from "@medusajs/icons"
import { clx } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { countries } from "../../../lib/countries"
export const CountrySelect = forwardRef<
HTMLSelectElement,
React.ComponentPropsWithoutRef<"select"> & { placeholder?: string }
>(({ className, disabled, placeholder, ...props }, ref) => {
const { t } = useTranslation()
return (
<div className="relative">
<TrianglesMini
className={clx(
"absolute right-2 top-1/2 -translate-y-1/2 text-ui-fg-muted transition-fg pointer-events-none",
{
"text-ui-fg-disabled": disabled,
}
)}
/>
<select
disabled={disabled}
className={clx(
"appearance-none bg-ui-bg-field shadow-buttons-neutral transition-fg flex w-full select-none items-center justify-between rounded-md outline-none px-2 py-1 txt-compact-small",
"placeholder:text-ui-fg-muted text-ui-fg-base",
"hover:bg-ui-bg-field-hover",
"focus-visible:shadow-borders-interactive-with-active data-[state=open]:!shadow-borders-interactive-with-active",
"aria-[invalid=true]:border-ui-border-error aria-[invalid=true]:shadow-borders-error",
"invalid::border-ui-border-error invalid:shadow-borders-error",
"disabled:!bg-ui-bg-disabled disabled:!text-ui-fg-disabled",
className
)}
{...props}
ref={ref}
>
{/* Add an empty option so the first option is preselected */}
<option value="" disabled hidden className="text-ui-fg-muted">
{placeholder || t("fields.selectCountry")}
</option>
{countries.map((country) => {
return (
<option key={country.iso_2} value={country.iso_2}>
{country.display_name}
</option>
)
})}
</select>
</div>
)
})
CountrySelect.displayName = "CountrySelect"

View File

@@ -0,0 +1 @@
export * from "./country-select"

View File

@@ -15,6 +15,7 @@ export const DebouncedSearch = ({
value: initialValue,
onChange,
debounce = 500,
size = "small",
placeholder,
...props
}: DebouncedSearchProps) => {

View File

@@ -0,0 +1,59 @@
import { ExclamationCircle, MagnifyingGlass } from "@medusajs/icons"
import { Button, Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
type NoResultsProps = {
title: string
message?: string
}
export const NoResults = ({ title, message }: NoResultsProps) => {
const { t } = useTranslation()
return (
<div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-y-2">
<MagnifyingGlass />
<Text size="small" leading="compact" weight="plus">
{title}
</Text>
<Text size="small" className="text-ui-fg-subtle">
{message ?? t("general.noResultsMessage")}
</Text>
</div>
</div>
)
}
type NoRecordsProps = {
title?: string
message?: string
action?: {
to: string
label: string
}
}
export const NoRecords = ({ title, message, action }: NoRecordsProps) => {
const { t } = useTranslation()
return (
<div className="flex h-[400px] w-full flex-col items-center justify-center gap-y-6">
<div className="flex flex-col items-center gap-y-2">
<ExclamationCircle />
<Text size="small" leading="compact" weight="plus">
{title ?? t("general.noRecordsTitle")}
</Text>
<Text size="small" className="text-ui-fg-subtle">
{message ?? t("general.noRecordsMessage")}
</Text>
</div>
{action && (
<Link to={action.to}>
<Button variant="secondary">{action.label}</Button>
</Link>
)}
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./empty-table-content"

View File

@@ -0,0 +1 @@
export * from "./json-view-section"

View File

@@ -21,23 +21,27 @@ type JsonViewProps = {
}
// TODO: Fix the positioning of the copy btn
export const JsonView = ({ data, root }: JsonViewProps) => {
export const JsonViewSection = ({ data, root }: JsonViewProps) => {
const numberOfKeys = Object.keys(data).length
return (
<Container className="flex items-center justify-between py-6">
<Container className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-x-4">
<Heading level="h2">JSON</Heading>
<Badge>{numberOfKeys} keys</Badge>
</div>
<Drawer>
<Drawer.Trigger asChild>
<IconButton variant="transparent" className="text-ui-fg-subtle">
<IconButton
size="small"
variant="transparent"
className="text-ui-fg-subtle"
>
<ArrowsPointingOut />
</IconButton>
</Drawer.Trigger>
<Drawer.Content className="border-ui-code-border bg-ui-code-bg-base text-ui-code-text-base dark overflow-hidden border shadow-none max-md:inset-x-2 max-md:max-w-[calc(100%-16px)]">
<div className="bg-ui-code-bg-header border-ui-code-border flex items-center justify-between border-b px-8 py-6">
<div className="bg-ui-code-bg-header border-ui-code-border flex items-center justify-between border-b px-6 py-4">
<div className="flex items-center gap-x-4">
<Heading>JSON</Heading>
<Badge>{numberOfKeys} keys</Badge>
@@ -45,7 +49,11 @@ export const JsonView = ({ data, root }: JsonViewProps) => {
<div className="flex items-center gap-x-2">
<Kbd>esc</Kbd>
<Drawer.Close asChild>
<IconButton variant="transparent" className="text-ui-fg-subtle">
<IconButton
size="small"
variant="transparent"
className="text-ui-fg-subtle"
>
<XMarkMini />
</IconButton>
</Drawer.Close>

View File

@@ -1 +0,0 @@
export * from "./json-view";

View File

@@ -8,7 +8,7 @@ import { StatusBadge, Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { Thumbnail } from "../thumbnail"
export const ProductInventoryCell = ({
export const ProductVariantCell = ({
variants,
}: {
variants: ProductVariant[] | null
@@ -23,13 +23,10 @@ export const ProductInventoryCell = ({
)
}
const inventory = variants.reduce((acc, v) => acc + v.inventory_quantity, 0)
return (
<Text size="small" className="text-ui-fg-base">
{t("products.inStockVariants", {
{t("products.variantCount", {
count: variants.length,
inventory: inventory,
})}
</Text>
)

View File

@@ -0,0 +1 @@
export * from "./skeleton"

View File

@@ -0,0 +1,16 @@
import { clx } from "@medusajs/ui"
type SkeletonProps = {
className?: string
}
export const Skeleton = ({ className }: SkeletonProps) => {
return (
<div
className={clx(
"bg-ui-bg-component animate-pulse w-3 h-3 rounded-[4px]",
className
)}
/>
)
}

View File

@@ -1,5 +1,5 @@
import type { AxiosError } from "axios"
import { Navigate, useLocation, useRouteError } from "react-router-dom"
import { isAxiosError } from "../../../lib/is-axios-error"
export const ErrorBoundary = () => {
const error = useRouteError()
@@ -20,7 +20,3 @@ export const ErrorBoundary = () => {
// TODO: Actual catch-all error page
return <div>Dang!</div>
}
const isAxiosError = (error: any): error is AxiosError => {
return error.isAxiosError
}

View File

@@ -0,0 +1,54 @@
import { Button, DropdownMenu } from "@medusajs/ui"
import { ReactNode } from "react"
import { useSearchParams } from "react-router-dom"
type FilterGroupProps = {
filters: {
[key: string]: ReactNode
}
}
export const FilterGroup = ({ filters }: FilterGroupProps) => {
const [searchParams] = useSearchParams()
const filterKeys = Object.keys(filters)
if (filterKeys.length === 0) {
return null
}
const isClearable = filterKeys.some((key) => searchParams.get(key))
const hasMore = !filterKeys.every((key) => searchParams.get(key))
const availableKeys = filterKeys.filter((key) => !searchParams.get(key))
return (
<div className="flex items-center flex-wrap gap-2">
{hasMore && <AddFilterMenu availableKeys={availableKeys} />}
{isClearable && (
<Button variant="transparent" size="small">
Clear all
</Button>
)}
</div>
)
}
type AddFilterMenuProps = {
availableKeys: string[]
}
const AddFilterMenu = ({ availableKeys }: AddFilterMenuProps) => {
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<Button variant="secondary" size="small">
Add filter
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{availableKeys.map((key) => (
<DropdownMenu.Item key={key}>{key}</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu>
)
}

View File

@@ -0,0 +1 @@
export * from "./filter-group"

View File

@@ -0,0 +1 @@
export * from "./order-by"

View File

@@ -0,0 +1,148 @@
import { ArrowUpDown } from "@medusajs/icons"
import { DropdownMenu, IconButton } from "@medusajs/ui"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { useSearchParams } from "react-router-dom"
type OrderByProps = {
keys: string[]
}
enum SortDirection {
ASC = "asc",
DESC = "desc",
}
type SortState = {
key?: string
dir: SortDirection
}
const initState = (params: URLSearchParams): SortState => {
const sortParam = params.get("order")
if (!sortParam) {
return {
dir: SortDirection.ASC,
}
}
const dir = sortParam.startsWith("-") ? SortDirection.DESC : SortDirection.ASC
const key = sortParam.replace("-", "")
return {
key,
dir,
}
}
const formatKey = (key: string) => {
const words = key.split("_")
const formattedWords = words.map((word, index) => {
if (index === 0) {
return word.charAt(0).toUpperCase() + word.slice(1)
} else {
return word
}
})
return formattedWords.join(" ")
}
export const OrderBy = ({ keys }: OrderByProps) => {
const [searchParams, setSearchParams] = useSearchParams()
const [state, setState] = useState<{
key?: string
dir: SortDirection
}>(initState(searchParams))
const { t } = useTranslation()
const handleDirChange = (dir: string) => {
setState((prev) => ({
...prev,
dir: dir as SortDirection,
}))
updateOrderParam({
key: state.key,
dir: dir as SortDirection,
})
}
const handleKeyChange = (value: string) => {
setState((prev) => ({
...prev,
key: value,
}))
updateOrderParam({
key: value,
dir: state.dir,
})
}
const updateOrderParam = (state: SortState) => {
if (!state.key) {
setSearchParams((prev) => {
prev.delete("order")
return prev
})
return
}
const orderParam =
state.dir === SortDirection.ASC ? state.key : `-${state.key}`
setSearchParams((prev) => {
prev.set("order", orderParam)
return prev
})
}
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<IconButton size="small">
<ArrowUpDown />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.RadioGroup
value={state.key}
onValueChange={handleKeyChange}
>
{keys.map((key) => (
<DropdownMenu.RadioItem
key={key}
value={key}
onSelect={(event) => event.preventDefault()}
>
{formatKey(key)}
</DropdownMenu.RadioItem>
))}
</DropdownMenu.RadioGroup>
<DropdownMenu.Separator />
<DropdownMenu.RadioGroup
value={state.dir}
onValueChange={handleDirChange}
>
<DropdownMenu.RadioItem
className="flex items-center justify-between"
value="asc"
onSelect={(event) => event.preventDefault()}
>
{t("general.ascending")}
<DropdownMenu.Label>1 - 30</DropdownMenu.Label>
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem
className="flex items-center justify-between"
value="desc"
onSelect={(event) => event.preventDefault()}
>
{t("general.descending")}
<DropdownMenu.Label>30 - 1</DropdownMenu.Label>
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu>
)
}

View File

@@ -0,0 +1 @@
export * from "./query"

View File

@@ -0,0 +1,57 @@
import { Input } from "@medusajs/ui"
import { debounce } from "lodash"
import { ChangeEvent, useCallback, useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { useSearchParams } from "react-router-dom"
type QueryProps = {
placeholder?: string
}
export const Query = ({ placeholder }: QueryProps) => {
const { t } = useTranslation()
const placeholderText = placeholder || t("general.search")
const [searchParams, setSearchParams] = useSearchParams()
const [inputValue, setInputValue] = useState(searchParams.get("q") || "")
const updateSearchParams = (newValue: string) => {
if (!newValue) {
setSearchParams((prev) => {
prev.delete("q")
return prev
})
return
}
setSearchParams((prev) => ({ ...prev, q: newValue || "" }))
}
const debouncedUpdate = useCallback(
debounce((newValue: string) => updateSearchParams(newValue), 500),
[]
)
useEffect(() => {
debouncedUpdate(inputValue)
return () => {
debouncedUpdate.cancel()
}
}, [inputValue, debouncedUpdate])
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value)
}
return (
<Input
type="search"
size="small"
value={inputValue}
onChange={handleInputChange}
placeholder={placeholderText}
/>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -1 +0,0 @@
export * from "./app-layout";

View File

@@ -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))
);
};
};

View File

@@ -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>
)
}

View File

@@ -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
);
};

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
);
};

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./main-layout"

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./nav-item"

View File

@@ -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
)
}

View File

@@ -0,0 +1 @@
export * from "./notifications"

View File

@@ -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>
);
};
)
}

View File

@@ -0,0 +1 @@
export * from "./settings-layout"

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./shell"

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./localized-date-picker"

View File

@@ -0,0 +1,36 @@
import { DatePicker } from "@medusajs/ui"
import { ComponentPropsWithoutRef } from "react"
import { useTranslation } from "react-i18next"
import { languages } from "../../../i18n/config"
type LocalizedDatePickerProps = Omit<
ComponentPropsWithoutRef<typeof DatePicker>,
"translations" | "locale"
>
export const LocalizedDatePicker = ({
mode = "single",
...props
}: LocalizedDatePickerProps) => {
const { i18n, t } = useTranslation()
const locale = languages.find((lang) => lang.code === i18n.language)
?.date_locale
const translations = {
cancel: t("general.cancel"),
apply: t("general.apply"),
end: t("general.end"),
start: t("general.start"),
range: t("general.range"),
}
return (
<DatePicker
mode={mode}
translations={translations}
locale={locale}
{...(props as any)}
/>
)
}

View File

@@ -0,0 +1 @@
export * from "./localized-table-pagination"

View File

@@ -0,0 +1,26 @@
import { Table } from "@medusajs/ui"
import { ComponentPropsWithoutRef, ElementRef, forwardRef } from "react"
import { useTranslation } from "react-i18next"
type LocalizedTablePaginationProps = Omit<
ComponentPropsWithoutRef<typeof Table.Pagination>,
"translations"
>
export const LocalizedTablePagination = forwardRef<
ElementRef<typeof Table.Pagination>,
LocalizedTablePaginationProps
>((props, ref) => {
const { t } = useTranslation()
const translations = {
of: t("general.of"),
results: t("general.results"),
pages: t("general.pages"),
prev: t("general.prev"),
next: t("general.next"),
}
return <Table.Pagination {...props} translations={translations} ref={ref} />
})
LocalizedTablePagination.displayName = "LocalizedTablePagination"

View File

@@ -1,5 +1,5 @@
import { MagnifyingGlass } from "@medusajs/icons"
import { clx } from "@medusajs/ui"
import { Kbd, Text, clx } from "@medusajs/ui"
import * as Dialog from "@radix-ui/react-dialog"
import { Command } from "cmdk"
import {
@@ -10,6 +10,7 @@ import {
useMemo,
} from "react"
import { useTranslation } from "react-i18next"
import { useSearch } from "../../providers/search-provider"
export const Search = () => {
@@ -100,6 +101,18 @@ const useLinks = (): CommandGroupProps[] => {
{
label: t("users.domain"),
},
{
label: t("regions.domain"),
},
{
label: t("locations.domain"),
},
{
label: t("salesChannels.domain"),
},
{
label: t("apiKeyManagement.domain"),
},
],
},
],
@@ -129,11 +142,30 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
<Dialog.Root {...props}>
<Dialog.Portal>
<Dialog.Overlay className="bg-ui-bg-overlay fixed inset-0" />
<Dialog.Content className="bg-ui-bg-subtle shadow-elevation-modal fixed left-[50%] top-[50%] w-full max-w-2xl translate-x-[-50%] translate-y-[-50%] overflow-hidden rounded-xl p-0">
<Dialog.Content className="bg-ui-bg-base shadow-elevation-modal fixed left-[50%] top-[50%] w-full max-w-2xl translate-x-[-50%] translate-y-[-50%] overflow-hidden rounded-xl p-0">
<CommandPalette className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</CommandPalette>
<div className="border-t px-4 pb-4 pt-3"></div>
<div className="flex items-center justify-between border-t px-4 pb-4 pt-3">
<div></div>
<div className="flex items-center gap-x-3">
<div className="flex items-center gap-x-2">
<Text size="xsmall" leading="compact">
Navigation
</Text>
<div className="flex items-center gap-x-1">
<Kbd></Kbd>
<Kbd></Kbd>
</div>
</div>
<div className="flex items-center gap-x-2">
<Text size="xsmall" leading="compact">
Open Result
</Text>
<Kbd></Kbd>
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
@@ -149,7 +181,7 @@ const CommandInput = forwardRef<
<Command.Input
ref={ref}
className={clx(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50",
"placeholder:text-ui-fg-muted flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
@@ -166,7 +198,7 @@ const CommandList = forwardRef<
<Command.List
ref={ref}
className={clx(
"max-h-[300px] overflow-y-auto overflow-x-hidden",
"max-h-[300px] overflow-y-auto overflow-x-hidden pb-4",
className
)}
{...props}
@@ -219,7 +251,7 @@ const CommandItem = forwardRef<
<Command.Item
ref={ref}
className={clx(
"aria-selected:bg-accent aria-selected:text-accent-foreground txt-compact-small relative flex cursor-default select-none items-center rounded-sm p-2 outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"aria-selected:bg-ui-bg-base-hover hover:bg-ui-bg-base-hover focus-visible:bg-ui-bg-base-hover txt-compact-small relative flex cursor-default select-none items-center rounded-md p-2 outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}