feat 使用 shadcn-admin

This commit is contained in:
PoRi
2026-04-25 17:09:20 +08:00
parent 88fa2666d2
commit 6cf1681a42
159 changed files with 11459 additions and 2989 deletions
+61
View File
@@ -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
}
+58
View File
@@ -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
}
+85
View File
@@ -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
}
+46
View File
@@ -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
}
+110
View File
@@ -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
}