feat 使用 shadcn-admin
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { DirectionProvider as RdxDirProvider } from '@radix-ui/react-direction'
|
||||
import { getCookie, setCookie, removeCookie } from '@/lib/cookies'
|
||||
|
||||
export type Direction = 'ltr' | 'rtl'
|
||||
|
||||
const DEFAULT_DIRECTION = 'ltr'
|
||||
const DIRECTION_COOKIE_NAME = 'dir'
|
||||
const DIRECTION_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 // 1 year
|
||||
|
||||
type DirectionContextType = {
|
||||
defaultDir: Direction
|
||||
dir: Direction
|
||||
setDir: (dir: Direction) => void
|
||||
resetDir: () => void
|
||||
}
|
||||
|
||||
const DirectionContext = createContext<DirectionContextType | null>(null)
|
||||
|
||||
export function DirectionProvider({ children }: { children: React.ReactNode }) {
|
||||
const [dir, _setDir] = useState<Direction>(
|
||||
() => (getCookie(DIRECTION_COOKIE_NAME) as Direction) || DEFAULT_DIRECTION
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const htmlElement = document.documentElement
|
||||
htmlElement.setAttribute('dir', dir)
|
||||
}, [dir])
|
||||
|
||||
const setDir = (dir: Direction) => {
|
||||
_setDir(dir)
|
||||
setCookie(DIRECTION_COOKIE_NAME, dir, DIRECTION_COOKIE_MAX_AGE)
|
||||
}
|
||||
|
||||
const resetDir = () => {
|
||||
_setDir(DEFAULT_DIRECTION)
|
||||
removeCookie(DIRECTION_COOKIE_NAME)
|
||||
}
|
||||
|
||||
return (
|
||||
<DirectionContext
|
||||
value={{
|
||||
defaultDir: DEFAULT_DIRECTION,
|
||||
dir,
|
||||
setDir,
|
||||
resetDir,
|
||||
}}
|
||||
>
|
||||
<RdxDirProvider dir={dir}>{children}</RdxDirProvider>
|
||||
</DirectionContext>
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useDirection() {
|
||||
const context = useContext(DirectionContext)
|
||||
if (!context) {
|
||||
throw new Error('useDirection must be used within a DirectionProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { fonts } from '@/config/fonts'
|
||||
import { getCookie, setCookie, removeCookie } from '@/lib/cookies'
|
||||
|
||||
type Font = (typeof fonts)[number]
|
||||
|
||||
const FONT_COOKIE_NAME = 'font'
|
||||
const FONT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 // 1 year
|
||||
|
||||
type FontContextType = {
|
||||
font: Font
|
||||
setFont: (font: Font) => void
|
||||
resetFont: () => void
|
||||
}
|
||||
|
||||
const FontContext = createContext<FontContextType | null>(null)
|
||||
|
||||
export function FontProvider({ children }: { children: React.ReactNode }) {
|
||||
const [font, _setFont] = useState<Font>(() => {
|
||||
const savedFont = getCookie(FONT_COOKIE_NAME)
|
||||
return fonts.includes(savedFont as Font) ? (savedFont as Font) : fonts[0]
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const applyFont = (font: string) => {
|
||||
const root = document.documentElement
|
||||
root.classList.forEach((cls) => {
|
||||
if (cls.startsWith('font-')) root.classList.remove(cls)
|
||||
})
|
||||
root.classList.add(`font-${font}`)
|
||||
}
|
||||
|
||||
applyFont(font)
|
||||
}, [font])
|
||||
|
||||
const setFont = (font: Font) => {
|
||||
setCookie(FONT_COOKIE_NAME, font, FONT_COOKIE_MAX_AGE)
|
||||
_setFont(font)
|
||||
}
|
||||
|
||||
const resetFont = () => {
|
||||
removeCookie(FONT_COOKIE_NAME)
|
||||
_setFont(fonts[0])
|
||||
}
|
||||
|
||||
return (
|
||||
<FontContext value={{ font, setFont, resetFont }}>{children}</FontContext>
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const useFont = () => {
|
||||
const context = useContext(FontContext)
|
||||
if (!context) {
|
||||
throw new Error('useFont must be used within a FontProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createContext, useContext, useState } from 'react'
|
||||
import { getCookie, setCookie } from '@/lib/cookies'
|
||||
|
||||
export type Collapsible = 'offcanvas' | 'icon' | 'none'
|
||||
type Variant = 'inset' | 'sidebar' | 'floating'
|
||||
|
||||
// Cookie constants following the pattern from sidebar.tsx
|
||||
const LAYOUT_COLLAPSIBLE_COOKIE_NAME = 'layout_collapsible'
|
||||
const LAYOUT_VARIANT_COOKIE_NAME = 'layout_variant'
|
||||
const LAYOUT_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 // 7 days
|
||||
|
||||
// Default values
|
||||
const DEFAULT_VARIANT = 'inset'
|
||||
const DEFAULT_COLLAPSIBLE = 'icon'
|
||||
|
||||
type LayoutContextType = {
|
||||
resetLayout: () => void
|
||||
|
||||
defaultCollapsible: Collapsible
|
||||
collapsible: Collapsible
|
||||
setCollapsible: (collapsible: Collapsible) => void
|
||||
|
||||
defaultVariant: Variant
|
||||
variant: Variant
|
||||
setVariant: (variant: Variant) => void
|
||||
}
|
||||
|
||||
const LayoutContext = createContext<LayoutContextType | null>(null)
|
||||
|
||||
type LayoutProviderProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function LayoutProvider({ children }: LayoutProviderProps) {
|
||||
const [collapsible, _setCollapsible] = useState<Collapsible>(() => {
|
||||
const saved = getCookie(LAYOUT_COLLAPSIBLE_COOKIE_NAME)
|
||||
return (saved as Collapsible) || DEFAULT_COLLAPSIBLE
|
||||
})
|
||||
|
||||
const [variant, _setVariant] = useState<Variant>(() => {
|
||||
const saved = getCookie(LAYOUT_VARIANT_COOKIE_NAME)
|
||||
return (saved as Variant) || DEFAULT_VARIANT
|
||||
})
|
||||
|
||||
const setCollapsible = (newCollapsible: Collapsible) => {
|
||||
_setCollapsible(newCollapsible)
|
||||
setCookie(
|
||||
LAYOUT_COLLAPSIBLE_COOKIE_NAME,
|
||||
newCollapsible,
|
||||
LAYOUT_COOKIE_MAX_AGE
|
||||
)
|
||||
}
|
||||
|
||||
const setVariant = (newVariant: Variant) => {
|
||||
_setVariant(newVariant)
|
||||
setCookie(LAYOUT_VARIANT_COOKIE_NAME, newVariant, LAYOUT_COOKIE_MAX_AGE)
|
||||
}
|
||||
|
||||
const resetLayout = () => {
|
||||
setCollapsible(DEFAULT_COLLAPSIBLE)
|
||||
setVariant(DEFAULT_VARIANT)
|
||||
}
|
||||
|
||||
const contextValue: LayoutContextType = {
|
||||
resetLayout,
|
||||
defaultCollapsible: DEFAULT_COLLAPSIBLE,
|
||||
collapsible,
|
||||
setCollapsible,
|
||||
defaultVariant: DEFAULT_VARIANT,
|
||||
variant,
|
||||
setVariant,
|
||||
}
|
||||
|
||||
return <LayoutContext value={contextValue}>{children}</LayoutContext>
|
||||
}
|
||||
|
||||
// Define the hook for the provider
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useLayout() {
|
||||
const context = useContext(LayoutContext)
|
||||
if (!context) {
|
||||
throw new Error('useLayout must be used within a LayoutProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { CommandMenu } from '@/components/command-menu'
|
||||
|
||||
type SearchContextType = {
|
||||
open: boolean
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
const SearchContext = createContext<SearchContextType | null>(null)
|
||||
|
||||
type SearchProviderProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function SearchProvider({ children }: SearchProviderProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
setOpen((open) => !open)
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', down)
|
||||
return () => document.removeEventListener('keydown', down)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SearchContext value={{ open, setOpen }}>
|
||||
{children}
|
||||
<CommandMenu />
|
||||
</SearchContext>
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const useSearch = () => {
|
||||
const searchContext = useContext(SearchContext)
|
||||
|
||||
if (!searchContext) {
|
||||
throw new Error('useSearch has to be used within SearchProvider')
|
||||
}
|
||||
|
||||
return searchContext
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { createContext, useContext, useEffect, useState, useMemo } from 'react'
|
||||
import { getCookie, setCookie, removeCookie } from '@/lib/cookies'
|
||||
|
||||
type Theme = 'dark' | 'light' | 'system'
|
||||
type ResolvedTheme = Exclude<Theme, 'system'>
|
||||
|
||||
const DEFAULT_THEME = 'system'
|
||||
const THEME_COOKIE_NAME = 'vite-ui-theme'
|
||||
const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 // 1 year
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
type ThemeProviderState = {
|
||||
defaultTheme: Theme
|
||||
resolvedTheme: ResolvedTheme
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
resetTheme: () => void
|
||||
}
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
defaultTheme: DEFAULT_THEME,
|
||||
resolvedTheme: 'light',
|
||||
theme: DEFAULT_THEME,
|
||||
setTheme: () => null,
|
||||
resetTheme: () => null,
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeProviderState>(initialState)
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = DEFAULT_THEME,
|
||||
storageKey = THEME_COOKIE_NAME,
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, _setTheme] = useState<Theme>(
|
||||
() => (getCookie(storageKey) as Theme) || defaultTheme
|
||||
)
|
||||
|
||||
// Optimized: Memoize the resolved theme calculation to prevent unnecessary re-computations
|
||||
const resolvedTheme = useMemo((): ResolvedTheme => {
|
||||
if (theme === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
}
|
||||
return theme as ResolvedTheme
|
||||
}, [theme])
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
const applyTheme = (currentResolvedTheme: ResolvedTheme) => {
|
||||
root.classList.remove('light', 'dark') // Remove existing theme classes
|
||||
root.classList.add(currentResolvedTheme) // Add the new theme class
|
||||
}
|
||||
|
||||
const handleChange = () => {
|
||||
if (theme === 'system') {
|
||||
const systemTheme = mediaQuery.matches ? 'dark' : 'light'
|
||||
applyTheme(systemTheme)
|
||||
}
|
||||
}
|
||||
|
||||
applyTheme(resolvedTheme)
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
|
||||
return () => mediaQuery.removeEventListener('change', handleChange)
|
||||
}, [theme, resolvedTheme])
|
||||
|
||||
const setTheme = (theme: Theme) => {
|
||||
setCookie(storageKey, theme, THEME_COOKIE_MAX_AGE)
|
||||
_setTheme(theme)
|
||||
}
|
||||
|
||||
const resetTheme = () => {
|
||||
removeCookie(storageKey)
|
||||
_setTheme(DEFAULT_THEME)
|
||||
}
|
||||
|
||||
const contextValue = {
|
||||
defaultTheme,
|
||||
resolvedTheme,
|
||||
resetTheme,
|
||||
theme,
|
||||
setTheme,
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext value={contextValue} {...props}>
|
||||
{children}
|
||||
</ThemeContext>
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
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