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:
@@ -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"
|
||||
+63
@@ -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"
|
||||
+141
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user