feat(admin-sdk,admin-bundler,admin-shared,medusa): Restructure admin packages (#8988)

**What**
- Renames /admin-next -> /admin
- Renames @medusajs/admin-sdk -> @medusajs/admin-bundler
- Creates a new package called @medusajs/admin-sdk that will hold all tooling relevant to creating admin extensions. This is currently `defineRouteConfig` and `defineWidgetConfig`, but will eventually also export methods for adding custom fields, register translation, etc. 
  - cc: @shahednasser we should update the examples in the docs so these functions are imported from `@medusajs/admin-sdk`. People will also need to install the package in their project, as it's no longer a transient dependency.
  - cc: @olivermrbl we might want to publish a changelog when this is merged, as it is a breaking change, and will require people to import the `defineXConfig` from the new package instead of `@medusajs/admin-shared`.
- Updates CODEOWNERS so /admin packages does not require a review from the UI team.
This commit is contained in:
Kasper Fabricius Kristensen
2024-09-04 19:00:25 +00:00
committed by GitHub
parent beaa851302
commit 0fe1201435
1440 changed files with 122 additions and 86 deletions
@@ -0,0 +1 @@
export * from "./main-layout"
@@ -0,0 +1,377 @@
import {
BuildingStorefront,
Buildings,
ChevronDownMini,
CogSixTooth,
CurrencyDollar,
EllipsisHorizontal,
MagnifyingGlass,
MinusMini,
OpenRectArrowOut,
ReceiptPercent,
ShoppingCart,
SquaresPlus,
Tag,
Users,
} from "@medusajs/icons"
import { Avatar, DropdownMenu, Text, clx } from "@medusajs/ui"
import * as Collapsible from "@radix-ui/react-collapsible"
import { useTranslation } from "react-i18next"
import { useStore } from "../../../hooks/api/store"
import { settingsRouteRegex } from "../../../lib/extension-helpers"
import { Divider } from "../../common/divider"
import { Skeleton } from "../../common/skeleton"
import { NavItem, NavItemProps } from "../../layout/nav-item"
import { Shell } from "../../layout/shell"
import { Link, useLocation, useNavigate } from "react-router-dom"
import routes from "virtual:medusa/routes/links"
import { useLogout } from "../../../hooks/api"
import { queryClient } from "../../../lib/query-client"
import { useSearch } from "../../../providers/search-provider"
import { UserMenu } from "../user-menu"
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">
<Divider variant="dashed" />
</div>
</div>
<div className="flex flex-1 flex-col justify-between">
<div className="flex flex-1 flex-col">
<CoreRouteSection />
<ExtensionRouteSection />
</div>
<UtilitySection />
</div>
<div className="bg-ui-bg-subtle sticky bottom-0">
<UserSection />
</div>
</div>
</aside>
)
}
const Logout = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const { mutateAsync: logoutMutation } = useLogout()
const handleLogout = async () => {
await logoutMutation(undefined, {
onSuccess: () => {
/**
* When the user logs out, we want to clear the query cache
*/
queryClient.clear()
navigate("/login")
},
})
}
return (
<DropdownMenu.Item onClick={handleLogout}>
<div className="flex items-center gap-x-2">
<OpenRectArrowOut className="text-ui-fg-subtle" />
<span>{t("app.menus.actions.logout")}</span>
</div>
</DropdownMenu.Item>
)
}
const Header = () => {
const { t } = useTranslation()
const { store, isPending, isError, error } = useStore()
const name = store?.name
const fallback = store?.name?.slice(0, 1).toUpperCase()
const isLoaded = !isPending && !!store && !!name && !!fallback
if (isError) {
throw error
}
return (
<div className="w-full p-3">
<DropdownMenu>
<DropdownMenu.Trigger
disabled={!isLoaded}
className={clx(
"bg-ui-bg-subtle transition-fg grid w-full grid-cols-[24px_1fr_15px] items-center gap-x-3 rounded-md p-0.5 pr-2 outline-none",
"hover:bg-ui-bg-subtle-hover",
"data-[state=open]:bg-ui-bg-subtle-hover",
"focus-visible:shadow-borders-focus"
)}
>
{fallback ? (
<Avatar variant="squared" size="xsmall" fallback={fallback} />
) : (
<Skeleton className="h-6 w-6 rounded-md" />
)}
<div className="block overflow-hidden text-left">
{name ? (
<Text
size="small"
weight="plus"
leading="compact"
className="truncate"
>
{store.name}
</Text>
) : (
<Skeleton className="h-[9px] w-[120px]" />
)}
</div>
<EllipsisHorizontal className="text-ui-fg-muted" />
</DropdownMenu.Trigger>
{isLoaded && (
<DropdownMenu.Content className="w-[var(--radix-dropdown-menu-trigger-width)] min-w-0">
<div className="flex items-center gap-x-3 px-2 py-1">
<Avatar variant="squared" size="small" fallback={fallback} />
<div className="flex flex-col overflow-hidden">
<Text
size="small"
weight="plus"
leading="compact"
className="truncate"
>
{name}
</Text>
<Text
size="xsmall"
leading="compact"
className="text-ui-fg-subtle"
>
{t("app.nav.main.store")}
</Text>
</div>
</div>
<DropdownMenu.Separator />
<DropdownMenu.Item className="gap-x-2" asChild>
<Link to="/settings/store">
<BuildingStorefront className="text-ui-fg-subtle" />
{t("app.nav.main.storeSettings")}
</Link>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<Logout />
</DropdownMenu.Content>
)}
</DropdownMenu>
</div>
)
}
const useCoreRoutes = (): Omit<NavItemProps, "pathname">[] => {
const { t } = useTranslation()
return [
{
icon: <ShoppingCart />,
label: t("orders.domain"),
to: "/orders",
items: [
// TODO: Enable when domin is introduced
// {
// 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",
},
// TODO: Enable when domin is introduced
// {
// label: t("giftCards.domain"),
// to: "/gift-cards",
// },
],
},
{
icon: <Buildings />,
label: t("inventory.domain"),
to: "/inventory",
items: [
{
label: t("reservations.domain"),
to: "/reservations",
},
],
},
{
icon: <Users />,
label: t("customers.domain"),
to: "/customers",
items: [
{
label: t("customerGroups.domain"),
to: "/customer-groups",
},
],
},
{
icon: <ReceiptPercent />,
label: t("promotions.domain"),
to: "/promotions",
items: [
{
label: t("campaigns.domain"),
to: "/campaigns",
},
],
},
{
icon: <CurrencyDollar />,
label: t("priceLists.domain"),
to: "/price-lists",
},
]
}
const Searchbar = () => {
const { t } = useTranslation()
const { toggleSearch } = useSearch()
return (
<div className="px-3">
<button
onClick={toggleSearch}
className={clx(
"bg-ui-bg-subtle text-ui-fg-subtle flex w-full items-center gap-x-2.5 rounded-md px-2 py-1 outline-none",
"hover:bg-ui-bg-subtle-hover",
"focus-visible:shadow-borders-focus"
)}
>
<MagnifyingGlass />
<div className="flex-1 text-left">
<Text size="small" leading="compact" weight="plus">
{t("app.search.label")}
</Text>
</div>
<Text size="small" leading="compact" className="text-ui-fg-muted">
K
</Text>
</button>
</div>
)
}
const CoreRouteSection = () => {
const coreRoutes = useCoreRoutes()
return (
<nav className="flex flex-col gap-y-1 py-3">
<Searchbar />
{coreRoutes.map((route) => {
return <NavItem key={route.to} {...route} />
})}
</nav>
)
}
const ExtensionRouteSection = () => {
const { t } = useTranslation()
const links = routes.links
const extensionLinks = links
.filter((link) => !settingsRouteRegex.test(link.path))
.sort((a, b) => a.label.localeCompare(b.label))
if (!extensionLinks.length) {
return null
}
return (
<div>
<div className="px-3">
<Divider variant="dashed" />
</div>
<div className="flex flex-col gap-y-1 py-3">
<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("app.nav.common.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>
<nav className="flex flex-col gap-y-0.5 py-1 pb-4">
{extensionLinks.map((link) => {
return (
<NavItem
key={link.path}
to={link.path}
label={link.label}
icon={link.icon ? <link.icon /> : <SquaresPlus />}
type="extension"
/>
)
})}
</nav>
</Collapsible.Content>
</Collapsible.Root>
</div>
</div>
)
}
const UtilitySection = () => {
const location = useLocation()
const { t } = useTranslation()
return (
<div className="flex flex-col gap-y-0.5 py-3">
<NavItem
label={t("app.nav.settings.header")}
to="/settings"
from={location.pathname}
icon={<CogSixTooth />}
/>
</div>
)
}
const UserSection = () => {
return (
<div>
<div className="px-3">
<Divider variant="dashed" />
</div>
<UserMenu />
</div>
)
}
@@ -0,0 +1 @@
export * from "./nav-item"
@@ -0,0 +1,231 @@
import { Kbd, Text, clx } from "@medusajs/ui"
import * as Collapsible from "@radix-ui/react-collapsible"
import {
PropsWithChildren,
ReactNode,
useCallback,
useEffect,
useState,
} from "react"
import { useTranslation } from "react-i18next"
import { NavLink, useLocation } from "react-router-dom"
import { useGlobalShortcuts } from "../../../providers/keybind-provider/hooks"
import { ConditionalTooltip } from "../../common/conditional-tooltip"
type ItemType = "core" | "extension" | "setting"
type NestedItemProps = {
label: string
to: string
}
export type NavItemProps = {
icon?: ReactNode
label: string
to: string
items?: NestedItemProps[]
type?: ItemType
from?: string
}
const BASE_NAV_LINK_CLASSES =
"text-ui-fg-subtle transition-fg hover:bg-ui-bg-subtle-hover flex items-center gap-x-2 rounded-md py-1 pl-0.5 pr-2 outline-none [&>svg]:text-ui-fg-subtle focus-visible:shadow-borders-focus"
const ACTIVE_NAV_LINK_CLASSES =
"bg-ui-bg-base shadow-elevation-card-rest text-ui-fg-base hover:bg-ui-bg-base"
const NESTED_NAV_LINK_CLASSES = "pl-[34px] pr-2 w-full text-ui-fg-muted"
const SETTING_NAV_LINK_CLASSES = "pl-2"
const getIsOpen = (
to: string,
items: NestedItemProps[] | undefined,
pathname: string
) => {
return [to, ...(items?.map((i) => i.to) ?? [])].some((p) =>
pathname.startsWith(p)
)
}
const NavItemTooltip = ({
to,
children,
}: PropsWithChildren<{ to: string }>) => {
const { t } = useTranslation()
const globalShortcuts = useGlobalShortcuts()
const shortcut = globalShortcuts.find((s) => s.to === to)
return (
<ConditionalTooltip
showTooltip={!!shortcut}
maxWidth={9999} // Don't limit the width of the tooltip
content={
<div className="txt-compact-xsmall flex h-5 items-center justify-between gap-x-2 whitespace-nowrap">
<span>{shortcut?.label}</span>
<div className="flex items-center gap-x-1">
{shortcut?.keys.Mac?.map((key, index) => (
<div className="flex items-center gap-x-1" key={index}>
<Kbd key={key}>{key}</Kbd>
{index < (shortcut.keys.Mac?.length || 0) - 1 && (
<span className="text-ui-fg-muted txt-compact-xsmall">
{t("app.keyboardShortcuts.then")}
</span>
)}
</div>
))}
</div>
</div>
}
side="right"
delayDuration={1500}
>
<div className="w-full">{children}</div>
</ConditionalTooltip>
)
}
export const NavItem = ({
icon,
label,
to,
items,
type = "core",
from,
}: NavItemProps) => {
const { pathname } = useLocation()
const [open, setOpen] = useState(getIsOpen(to, items, pathname))
useEffect(() => {
setOpen(getIsOpen(to, items, pathname))
}, [pathname, to, items])
const navLinkClassNames = useCallback(
({
isActive,
isNested = false,
isSetting = false,
}: {
isActive: boolean
isNested?: boolean
isSetting?: boolean
}) =>
clx(BASE_NAV_LINK_CLASSES, {
[NESTED_NAV_LINK_CLASSES]: isNested,
[ACTIVE_NAV_LINK_CLASSES]: isActive,
[SETTING_NAV_LINK_CLASSES]: isSetting,
}),
[]
)
const isSetting = type === "setting"
return (
<div className="px-3">
<NavItemTooltip to={to}>
<NavLink
to={to}
state={
from
? {
from,
}
: undefined
}
className={(props) =>
clx(navLinkClassNames({ ...props, isSetting }), {
"max-lg:hidden": !!items?.length,
})
}
>
{type !== "setting" && (
<div className="flex size-6 items-center justify-center">
<Icon icon={icon} type={type} />
</div>
)}
<Text size="small" weight="plus" leading="compact">
{label}
</Text>
</NavLink>
</NavItemTooltip>
{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 py-1 pl-0.5 pr-2 outline-none lg:hidden",
{ "pl-2": isSetting }
)}
>
<div className="flex size-6 items-center justify-center">
<Icon icon={icon} type={type} />
</div>
<Text size="small" weight="plus" leading="compact">
{label}
</Text>
</Collapsible.Trigger>
<Collapsible.Content>
<div className="flex flex-col gap-y-0.5 pb-2 pt-0.5">
<ul className="flex flex-col gap-y-0.5">
<li className="flex w-full items-center gap-x-1 lg:hidden">
<NavItemTooltip to={to}>
<NavLink
to={to}
className={(props) =>
clx(
navLinkClassNames({
...props,
isNested: true,
isSetting,
})
)
}
>
<Text size="small" weight="plus" leading="compact">
{label}
</Text>
</NavLink>
</NavItemTooltip>
</li>
{items.map((item) => {
return (
<li key={item.to} className="flex h-7 items-center">
<NavItemTooltip to={item.to}>
<NavLink
to={item.to}
className={(props) =>
clx(
navLinkClassNames({
...props,
isNested: true,
isSetting,
})
)
}
>
<Text size="small" weight="plus" leading="compact">
{item.label}
</Text>
</NavLink>
</NavItemTooltip>
</li>
)
})}
</ul>
</div>
</Collapsible.Content>
</Collapsible.Root>
)}
</div>
)
}
const Icon = ({ icon, type }: { icon?: 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-[15px] w-[15px] overflow-hidden rounded-sm">{icon}</div>
</div>
) : (
icon
)
}
@@ -0,0 +1 @@
export * from "./notifications"
@@ -0,0 +1,226 @@
import {
BellAlert,
BellAlertDone,
InformationCircleSolid,
} from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { clx, Drawer, Heading, IconButton, Text } from "@medusajs/ui"
import { formatDistance } from "date-fns"
import { TFunction } from "i18next"
import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { notificationQueryKeys, useNotifications } from "../../../hooks/api"
import { sdk } from "../../../lib/client"
import { FilePreview } from "../../common/file-preview"
import { InfiniteList } from "../../common/infinite-list"
interface NotificationData {
title: string
description?: string
file?: {
filename?: string
url?: string
mimeType?: string
}
}
const LAST_READ_NOTIFICATION_KEY = "notificationsLastReadAt"
export const Notifications = () => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [hasUnread, setHasUnread] = useUnreadNotifications()
// This is used to show the unread icon on the notification when the drawer is open,
// so it should lag behind the local storage data and should only be reset on close
const [lastReadAt, setLastReadAt] = useState(
localStorage.getItem(LAST_READ_NOTIFICATION_KEY)
)
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "n" && (e.metaKey || e.ctrlKey)) {
setOpen((prev) => !prev)
}
}
document.addEventListener("keydown", onKeyDown)
return () => {
document.removeEventListener("keydown", onKeyDown)
}
}, [])
const handleOnOpen = (shouldOpen: boolean) => {
if (shouldOpen) {
setHasUnread(false)
setOpen(true)
localStorage.setItem(LAST_READ_NOTIFICATION_KEY, new Date().toISOString())
} else {
setOpen(false)
setLastReadAt(localStorage.getItem(LAST_READ_NOTIFICATION_KEY))
}
}
return (
<Drawer open={open} onOpenChange={handleOnOpen}>
<Drawer.Trigger asChild>
<IconButton
variant="transparent"
className="text-ui-fg-muted hover:text-ui-fg-subtle"
>
{hasUnread ? <BellAlertDone /> : <BellAlert />}
</IconButton>
</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title asChild>
<Heading>{t("notifications.domain")}</Heading>
</Drawer.Title>
<Drawer.Description className="sr-only">
{t("notifications.accessibility.description")}
</Drawer.Description>
</Drawer.Header>
<Drawer.Body className="overflow-y-auto px-0">
<InfiniteList<
HttpTypes.AdminNotificationListResponse,
HttpTypes.AdminNotification,
HttpTypes.AdminNotificationListParams
>
responseKey="notifications"
queryKey={notificationQueryKeys.all}
queryFn={(params) => sdk.admin.notification.list(params)}
queryOptions={{ enabled: open }}
renderEmpty={() => <NotificationsEmptyState t={t} />}
renderItem={(notification) => {
return (
<Notification
key={notification.id}
notification={notification}
unread={
Date.parse(notification.created_at) >
(lastReadAt ? Date.parse(lastReadAt) : 0)
}
/>
)
}}
/>
</Drawer.Body>
</Drawer.Content>
</Drawer>
)
}
const Notification = ({
notification,
unread,
}: {
notification: HttpTypes.AdminNotification
unread?: boolean
}) => {
const data = notification.data as unknown as NotificationData | undefined
// We need at least the title to render a notification in the feed
if (!data?.title) {
return null
}
return (
<>
<div className="flex items-start justify-center gap-3 border-b p-6 relative">
<div className="text-ui-fg-muted flex size-5 items-center justify-center">
<InformationCircleSolid />
</div>
<div className="flex w-full flex-col gap-y-3">
<div className="flex flex-col">
<div className="items-center flex justify-between">
<Text size="small" leading="compact" weight="plus">
{data.title}
</Text>
<div className="items-center flex justify-center align-center gap-2">
<Text
as={"span"}
className={clx("text-ui-fg-subtle", {
"text-ui-fg-base": unread,
})}
size="small"
leading="compact"
weight="plus"
>
{formatDistance(notification.created_at, new Date(), {
addSuffix: true,
})}
</Text>
{unread && (
<div
className="h-2 w-2 rounded bg-ui-bg-interactive"
role="status"
/>
)}
</div>
</div>
{!!data.description && (
<Text
className="text-ui-fg-subtle whitespace-pre-line"
size="small"
>
{data.description}
</Text>
)}
</div>
{!!data?.file?.url && (
<FilePreview
filename={data.file.filename ?? ""}
url={data.file.url}
hideThumbnail
/>
)}
</div>
</div>
</>
)
}
const NotificationsEmptyState = ({ t }: { t: TFunction }) => {
return (
<div className="flex h-full flex-col items-center justify-center">
<BellAlertDone />
<Text size="small" leading="compact" weight="plus" className="mt-3">
{t("notifications.emptyState.title")}
</Text>
<Text
size="small"
className="text-ui-fg-muted mt-1 max-w-[294px] text-center"
>
{t("notifications.emptyState.description")}
</Text>
</div>
)
}
const useUnreadNotifications = () => {
const [hasUnread, setHasUnread] = useState(false)
const { notifications } = useNotifications(
{ limit: 1, offset: 0, fields: "created_at" },
{ refetchInterval: 3000 }
)
const lastNotification = notifications?.[0]
useEffect(() => {
if (!lastNotification) {
return
}
const lastNotificationAsTimestamp = Date.parse(lastNotification.created_at)
const lastReadDatetime = localStorage.getItem(LAST_READ_NOTIFICATION_KEY)
const lastReadAsTimestamp = lastReadDatetime
? Date.parse(lastReadDatetime)
: 0
if (lastNotificationAsTimestamp > lastReadAsTimestamp) {
setHasUnread(true)
}
}, [lastNotification])
return [hasUnread, setHasUnread] as const
}
@@ -0,0 +1,2 @@
export * from "./single-column-page"
export * from "./two-column-page"
@@ -0,0 +1 @@
export * from "./single-column-page"
@@ -0,0 +1,63 @@
import { Outlet } from "react-router-dom"
import { JsonViewSection } from "../../../common/json-view-section"
import { MetadataSection } from "../../../common/metadata-section"
import { PageProps } from "../types"
export const SingleColumnPage = <TData,>({
children,
widgets,
/**
* Data of the page which is passed to Widgets, JSON view, and Metadata view.
*/
data,
/**
* Whether the page should render an outlet for children routes. Defaults to true.
*/
hasOutlet = true,
/**
* Whether to show JSON view of the data. Defaults to false.
*/
showJSON,
/**
* Whether to show metadata view of the data. Defaults to false.
*/
showMetadata,
}: PageProps<TData>) => {
const { before, after } = widgets
const widgetProps = { data }
if (showJSON && !data) {
if (process.env.NODE_ENV === "development") {
console.warn(
"`showJSON` is true but no data is provided. To display JSON, provide data prop."
)
}
showJSON = false
}
if (showMetadata && !data) {
if (process.env.NODE_ENV === "development") {
console.warn(
"`showMetadata` is true but no data is provided. To display metadata, provide data prop."
)
}
showMetadata = false
}
return (
<div className="flex flex-col gap-y-3">
{before.widgets.map((w, i) => {
return <w.Component {...widgetProps} key={i} />
})}
{children}
{after.widgets.map((w, i) => {
return <w.Component {...widgetProps} key={i} />
})}
{showMetadata && <MetadataSection data={data!} />}
{showJSON && <JsonViewSection data={data!} />}
{hasOutlet && <Outlet />}
</div>
)
}
@@ -0,0 +1 @@
export * from "./two-column-page"
@@ -0,0 +1,141 @@
import { clx } from "@medusajs/ui"
import { Children, ComponentPropsWithoutRef } from "react"
import { Outlet } from "react-router-dom"
import { JsonViewSection } from "../../../common/json-view-section"
import { MetadataSection } from "../../../common/metadata-section"
import { PageProps, WidgetImport, WidgetProps } from "../types"
interface TwoColumnWidgetProps extends WidgetProps {
sideBefore: WidgetImport
sideAfter: WidgetImport
}
interface TwoColumnPageProps<TData> extends PageProps<TData> {
widgets: TwoColumnWidgetProps
}
const Root = <TData,>({
children,
/**
* Widgets to be rendered in the main content area and sidebar.
*/
widgets,
/**
* Data to be passed to widgets, JSON view, and Metadata view.
*/
data,
/**
* Whether to show JSON view of the data. Defaults to false.
*/
showJSON = false,
/**
* Whether to show metadata view of the data. Defaults to false.
*/
showMetadata = false,
/**
* Whether to render an outlet for children routes. Defaults to true.
*/
hasOutlet = true,
}: TwoColumnPageProps<TData>) => {
const widgetProps = { data }
const { before, after, sideBefore, sideAfter } = widgets
if (showJSON && !data) {
if (process.env.NODE_ENV === "development") {
console.warn(
"`showJSON` is true but no data is provided. To display JSON, provide data prop."
)
}
showJSON = false
}
if (showMetadata && !data) {
if (process.env.NODE_ENV === "development") {
console.warn(
"`showMetadata` is true but no data is provided. To display metadata, provide data prop."
)
}
showMetadata = false
}
const childrenArray = Children.toArray(children)
if (childrenArray.length !== 2) {
throw new Error("TwoColumnPage expects exactly two children")
}
const [main, sidebar] = childrenArray
const showExtraData = showJSON || showMetadata
return (
<div className="flex flex-col gap-y-3">
{before.widgets.map((w, i) => {
return <w.Component {...widgetProps} key={i} />
})}
<div className="flex flex-col gap-x-4 gap-y-3 xl:flex-row xl:items-start">
<div className="flex w-full flex-col gap-y-3">
{main}
{after.widgets.map((w, i) => {
return <w.Component {...widgetProps} key={i} />
})}
{showExtraData && (
<div className="hidden flex-col gap-y-3 xl:flex">
{showMetadata && <MetadataSection data={data!} />}
{showJSON && <JsonViewSection data={data!} />}
</div>
)}
</div>
<div className="flex w-full max-w-[100%] flex-col gap-y-3 xl:mt-0 xl:max-w-[440px]">
{sideBefore.widgets.map((w, i) => {
return <w.Component {...widgetProps} key={i} />
})}
{sidebar}
{sideAfter.widgets.map((w, i) => {
return <w.Component {...widgetProps} key={i} />
})}
{showExtraData && (
<div className="flex flex-col gap-y-3 xl:hidden">
{showMetadata && <MetadataSection data={data!} />}
{showJSON && <JsonViewSection data={data!} />}
</div>
)}
</div>
</div>
{hasOutlet && <Outlet />}
</div>
)
}
const Main = ({
children,
className,
...props
}: ComponentPropsWithoutRef<"div">) => {
return (
<div className={clx("flex w-full flex-col gap-y-3", className)} {...props}>
{children}
</div>
)
}
const Sidebar = ({
children,
className,
...props
}: ComponentPropsWithoutRef<"div">) => {
return (
<div
className={clx(
"flex w-full max-w-[100%] flex-col gap-y-3 xl:mt-0 xl:max-w-[440px]",
className
)}
{...props}
>
{children}
</div>
)
}
export const TwoColumnPage = Object.assign(Root, { Main, Sidebar })
@@ -0,0 +1,23 @@
import { ReactNode } from "react"
export type Widget = {
Component: React.ComponentType<any>
}
export type WidgetImport = {
widgets: Widget[]
}
export interface WidgetProps {
before: WidgetImport
after: WidgetImport
}
export interface PageProps<TData> {
children: ReactNode
widgets: WidgetProps
data?: TData
showJSON?: boolean
showMetadata?: boolean
hasOutlet?: boolean
}
@@ -0,0 +1 @@
export * from "./public-layout";
@@ -0,0 +1,14 @@
import { Outlet } from "react-router-dom";
export const PublicLayout = () => {
return (
<div className="min-h-screen flex items-center justify-center px-4 py-6">
<div className="bg-ui-bg-base text-ui-fg-subtle w-[520px] px-16 py-20 rounded-[32px] shadow-elevation-modal flex flex-col gap-y-12 items-center">
<div className="w-24 h-24 rounded-3xl bg-ui-bg-subtle shadow-elevation-card-hover"></div>
<div className="w-full">
<Outlet />
</div>
</div>
</div>
);
};
@@ -0,0 +1 @@
export * from "./settings-layout"
@@ -0,0 +1,268 @@
import { ArrowUturnLeft, MinusMini } from "@medusajs/icons"
import { IconButton, Text, clx } from "@medusajs/ui"
import * as Collapsible from "@radix-ui/react-collapsible"
import { Fragment, useEffect, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link, useLocation } from "react-router-dom"
import { settingsRouteRegex } from "../../../lib/extension-helpers"
import { Divider } from "../../common/divider"
import { NavItem, NavItemProps } from "../nav-item"
import { Shell } from "../shell"
import routes from "virtual:medusa/routes/links"
import { UserMenu } from "../user-menu"
export const SettingsLayout = () => {
return (
<Shell>
<SettingsSidebar />
</Shell>
)
}
const useSettingRoutes = (): NavItemProps[] => {
const { t } = useTranslation()
return useMemo(
() => [
{
label: t("store.domain"),
to: "/settings/store",
},
{
label: t("users.domain"),
to: "/settings/users",
},
{
label: t("regions.domain"),
to: "/settings/regions",
},
{
label: t("taxRegions.domain"),
to: "/settings/tax-regions",
},
{
label: t("returnReasons.domain"),
to: "/settings/return-reasons",
},
{
label: t("salesChannels.domain"),
to: "/settings/sales-channels",
},
{
label: t("productTypes.domain"),
to: "/settings/product-types",
},
{
label: t("productTags.domain"),
to: "/settings/product-tags",
},
{
label: t("stockLocations.domain"),
to: "/settings/locations",
},
],
[t]
)
}
const useDeveloperRoutes = (): NavItemProps[] => {
const { t } = useTranslation()
return useMemo(
() => [
{
label: t("apiKeyManagement.domain.publishable"),
to: "/settings/publishable-api-keys",
},
{
label: t("apiKeyManagement.domain.secret"),
to: "/settings/secret-api-keys",
},
{
label: t("workflowExecutions.domain"),
to: "/settings/workflows",
},
],
[t]
)
}
const useMyAccountRoutes = (): NavItemProps[] => {
const { t } = useTranslation()
return useMemo(
() => [
{
label: t("profile.domain"),
to: "/settings/profile",
},
],
[t]
)
}
const useExtensionRoutes = (): NavItemProps[] => {
const links = routes.links
return useMemo(() => {
const settingsLinks = links.filter((link) =>
settingsRouteRegex.test(link.path)
)
return settingsLinks.map((link) => ({
label: link.label,
to: link.path,
}))
}, [links])
}
/**
* Ensure that the `from` prop is not another settings route, to avoid
* the user getting stuck in a navigation loop.
*/
const getSafeFromValue = (from: string) => {
if (from.startsWith("/settings")) {
return "/orders"
}
return from
}
const SettingsSidebar = () => {
const routes = useSettingRoutes()
const developerRoutes = useDeveloperRoutes()
const extensionRoutes = useExtensionRoutes()
const myAccountRoutes = useMyAccountRoutes()
const { t } = useTranslation()
return (
<aside className="relative flex flex-1 flex-col justify-between overflow-y-auto">
<div className="bg-ui-bg-subtle sticky top-0">
<Header />
<div className="flex items-center justify-center px-3">
<Divider variant="dashed" />
</div>
</div>
<div className="flex flex-1 flex-col">
<div className="flex flex-1 flex-col overflow-y-auto">
<CollapsibleSection
label={t("app.nav.settings.general")}
items={routes}
/>
<div className="flex items-center justify-center px-3">
<Divider variant="dashed" />
</div>
<CollapsibleSection
label={t("app.nav.settings.developer")}
items={developerRoutes}
/>
<div className="flex items-center justify-center px-3">
<Divider variant="dashed" />
</div>
<CollapsibleSection
label={t("app.nav.settings.myAccount")}
items={myAccountRoutes}
/>
{extensionRoutes.length > 0 && (
<Fragment>
<div className="flex items-center justify-center px-3">
<Divider variant="dashed" />
</div>
<CollapsibleSection
label={t("app.nav.common.extensions")}
items={extensionRoutes}
/>
</Fragment>
)}
</div>
<div className="bg-ui-bg-subtle sticky bottom-0">
<UserSection />
</div>
</div>
</aside>
)
}
const Header = () => {
const [from, setFrom] = useState("/orders")
const { t } = useTranslation()
const location = useLocation()
useEffect(() => {
if (location.state?.from) {
setFrom(getSafeFromValue(location.state.from))
}
}, [location])
return (
<div className="bg-ui-bg-subtle p-3">
<Link
to={from}
replace
className={clx(
"bg-ui-bg-subtle transition-fg flex items-center rounded-md outline-none",
"hover:bg-ui-bg-subtle-hover",
"focus-visible:shadow-borders-focus"
)}
>
<div className="flex items-center gap-x-2.5 px-2 py-1">
<div className="flex items-center justify-center">
<ArrowUturnLeft className="text-ui-fg-subtle" />
</div>
<Text leading="compact" weight="plus" size="small">
{t("app.nav.settings.header")}
</Text>
</div>
</Link>
</div>
)
}
const CollapsibleSection = ({
label,
items,
}: {
label: string
items: NavItemProps[]
}) => {
return (
<Collapsible.Root defaultOpen className="py-3">
<div className="px-3">
<div className="text-ui-fg-muted flex h-7 items-center justify-between px-2">
<Text size="small" leading="compact">
{label}
</Text>
<Collapsible.Trigger asChild>
<IconButton size="2xsmall" variant="transparent" className="static">
<MinusMini className="text-ui-fg-muted" />
</IconButton>
</Collapsible.Trigger>
</div>
</div>
<Collapsible.Content>
<div className="pt-0.5">
<nav className="flex flex-col gap-y-0.5">
{items.map((setting) => (
<NavItem key={setting.to} type="setting" {...setting} />
))}
</nav>
</div>
</Collapsible.Content>
</Collapsible.Root>
)
}
const UserSection = () => {
return (
<div>
<div className="px-3">
<Divider variant="dashed" />
</div>
<UserMenu />
</div>
)
}
@@ -0,0 +1 @@
export * from "./shell"
@@ -0,0 +1,213 @@
import * as Dialog from "@radix-ui/react-dialog"
import { SidebarLeft, TriangleRightMini, XMark } from "@medusajs/icons"
import { IconButton, clx } from "@medusajs/ui"
import { PropsWithChildren } from "react"
import { useTranslation } from "react-i18next"
import { Link, Outlet, UIMatch, useMatches } from "react-router-dom"
import { KeybindProvider } from "../../../providers/keybind-provider"
import { useGlobalShortcuts } from "../../../providers/keybind-provider/hooks"
import { useSidebar } from "../../../providers/sidebar-provider"
import { Notifications } from "../notifications"
export const Shell = ({ children }: PropsWithChildren) => {
const globalShortcuts = useGlobalShortcuts()
return (
<KeybindProvider shortcuts={globalShortcuts}>
<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 h-screen w-full flex-col overflow-auto">
<Topbar />
<main className="flex h-full w-full flex-col items-center overflow-y-auto">
<Gutter>
<Outlet />
</Gutter>
</main>
</div>
</div>
</KeybindProvider>
)
}
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
let label: string | null = null
try {
label = handle.crumb!(match.data)
} catch (error) {
// noop
}
if (!label) {
return null
}
return {
label: label,
path: match.pathname,
}
})
.filter(Boolean) as { label: string; path: string }[]
return (
<ol
className={clx(
"text-ui-fg-muted txt-compact-small-plus flex select-none items-center"
)}
>
{crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1
const isSingle = crumbs.length === 1
return (
<li key={index} className={clx("flex items-center")}>
{!isLast ? (
<Link
className="transition-fg hover:text-ui-fg-subtle"
to={crumb.path}
>
{crumb.label}
</Link>
) : (
<div>
{!isSingle && <span className="block lg:hidden">...</span>}
<span
key={index}
className={clx({
"hidden lg:block": !isSingle,
})}
>
{crumb.label}
</span>
</div>
)}
{!isLast && (
<span className="mx-2">
<TriangleRightMini />
</span>
)}
</li>
)
})}
</ol>
)
}
const ToggleSidebar = () => {
const { toggle } = useSidebar()
return (
<div>
<IconButton
className="hidden lg:flex"
variant="transparent"
onClick={() => toggle("desktop")}
size="small"
>
<SidebarLeft className="text-ui-fg-muted" />
</IconButton>
<IconButton
className="hidden max-lg:flex"
variant="transparent"
onClick={() => toggle("mobile")}
size="small"
>
<SidebarLeft className="text-ui-fg-muted" />
</IconButton>
</div>
)
}
const Topbar = () => {
return (
<div className="grid w-full grid-cols-2 border-b p-3">
<div className="flex items-center gap-x-1.5">
<ToggleSidebar />
<Breadcrumbs />
</div>
<div className="flex items-center justify-end gap-x-3">
<Notifications />
</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 { t } = useTranslation()
const { mobile, toggle } = useSidebar()
return (
<Dialog.Root open={mobile} onOpenChange={() => toggle("mobile")}>
<Dialog.Portal>
<Dialog.Overlay
className={clx(
"bg-ui-bg-overlay fixed inset-0",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
)}
/>
<Dialog.Content
className={clx(
"bg-ui-bg-subtle shadow-elevation-modal fixed inset-y-2 left-2 flex w-full max-w-[304px] flex-col overflow-hidden rounded-lg border-r",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-out-to-left-1/2 data-[state=open]:slide-in-from-left-1/2 duration-200"
)}
>
<div className="p-3">
<Dialog.Close asChild>
<IconButton
size="small"
variant="transparent"
className="text-ui-fg-subtle"
>
<XMark />
</IconButton>
</Dialog.Close>
<Dialog.Title className="sr-only">
{t("app.nav.accessibility.title")}
</Dialog.Title>
<Dialog.Description className="sr-only">
{t("app.nav.accessibility.description")}
</Dialog.Description>
</div>
{children}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
@@ -0,0 +1 @@
export * from "./split-view"
@@ -0,0 +1,103 @@
import { Button, clx } from "@medusajs/ui"
import * as Dialog from "@radix-ui/react-dialog"
import {
ComponentPropsWithoutRef,
PropsWithChildren,
createContext,
useContext,
useRef,
} from "react"
type SplitViewContextValue = {
open: boolean
onOpenChange: (open: boolean) => void
}
const SplitViewContext = createContext<SplitViewContextValue | null>(null)
const useSplitViewContext = () => {
const context = useContext(SplitViewContext)
if (!context) {
throw new Error("useSplitViewContext must be used within a SplitView")
}
return context
}
type SplitViewProps = PropsWithChildren<{
open?: boolean
onOpenChange?: (open: boolean) => void
}>
const Root = ({ open, onOpenChange, children }: SplitViewProps) => {
const containerRef = useRef<HTMLDivElement>(null)
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<div ref={containerRef} className="relative size-full overflow-hidden">
{children}
</div>
</Dialog.Root>
)
}
const Content = ({
children,
className,
...props
}: ComponentPropsWithoutRef<"div">) => {
return (
<div
className={clx("relative h-full overflow-y-auto", className)}
{...props}
>
{children}
</div>
)
}
const Drawer = ({ children }: PropsWithChildren) => {
return (
<div>
<Dialog.Overlay
className={clx(
"bg-ui-bg-base absolute inset-0 opacity-40",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
)}
/>
<Dialog.Content
className={clx(
"bg-ui-bg-base border-ui-border-base absolute inset-y-0 right-0 flex w-full max-w-[calc(100%-128px)] flex-1 flex-col border-l focus:outline-none md:max-w-[80%] lg:max-w-[50%]",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-out-to-right-1/2 data-[state=open]:slide-in-from-right-1/2 duration-200"
)}
>
{children}
</Dialog.Content>
</div>
)
}
const Close = ({
variant = "secondary",
size = "small",
children,
...props
}: ComponentPropsWithoutRef<typeof Button>) => {
return (
<Dialog.Close asChild>
<Button size={size} variant={variant} {...props}>
{children}
</Button>
</Dialog.Close>
)
}
/**
* SplitView is a layout component that allows you to create a split view layout within a FocusModal.
*/
export const SplitView = Object.assign(Root, {
Content,
Drawer,
Close,
})
@@ -0,0 +1 @@
export * from "./user-menu"
@@ -0,0 +1,342 @@
import {
BookOpen,
CircleHalfSolid,
EllipsisHorizontal,
Keyboard,
OpenRectArrowOut,
TimelineVertical,
User as UserIcon,
XMark,
} from "@medusajs/icons"
import {
Avatar,
DropdownMenu,
Heading,
IconButton,
Input,
Kbd,
Text,
clx,
} from "@medusajs/ui"
import * as Dialog from "@radix-ui/react-dialog"
import { useTranslation } from "react-i18next"
import { Skeleton } from "../../common/skeleton"
import { useState } from "react"
import { Link, useLocation, useNavigate } from "react-router-dom"
import { useLogout, useMe } from "../../../hooks/api"
import { queryClient } from "../../../lib/query-client"
import { useGlobalShortcuts } from "../../../providers/keybind-provider/hooks"
import { useTheme } from "../../../providers/theme-provider"
export const UserMenu = () => {
const { t } = useTranslation()
const location = useLocation()
const [openMenu, setOpenMenu] = useState(false)
const [openModal, setOpenModal] = useState(false)
const toggleModal = () => {
setOpenMenu(false)
setOpenModal(!openModal)
}
return (
<div>
<DropdownMenu open={openMenu} onOpenChange={setOpenMenu}>
<UserBadge />
<DropdownMenu.Content className="min-w-[var(--radix-dropdown-menu-trigger-width)] max-w-[var(--radix-dropdown-menu-trigger-width)]">
<UserItem />
<DropdownMenu.Separator />
<DropdownMenu.Item asChild>
<Link to="/settings/profile" state={{ from: location.pathname }}>
<UserIcon className="text-ui-fg-subtle mr-2" />
{t("app.menus.user.profileSettings")}
</Link>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item asChild>
<Link to="https://docs.medusajs.com/v2" target="_blank">
<BookOpen className="text-ui-fg-subtle mr-2" />
{t("app.menus.user.documentation")}
</Link>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<Link to="https://medusajs.com/changelog/" target="_blank">
<TimelineVertical className="text-ui-fg-subtle mr-2" />
{t("app.menus.user.changelog")}
</Link>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onClick={toggleModal}>
<Keyboard className="text-ui-fg-subtle mr-2" />
{t("app.menus.user.shortcuts")}
</DropdownMenu.Item>
<ThemeToggle />
<DropdownMenu.Separator />
<Logout />
</DropdownMenu.Content>
</DropdownMenu>
<GlobalKeybindsModal open={openModal} onOpenChange={setOpenModal} />
</div>
)
}
const UserBadge = () => {
const { user, isPending, isError, error } = useMe()
const name = [user?.first_name, user?.last_name].filter(Boolean).join(" ")
const displayName = name || user?.email
const fallback = displayName ? displayName[0].toUpperCase() : null
if (isPending) {
return (
<button className="shadow-borders-base flex max-w-[192px] select-none items-center gap-x-2 overflow-hidden text-ellipsis whitespace-nowrap rounded-full py-1 pl-1 pr-2.5">
<Skeleton className="h-5 w-5 rounded-full" />
<Skeleton className="h-[9px] w-[70px]" />
</button>
)
}
if (isError) {
throw error
}
return (
<div className="p-3">
<DropdownMenu.Trigger
disabled={!user}
className={clx(
"bg-ui-bg-subtle grid w-full cursor-pointer grid-cols-[24px_1fr_15px] items-center gap-2 rounded-md py-1 pl-0.5 pr-2 outline-none",
"hover:bg-ui-bg-subtle-hover",
"data-[state=open]:bg-ui-bg-subtle-hover",
"focus-visible:shadow-borders-focus"
)}
>
<div className="flex size-6 items-center justify-center">
{fallback ? (
<Avatar size="xsmall" fallback={fallback} />
) : (
<Skeleton className="h-6 w-6 rounded-full" />
)}
</div>
<div className="flex items-center overflow-hidden">
{displayName ? (
<Text
size="xsmall"
weight="plus"
leading="compact"
className="truncate"
>
{displayName}
</Text>
) : (
<Skeleton className="h-[9px] w-[70px]" />
)}
</div>
<EllipsisHorizontal className="text-ui-fg-muted" />
</DropdownMenu.Trigger>
</div>
)
}
const ThemeToggle = () => {
const { t } = useTranslation()
const { theme, setTheme } = useTheme()
return (
<DropdownMenu.SubMenu>
<DropdownMenu.SubMenuTrigger className="rounded-md">
<CircleHalfSolid className="text-ui-fg-subtle mr-2" />
{t("app.menus.user.theme.label")}
</DropdownMenu.SubMenuTrigger>
<DropdownMenu.SubMenuContent>
<DropdownMenu.RadioGroup value={theme}>
<DropdownMenu.RadioItem
value="system"
onClick={(e) => {
e.preventDefault()
setTheme("system")
}}
>
{t("app.menus.user.theme.system")}
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem
value="light"
onClick={(e) => {
e.preventDefault()
setTheme("light")
}}
>
{t("app.menus.user.theme.light")}
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem
value="dark"
onClick={(e) => {
e.preventDefault()
setTheme("dark")
}}
>
{t("app.menus.user.theme.dark")}
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.SubMenuContent>
</DropdownMenu.SubMenu>
)
}
const Logout = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const { mutateAsync: logoutMutation } = useLogout()
const handleLogout = async () => {
await logoutMutation(undefined, {
onSuccess: () => {
/**
* When the user logs out, we want to clear the query cache
*/
queryClient.clear()
navigate("/login")
},
})
}
return (
<DropdownMenu.Item onClick={handleLogout}>
<div className="flex items-center gap-x-2">
<OpenRectArrowOut className="text-ui-fg-subtle" />
<span>{t("app.menus.actions.logout")}</span>
</div>
</DropdownMenu.Item>
)
}
const GlobalKeybindsModal = (props: {
open: boolean
onOpenChange: (open: boolean) => void
}) => {
const { t } = useTranslation()
const globalShortcuts = useGlobalShortcuts()
const [searchValue, onSearchValueChange] = useState("")
const searchResults = searchValue
? globalShortcuts.filter((shortcut) => {
return shortcut.label.toLowerCase().includes(searchValue?.toLowerCase())
})
: globalShortcuts
return (
<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%] flex h-full max-h-[612px] w-full max-w-[560px] translate-x-[-50%] translate-y-[-50%] flex-col divide-y overflow-hidden rounded-lg">
<div className="flex flex-col gap-y-3 px-6 py-4">
<div className="flex items-center justify-between">
<div>
<Dialog.Title asChild>
<Heading>{t("app.menus.user.shortcuts")}</Heading>
</Dialog.Title>
<Dialog.Description className="sr-only"></Dialog.Description>
</div>
<div className="flex items-center gap-x-2">
<Kbd>esc</Kbd>
<Dialog.Close asChild>
<IconButton variant="transparent" size="small">
<XMark />
</IconButton>
</Dialog.Close>
</div>
</div>
<div>
<Input
type="search"
value={searchValue}
onChange={(e) => onSearchValueChange(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col divide-y overflow-y-auto">
{searchResults.map((shortcut, index) => {
return (
<div
key={index}
className="text-ui-fg-subtle flex items-center justify-between px-6 py-3"
>
<Text size="small">{shortcut.label}</Text>
<div className="flex items-center gap-x-1">
{shortcut.keys.Mac?.map((key, index) => {
return (
<div className="flex items-center gap-x-1" key={index}>
<Kbd>{key}</Kbd>
{index < (shortcut.keys.Mac?.length || 0) - 1 && (
<span className="txt-compact-xsmall text-ui-fg-subtle">
{t("app.keyboardShortcuts.then")}
</span>
)}
</div>
)
})}
</div>
</div>
)
})}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
const UserItem = () => {
const { user, isPending, isError, error } = useMe()
const loaded = !isPending && !!user
if (!loaded) {
return <div></div>
}
const name = [user.first_name, user.last_name].filter(Boolean).join(" ")
const email = user.email
const fallback = name ? name[0].toUpperCase() : email[0].toUpperCase()
const avatar = user.avatar_url
if (isError) {
throw error
}
return (
<div className="flex items-center gap-x-3 overflow-hidden px-2 py-1">
<Avatar
size="small"
variant="rounded"
src={avatar || undefined}
fallback={fallback}
/>
<div className="block w-full min-w-0 max-w-[187px] overflow-hidden whitespace-nowrap">
<Text
size="small"
weight="plus"
leading="compact"
className="overflow-hidden text-ellipsis whitespace-nowrap"
>
{name || email}
</Text>
{!!name && (
<Text
size="xsmall"
leading="compact"
className="text-ui-fg-subtle overflow-hidden text-ellipsis whitespace-nowrap"
>
{email}
</Text>
)}
</div>
</div>
)
}