实现响应式导航栏组件
This commit is contained in:
@@ -8,7 +8,7 @@ import Image from 'next/image'
|
||||
import alipay from '@/components/composites/purchase/_assets/alipay.svg'
|
||||
import wechat from '@/components/composites/purchase/_assets/wechat.svg'
|
||||
import balance from '@/components/composites/purchase/_assets/balance.svg'
|
||||
import {useProfileStore} from '@/components/providers/StoreProvider'
|
||||
import {useProfileStore} from '@/app/stores'
|
||||
import RechargeModal from '@/components/composites/recharge'
|
||||
import Pay from '@/components/composites/purchase/pay'
|
||||
import {buttonVariants} from '@/components/ui/button'
|
||||
|
||||
@@ -6,7 +6,7 @@ import wechat from './_assets/wechat.svg'
|
||||
import balance from './_assets/balance.svg'
|
||||
import Image from 'next/image'
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import {useProfileStore} from '@/components/providers/StoreProvider'
|
||||
import {useProfileStore} from '@/app/stores'
|
||||
import {Alert, AlertTitle} from '@/components/ui/alert'
|
||||
import {ApiResponse, ExtraResp, ExtraReq} from '@/lib/api'
|
||||
import {toast} from 'sonner'
|
||||
|
||||
@@ -8,7 +8,7 @@ import Image from 'next/image'
|
||||
import alipay from '@/components/composites/purchase/_assets/alipay.svg'
|
||||
import wechat from '@/components/composites/purchase/_assets/wechat.svg'
|
||||
import balance from '@/components/composites/purchase/_assets/balance.svg'
|
||||
import {useProfileStore} from '@/components/providers/StoreProvider'
|
||||
import {useProfileStore} from '@/app/stores'
|
||||
import RechargeModal from '@/components/composites/recharge'
|
||||
import {buttonVariants} from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
|
||||
@@ -21,7 +21,7 @@ import {useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {Loader} from 'lucide-react'
|
||||
import {RechargeByPay, RechargeByAlipayConfirm, RechargeByWechat, RechargeByWechatConfirm} from '@/actions/user'
|
||||
import * as qrcode from 'qrcode'
|
||||
import {useProfileStore} from '@/components/providers/StoreProvider'
|
||||
import {useProfileStore} from '@/app/stores'
|
||||
import {merge} from '@/lib/utils'
|
||||
import {
|
||||
Platform,
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
'use client'
|
||||
import {useProfileStore} from '@/components/providers/StoreProvider'
|
||||
import {useProfileStore} from '@/app/stores'
|
||||
import {Button} from '@/components/ui/button'
|
||||
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@/components/ui/dropdown-menu'
|
||||
import {Avatar, AvatarFallback, AvatarImage} from '@/components/ui/avatar'
|
||||
import {LogOutIcon, UserIcon, UserPenIcon} from 'lucide-react'
|
||||
import {LoaderIcon, LogOutIcon, UserIcon, UserPenIcon} from 'lucide-react'
|
||||
import {usePathname, useRouter} from 'next/navigation'
|
||||
import {logout} from '@/actions/auth'
|
||||
import {useState} from 'react'
|
||||
import {HoverCard, HoverCardContent, HoverCardTrigger} from '@/components/ui/hover-card'
|
||||
import {User} from '@/lib/models'
|
||||
|
||||
export default function UserCenter() {
|
||||
type UserCenterProps = {
|
||||
profile: User
|
||||
}
|
||||
|
||||
export default function UserCenter(props: UserCenterProps) {
|
||||
const router = useRouter()
|
||||
|
||||
// 登录控制
|
||||
const profile = useProfileStore(store => store.profile)
|
||||
const refreshProfile = useProfileStore(store => store.refreshProfile)
|
||||
const doLogout = async () => {
|
||||
const resp = await logout()
|
||||
@@ -31,13 +33,7 @@ export default function UserCenter() {
|
||||
}
|
||||
}
|
||||
|
||||
return !profile ? (
|
||||
<Button
|
||||
theme="fail"
|
||||
onClick={() => router.push('/login')}>
|
||||
去登录
|
||||
</Button>
|
||||
) : (
|
||||
return (
|
||||
<HoverCard openDelay={150} closeDelay={150}>
|
||||
<HoverCardTrigger>
|
||||
<Button
|
||||
@@ -46,10 +42,10 @@ export default function UserCenter() {
|
||||
onClick={toAdminPage}
|
||||
>
|
||||
<Avatar>
|
||||
<AvatarImage src={profile.avatar} alt="avatar"/>
|
||||
<AvatarImage src={props.profile.avatar} alt="avatar"/>
|
||||
<AvatarFallback className="bg-primary-muted"><UserIcon/></AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{profile.name}</span>
|
||||
<span>{props.profile.name}</span>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-36 p-1" align="end">
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
'use client'
|
||||
import {User} from '@/lib/models'
|
||||
import {createContext, ReactNode, useContext, useRef} from 'react'
|
||||
import {StoreApi} from 'zustand/vanilla'
|
||||
import {useStore} from 'zustand/react'
|
||||
import {createProfileStore, ProfileStore} from '@/lib/stores/profile'
|
||||
import {createLayoutStore, LayoutStore} from '@/lib/stores/layout'
|
||||
|
||||
export type StoreContextType = {
|
||||
profile: StoreApi<ProfileStore>
|
||||
layout: StoreApi<LayoutStore>
|
||||
}
|
||||
|
||||
export const StoreContext = createContext<StoreContextType | null>(null)
|
||||
|
||||
export type ProfileProviderProps = {
|
||||
user: User | null
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function StoreProvider(props: ProfileProviderProps) {
|
||||
const profile = useRef<StoreApi<ProfileStore>>(null)
|
||||
if (!profile.current) {
|
||||
console.log('📦 create profile store')
|
||||
profile.current = createProfileStore(props.user)
|
||||
}
|
||||
|
||||
const layout = useRef<StoreApi<LayoutStore>>(null)
|
||||
if (!layout.current) {
|
||||
console.log('📦 create layout store')
|
||||
const expand = window ? window.matchMedia(`(min-width: 1024px)`).matches : true
|
||||
layout.current = createLayoutStore(expand)
|
||||
}
|
||||
|
||||
return (
|
||||
<StoreContext.Provider value={{
|
||||
profile: profile.current,
|
||||
layout: layout.current,
|
||||
}}>
|
||||
{props.children}
|
||||
</StoreContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useProfileStore<T>(selector: (store: ProfileStore) => T) {
|
||||
const ctx = useContext(StoreContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useProfileStore must be used within a StoreProvider')
|
||||
}
|
||||
return useStore(ctx.profile, selector)
|
||||
}
|
||||
|
||||
export function useLayoutStore<T>(selector: (store: LayoutStore) => T) {
|
||||
const ctx = useContext(StoreContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useLayoutStore must be used within a StoreProvider')
|
||||
}
|
||||
return useStore(ctx.layout, selector)
|
||||
}
|
||||
@@ -4,21 +4,22 @@ import {cva, VariantProps} from 'class-variance-authority'
|
||||
|
||||
export const buttonVariants = cva(
|
||||
[
|
||||
`transition-all duration-200 ease-in-out`,
|
||||
`h-10 px-4 rounded-md cursor-pointer whitespace-nowrap`,
|
||||
'inline-flex items-center justify-center gap-2',
|
||||
`transition-all duration-200 ease-in-out`, // 过渡动画
|
||||
`h-10 px-4 rounded-md cursor-pointer whitespace-nowrap`, // 样式
|
||||
'outline-none focus-visible:ring-4 ring-blue-200', // 焦点样式
|
||||
'disabled:pointer-events-none disabled:opacity-50 ', // 禁用样式
|
||||
'aria-invalid:ring-fail/20 dark:aria-invalid:ring-fail/40 aria-invalid:border-fail', // 无效状态样式
|
||||
'inline-flex items-center justify-center gap-2', // 布局
|
||||
'[&_svg]:pointer-events-none [&_svg:not([class*="size-"])]:size-4 shrink-0 [&_svg]:shrink-0 ',
|
||||
'outline-none focus-visible:ring-4 ring-blue-200',
|
||||
'disabled:pointer-events-none disabled:opacity-50 ',
|
||||
'aria-invalid:ring-fail/20 dark:aria-invalid:ring-fail/40 aria-invalid:border-fail',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
theme: {
|
||||
gradient: 'bg-gradient-to-r from-blue-400 to-cyan-300 text-white ring-offset-2',
|
||||
gradient: 'bg-gradient-to-r from-blue-400 to-cyan-300 text-white ring-offset-2 hover:from-blue-500 hover:to-cyan-400',
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
outline: 'border bg-background hover:bg-secondary hover:text-secondary-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
ghost: 'text-foreground bg-transparent',
|
||||
text: '',
|
||||
accent: 'bg-accent text-accent-foreground hover:bg-accent/90',
|
||||
fail: 'bg-fail text-white hover:bg-fail/90',
|
||||
warn: '',
|
||||
@@ -70,6 +71,11 @@ export const buttonVariants = cva(
|
||||
color: 'fail',
|
||||
className: 'hover:bg-fail/10 text-fail',
|
||||
},
|
||||
{
|
||||
theme: 'text',
|
||||
color: 'primary',
|
||||
className: 'hover:text-primary',
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
theme: 'default',
|
||||
|
||||
126
src/components/ui/navigation-menu.tsx
Normal file
126
src/components/ui/navigation-menu.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import {ComponentProps, ReactNode} from 'react'
|
||||
import Link from 'next/link'
|
||||
import * as Primitive from '@radix-ui/react-navigation-menu'
|
||||
import {ChevronDownIcon, ChevronUpIcon} from 'lucide-react'
|
||||
import {Button, buttonVariants} from './button'
|
||||
import {merge} from '@/lib/utils'
|
||||
|
||||
const NavigationMenuItemStyle = buttonVariants({
|
||||
theme: 'text',
|
||||
color: `primary`,
|
||||
className: 'h-full rounded-none',
|
||||
})
|
||||
|
||||
function Navigation(props: ComponentProps<typeof Primitive.Root>) {
|
||||
return <Primitive.Root {...props} delayDuration={0} skipDelayDuration={0}/>
|
||||
}
|
||||
|
||||
function NavigationGroup(props: ComponentProps<typeof Primitive.List>) {
|
||||
return <Primitive.List {...props} className={merge('h-full flex items-stretch', props.className)}/>
|
||||
}
|
||||
|
||||
function NavigationItem(props: ComponentProps<typeof Primitive.Item>) {
|
||||
return <Primitive.Item {...props}/>
|
||||
}
|
||||
|
||||
function NavigationLink(props: {
|
||||
text: ReactNode
|
||||
href: string
|
||||
classNameOverride?: string
|
||||
}) {
|
||||
return (
|
||||
<Primitive.Link asChild className={merge(props.classNameOverride ?? NavigationMenuItemStyle, 'text-lg')}>
|
||||
<Link href={props.href}>
|
||||
{props.text}
|
||||
</Link>
|
||||
</Primitive.Link>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationTrigger(props: {
|
||||
suffix?: boolean
|
||||
text: ReactNode
|
||||
}) {
|
||||
const suffix = props.suffix ?? true
|
||||
|
||||
return (
|
||||
<Primitive.Trigger asChild>
|
||||
<Button theme="text" className="text-lg gap-2 h-full group/trigger">
|
||||
{props.text}
|
||||
{suffix && (
|
||||
<ChevronUpIcon
|
||||
className="size-4 transition-transform duration-150 ease-in-out rotate-0 group-data-[state=open]/trigger:rotate-180"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</Primitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationItemContent(props: ComponentProps<typeof Primitive.Content>) {
|
||||
return (
|
||||
<Primitive.Content {...props}/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationLinkItem(props: {
|
||||
href: string
|
||||
text: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Primitive.Item>
|
||||
<NavigationLink {...props}/>
|
||||
</Primitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationTriggerItem(props: {
|
||||
text: ReactNode
|
||||
suffix?: boolean
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Primitive.Item>
|
||||
<NavigationTrigger text={props.text} suffix={props.suffix}/>
|
||||
<NavigationItemContent className={props.className}>
|
||||
{props.children}
|
||||
</NavigationItemContent>
|
||||
</Primitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationIndicator() {
|
||||
return (
|
||||
<Primitive.Indicator className={merge(
|
||||
'w-full h-1 rounded-xs bg-primary z-10 top-[calc(100%-4px)]',
|
||||
'transition-transform duration-150 ease-out',
|
||||
'data-[state=visible]:animate-fadein',
|
||||
'data-[state=hidden]:animate-fadeout',
|
||||
)}/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuViewport(props: ComponentProps<typeof Primitive.Viewport>) {
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<Primitive.Viewport
|
||||
{...props}
|
||||
className="data-[state=open]:animate-fadein data-[state=closed]:animate-fadeout"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Navigation,
|
||||
NavigationGroup,
|
||||
NavigationItem,
|
||||
NavigationItemContent,
|
||||
NavigationTrigger,
|
||||
NavigationLink,
|
||||
NavigationIndicator,
|
||||
NavigationMenuViewport,
|
||||
NavigationLinkItem,
|
||||
NavigationTriggerItem,
|
||||
}
|
||||
Reference in New Issue
Block a user