mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 16:16:37 +08:00
refactor(repo): consolidate openflare-server to root and move subprojects to internal/apps
- Merge all files inside openflare-server to the repository root directory. - Relocate agent, relay, and flared subprojects from internal/ to internal/apps/. - Combine docker-compose files and update build context paths to root. - Update GitHub workflows and Dockerfiles to refer to new directories and package names. - Rewrite Go package imports across all files. - Resolve database renew test race condition and clean up docs.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 圣诞节主题装饰组件归档
|
||||
* 包含圣诞树、圣诞帽图标以及下雪效果的集成组件
|
||||
*
|
||||
* 使用示例:
|
||||
* ```tsx
|
||||
* import { ChristmasDecorations } from "@/components/layout/avater-style/Christmas"
|
||||
*
|
||||
* function MyComponent() {
|
||||
* const [showChristmas, setShowChristmas] = useState(false)
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* {showChristmas && <ChristmasDecorations.SnowEffect />}
|
||||
* <div onClick={() => setShowChristmas(!showChristmas)}>
|
||||
* <ChristmasDecorations.Hat className="w-8 h-8" />
|
||||
* <ChristmasDecorations.Tree
|
||||
* className="w-12 h-12"
|
||||
* show={showChristmas}
|
||||
* />
|
||||
* </div>
|
||||
* </>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
import {SnowEffect} from "@/components/ui/snow-effect"
|
||||
|
||||
/**
|
||||
* 圣诞树图标组件
|
||||
*/
|
||||
export const ChristmasTree = ({
|
||||
className,
|
||||
show = true
|
||||
}: {
|
||||
className?: string
|
||||
show?: boolean
|
||||
}) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn(
|
||||
"transition-all duration-500 ease-out",
|
||||
show ? "opacity-90 scale-100 translate-y-0" : "opacity-0 scale-50 translate-y-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<rect x="10" y="18" width="4" height="4" fill="#92400E" rx="0.5" />
|
||||
<path d="M12 4L8 9H16L12 4Z" fill="#15803D" />
|
||||
<path d="M12 8L7 14H17L12 8Z" fill="#16A34A" />
|
||||
<path d="M12 12L6 19H18L12 12Z" fill="#22C55E" />
|
||||
<circle cx="10" cy="10" r="0.8" fill="#EF4444" />
|
||||
<circle cx="14" cy="11" r="0.8" fill="#F59E0B" />
|
||||
<circle cx="9" cy="15" r="0.8" fill="#3B82F6" />
|
||||
<circle cx="15" cy="16" r="0.8" fill="#EF4444" />
|
||||
<circle cx="12" cy="14" r="0.8" fill="#F59E0B" />
|
||||
<path d="M12 2L12.5 3.5L14 4L12.5 4.5L12 6L11.5 4.5L10 4L11.5 3.5L12 2Z" fill="#FBBF24" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* 圣诞帽图标组件
|
||||
*/
|
||||
export const ChristmasHat = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M12 3C12 3 5 10 5 15C5 17.5 7 19 8 19H16C17 19 19 17.5 19 15C19 10 12 3 12 3Z"
|
||||
fill="#EF4444"
|
||||
stroke="#B91C1C"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="3" r="2.5" fill="white" stroke="#E5E7EB" strokeWidth="1.5" />
|
||||
<rect x="4" y="16" width="16" height="5" rx="2.5" fill="white" stroke="#E5E7EB" strokeWidth="1.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* 圣诞节装饰集合
|
||||
* 包含所有圣诞节主题组件的命名空间
|
||||
*/
|
||||
export const ChristmasDecorations = {
|
||||
Tree: ChristmasTree,
|
||||
Hat: ChristmasHat,
|
||||
SnowEffect: SnowEffect,
|
||||
}
|
||||
|
||||
export default ChristmasDecorations
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 元旦/新年主题装饰组件归档
|
||||
* 包含灯笼、福字以及烟花效果的集成组件
|
||||
*
|
||||
* 使用示例:
|
||||
* ```tsx
|
||||
* import { NewYearDecorations } from "@/components/layout/avater-style/NewYear"
|
||||
*
|
||||
* function MyComponent() {
|
||||
* const [showNewYear, setShowNewYear] = useState(false)
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* {showNewYear && <NewYearDecorations.FireworksEffect />}
|
||||
* <div onClick={() => setShowNewYear(!showNewYear)}>
|
||||
* <NewYearDecorations.Lantern className="w-8 h-8" />
|
||||
* <NewYearDecorations.Fu
|
||||
* className="w-12 h-12"
|
||||
* show={showNewYear}
|
||||
* />
|
||||
* </div>
|
||||
* </>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
import {FireworksEffect} from "@/components/ui/fireworks-effect"
|
||||
|
||||
/**
|
||||
* 红灯笼组件 - 默认头像装饰
|
||||
*/
|
||||
export const Lantern = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
viewBox="0 0 1024 1024"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M512 80V160" stroke="#FFD700" strokeWidth="32" strokeLinecap="round" />
|
||||
|
||||
<ellipse cx="512" cy="420" rx="300" ry="260" fill="#D92424" />
|
||||
<ellipse cx="512" cy="420" rx="300" ry="260" stroke="#961C1C" strokeWidth="10" />
|
||||
|
||||
<path d="M340 180H684C700 180 712 192 712 208V220H312V208C312 192 324 180 340 180Z" fill="#FFD700" />
|
||||
<path d="M340 620H684C700 620 712 632 712 648V660H312V648C312 632 324 620 340 620Z" fill="#FFD700" />
|
||||
|
||||
<path d="M512 160C512 160 620 250 620 420C620 590 512 680 512 680" stroke="#FFD700" strokeWidth="20" strokeLinecap="round" strokeOpacity="0.4" />
|
||||
<path d="M512 160C512 160 404 250 404 420C404 590 512 680 512 680" stroke="#FFD700" strokeWidth="20" strokeLinecap="round" strokeOpacity="0.4" />
|
||||
<path d="M512 160V680" stroke="#FFD700" strokeWidth="20" strokeOpacity="0.4" />
|
||||
|
||||
<circle cx="512" cy="420" r="80" fill="#FFD700" fillOpacity="0.9" />
|
||||
<text x="512" y="450" fontSize="80" fill="#D92424" textAnchor="middle" fontWeight="bold" fontFamily="serif">福</text>
|
||||
|
||||
<path d="M512 660V720" stroke="#FFD700" strokeWidth="20" />
|
||||
<path d="M480 720H544" stroke="#D92424" strokeWidth="40" strokeLinecap="round" />
|
||||
|
||||
<path d="M492 740V900" stroke="#D92424" strokeWidth="16" strokeLinecap="round" />
|
||||
<path d="M512 740V940" stroke="#D92424" strokeWidth="16" strokeLinecap="round" />
|
||||
<path d="M532 740V900" stroke="#D92424" strokeWidth="16" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* "福"字挂件 - 点击出现的装饰
|
||||
* 一个菱形的福字牌,使用文字渲染确保准确
|
||||
*/
|
||||
export const FuCharacter = ({
|
||||
className,
|
||||
show = true
|
||||
}: {
|
||||
className?: string
|
||||
show?: boolean
|
||||
}) => (
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn(
|
||||
"transition-all duration-500 ease-out origin-center",
|
||||
show ? "opacity-100 scale-100 rotate-0" : "opacity-0 scale-50 rotate-[-15deg]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<path d="M50 0V15" stroke="#F59E0B" strokeWidth="2" />
|
||||
<g transform="rotate(25 50 50)">
|
||||
<rect x="20" y="20" width="60" height="60" rx="2" fill="#D92424" stroke="#F59E0B" strokeWidth="1.5" />
|
||||
<rect x="23" y="23" width="54" height="54" rx="1" stroke="#F59E0B" strokeWidth="1" strokeOpacity="0.5" strokeDasharray="4 2" fill="none" />
|
||||
|
||||
<text
|
||||
x="50"
|
||||
y="60"
|
||||
fontSize="32"
|
||||
fill="#FFD700"
|
||||
textAnchor="middle"
|
||||
fontWeight="bold"
|
||||
fontFamily="'YouYuan', 'Yuanti SC', 'STYuanti', 'HanyiYuanti', 'Comic Sans MS', sans-serif"
|
||||
style={{ letterSpacing: "0px" }}
|
||||
transform="rotate(130 50 50)"
|
||||
>
|
||||
福
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<path d="M50 85V95" stroke="#D92424" strokeWidth="3" />
|
||||
<circle cx="50" cy="95" r="2" fill="#F59E0B" />
|
||||
<path d="M50 95L47 100M50 95L53 100" stroke="#D92424" strokeWidth="1" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* 元旦/新年装饰集合
|
||||
*/
|
||||
export const NewYearDecorations = {
|
||||
Lantern,
|
||||
Fu: FuCharacter,
|
||||
FireworksEffect,
|
||||
}
|
||||
|
||||
export default NewYearDecorations
|
||||
@@ -0,0 +1,48 @@
|
||||
import {AvatarDecorationTheme} from "./types"
|
||||
import {ChristmasDecorations} from "./Christmas"
|
||||
import {NewYearDecorations} from "./NewYear"
|
||||
|
||||
export const DecorationThemes: Record<string, AvatarDecorationTheme> = {
|
||||
christmas: {
|
||||
key: "christmas",
|
||||
MainDecoration: ChristmasDecorations.Hat,
|
||||
InteractionDecoration: ChristmasDecorations.Tree,
|
||||
BackgroundEffect: ChristmasDecorations.SnowEffect,
|
||||
smallMainDecorationConfig: {
|
||||
className: "absolute -top-1 -left-1 w-3 h-3 z-20 rotate-[-20deg] drop-shadow-sm"
|
||||
},
|
||||
largeMainDecorationConfig: {
|
||||
className: "absolute -top-2 -left-1 w-5 h-5 z-20 rotate-[-20deg] drop-shadow-sm"
|
||||
},
|
||||
interactionDecorationConfig: {
|
||||
className: "absolute -top-2 right-2 w-12 h-12 z-0 rotate-[10deg] transition-all duration-500 ease-out"
|
||||
}
|
||||
},
|
||||
newyear: {
|
||||
key: "newyear",
|
||||
MainDecoration: NewYearDecorations.Lantern,
|
||||
InteractionDecoration: NewYearDecorations.Fu,
|
||||
BackgroundEffect: NewYearDecorations.FireworksEffect,
|
||||
smallMainDecorationConfig: {
|
||||
className: "absolute -top-2 -right-1 w-8 h-8 z-20 rotate-[10deg] drop-shadow-md pointer-events-none"
|
||||
},
|
||||
largeMainDecorationConfig: {
|
||||
className: "absolute -top-3 -right-3 w-8 h-8 z-20 rotate-[10deg] drop-shadow-md pointer-events-none"
|
||||
},
|
||||
interactionDecorationConfig: {
|
||||
className: "absolute top-6 left-4 w-10 h-10 z-20 -rotate-[5deg] transition-all duration-500 ease-out"
|
||||
}
|
||||
},
|
||||
none: {
|
||||
key: "none",
|
||||
MainDecoration: () => null,
|
||||
InteractionDecoration: () => null,
|
||||
}
|
||||
}
|
||||
|
||||
/* 可选值: 'christmas', 'newyear', 'none' */
|
||||
export const CURRENT_THEME_KEY: string = "none"
|
||||
|
||||
export const getCurrentTheme = (): AvatarDecorationTheme => {
|
||||
return DecorationThemes[CURRENT_THEME_KEY] || DecorationThemes.none
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {ComponentType} from "react"
|
||||
|
||||
export interface AvatarDecorationTheme {
|
||||
key: string
|
||||
MainDecoration: ComponentType<{ className?: string }>
|
||||
InteractionDecoration: ComponentType<{ className?: string; show?: boolean }>
|
||||
BackgroundEffect?: ComponentType
|
||||
smallMainDecorationConfig?: {
|
||||
className?: string
|
||||
}
|
||||
largeMainDecorationConfig?: {
|
||||
className?: string
|
||||
}
|
||||
interactionDecorationConfig?: {
|
||||
className?: string
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import * as React from "react"
|
||||
import {FileTextIcon} from "lucide-react"
|
||||
import {Button} from "@/components/ui/button"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
|
||||
/**
|
||||
* 空状态展示组件属性
|
||||
* 用于统一显示无数据、无内容等空状态
|
||||
*/
|
||||
export interface EmptyStateProps extends React.ComponentProps<"div"> {
|
||||
title?: string
|
||||
description?: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
actionText?: string
|
||||
onAction?: () => void
|
||||
iconSize?: "sm" | "md" | "lg"
|
||||
}
|
||||
|
||||
/**
|
||||
* 空状态展示组件
|
||||
* 用于统一显示无数据、无内容等空状态
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 基础用法
|
||||
* <EmptyState
|
||||
* title="暂无数据"
|
||||
* description="当前没有任何记录"
|
||||
* />
|
||||
*
|
||||
* // 带操作按钮
|
||||
* <EmptyState
|
||||
* title="暂无交易记录"
|
||||
* description="还没有任何交易"
|
||||
* actionText="创建交易"
|
||||
* onAction={() => router.push('/create')}
|
||||
* />
|
||||
*
|
||||
* // 自定义图标
|
||||
* <EmptyState
|
||||
* icon={BarChartIcon}
|
||||
* description="未发现提现记录"
|
||||
* />
|
||||
* ```
|
||||
* @param {EmptyStateProps} props - 空状态展示组件属性
|
||||
* @returns {React.ReactNode} 空状态展示组件
|
||||
*/
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
icon: Icon = FileTextIcon,
|
||||
actionText,
|
||||
onAction,
|
||||
className,
|
||||
iconSize = "md",
|
||||
}: EmptyStateProps) {
|
||||
const iconSizes = { sm: "size-10", md: "size-12", lg: "size-16" }
|
||||
const iconInnerSizes = { sm: "size-5", md: "size-6", lg: "size-8" }
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center justify-center py-12 text-center", className)}>
|
||||
<div className={cn(
|
||||
"rounded-full bg-muted flex items-center justify-center mb-4",
|
||||
iconSizes[iconSize],
|
||||
)}>
|
||||
<Icon className={cn("text-muted-foreground", iconInnerSizes[iconSize])} />
|
||||
</div>
|
||||
|
||||
{title && (
|
||||
<h3 className="text-base font-medium mb-1">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{onAction && actionText && (
|
||||
<Button
|
||||
onClick={onAction}
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
>
|
||||
{actionText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 带边框的空状态组件
|
||||
* 适合在卡片或容器内使用
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <EmptyStateWithBorder
|
||||
* title="暂无数据"
|
||||
* description="当前没有任何记录"
|
||||
* />
|
||||
* ```
|
||||
* @param {EmptyStateProps} props - 空状态展示组件属性
|
||||
* @returns {React.ReactNode} 带边框的空状态组件
|
||||
*/
|
||||
export function EmptyStateWithBorder(props: EmptyStateProps) {
|
||||
return (
|
||||
<div className="border border-dashed rounded-lg">
|
||||
<EmptyState {...props} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化版空状态组件(内联使用)
|
||||
* 适合在较小的区域显示
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <EmptyInline
|
||||
* message="暂无数据"
|
||||
* icon={FileTextIcon}
|
||||
* />
|
||||
* ```
|
||||
* @param {Pick<EmptyStateProps, 'icon' | 'className'> & { message?: string }} props - 简化版空状态组件属性
|
||||
* @returns {React.ReactNode} 简化版空状态组件
|
||||
*/
|
||||
export function EmptyInline({
|
||||
message = "暂无数据",
|
||||
icon: Icon = FileTextIcon,
|
||||
className,
|
||||
}: Pick<EmptyStateProps, 'icon' | 'className'> & { message?: string }) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground", className)}>
|
||||
<Icon className="size-4" />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import * as React from "react"
|
||||
import {AlertCircle} from "lucide-react"
|
||||
import {Button} from "@/components/ui/button"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
|
||||
/**
|
||||
* 错误展示组件属性
|
||||
* 用于统一显示加载失败、请求失败等错误状态
|
||||
*/
|
||||
export interface ErrorDisplayProps {
|
||||
title?: string
|
||||
message?: string
|
||||
error?: Error | null
|
||||
onRetry?: () => void
|
||||
retryText?: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
className?: string
|
||||
showStack?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误展示组件
|
||||
* 用于统一显示加载失败、请求失败等错误状态
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 基础用法
|
||||
* <ErrorDisplay
|
||||
* title="加载失败"
|
||||
* message="无法获取数据,请稍后重试"
|
||||
* />
|
||||
*
|
||||
* // 带重试按钮
|
||||
* <ErrorDisplay
|
||||
* error={error}
|
||||
* onRetry={() => refetch()}
|
||||
* />
|
||||
*
|
||||
* // 自定义样式
|
||||
* <ErrorDisplay
|
||||
* title="网络错误"
|
||||
* message="请检查您的网络连接"
|
||||
* className="min-h-[400px]"
|
||||
* />
|
||||
* ```
|
||||
* @param {ErrorDisplayProps} props - 错误展示组件属性
|
||||
* @returns {React.ReactNode} 错误展示组件
|
||||
*/
|
||||
export function ErrorDisplay({
|
||||
title = "加载失败",
|
||||
message,
|
||||
error,
|
||||
onRetry,
|
||||
retryText = "重试",
|
||||
icon: Icon = AlertCircle,
|
||||
className,
|
||||
showStack = false,
|
||||
}: ErrorDisplayProps) {
|
||||
const errorMessage = message || error?.message || "发生未知错误,请稍后重试"
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center justify-center py-12 text-center", className)}>
|
||||
<div className="size-12 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center mb-4">
|
||||
<Icon className="size-6 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold mb-2">{title}</h3>
|
||||
|
||||
<p className="text-sm text-muted-foreground max-w-md mb-4">{errorMessage}</p>
|
||||
|
||||
{onRetry && (
|
||||
<Button
|
||||
onClick={onRetry}
|
||||
variant="outline"
|
||||
className="mt-2"
|
||||
>
|
||||
{retryText}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showStack && error?.stack && process.env.NODE_ENV === 'development' && (
|
||||
<details className="mt-6 text-left max-w-2xl w-full">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
|
||||
查看详细错误信息
|
||||
</summary>
|
||||
<pre className="mt-2 p-4 bg-muted rounded-lg text-xs overflow-auto">
|
||||
{error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 区域级错误组件
|
||||
* 适合在较小的区域显示错误
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ErrorInline
|
||||
* message="发生错误"
|
||||
* error={error}
|
||||
* onRetry={() => refetch()}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function ErrorInline({
|
||||
message,
|
||||
error,
|
||||
onRetry,
|
||||
className,
|
||||
}: Omit<ErrorDisplayProps, 'title' | 'icon' | 'showStack'>) {
|
||||
const errorMessage = message || error?.message || "发生未知错误"
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2 text-sm text-red-600 dark:text-red-400", className)}>
|
||||
<AlertCircle className="size-3 shrink-0 text-destructive" />
|
||||
<span className="flex-1 text-xs">{errorMessage}</span>
|
||||
{onRetry && (
|
||||
<Button
|
||||
onClick={onRetry}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-6 px-4 text-xs"
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面级错误组件
|
||||
* 适合作为页面主要内容的错误状态
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ErrorPage
|
||||
* title="加载失败"
|
||||
* message="无法获取数据,请稍后重试"
|
||||
* error={error}
|
||||
* onRetry={() => refetch()}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function ErrorPage(props: ErrorDisplayProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[calc(100vh-8rem)]">
|
||||
<ErrorDisplay
|
||||
{...props}
|
||||
showStack={props.showStack ?? process.env.NODE_ENV === 'development'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client"
|
||||
|
||||
import {memo, useEffect, useState} from "react"
|
||||
import {AnimatePresence, motion} from "motion/react"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Bell, Info, Maximize2, Minimize2, Moon, Search, Settings, Sun} from "lucide-react"
|
||||
import {useUser} from "@/contexts/user-context"
|
||||
import {useBellRing} from "@/contexts/bell-ring-context"
|
||||
import {useNotificationSettings} from "@/contexts/notification-settings-context"
|
||||
import {SidebarTrigger} from "@/components/ui/sidebar"
|
||||
import {useTheme} from "next-themes"
|
||||
import {useRouter} from "next/navigation"
|
||||
import {SearchDialog} from "@/components/layout/search-dialog"
|
||||
import {Kbd} from "@/components/ui/kbd"
|
||||
|
||||
|
||||
/**
|
||||
* 铃铛按钮组件
|
||||
*/
|
||||
const BellButton = memo(function BellButton() {
|
||||
const { isRinging } = useBellRing()
|
||||
const { showBell, isMounted } = useNotificationSettings()
|
||||
const [isClickAnimating, setIsClickAnimating] = useState(false)
|
||||
|
||||
if (!isMounted) return null
|
||||
if (!showBell) return null
|
||||
|
||||
const handleClick = () => {
|
||||
setIsClickAnimating(true)
|
||||
setTimeout(() => setIsClickAnimating(false), 600)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-9 text-muted-foreground hover:text-foreground"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Bell
|
||||
className="size-[18px]"
|
||||
style={(isRinging || isClickAnimating) ? { animation: 'var(--animate-bell-ring)', transformOrigin: 'top center' } : undefined}
|
||||
/>
|
||||
<span className="sr-only">通知</span>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* 站点头部组件
|
||||
* 用于显示站点头部
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SiteHeader />
|
||||
* ```
|
||||
*/
|
||||
export function SiteHeader({ isFullWidth = false, onToggleFullWidth }: { isFullWidth?: boolean, onToggleFullWidth?: (value: boolean) => void }) {
|
||||
const { user } = useUser()
|
||||
const { setTheme, resolvedTheme } = useTheme()
|
||||
const router = useRouter()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
|
||||
const [metaKey, setMetaKey] = useState("⌘")
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
if (typeof navigator !== 'undefined' && !navigator.userAgent?.includes("Mac")) {
|
||||
setMetaKey("Ctrl")
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<AppBanner />
|
||||
{user?.need_change_password && (
|
||||
<div className="bg-amber-500/10 border-b border-amber-500/20 px-4 py-3 text-amber-500 text-xs sm:text-sm flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="size-4 shrink-0 text-amber-500" />
|
||||
<span>为了您的账号安全,请立即修改密码!</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-amber-500 hover:text-amber-600 border-amber-500/30 hover:border-amber-500/50 bg-transparent shrink-0 h-8 px-3 text-xs"
|
||||
onClick={() => router.push("/settings/profile")}
|
||||
>
|
||||
去修改密码
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<header className="flex h-(--header-height) shrink-0 items-center bg-background px-4 md:px-0">
|
||||
<div className="flex w-full items-center justify-between md:hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger />
|
||||
<span className="text-sm font-medium truncate max-w-[120px]">
|
||||
{user?.nickname || user?.username || "Guest"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => setSearchOpen(true)}>
|
||||
<Search className="size-[18px]" />
|
||||
<span className="sr-only">搜索</span>
|
||||
</Button>
|
||||
<BellButton />
|
||||
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => router.push('/settings')}>
|
||||
<Settings className="size-[18px]" />
|
||||
<span className="sr-only">设置</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`hidden md:flex w-full items-center gap-4 transition-all duration-300 ease-in-out ${!isFullWidth ? "max-w-[1320px]" : "max-w-full"} mx-auto md:px-8 lg:px-12`}>
|
||||
<div className="relative w-64 cursor-pointer" onClick={() => setSearchOpen(true)}>
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<div className="flex h-8 items-center rounded-md border border-border/60 bg-muted/70 pl-10 pr-2.5 text-sm text-muted-foreground transition-colors hover:border-border hover:bg-muted">
|
||||
<span>搜索</span>
|
||||
<Kbd className="ml-auto gap-0.5 font-mono">
|
||||
<span>{metaKey}</span>
|
||||
<span>K</span>
|
||||
</Kbd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<BellButton />
|
||||
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => router.push('/settings')}>
|
||||
<Settings className="size-[18px]" />
|
||||
<span className="sr-only">设置</span>
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => onToggleFullWidth?.(!isFullWidth)}>
|
||||
{isFullWidth ? <Minimize2 className="size-[18px]" /> : <Maximize2 className="size-[18px]" />}
|
||||
<span className="sr-only">切换全宽</span>
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}>
|
||||
{mounted ? (resolvedTheme === 'dark' ? <Sun className="size-[18px]" /> : <Moon className="size-[18px]" />) : <Moon className="size-[18px]" />}
|
||||
<span className="sr-only">主题切换</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mounted && <SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />}
|
||||
</header>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AppBanner() {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const bannerKey = 'ldc-banner-dismissed-script'
|
||||
|
||||
useEffect(() => {
|
||||
const dismissed = localStorage.getItem(bannerKey)
|
||||
if (!dismissed) {
|
||||
setIsVisible(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem(bannerKey, 'true')
|
||||
setIsVisible(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
className="relative flex w-full items-center justify-center bg-muted/50 overflow-hidden"
|
||||
>
|
||||
<div className="flex w-full items-center justify-center px-4 pr-10 py-2 text-xs md:text-[13px] font-medium text-muted-foreground">
|
||||
<p className="flex flex-wrap items-center justify-center gap-x-2 text-center">
|
||||
<span className="text-foreground">最新通知</span>
|
||||
<a href="https://example.com" target="_blank" className="underline underline-offset-4 hover:text-foreground">
|
||||
查看详情
|
||||
</a>
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 size-6 -translate-y-1/2 text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
<span className="sr-only">Dismiss</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="lucide lucide-x"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import * as React from "react"
|
||||
import {motion} from "motion/react"
|
||||
import {cn} from "@/lib/utils"
|
||||
import {FileTextIcon} from "lucide-react"
|
||||
|
||||
/**
|
||||
* 加载页面组件
|
||||
*
|
||||
* 用于统一显示加载状态
|
||||
* @example
|
||||
* ```tsx
|
||||
* <LoadingPage text="系统" badgeText="系统" />
|
||||
* ```
|
||||
* @param {string} text - 显示的文本内容,默认为"系统"
|
||||
* @param {string} badgeText - 显示的徽章文本,默认为"系统"
|
||||
* @returns {React.ReactNode} 加载页面组件
|
||||
*/
|
||||
|
||||
export function AppleSpinner({ className = "size-8 text-muted-foreground" }: { className?: string }) {
|
||||
const spokes = Array.from({ length: 12 })
|
||||
return (
|
||||
<div className={cn("relative flex items-center justify-center", className)}>
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
@keyframes apple-spinner-fade {
|
||||
0% { opacity: 1; }
|
||||
100% { opacity: 0.15; }
|
||||
}
|
||||
.apple-spinner-spoke {
|
||||
animation: apple-spinner-fade 1.2s linear infinite;
|
||||
}
|
||||
`}} />
|
||||
<div className="relative size-full">
|
||||
{spokes.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="apple-spinner-spoke absolute top-0 left-[46%] w-[8%] h-[28%] bg-current rounded-full"
|
||||
style={{
|
||||
transform: `rotate(${i * 30}deg)`,
|
||||
transformOrigin: "50% 178.5%",
|
||||
animationDelay: `${(i - 12) * 0.1}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载页面组件
|
||||
*
|
||||
* 用于统一显示加载状态
|
||||
*/
|
||||
export function LoadingPage(props: { text?: string; badgeText?: string }) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const _ = props
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 overflow-hidden font-sans bg-background/80 backdrop-blur-md text-foreground flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<AppleSpinner className="size-9 text-muted-foreground/80" />
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface LoadingStateProps extends React.ComponentProps<"div"> {
|
||||
title?: string
|
||||
description?: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
iconSize?: "sm" | "md" | "lg"
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载状态展示组件
|
||||
* 用于统一显示加载中的状态
|
||||
*/
|
||||
export function LoadingState({
|
||||
title = "加载中",
|
||||
description = "正在获取活动数据...",
|
||||
icon: Icon = FileTextIcon,
|
||||
className,
|
||||
iconSize = "md",
|
||||
}: LoadingStateProps) {
|
||||
const iconSizes = { sm: "size-8", md: "size-10", lg: "size-14" }
|
||||
const iconInnerSizes = { sm: "size-4", md: "size-5", lg: "size-7" }
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center justify-center py-12 text-center", className)}>
|
||||
<div className={cn(
|
||||
"rounded-full bg-muted flex items-center justify-center mb-2 animate-pulse",
|
||||
iconSizes[iconSize]
|
||||
)}>
|
||||
<Icon className={cn("text-muted-foreground", iconInnerSizes[iconSize])} />
|
||||
</div>
|
||||
|
||||
{title && (
|
||||
<h3 className="text-sm font-medium mb-1 animate-pulse">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground max-w-md animate-pulse">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 带边框的加载状态组件
|
||||
*/
|
||||
export function LoadingStateWithBorder(props: LoadingStateProps) {
|
||||
return (
|
||||
<div className="border border-dashed rounded-lg">
|
||||
<LoadingState {...props} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {usePathname} from 'next/navigation';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {ChevronRight} from 'lucide-react';
|
||||
|
||||
import {Collapsible, CollapsibleContent, CollapsibleTrigger} from '@/components/ui/collapsible';
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import type {OpenFlareNavGroup} from '@/lib/navigation/openflare-nav';
|
||||
import {
|
||||
matchesNavPath,
|
||||
openflareSidebarNav,
|
||||
isNavGroupActive,
|
||||
} from '@/lib/navigation/openflare-nav';
|
||||
|
||||
function SidebarNavGroupMenuItem({
|
||||
group,
|
||||
pathname,
|
||||
onNavigate,
|
||||
}: {
|
||||
group: OpenFlareNavGroup;
|
||||
pathname: string;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const groupActive = isNavGroupActive(pathname, group);
|
||||
const [open, setOpen] = useState(groupActive);
|
||||
|
||||
useEffect(() => {
|
||||
if (groupActive) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [groupActive]);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
asChild
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton tooltip={group.title} isActive={groupActive}>
|
||||
<group.icon />
|
||||
<span>{group.title}</span>
|
||||
<ChevronRight className="ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{group.items.map((item) => (
|
||||
<SidebarMenuSubItem key={item.title}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={matchesNavPath(pathname, item.url, item.childUrls)}
|
||||
>
|
||||
<Link href={item.url} onClick={onNavigate}>
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenFlareSidebarMenu({
|
||||
onNavigate,
|
||||
}: {
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<SidebarMenu className="gap-1">
|
||||
{openflareSidebarNav.map((entry) => {
|
||||
if (entry.kind === 'group') {
|
||||
return (
|
||||
<SidebarNavGroupMenuItem
|
||||
key={entry.title}
|
||||
group={entry}
|
||||
pathname={pathname}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={entry.title}>
|
||||
<SidebarMenuButton
|
||||
tooltip={entry.title}
|
||||
isActive={matchesNavPath(pathname, entry.url, entry.childUrls)}
|
||||
asChild
|
||||
>
|
||||
<Link href={entry.url} onClick={onNavigate}>
|
||||
<entry.icon />
|
||||
<span>{entry.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client"
|
||||
|
||||
import {usePublicConfig} from "@/hooks/use-public-config"
|
||||
|
||||
export function RobotsMeta() {
|
||||
const { config } = usePublicConfig()
|
||||
|
||||
// Default to noindex, nofollow if config is loading or if it's explicitly disabled
|
||||
const enabled = config?.search_engine_indexing_enabled === "true"
|
||||
|
||||
return (
|
||||
<meta
|
||||
name="robots"
|
||||
content={enabled ? "index, follow" : "noindex, nofollow"}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
"use client"
|
||||
|
||||
import {useCallback, useEffect, useState} from "react"
|
||||
import {useRouter} from "next/navigation"
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import {Kbd, KbdGroup} from "@/components/ui/kbd"
|
||||
import {type SearchItem, searchItems} from "@/lib/utils/search-data"
|
||||
import {FileText, Home, Settings, Shield} from "lucide-react"
|
||||
import {useUser} from "@/contexts/user-context"
|
||||
|
||||
interface SearchDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const categoryIcons = {
|
||||
page: Home,
|
||||
feature: FileText,
|
||||
setting: Settings,
|
||||
admin: Shield,
|
||||
}
|
||||
|
||||
const categoryLabels = {
|
||||
page: '页面',
|
||||
feature: '功能',
|
||||
setting: '设置',
|
||||
admin: '管理',
|
||||
}
|
||||
|
||||
const getTips = (metaKey: string) => [
|
||||
(
|
||||
<>
|
||||
<span className="text-muted-foreground/80 lowercase">Tips: 还可以使用</span>
|
||||
<Kbd className="mx-1">/</Kbd>
|
||||
<span className="text-muted-foreground/80 lowercase">来打开此界面</span>
|
||||
</>
|
||||
),
|
||||
(
|
||||
<>
|
||||
<span className="text-muted-foreground/80 lowercase">Tips: 使用</span>
|
||||
<Kbd className="mx-1">↑</Kbd>
|
||||
<Kbd className="mx-1">↓</Kbd>
|
||||
<span className="text-muted-foreground/80 lowercase">来切换选中项</span>
|
||||
</>
|
||||
),
|
||||
(
|
||||
<>
|
||||
<span className="text-muted-foreground/80 lowercase">Tips: 按住</span>
|
||||
<Kbd className="mx-1">{metaKey}</Kbd>
|
||||
<span className="text-muted-foreground/80 lowercase">+</span>
|
||||
<Kbd className="mx-1">↵</Kbd>
|
||||
<span className="text-muted-foreground/80 lowercase">在新标签页打开</span>
|
||||
</>
|
||||
),
|
||||
(
|
||||
<>
|
||||
<span className="text-muted-foreground/80 lowercase">你知道吗:搜索功能还在持续升级中</span>
|
||||
</>
|
||||
)
|
||||
]
|
||||
|
||||
export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
|
||||
const router = useRouter()
|
||||
const { user } = useUser()
|
||||
const [search, setSearch] = useState('')
|
||||
const [currentTip, setCurrentTip] = useState<React.ReactNode>(null)
|
||||
const [results, setResults] = useState<SearchItem[]>([])
|
||||
const [metaKey, setMetaKey] = useState("⌘")
|
||||
const [isCtrlPressed, setIsCtrlPressed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof navigator !== 'undefined' && !navigator.userAgent?.includes("Mac")) {
|
||||
setMetaKey("Ctrl")
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Control' || e.key === 'Meta') {
|
||||
setIsCtrlPressed(true)
|
||||
}
|
||||
}
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Control' || e.key === 'Meta') {
|
||||
setIsCtrlPressed(false)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('keyup', handleKeyUp)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const tips = getTips(metaKey)
|
||||
const randomTip = tips[Math.floor(Math.random() * tips.length)]
|
||||
setCurrentTip(randomTip)
|
||||
}
|
||||
}, [open, metaKey])
|
||||
|
||||
useEffect(() => {
|
||||
const items = searchItems(search, user?.is_admin)
|
||||
setResults(items)
|
||||
}, [search, user?.is_admin])
|
||||
|
||||
const handleSelect = useCallback((item: SearchItem, openInNewTab = false) => {
|
||||
onOpenChange(false)
|
||||
if (openInNewTab) {
|
||||
window.open(item.url, '_blank')
|
||||
} else {
|
||||
router.push(item.url)
|
||||
}
|
||||
setSearch('')
|
||||
}, [onOpenChange, router])
|
||||
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
onOpenChange(!open)
|
||||
}
|
||||
|
||||
if (e.key === '/'&& !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
const target = e.target as HTMLElement
|
||||
const isEditing = target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT' ||
|
||||
target.isContentEditable ||
|
||||
target.closest('[contenteditable="true"]')
|
||||
|
||||
if (!isEditing) {
|
||||
e.preventDefault()
|
||||
onOpenChange(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', down)
|
||||
return () => document.removeEventListener('keydown', down)
|
||||
}, [open, onOpenChange])
|
||||
|
||||
// Group results by category
|
||||
const groupedResults = results.reduce((acc, item) => {
|
||||
if (!acc[item.category]) {
|
||||
acc[item.category] = []
|
||||
}
|
||||
acc[item.category].push(item)
|
||||
return acc
|
||||
}, {} as Record<string, SearchItem[]>)
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
shouldFilter={false}
|
||||
className="max-w-[calc(100%-1.5rem)] sm:max-w-[500px] md:max-w-[540px]"
|
||||
>
|
||||
<CommandInput
|
||||
placeholder="搜索页面和功能..."
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>没有找到相关内容,换个词试试?</CommandEmpty>
|
||||
{Object.entries(groupedResults).map(([category, items]) => {
|
||||
const Icon = categoryIcons[category as keyof typeof categoryIcons]
|
||||
return (
|
||||
<CommandGroup key={category} heading={categoryLabels[category as keyof typeof categoryLabels]}>
|
||||
{items.map((item) => (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
value={item.title}
|
||||
onSelect={() => handleSelect(item, isCtrlPressed)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/70">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span>
|
||||
{item.matchRange ? (
|
||||
<>
|
||||
{item.title.substring(0, item.matchRange[0])}
|
||||
<span className="text-primary font-bold">
|
||||
{item.title.substring(item.matchRange[0], item.matchRange[1] + 1)}
|
||||
</span>
|
||||
{item.title.substring(item.matchRange[1] + 1)}
|
||||
</>
|
||||
) : (
|
||||
item.title
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate text-[11px] leading-4 text-muted-foreground">{item.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[10px] leading-none font-medium text-muted-foreground">
|
||||
{categoryLabels[item.category as keyof typeof categoryLabels]}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)
|
||||
})}
|
||||
</CommandList>
|
||||
<div className="hidden border-t bg-muted/20 px-4 py-2.5 md:flex items-center gap-4 text-[10px] text-muted-foreground uppercase tracking-wider font-medium select-none">
|
||||
<div className="flex items-center gap-1">
|
||||
<KbdGroup>
|
||||
{isCtrlPressed && <Kbd>{metaKey}</Kbd>}
|
||||
<Kbd>↵</Kbd>
|
||||
</KbdGroup>
|
||||
<span>{isCtrlPressed ? '在新标签页打开' : '打开'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Kbd>Esc</Kbd>
|
||||
<span>关闭搜索界面</span>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{currentTip}
|
||||
</div>
|
||||
</div>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import {usePathname, useRouter} from "next/navigation"
|
||||
import {APP_BUILD_DATE, APP_VERSION} from "@/lib/app-info"
|
||||
import {toast} from "sonner"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Spinner} from "@/components/ui/spinner"
|
||||
import {AnimateIcon} from "@/components/animate-ui/icons/icon"
|
||||
import {ChevronLeft} from "@/components/animate-ui/icons/chevron-left"
|
||||
import {ChevronRight} from "@/components/animate-ui/icons/chevron-right"
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {getCurrentTheme} from "@/components/layout/avater-style/registry"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Bell,
|
||||
ChevronDown,
|
||||
Code,
|
||||
CreditCard,
|
||||
Database,
|
||||
FileQuestionMark,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Layers,
|
||||
LogOut,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Terminal,
|
||||
UserRound,
|
||||
} from "lucide-react"
|
||||
|
||||
import {useUser} from "@/contexts/user-context"
|
||||
import {usePublicConfig} from "@/hooks/use-public-config"
|
||||
import {OpenFlareSidebarMenu} from "@/components/layout/openflare-sidebar-menu"
|
||||
import {openflareSidebarNav} from "@/lib/navigation/openflare-nav"
|
||||
|
||||
/* 导航数据 */
|
||||
const data = {
|
||||
admin: [
|
||||
{ title: "用户管理", url: "/admin/users", icon: UserRound },
|
||||
{ title: "任务管理", url: "/admin/tasks", icon: Layers },
|
||||
{ title: "存储管理", url: "/admin/files", icon: FolderOpen },
|
||||
{ title: "数据管理", url: "/admin/database", icon: Database },
|
||||
{ title: "通知推送", url: "/admin/push", icon: Bell },
|
||||
{ title: "系统日志", url: "/admin/logs", icon: Terminal },
|
||||
{ title: "系统配置", url: "/admin/system", icon: ShieldCheck },
|
||||
{ title: "系统设置", url: "/admin/settings", icon: Settings },
|
||||
],
|
||||
document: [
|
||||
{ title: "规范示例", url: "/admin/demo", icon: Code },
|
||||
{ title: "接口文档", url: "/docs/api", icon: CreditCard, external: true },
|
||||
{ title: "使用文档", url: "/docs/how-to-use", icon: FileText, external: true },
|
||||
],
|
||||
}
|
||||
|
||||
function parseMenuDisplayConfig(raw: string | undefined): Record<string, boolean> {
|
||||
if (!raw) return {}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}
|
||||
|
||||
return Object.entries(parsed).reduce<Record<string, boolean>>((result, [key, value]) => {
|
||||
if (typeof value === "boolean") {
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}, {})
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用侧边栏组件
|
||||
* 显示应用侧边栏
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AppSidebar />
|
||||
* ```
|
||||
* @returns {React.ReactNode} 应用侧边栏组件
|
||||
*/
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { toggleSidebar, state, isMobile, setOpenMobile } = useSidebar()
|
||||
const {user, logout, loading: userLoading} = useUser()
|
||||
const { config } = usePublicConfig()
|
||||
const [showLogoutDialog, setShowLogoutDialog] = React.useState(false)
|
||||
const [isLoggingOut, setIsLoggingOut] = React.useState(false)
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
|
||||
const currentTheme = getCurrentTheme()
|
||||
const {
|
||||
MainDecoration,
|
||||
InteractionDecoration,
|
||||
BackgroundEffect,
|
||||
smallMainDecorationConfig,
|
||||
largeMainDecorationConfig,
|
||||
interactionDecorationConfig
|
||||
} = currentTheme
|
||||
const hasAvatarDecoration = currentTheme.key !== "none"
|
||||
|
||||
const [showEffect, setShowEffect] = React.useState(false)
|
||||
const [userMenuOpen, setUserMenuOpen] = React.useState(false)
|
||||
const userMenuLastToggle = React.useRef(0)
|
||||
|
||||
const handleUserMenuOpenChange = React.useCallback((open: boolean) => {
|
||||
const now = Date.now()
|
||||
if (now - userMenuLastToggle.current < 65) return
|
||||
userMenuLastToggle.current = now
|
||||
setUserMenuOpen(open)
|
||||
}, [])
|
||||
|
||||
const handleCloseSidebar = React.useCallback(() => {
|
||||
if (isMobile) {
|
||||
setOpenMobile(false)
|
||||
}
|
||||
}, [isMobile, setOpenMobile])
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsLoggingOut(true)
|
||||
try {
|
||||
await logout()
|
||||
setShowLogoutDialog(false)
|
||||
} catch (error) {
|
||||
toast.error("登出失败", {
|
||||
description: error instanceof Error ? error.message : "登出时发生错误,请重试"
|
||||
})
|
||||
setIsLoggingOut(false)
|
||||
}
|
||||
}
|
||||
|
||||
const adminFiltered = React.useMemo(() => {
|
||||
const displayConfig = parseMenuDisplayConfig(config?.menu_display_config)
|
||||
return data.admin.filter((item) => displayConfig[item.url] !== false)
|
||||
}, [config])
|
||||
|
||||
const documentFiltered = React.useMemo(() => {
|
||||
const displayConfig = parseMenuDisplayConfig(config?.menu_display_config)
|
||||
return data.document.filter((item) => displayConfig[item.url] !== false)
|
||||
}, [config])
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasAvatarDecoration && showEffect && BackgroundEffect && <BackgroundEffect />}
|
||||
<Sidebar collapsible="icon" {...props} className="px-2 relative border-r border-border/40 group-data-[collapsible=icon]">
|
||||
<Button
|
||||
onClick={toggleSidebar}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-1/2 -right-6 w-2 h-4 text-muted-foreground hover:bg-background hidden md:flex"
|
||||
>
|
||||
{state === "expanded" ? (
|
||||
<AnimateIcon animateOnHover>
|
||||
<ChevronLeft className="size-6" />
|
||||
</AnimateIcon>
|
||||
) : (
|
||||
<AnimateIcon animateOnHover>
|
||||
<ChevronRight className="size-6" />
|
||||
</AnimateIcon>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<SidebarHeader className="py-4 md:-ml-2">
|
||||
<DropdownMenu open={userMenuOpen} onOpenChange={handleUserMenuOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex h-12 hover:bg-accent group-data-[collapsible=icon]:ml-2"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<div className="relative overflow-visible">
|
||||
{hasAvatarDecoration && <MainDecoration className={smallMainDecorationConfig?.className} />}
|
||||
<Avatar className="size-6 rounded relative z-10">
|
||||
<AvatarImage
|
||||
src={user?.avatar_url}
|
||||
alt={user?.nickname}
|
||||
/>
|
||||
<AvatarFallback className="rounded bg-muted text-sm">
|
||||
{user?.nickname?.charAt(0)?.toUpperCase() || "L"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="flex flex-col items-start flex-1 text-left group-data-[collapsible=icon]:hidden">
|
||||
<span className="text-xs font-medium truncate w-full text-left ml-2">
|
||||
{user?.nickname || user?.username || "Unknown User"}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-muted-foreground/100 truncate w-full text-left ml-2">
|
||||
{user?.is_admin ? "系统管理员" : "普通用户"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className="size-4 text-muted-foreground ml-auto group-data-[collapsible=icon]:hidden" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className={isMobile || state === "collapsed" ? "ml-1 w-68 z-[200]" : "w-64 z-[200]"}
|
||||
align="start"
|
||||
side={isMobile || state === "collapsed" ? "bottom" : "right"}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
<div className="flex flex-col items-center mb-4 gap-1 pt-4">
|
||||
<div
|
||||
className={`relative w-32 h-16 flex items-center justify-center overflow-visible select-none transition-transform ${hasAvatarDecoration ? "cursor-pointer active:scale-95" : ""}`}
|
||||
onClick={hasAvatarDecoration ? () => setShowEffect(!showEffect) : undefined}
|
||||
>
|
||||
{hasAvatarDecoration && (
|
||||
<InteractionDecoration
|
||||
show={showEffect}
|
||||
className={interactionDecorationConfig?.className}
|
||||
/>
|
||||
)}
|
||||
<div className="relative z-10">
|
||||
{hasAvatarDecoration && <MainDecoration className={largeMainDecorationConfig?.className} />}
|
||||
<Avatar className="size-14 rounded-full pointer-events-none">
|
||||
<AvatarImage
|
||||
src={user?.avatar_url}
|
||||
alt={user?.nickname}
|
||||
/>
|
||||
<AvatarFallback className="rounded-full bg-transparent text-lg">
|
||||
{user?.nickname?.charAt(0)?.toUpperCase() || "L"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-base font-semibold truncate mt-2">
|
||||
{userLoading
|
||||
? "加载中..."
|
||||
: user?.nickname || user?.username || "未登录"}
|
||||
</span>
|
||||
<span className="text-xs font-base text-muted-foreground">
|
||||
{userLoading ? "正在同步账户" : user?.is_admin ? "系统管理员" : "普通用户"}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => {
|
||||
router.push("/settings/profile")
|
||||
handleCloseSidebar()
|
||||
}}>
|
||||
<UserRound className="mr-2 size-4" />
|
||||
<span>我的资料</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => {
|
||||
router.push("/settings")
|
||||
handleCloseSidebar()
|
||||
}}>
|
||||
<Settings className="mr-2 size-4" />
|
||||
<span>设置</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="my-2" />
|
||||
<DropdownMenuItem onClick={() => {
|
||||
router.push("/docs/how-to-use")
|
||||
handleCloseSidebar()
|
||||
}}>
|
||||
<FileQuestionMark className="mr-2 size-4" />
|
||||
<span>使用帮助</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive hover:bg-destructive/50" onClick={() => setShowLogoutDialog(true)}>
|
||||
<LogOut className="mr-2 size-4 text-destructive" />
|
||||
<span>退出登录</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent className="group-data-[collapsible=icon]">
|
||||
{openflareSidebarNav.length > 0 && (
|
||||
<SidebarGroup className="py-0">
|
||||
<SidebarGroupContent className="py-1">
|
||||
<OpenFlareSidebarMenu onNavigate={handleCloseSidebar} />
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
{user?.is_admin && adminFiltered.length > 0 && (
|
||||
<SidebarGroup className="py-0 pt-4">
|
||||
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
|
||||
管理
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent className="py-1">
|
||||
<SidebarMenu className="gap-1">
|
||||
{adminFiltered.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
isActive={pathname === item.url || pathname.startsWith(`${item.url}/`)}
|
||||
asChild
|
||||
>
|
||||
<Link href={item.url} onClick={handleCloseSidebar}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
{documentFiltered.length > 0 && (
|
||||
<SidebarGroup className="py-0 pt-4">
|
||||
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
|
||||
文档库
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent className="py-1">
|
||||
<SidebarMenu className="gap-1">
|
||||
{documentFiltered.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
isActive={pathname === item.url}
|
||||
asChild
|
||||
>
|
||||
{item.external ? (
|
||||
<Link href={item.url} target="_blank" rel="noopener noreferrer" onClick={handleCloseSidebar}>
|
||||
{item.icon && <item.icon />}
|
||||
<span className="flex-1">{item.title}</span>
|
||||
<ArrowUpRight className="size-3 text-muted-foreground" />
|
||||
</Link>
|
||||
) : (
|
||||
<Link href={item.url} onClick={handleCloseSidebar}>
|
||||
{item.icon && <item.icon />}
|
||||
<span className="flex-1">{item.title}</span>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="mt-auto px-3 py-3 group-data-[collapsible=icon]:hidden">
|
||||
<div className="border-t border-border/60 pt-3 text-[11px] leading-5 text-muted-foreground">
|
||||
<div>Version {APP_VERSION}</div>
|
||||
{APP_BUILD_DATE && <div>Build At {APP_BUILD_DATE}</div>}
|
||||
</div>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
||||
<AlertDialog open={showLogoutDialog} onOpenChange={(open) => !isLoggingOut && setShowLogoutDialog(open)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认登出</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{isLoggingOut ? '正在登出,请稍候...' : '您确定要登出当前账户吗?'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoggingOut}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleLogout} disabled={isLoggingOut}>
|
||||
{isLoggingOut && <Spinner className="mr-2" />}
|
||||
{isLoggingOut ? '登出中...' : '确认登出'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {ThemeProvider as NextThemesProvider} from "next-themes"
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {motion} from "motion/react"
|
||||
|
||||
|
||||
/**
|
||||
* 页面过渡组件
|
||||
* 用于统一显示页面过渡效果
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PageTransition>
|
||||
* <div>内容</div>
|
||||
* </PageTransition>
|
||||
* ```
|
||||
* @param {React.ReactNode} children - 页面过渡组件的子元素
|
||||
* @returns {React.ReactNode} 页面过渡组件
|
||||
*/
|
||||
export function PageTransition({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user