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,16 @@
|
||||
import { I18nProvider as Provider } from "@medusajs/ui"
|
||||
import { PropsWithChildren } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { languages } from "../../i18n/languages"
|
||||
|
||||
type I18nProviderProps = PropsWithChildren
|
||||
|
||||
export const I18nProvider = ({ children }: I18nProviderProps) => {
|
||||
const { i18n } = useTranslation()
|
||||
|
||||
const locale =
|
||||
languages.find((lan) => lan.code === i18n.language)?.code ||
|
||||
languages[0].code
|
||||
|
||||
return <Provider locale={locale}>{children}</Provider>
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./i18n-provider"
|
||||
@@ -0,0 +1,319 @@
|
||||
import debounceFn from "lodash/debounce"
|
||||
import { useCallback, useContext, useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
|
||||
import { useLogout } from "../../hooks/api/auth"
|
||||
import { queryClient } from "../../lib/query-client"
|
||||
import { KeybindContext } from "./keybind-context"
|
||||
import { Shortcut } from "./types"
|
||||
import { findShortcut } from "./utils"
|
||||
|
||||
export const useKeybind = () => {
|
||||
const context = useContext(KeybindContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useKeybind must be used within a KeybindProvider")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export const useRegisterShortcut = () => {}
|
||||
|
||||
export const useShortcuts = ({
|
||||
shortcuts = [],
|
||||
debounce,
|
||||
}: {
|
||||
shortcuts?: Shortcut[]
|
||||
debounce: number
|
||||
}) => {
|
||||
const [keys, setKeys] = useState<string[]>([])
|
||||
const navigate = useNavigate()
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const removeKeys = useCallback(
|
||||
debounceFn(() => setKeys([]), debounce),
|
||||
[]
|
||||
)
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const invokeShortcut = useCallback(
|
||||
debounceFn((shortcut: Shortcut | null) => {
|
||||
if (shortcut && shortcut.callback) {
|
||||
shortcut.callback()
|
||||
setKeys([])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (shortcut && shortcut.to) {
|
||||
navigate(shortcut.to)
|
||||
setKeys([])
|
||||
|
||||
return
|
||||
}
|
||||
}, debounce / 2),
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (keys.length > 0 && shortcuts.length > 0) {
|
||||
const shortcut = findShortcut(shortcuts, keys)
|
||||
invokeShortcut(shortcut)
|
||||
}
|
||||
|
||||
return () => invokeShortcut.cancel()
|
||||
}, [keys, shortcuts, invokeShortcut])
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
|
||||
/**
|
||||
* Ignore key events from input, textarea and contenteditable elements
|
||||
*/
|
||||
if (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.contentEditable === "true"
|
||||
) {
|
||||
removeKeys()
|
||||
return
|
||||
}
|
||||
|
||||
setKeys((oldKeys) => [...oldKeys, event.key])
|
||||
removeKeys()
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", listener)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", listener)
|
||||
}
|
||||
}, [removeKeys])
|
||||
}
|
||||
|
||||
export const useGlobalShortcuts = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { mutateAsync } = useLogout()
|
||||
|
||||
const handleLogout = async () => {
|
||||
await mutateAsync(undefined, {
|
||||
onSuccess: () => {
|
||||
queryClient.clear()
|
||||
navigate("/login")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const globalShortcuts: Shortcut[] = [
|
||||
// Pages
|
||||
{
|
||||
keys: {
|
||||
Mac: ["G", "O"],
|
||||
},
|
||||
label: t("app.keyboardShortcuts.navigation.goToOrders"),
|
||||
type: "pageShortcut",
|
||||
to: "/orders",
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
Mac: ["G", "P"],
|
||||
},
|
||||
label: t("app.keyboardShortcuts.navigation.goToProducts"),
|
||||
type: "pageShortcut",
|
||||
to: "/products",
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
Mac: ["G", "C"],
|
||||
},
|
||||
label: t("app.keyboardShortcuts.navigation.goToCollections"),
|
||||
type: "pageShortcut",
|
||||
to: "/collections",
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
Mac: ["G", "A"],
|
||||
},
|
||||
label: t("app.keyboardShortcuts.navigation.goToCategories"),
|
||||
type: "pageShortcut",
|
||||
to: "/categories",
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
Mac: ["G", "U"],
|
||||
},
|
||||
label: t("app.keyboardShortcuts.navigation.goToCustomers"),
|
||||
type: "pageShortcut",
|
||||
to: "/customers",
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
Mac: ["G", "G"],
|
||||
},
|
||||
label: t("app.keyboardShortcuts.navigation.goToCustomerGroups"),
|
||||
type: "pageShortcut",
|
||||
to: "/customer-groups",
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
Mac: ["G", "I"],
|
||||
},
|
||||
label: t("app.keyboardShortcuts.navigation.goToInventory"),
|
||||
type: "pageShortcut",
|
||||
to: "/inventory",
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
Mac: ["G", "R"],
|
||||
},
|
||||
label: t("app.keyboardShortcuts.navigation.goToReservations"),
|
||||
type: "pageShortcut",
|
||||
to: "/reservations",
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
Mac: ["G", "L"],
|
||||
},
|
||||
label: t("app.keyboardShortcuts.navigation.goToPriceLists"),
|
||||
type: "pageShortcut",
|
||||
to: "/price-lists",
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
Mac: ["G", "M"],
|
||||
},
|
||||
label: t("app.keyboardShortcuts.navigation.goToPromotions"),
|
||||
type: "pageShortcut",
|
||||
to: "/promotions",
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
Mac: ["G", "K"],
|
||||
},
|
||||
label: t("app.keyboardShortcuts.navigation.goToCampaigns"),
|
||||
type: "pageShortcut",
|
||||
to: "/campaigns",
|
||||
},
|
||||
// Settings
|
||||
{
|
||||
keys: {
|
||||
Mac: ["G", ","],
|
||||
},
|
||||
label: t("app.keyboardShortcuts.settings.goToSettings"),
|
||||
type: "settingShortcut",
|
||||
to: "/settings",
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
Mac: ["G", ",", "S"],
|
||||
},
|
||||
label: t("app.keyboardShortcuts.settings.goToStore"),
|
||||
type: "settingShortcut",
|
||||
to: "/settings/store",
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
Mac: ["G", ",", "U"],
|
||||
},
|
||||
label: t("app.keyboardShortcuts.settings.goToUsers"),
|
||||
type: "settingShortcut",
|
||||
to: "/settings/users",
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
Mac: ["G", ",", "R"],
|
||||
},
|
||||
label: t("app.keyboardShortcuts.settings.goToRegions"),
|
||||
type: "settingShortcut",
|
||||
to: "/settings/regions",
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
Mac: ["G", ",", "T"],
|
||||
},
|
||||
label: t("app.keyboardShortcuts.settings.goToTaxRegions"),
|
||||
type: "settingShortcut",
|
||||
to: "/settings/tax-regions",
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
Mac: ["G", ",", "A"],
|
||||
},
|
||||
label: t("app.keyboardShortcuts.settings.goToSalesChannels"),
|
||||
type: "settingShortcut",
|
||||
to: "/settings/sales-channels",
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
Mac: ["G", ",", "P"],
|
||||
},
|
||||
label: t("app.keyboardShortcuts.settings.goToProductTypes"),
|
||||
type: "settingShortcut",
|
||||
to: "/settings/product-types",
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
Mac: ["G", ",", "L"],
|
||||
},
|
||||
label: t("app.keyboardShortcuts.settings.goToLocations"),
|
||||
type: "settingShortcut",
|
||||
to: "/settings/locations",
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
Mac: ["G", ",", "M"],
|
||||
},
|
||||
label: t("app.keyboardShortcuts.settings.goToReturnReasons"),
|
||||
type: "settingShortcut",
|
||||
to: "/settings/return-reasons",
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
Mac: ["G", ",", "J"],
|
||||
},
|
||||
label: t("app.keyboardShortcuts.settings.goToPublishableApiKeys"),
|
||||
type: "settingShortcut",
|
||||
to: "/settings/publishable-api-keys",
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
Mac: ["G", ",", "K"],
|
||||
},
|
||||
label: t("app.keyboardShortcuts.settings.goToSecretApiKeys"),
|
||||
type: "settingShortcut",
|
||||
to: "/settings/secret-api-keys",
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
Mac: ["G", ",", "W"],
|
||||
},
|
||||
label: t("app.keyboardShortcuts.settings.goToWorkflows"),
|
||||
type: "settingShortcut",
|
||||
to: "/settings/workflows",
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
Mac: ["G", ",", "M"],
|
||||
},
|
||||
label: t("app.keyboardShortcuts.settings.goToProfile"),
|
||||
type: "settingShortcut",
|
||||
to: "/settings/profile",
|
||||
},
|
||||
// Commands
|
||||
{
|
||||
keys: {
|
||||
Mac: ["B", "Y", "E"],
|
||||
},
|
||||
label: t("actions.logout"),
|
||||
type: "commandShortcut",
|
||||
callback: () => handleLogout(),
|
||||
},
|
||||
]
|
||||
|
||||
return globalShortcuts
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useRegisterShortcut } from "./hooks"
|
||||
export * from "./keybind-provider"
|
||||
export type { Shortcut, ShortcutType } from "./types"
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createContext } from "react"
|
||||
import { KeybindContextState } from "./types"
|
||||
|
||||
export const KeybindContext = createContext<KeybindContextState | null>(null)
|
||||
@@ -0,0 +1,64 @@
|
||||
import { PropsWithChildren, useCallback, useMemo, useState } from "react"
|
||||
|
||||
import { useShortcuts } from "./hooks"
|
||||
import { KeybindContext } from "./keybind-context"
|
||||
import { KeybindContextState, Shortcut } from "./types"
|
||||
import {
|
||||
findFirstPlatformMatch,
|
||||
findShortcutIndex,
|
||||
getShortcutKeys,
|
||||
getShortcutWithDefaultValues,
|
||||
} from "./utils"
|
||||
|
||||
type KeybindProviderProps = PropsWithChildren<{
|
||||
shortcuts: Shortcut[]
|
||||
debounce?: number
|
||||
}>
|
||||
|
||||
export const KeybindProvider = ({
|
||||
shortcuts,
|
||||
debounce = 500,
|
||||
children,
|
||||
}: KeybindProviderProps) => {
|
||||
const [storeShortcuts, setStoreCommands] = useState(
|
||||
shortcuts.map((shr) => getShortcutWithDefaultValues(shr))
|
||||
)
|
||||
const registerShortcut = useCallback(
|
||||
(shortcut: Shortcut) => {
|
||||
setStoreCommands((prevShortcuts) => {
|
||||
const idx = findShortcutIndex(shortcuts, getShortcutKeys(shortcut))
|
||||
|
||||
const newShortcuts = [...prevShortcuts]
|
||||
|
||||
if (idx > -1) {
|
||||
newShortcuts[idx] = getShortcutWithDefaultValues(shortcut)
|
||||
return prevShortcuts
|
||||
}
|
||||
|
||||
return [...prevShortcuts, getShortcutWithDefaultValues(shortcut)]
|
||||
})
|
||||
},
|
||||
[shortcuts]
|
||||
)
|
||||
|
||||
const getKeysByPlatform = useCallback((command: Shortcut) => {
|
||||
return findFirstPlatformMatch(command.keys)
|
||||
}, [])
|
||||
|
||||
useShortcuts({ shortcuts: storeShortcuts, debounce })
|
||||
|
||||
const commandsContext = useMemo<KeybindContextState>(
|
||||
() => ({
|
||||
shortcuts: storeShortcuts,
|
||||
registerShortcut,
|
||||
getKeysByPlatform,
|
||||
}),
|
||||
[storeShortcuts, registerShortcut, getKeysByPlatform]
|
||||
)
|
||||
|
||||
return (
|
||||
<KeybindContext.Provider value={commandsContext}>
|
||||
{children}
|
||||
</KeybindContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export type KeybindContextState = {}
|
||||
|
||||
export type Platform = "Mac" | "Windows" | "Linux"
|
||||
|
||||
export type Keys = {
|
||||
[key in Platform]?: string[]
|
||||
}
|
||||
|
||||
export type ShortcutType =
|
||||
| "pageShortcut"
|
||||
| "settingShortcut"
|
||||
| "commandShortcut"
|
||||
|
||||
export type Shortcut = {
|
||||
keys: Keys
|
||||
type: ShortcutType
|
||||
label: string
|
||||
_defaultKeys?: Keys
|
||||
} & (
|
||||
| {
|
||||
callback: () => void
|
||||
to?: never
|
||||
}
|
||||
| {
|
||||
to: string
|
||||
callback?: never
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Keys, Platform, Shortcut } from "./types"
|
||||
|
||||
export const findFirstPlatformMatch = (keys: Keys) => {
|
||||
const match =
|
||||
Object.entries(keys as Record<any, any>).filter(
|
||||
([, value]) => value.length > 0
|
||||
)[0] ?? []
|
||||
|
||||
return match.length
|
||||
? {
|
||||
platform: match[0] as Platform,
|
||||
keys: match[1] as string[],
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
export const getShortcutKeys = (shortcut: Shortcut) => {
|
||||
const platform: Platform = "Mac"
|
||||
|
||||
const keys: string[] | undefined = shortcut.keys[platform]
|
||||
|
||||
if (!keys) {
|
||||
const defaultPlatform = findFirstPlatformMatch(shortcut.keys)
|
||||
|
||||
console.warn(
|
||||
`No keys found for platform "${platform}" in "${shortcut.label}" ${
|
||||
defaultPlatform
|
||||
? `using keys for platform "${defaultPlatform.platform}"`
|
||||
: ""
|
||||
}`
|
||||
)
|
||||
|
||||
return defaultPlatform ? defaultPlatform.keys : []
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
const keysMatch = (keys1: string[], keys2: string[]) => {
|
||||
return (
|
||||
keys1.length === keys2.length &&
|
||||
keys1.every(
|
||||
(key, index) => key.toLowerCase() === keys2[index].toLowerCase()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export const findShortcutIndex = (shortcuts: Shortcut[], keys: string[]) => {
|
||||
if (!keys.length) {
|
||||
return -1
|
||||
}
|
||||
|
||||
let index = 0
|
||||
for (const shortcut of shortcuts) {
|
||||
const shortcutKeys = getShortcutKeys(shortcut)
|
||||
|
||||
if (keysMatch(shortcutKeys, keys)) {
|
||||
return index
|
||||
}
|
||||
|
||||
index++
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
export const findShortcut = (shortcuts: Shortcut[], keys: string[]) => {
|
||||
const shortcutIndex = findShortcutIndex(shortcuts, keys)
|
||||
return shortcutIndex > -1 ? shortcuts[shortcutIndex] : null
|
||||
}
|
||||
|
||||
export const getShortcutWithDefaultValues = (
|
||||
shortcut: Shortcut,
|
||||
platform: Platform = "Mac"
|
||||
): Shortcut => {
|
||||
const platforms: Platform[] = ["Mac", "Windows", "Linux"]
|
||||
|
||||
const defaultKeys = Object.values(shortcut.keys)[0] ?? shortcut.keys[platform]
|
||||
|
||||
const keys = platforms.reduce((acc, curr) => {
|
||||
return {
|
||||
...acc,
|
||||
[curr]: shortcut.keys[curr] ?? defaultKeys,
|
||||
}
|
||||
}, {})
|
||||
|
||||
return {
|
||||
...shortcut,
|
||||
keys,
|
||||
_defaultKeys: shortcut.keys,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./router-provider"
|
||||
@@ -0,0 +1,12 @@
|
||||
import routes from "virtual:medusa/routes/pages"
|
||||
|
||||
import { createRouteMap, settingsRouteRegex } from "../../lib/extension-helpers"
|
||||
|
||||
const pages = routes.pages
|
||||
.filter((ext) => !settingsRouteRegex.test(ext.path))
|
||||
.map((ext) => ext)
|
||||
|
||||
/**
|
||||
* Core Route extensions.
|
||||
*/
|
||||
export const RouteExtensions = createRouteMap(pages)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
RouterProvider as Provider,
|
||||
createBrowserRouter,
|
||||
} from "react-router-dom"
|
||||
|
||||
import { RouteMap } from "./route-map"
|
||||
|
||||
const router = createBrowserRouter(RouteMap, {
|
||||
basename: __BASE__ || "/",
|
||||
})
|
||||
|
||||
export const RouterProvider = () => {
|
||||
return <Provider router={router} />
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import routes from "virtual:medusa/routes/pages"
|
||||
|
||||
import { createRouteMap, settingsRouteRegex } from "../../lib/extension-helpers"
|
||||
|
||||
const pages = routes.pages
|
||||
.filter((ext) => settingsRouteRegex.test(ext.path))
|
||||
.map((ext) => ext)
|
||||
|
||||
/**
|
||||
* Settings Route extensions.
|
||||
*/
|
||||
export const SettingsExtensions = createRouteMap(pages, "/settings")
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SearchProvider } from "./search-provider"
|
||||
export { useSearch } from "./use-search"
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createContext } from "react"
|
||||
|
||||
type SearchContextValue = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
toggleSearch: () => void
|
||||
}
|
||||
|
||||
export const SearchContext = createContext<SearchContextValue | null>(null)
|
||||
@@ -0,0 +1,50 @@
|
||||
import { PropsWithChildren, useEffect, useState } from "react"
|
||||
import { Search } from "../../components/search"
|
||||
import { useSidebar } from "../sidebar-provider"
|
||||
import { SearchContext } from "./search-context"
|
||||
|
||||
export const SearchProvider = ({ children }: PropsWithChildren) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { mobile, toggle } = useSidebar()
|
||||
|
||||
const toggleSearch = () => {
|
||||
const update = !open
|
||||
|
||||
/**
|
||||
* If the mobile sidebar is open, then make sure
|
||||
* to close it when opening the search
|
||||
*/
|
||||
if (update && mobile) {
|
||||
toggle("mobile")
|
||||
}
|
||||
|
||||
setOpen(update)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
setOpen((prev) => !prev)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", onKeyDown)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SearchContext.Provider
|
||||
value={{
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
toggleSearch,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<Search />
|
||||
</SearchContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useContext } from "react"
|
||||
import { SearchContext } from "./search-context"
|
||||
|
||||
export const useSearch = () => {
|
||||
const context = useContext(SearchContext)
|
||||
if (!context) {
|
||||
throw new Error("useSearch must be used within a SearchProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./sidebar-provider"
|
||||
export * from "./use-sidebar"
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createContext } from "react"
|
||||
|
||||
type SidebarContextValue = {
|
||||
desktop: boolean
|
||||
mobile: boolean
|
||||
toggle: (view: "desktop" | "mobile") => void
|
||||
}
|
||||
|
||||
export const SidebarContext = createContext<SidebarContextValue | null>(null)
|
||||
@@ -0,0 +1,31 @@
|
||||
import { PropsWithChildren, useEffect, useState } from "react"
|
||||
import { useLocation } from "react-router-dom"
|
||||
import { SidebarContext } from "./sidebar-context"
|
||||
|
||||
export const SidebarProvider = ({ children }: PropsWithChildren) => {
|
||||
const [desktop, setDesktop] = useState(true)
|
||||
const [mobile, setMobile] = useState(false)
|
||||
|
||||
const { pathname } = useLocation()
|
||||
|
||||
const toggle = (view: "desktop" | "mobile") => {
|
||||
if (view === "desktop") {
|
||||
setDesktop(!desktop)
|
||||
} else {
|
||||
setMobile(!mobile)
|
||||
}
|
||||
}
|
||||
|
||||
// close the mobile sidebar on route change
|
||||
// this is to prevent the sidebar from staying open
|
||||
// when navigating to a new page
|
||||
useEffect(() => {
|
||||
setMobile(false)
|
||||
}, [pathname])
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={{ desktop, mobile, toggle }}>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useContext } from "react"
|
||||
import { SidebarContext } from "./sidebar-context"
|
||||
|
||||
export const useSidebar = () => {
|
||||
const context = useContext(SidebarContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type { ThemeOption as Theme } from "./theme-context"
|
||||
export * from "./theme-provider"
|
||||
export * from "./use-theme"
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createContext } from "react"
|
||||
|
||||
export type ThemeOption = "light" | "dark" | "system"
|
||||
export type ThemeValue = "light" | "dark"
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: ThemeOption
|
||||
setTheme: (theme: ThemeOption) => void
|
||||
}
|
||||
|
||||
export const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
@@ -0,0 +1,82 @@
|
||||
import { PropsWithChildren, useEffect, useState } from "react"
|
||||
import { ThemeContext, ThemeOption, ThemeValue } from "./theme-context"
|
||||
|
||||
const THEME_KEY = "medusa_admin_theme"
|
||||
|
||||
function getDefaultValue(): ThemeOption {
|
||||
const persisted = localStorage?.getItem(THEME_KEY) as ThemeOption
|
||||
|
||||
if (persisted) {
|
||||
return persisted
|
||||
}
|
||||
|
||||
return "system"
|
||||
}
|
||||
|
||||
function getThemeValue(selected: ThemeOption): ThemeValue {
|
||||
if (selected === "system") {
|
||||
if (window !== undefined) {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light"
|
||||
}
|
||||
|
||||
// Default to light theme if we can't detect the system preference
|
||||
return "light"
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
export const ThemeProvider = ({ children }: PropsWithChildren) => {
|
||||
const [state, setState] = useState<ThemeOption>(getDefaultValue())
|
||||
const [value, setValue] = useState<ThemeValue>(getThemeValue(state))
|
||||
|
||||
const setTheme = (theme: ThemeOption) => {
|
||||
localStorage.setItem(THEME_KEY, theme)
|
||||
|
||||
const themeValue = getThemeValue(theme)
|
||||
|
||||
setState(theme)
|
||||
setValue(themeValue)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const html = document.querySelector("html")
|
||||
if (html) {
|
||||
/**
|
||||
* Temporarily disable transitions to prevent
|
||||
* the theme change from flashing.
|
||||
*/
|
||||
const css = document.createElement("style")
|
||||
css.appendChild(
|
||||
document.createTextNode(
|
||||
`* {
|
||||
-webkit-transition: none !important;
|
||||
-moz-transition: none !important;
|
||||
-o-transition: none !important;
|
||||
-ms-transition: none !important;
|
||||
transition: none !important;
|
||||
}`
|
||||
)
|
||||
)
|
||||
document.head.appendChild(css)
|
||||
|
||||
html.classList.remove(value === "light" ? "dark" : "light")
|
||||
html.classList.add(value)
|
||||
|
||||
/**
|
||||
* Re-enable transitions after the theme has been set,
|
||||
* and force the browser to repaint.
|
||||
*/
|
||||
window.getComputedStyle(css).opacity
|
||||
document.head.removeChild(css)
|
||||
}
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme: state, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useContext } from "react"
|
||||
import { ThemeContext } from "./theme-context"
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext)
|
||||
if (!context) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
Reference in New Issue
Block a user