This commit is contained in:
eric
2025-11-13 05:05:53 +08:00
parent cbdc65f56e
commit 891af61146
691 changed files with 83952 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
interface NavigationButtonProps {
id: string
title: string
icon: string
items?: Array<{
title: string
href: string
}>
}
export function NavigationButton({ id, title, icon, items }: NavigationButtonProps) {
const [isExpanded, setIsExpanded] = useState(false)
if (!items?.length) {
return (
<Link href={`#${id}`} className="flex items-center w-full">
<i className={icon}></i>
<span>{title}</span>
</Link>
)
}
return (
<div>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center w-full"
>
<div className="menu-title">
<i className={icon}></i>
<span>{title}</span>
</div>
<i className={`fas fa-angle-${isExpanded ? 'down' : 'right'} menu-arrow`}></i>
</button>
{isExpanded && (
<ul>
{items.map(subItem => (
<li key={subItem.href}>
<Link href={subItem.href}>
<span>{subItem.title}</span>
</Link>
</li>
))}
</ul>
)}
</div>
)
}

View File

@@ -0,0 +1,41 @@
'use client'
import { NavigationToggle } from './NavigationToggle'
import { NavigationLink } from './NavigationLink'
import type { NavigationSubItem } from '@/types/navigation'
interface NavigationItemProps {
id: string
title: string
icon?: string
items?: NavigationSubItem[]
}
export function NavigationItem({ id, title, icon, items = [] }: NavigationItemProps) {
if (!items?.length) {
return (
<li>
<NavigationLink href={`#${id}`}>
<i className={icon}></i>
<span>{title}</span>
</NavigationLink>
</li>
)
}
return (
<li>
<NavigationToggle icon={icon} title={title}>
<ul>
{items.map(subItem => (
<li key={subItem.href}>
<NavigationLink href={subItem.href}>
<span>{subItem.title}</span>
</NavigationLink>
</li>
))}
</ul>
</NavigationToggle>
</li>
)
}

View File

@@ -0,0 +1,16 @@
'use client'
import Link from 'next/link'
interface NavigationLinkProps {
href: string
children: React.ReactNode
}
export function NavigationLink({ href, children }: NavigationLinkProps) {
return (
<Link href={href} className="flex items-center w-full">
{children}
</Link>
)
}

View File

@@ -0,0 +1,38 @@
'use client'
import { ScrollArea } from "@/components/ui/scroll-area"
import { NavigationItem } from './NavigationItem'
import type { NavigationItem as NavigationItemType, NavigationSubItem } from '@/types/navigation'
interface NavigationSidebarProps {
navigationItems: NavigationItemType[]
}
export function NavigationSidebar({ navigationItems }: NavigationSidebarProps) {
return (
<div className="pb-12 hidden lg:block">
<div className="space-y-4 py-4">
<div className="px-3 py-2">
<h2 className="mb-2 px-4 text-lg font-semibold tracking-tight">
导航菜单
</h2>
<ScrollArea className="h-[calc(100vh-8rem)]">
<div className="space-y-1">
<ul className="px-2">
{navigationItems.map((item) => (
<NavigationItem
key={item.id}
id={item.id}
title={item.title}
icon={item.icon}
items={item.items}
/>
))}
</ul>
</div>
</ScrollArea>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,29 @@
'use client'
import { useState } from 'react'
interface NavigationToggleProps {
icon?: string
title: string
children: React.ReactNode
}
export function NavigationToggle({ icon, title, children }: NavigationToggleProps) {
const [isExpanded, setIsExpanded] = useState(false)
return (
<div>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center w-full"
>
<div className="menu-title">
{icon && <i className={icon}></i>}
<span>{title}</span>
</div>
<i className={`fas fa-angle-${isExpanded ? 'down' : 'right'} menu-arrow`}></i>
</button>
{isExpanded && children}
</div>
)
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,53 @@
import { cn } from "@/lib/utils"
import Image from "next/image"
import Link from "next/link"
import { resolveIconPath } from "@/types/navigation"
interface ResourceCardProps {
title: string
description?: string
icon?: string
url: string
className?: string
}
export default function ResourceCard({
title,
description,
icon,
url,
className
}: ResourceCardProps) {
// 使用 resolveIconPath 解析图标路径
const resolvedIcon = resolveIconPath(icon)
return (
<Link href={url} target="_blank" className={cn("block", className)}>
<div className="group relative rounded-lg border p-6 hover:border-foreground">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
{resolvedIcon && (
<div className="relative h-8 w-8">
<Image
src={resolvedIcon}
alt={title}
className="object-contain"
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
onError={(e) => {
console.warn(`Failed to load icon for ${title}:`, resolvedIcon)
e.currentTarget.style.display = 'none'
}}
/>
</div>
)}
<h3 className="font-semibold">{title}</h3>
</div>
</div>
{description && (
<p className="text-sm text-muted-foreground line-clamp-3">{description}</p>
)}
</div>
</Link>
)
}

View File

@@ -0,0 +1,68 @@
'use client'
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs"
import ResourceCard from './ResourceCard'
import type { ResourceSection } from '@/types/navigation'
interface ResourceTabsProps {
resourceSections: ResourceSection[]
}
export function ResourceTabs({ resourceSections }: ResourceTabsProps) {
return (
<div className="col-span-3 lg:col-span-4 lg:border-l">
<div className="h-full px-4 py-6 lg:px-8">
<Tabs defaultValue="all" className="h-full space-y-6">
<div className="space-between flex items-center">
<TabsList>
<TabsTrigger value="all">全部</TabsTrigger>
<TabsTrigger value="tools">工具</TabsTrigger>
<TabsTrigger value="resources">资源</TabsTrigger>
</TabsList>
</div>
<TabsContent
value="all"
className="border-none p-0 outline-none"
>
<div className="space-y-8">
{resourceSections.map((section) => (
<div key={section.id} id={section.id}>
<div className="flex items-center justify-between">
<div className="space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">
{section.title}
</h2>
<p className="text-sm text-muted-foreground">
精选推荐的{section.title}资源
</p>
</div>
</div>
<Separator className="my-4" />
<ScrollArea>
<div className="flex space-x-4 pb-4">
{section.items.map(item => (
<ResourceCard
key={item.url}
{...item}
className="w-[250px]"
/>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
))}
</div>
</TabsContent>
</Tabs>
</div>
</div>
)
}

View File

@@ -0,0 +1,62 @@
"use client"
import { useEffect, useState } from 'react'
export function ScrollToTop() {
const [isVisible, setIsVisible] = useState(false)
const toggleVisibility = () => {
if (window.scrollY > 300) {
setIsVisible(true)
} else {
setIsVisible(false)
}
}
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth'
})
}
useEffect(() => {
window.addEventListener('scroll', toggleVisibility)
return () => {
window.removeEventListener('scroll', toggleVisibility)
}
}, [])
return (
<>
{isVisible && (
<button
onClick={scrollToTop}
className="fixed bottom-8 right-8 p-3 rounded-full shadow-lg
bg-white dark:bg-gray-800
hover:bg-gray-100 dark:hover:bg-gray-700
text-gray-800 dark:text-gray-200
ring-1 ring-gray-200 dark:ring-gray-700
transition-all duration-300
z-50"
aria-label="回到顶部"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 10l7-7m0 0l7 7m-7-7v18"
/>
</svg>
</button>
)}
</>
)
}

View File

@@ -0,0 +1,7 @@
export function SiteFooter() {
return (
<footer className="text-center py-4 text-sm text-gray-500">
<p>© 2024 编程爱好者网址导航. All rights reserved.</p>
</footer>
)
}

27
components/SiteHeader.tsx Normal file
View File

@@ -0,0 +1,27 @@
import Image from 'next/image'
import Link from 'next/link'
export function SiteHeader() {
return (
<header className="logo-env">
<div className="logo">
<Link href="/">
<Image
src="/assets/images/logo@2x.png"
alt="Logo"
width={160}
height={64}
className="logo-expanded"
/>
<Image
src="/assets/images/logo-collapsed@2x.png"
alt="Logo"
width={48}
height={48}
className="logo-collapsed"
/>
</Link>
</div>
</header>
)
}

View File

@@ -0,0 +1,37 @@
"use client"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import { Moon, Sun } from "lucide-react"
import { useEffect, useState } from "react"
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
// 等待客户端挂载完成
useEffect(() => {
setMounted(true)
}, [])
// 在客户端挂载完成前不渲染任何内容
if (!mounted) {
return null
}
return (
<Button
variant="ghost"
size="icon"
className="fixed bottom-20 right-4 h-10 w-10 rounded-full shadow-lg hover:shadow-xl transition-all duration-300 bg-primary hover:bg-primary/90"
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
>
{theme === "light" ? (
<Moon className="h-[1.2rem] w-[1.2rem] text-white" />
) : (
<Sun className="h-[1.2rem] w-[1.2rem] text-white" />
)}
<span className="sr-only">切换主题</span>
</Button>
)
}

View File

@@ -0,0 +1,25 @@
import Link from "next/link"
import { cn } from "@/lib/utils"
import Image from "next/image"
export function MainNav({
className,
...props
}: React.HTMLAttributes<HTMLElement>) {
return (
<nav
className={cn("flex items-center space-x-4 lg:space-x-6", className)}
{...props}
>
<Link href="/admin" className="flex items-center space-x-2">
<Image
src="/assets/images/logo@2x.png"
alt="Logo"
width={120}
height={40}
className="h-8 w-auto"
/>
</Link>
</nav>
)
}

View File

@@ -0,0 +1,14 @@
import { Input } from "@/components/ui/input"
import { Search as SearchIcon } from "lucide-react"
export function Search() {
return (
<div className="relative w-64">
<SearchIcon className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索..."
className="pl-8"
/>
</div>
)
}

View File

@@ -0,0 +1,71 @@
import Link from "next/link"
import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
LayoutDashboard,
Settings,
Menu as MenuIcon,
Database
} from "lucide-react"
const sidebarNavItems = [
{
title: "控制台",
href: "/admin",
icon: LayoutDashboard,
},
{
title: "站点设置",
href: "/admin/site",
icon: Settings,
},
{
title: "导航管理",
href: "/admin/navigation",
icon: MenuIcon,
},
{
title: "资源管理",
href: "/admin/resources",
icon: Database,
},
]
interface SideNavProps extends React.HTMLAttributes<HTMLDivElement> {}
export function SideNav({ className, ...props }: SideNavProps) {
const pathname = usePathname()
return (
<div className={cn("pb-12 border-r h-screen", className)} {...props}>
<div className="space-y-4 py-4">
<div className="px-3 py-2">
<div className="space-y-1">
<h2 className="mb-2 px-4 text-xl font-semibold tracking-tight">
管理菜单
</h2>
<ScrollArea className="h-[calc(100vh-8rem)]">
<div className="space-y-1">
{sidebarNavItems.map((item) => (
<Button
key={item.href}
variant={pathname === item.href ? "secondary" : "ghost"}
className="w-full justify-start"
asChild
>
<Link href={item.href}>
<item.icon className="mr-2 h-4 w-4" />
{item.title}
</Link>
</Button>
))}
</div>
</ScrollArea>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,80 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { signOut } from 'next-auth/react'
import { useTheme } from "next-themes"
import { Moon, Sun, Monitor } from "lucide-react"
interface UserNavProps {
user: {
name?: string | null
email?: string | null
image?: string | null
}
}
export function UserNav({ user }: UserNavProps) {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
<AvatarImage src={user.image || ''} alt={user.name || ''} />
<AvatarFallback>{user.name?.[0]}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{user.name}</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Sun className="h-4 w-4 mr-2 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 mr-2 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="ml-2">主题</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="h-4 w-4 mr-2" />
浅色
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="h-4 w-4 mr-2" />
深色
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="h-4 w-4 mr-2" />
跟随系统
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600 cursor-pointer"
onClick={() => signOut({ callbackUrl: '/' })}
>
退出登录
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,28 @@
'use client'
import { useEffect } from 'react'
import { useSiteInfo } from './site-provider'
export function DynamicMetadata() {
const { siteInfo } = useSiteInfo()
useEffect(() => {
if (siteInfo) {
// 更新文档标题
document.title = siteInfo.basic.title
// 更新 meta 描述
const metaDescription = document.querySelector('meta[name="description"]')
if (metaDescription) {
metaDescription.setAttribute('content', siteInfo.basic.description)
} else {
const meta = document.createElement('meta')
meta.name = 'description'
meta.content = siteInfo.basic.description
document.head.appendChild(meta)
}
}
}, [siteInfo])
return null
}

23
components/footer.tsx Normal file
View File

@@ -0,0 +1,23 @@
import Link from 'next/link'
import { Icons } from '@/components/icons'
import type { SiteConfig } from '@/types/site'
interface FooterProps {
siteInfo: SiteConfig
}
export function Footer({ siteInfo }: FooterProps) {
const currentYear = new Date().getFullYear()
return (
<footer className="border-t py-6 md:py-0">
<div className="container flex flex-col items-center gap-4 md:h-16 md:flex-row md:justify-center">
<div className="flex flex-col items-center gap-4 px-8 md:flex-row md:gap-2 md:px-0">
<p className="text-center text-sm leading-loose text-muted-foreground">
{currentYear} {siteInfo.basic.title}. All rights reserved.
</p>
</div>
</div>
</footer>
)
}

160
components/icons.tsx Normal file
View File

@@ -0,0 +1,160 @@
import {
ArrowLeft,
BookOpen,
Bookmark,
Briefcase,
Calendar,
Check,
ChevronLeft,
ChevronRight,
Clock,
Cloud,
Code,
Coffee,
Command,
Compass,
CornerDownRight,
Crown,
Database,
FileText,
Folder,
FolderOpen,
Gift,
Github,
Globe,
GripVertical,
Headphones,
Heart,
Home,
Image,
LayoutDashboard,
Library,
Link,
List,
Loader2,
LogOut,
Mail,
Map,
Menu,
MessageSquare,
Monitor,
MoreVertical,
Music,
Navigation,
Newspaper,
Package,
PanelLeftClose,
PanelRightOpen,
Pencil,
Phone,
Plus,
Printer,
Radio,
RefreshCw,
Rocket,
Save,
Scan,
Search,
Settings,
Shield,
Smartphone,
Star,
Sun,
Target,
Terminal,
Trash2,
Trophy,
Tv,
Upload,
User,
Users,
Video,
X,
Zap,
ChevronsUp,
ChevronsDown,
type LucideIcon,
} from "lucide-react"
export type Icon = LucideIcon
export const Icons = {
arrowLeft: ArrowLeft,
bookOpen: BookOpen,
bookmark: Bookmark,
briefcase: Briefcase,
calendar: Calendar,
check: Check,
chevronLeft: ChevronLeft,
chevronRight: ChevronRight,
clock: Clock,
cloud: Cloud,
code: Code,
coffee: Coffee,
command: Command,
compass: Compass,
cornerDownRight: CornerDownRight,
crown: Crown,
database: Database,
fileText: FileText,
folder: Folder,
folderOpen: FolderOpen,
gift: Gift,
github: Github,
globe: Globe,
gripVertical: GripVertical,
headphones: Headphones,
heart: Heart,
home: Home,
image: Image,
layoutDashboard: LayoutDashboard,
library: Library,
link: Link,
list: List,
loader2: Loader2,
logOut: LogOut,
mail: Mail,
map: Map,
menu: Menu,
messageSquare: MessageSquare,
monitor: Monitor,
moreVertical: MoreVertical,
music: Music,
navigation: Navigation,
newspaper: Newspaper,
package: Package,
panelLeftClose: PanelLeftClose,
panelRightOpen: PanelRightOpen,
pencil: Pencil,
phone: Phone,
plus: Plus,
printer: Printer,
radio: Radio,
refresh: RefreshCw,
rocket: Rocket,
save: Save,
scan: Scan,
search: Search,
settings: Settings,
shield: Shield,
smartphone: Smartphone,
star: Star,
sun: Sun,
target: Target,
terminal: Terminal,
trash: Trash2,
trophy: Trophy,
tv: Tv,
upload: Upload,
user: User,
users: Users,
video: Video,
x: X,
zap: Zap,
category: Folder,
placeholder: Folder,
logo: Navigation,
spinner: Loader2,
chevronsUp: ChevronsUp,
chevronsDown: ChevronsDown,
} as const

View File

@@ -0,0 +1,22 @@
'use client'
import { Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Button } from '@/registry/new-york/ui/button'
export function ModeToggle() {
const { theme, setTheme } = useTheme()
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="h-9 w-9"
>
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">切换主题</span>
</Button>
)
}

View File

@@ -0,0 +1,78 @@
import Link from 'next/link'
import Image from 'next/image'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/registry/new-york/ui/card'
import { Icons } from '@/components/icons'
import type { NavigationSubItem } from '@/types/navigation'
import type { SiteConfig } from '@/types/site'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
interface NavigationCardProps {
item: NavigationSubItem
siteConfig?: SiteConfig
}
export function NavigationCard({ item, siteConfig }: NavigationCardProps) {
const isExternalIcon = item.icon?.startsWith('http')
const isLocalIcon = item.icon && !isExternalIcon
const iconPath = isLocalIcon && item.icon
? item.icon.startsWith('/')
? item.icon
: `/${item.icon}`
: item.icon || '/placeholder-icon.png'
// 获取链接打开方式,默认为新窗口
const linkTarget = siteConfig?.navigation?.linkTarget || '_blank'
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Card className="overflow-hidden transition-all duration-300 ease-in-out transform hover:-translate-y-1 hover:shadow-lg">
<Link
href={item.href}
target={linkTarget}
rel="noopener noreferrer"
className="block h-full"
>
<CardHeader>
<div className="flex items-start gap-2 sm:gap-4">
{item.icon && (
<div className="flex-shrink-0 w-8 h-8 sm:w-11 sm:h-11">
<img
src={item.icon}
alt={`${item.title} icon`}
className="w-full h-full object-contain"
/>
</div>
)}
<div className="space-y-0.5 sm:space-y-1">
<CardTitle className="text-sm sm:text-base">{item.title}</CardTitle>
{item.description && (
<CardDescription className="text-xs sm:text-sm line-clamp-1">
{item.description}
</CardDescription>
)}
</div>
</div>
</CardHeader>
</Link>
</Card>
</TooltipTrigger>
<TooltipContent
side="bottom"
align="center"
sideOffset={8}
className="max-w-[280px] text-xs sm:text-sm"
>
<p>{item.description}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,240 @@
'use client'
import { useState, useMemo } from 'react'
import type { NavigationData, NavigationItem, NavigationSubItem } from '@/types/navigation'
import type { SiteConfig } from '@/types/site'
import { NavigationCard } from '@/components/navigation-card'
import { Sidebar } from '@/components/sidebar'
import { SearchBar } from '@/components/search-bar'
import { ModeToggle } from '@/components/mode-toggle'
import { Footer } from '@/components/footer'
import { Github, HelpCircle, Puzzle } from 'lucide-react'
import { Button } from "@/registry/new-york/ui/button"
import Link from 'next/link'
import { cn } from '@/lib/utils'
import { Menu } from 'lucide-react'
interface NavigationContentProps {
navigationData: NavigationData
siteData: SiteConfig
}
export function NavigationContent({ navigationData, siteData }: NavigationContentProps) {
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
// 修复类型检查和搜索逻辑
const searchResults = useMemo(() => {
const query = searchQuery.toLowerCase().trim()
if (!query) return []
const results: Array<{
category: NavigationItem
items: (NavigationItem | NavigationSubItem)[]
subCategories: Array<{
title: string
items: (NavigationItem | NavigationSubItem)[]
}>
}> = []
navigationData.navigationItems.forEach(category => {
// 搜索主分类下的项目(只搜索启用的)
const items = (category.items || []).filter(item => {
if (item.enabled === false) return false
const titleMatch = item.title.toLowerCase().includes(query)
const descMatch = item.description?.toLowerCase().includes(query)
return titleMatch || descMatch
})
// 搜索子分类下的项目(只搜索启用的)
const subResults: Array<{
title: string
items: (NavigationItem | NavigationSubItem)[]
}> = []
if (category.subCategories) {
category.subCategories.forEach(sub => {
if (sub.enabled === false) return
const subItems = (sub.items || []).filter(item => {
if (item.enabled === false) return false
const titleMatch = item.title.toLowerCase().includes(query)
const descMatch = item.description?.toLowerCase().includes(query)
return titleMatch || descMatch
})
if (subItems.length > 0) {
subResults.push({
title: sub.title,
items: subItems
})
}
})
}
// 只有当主分类或子分类有匹配结果时才添加到结果中
if (items.length > 0 || subResults.length > 0) {
results.push({
category,
items,
subCategories: subResults
})
}
})
// 调试信息
if (query && results.length > 0) {
console.log('搜索结果:', {
query,
totalResults: results.length,
results: results.map(r => ({
category: r.category.title,
mainItems: r.items.length,
subCategories: r.subCategories.map(s => ({
title: s.title,
items: s.items.length
}))
}))
})
}
return results
}, [navigationData, searchQuery])
const handleSearch = (query: string) => {
setSearchQuery(query)
}
return (
<div className="flex flex-col sm:flex-row min-h-screen">
<div className="hidden sm:block">
<Sidebar
navigationData={navigationData}
siteInfo={siteData}
className="sticky top-0 h-screen"
/>
</div>
<div className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm transition-all sm:hidden",
isSidebarOpen ? "opacity-100" : "opacity-0 pointer-events-none"
)}>
<div className={cn(
"fixed inset-y-0 right-0 sm:left-0 w-3/4 max-w-xs bg-background shadow-lg transform transition-transform duration-200 ease-in-out",
isSidebarOpen ? "translate-x-0" : "translate-x-full sm:-translate-x-full"
)}>
<Sidebar
navigationData={navigationData}
siteInfo={siteData}
onClose={() => setIsSidebarOpen(false)}
/>
</div>
</div>
<main className="flex-1">
<div className="sticky top-0 bg-background/90 backdrop-blur-sm z-30 px-3 sm:px-6 py-2">
<div className="flex items-center gap-3">
<div className="flex-1">
<SearchBar
navigationData={navigationData}
onSearch={handleSearch}
searchResults={searchResults}
searchQuery={searchQuery}
siteConfig={siteData}
/>
</div>
<div className="flex items-center gap-1">
<ModeToggle />
<Link
href="https://github.com/tianyaxiang/NavSphere"
target="_blank"
rel="noopener noreferrer"
aria-label="访问 GitHub 仓库"
>
<Button
variant="ghost"
size="icon"
className="hover:bg-accent hover:text-accent-foreground"
>
<Github className="h-5 w-5" />
</Button>
</Link>
<Link
href="https://github.com/tianyaxiang/navsphere-extension"
target="_blank"
rel="noopener noreferrer"
aria-label="下载浏览器插件"
>
<Button
variant="ghost"
size="icon"
className="hover:bg-accent hover:text-accent-foreground"
>
<Puzzle className="h-5 w-5" />
</Button>
</Link>
<Link
href="https://mp.weixin.qq.com/s/90LUmKilfLZfc5L63Ej3Sg"
target="_blank"
rel="noopener noreferrer"
aria-label="查看帮助文档"
>
<Button
variant="ghost"
size="icon"
className="hover:bg-accent hover:text-accent-foreground"
>
<HelpCircle className="h-5 w-5" />
</Button>
</Link>
<Button
variant="ghost"
size="icon"
className="sm:hidden"
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
>
<Menu className="h-5 w-5" />
</Button>
</div>
</div>
</div>
<div className="px-3 sm:px-6 py-3 sm:py-6">
<div className="space-y-6">
{navigationData.navigationItems.map((category) => (
<section key={category.id} id={category.id} className="scroll-m-16">
<div className="space-y-4">
<h2 className="text-base font-medium tracking-tight">
{category.title}
</h2>
{category.subCategories && category.subCategories.length > 0 ? (
category.subCategories.map((subCategory) => (
<div key={subCategory.id} id={subCategory.id} className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground">
{subCategory.title}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{(subCategory.items || []).map((item) => (
<NavigationCard key={item.id} item={item} siteConfig={siteData} />
))}
</div>
</div>
))
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{(category.items || []).map((item) => (
<NavigationCard key={item.id} item={item} siteConfig={siteData} />
))}
</div>
)}
</div>
</section>
))}
</div>
</div>
{/* 页脚 */}
<Footer siteInfo={siteData} />
</main>
</div>
)
}

17
components/providers.tsx Normal file
View File

@@ -0,0 +1,17 @@
'use client'
import { SWRConfig } from 'swr'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SWRConfig
value={{
provider: () => new Map(),
revalidateOnFocus: false,
revalidateOnReconnect: false
}}
>
{children}
</SWRConfig>
)
}

221
components/search-bar.tsx Normal file
View File

@@ -0,0 +1,221 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { Input } from '@/registry/new-york/ui/input'
import { Command, CommandList, CommandGroup, CommandItem } from '@/registry/new-york/ui/command'
import { Search, X } from 'lucide-react'
import { Button } from '@/registry/new-york/ui/button'
import type { NavigationData, NavigationItem, NavigationSubItem } from '@/types/navigation'
import type { SiteConfig } from '@/types/site'
interface SearchBarProps {
navigationData: NavigationData
onSearch: (query: string) => void
searchResults: Array<{
category: NavigationItem
items: (NavigationItem | NavigationSubItem)[]
subCategories: Array<{
title: string
items: (NavigationItem | NavigationSubItem)[]
}>
}>
searchQuery: string
siteConfig?: SiteConfig
}
export function SearchBar({ onSearch, searchResults, searchQuery, siteConfig }: SearchBarProps) {
const [isFocused, setIsFocused] = useState(false)
const searchRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
setIsFocused(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsFocused(false)
inputRef.current?.blur()
}
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
event.preventDefault()
inputRef.current?.focus()
setIsFocused(true)
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])
const highlightText = (text: string) => {
if (!searchQuery) return text
const parts = text.split(new RegExp(`(${searchQuery})`, 'gi'))
return parts.map((part, i) =>
part.toLowerCase() === searchQuery.toLowerCase() ?
<span key={i} className="bg-yellow-200 dark:bg-yellow-800">{part}</span> : part
)
}
const handleInputChange = (value: string) => {
onSearch(value)
setIsFocused(true)
}
const handleItemSelect = (item: NavigationItem | NavigationSubItem) => {
const itemWithHref = item as NavigationSubItem
if (itemWithHref.href) {
const linkTarget = siteConfig?.navigation?.linkTarget || '_blank'
if (linkTarget === '_self') {
window.location.href = itemWithHref.href
} else {
window.open(itemWithHref.href, linkTarget)
}
}
onSearch('')
setIsFocused(false)
}
const clearSearch = () => {
onSearch('')
setIsFocused(false)
inputRef.current?.focus()
}
const showResults = isFocused && searchQuery.trim().length > 0
return (
<div ref={searchRef} className="relative w-full max-w-lg mx-auto">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={inputRef}
placeholder="搜索导航..."
value={searchQuery}
onChange={(e) => handleInputChange(e.target.value)}
onFocus={() => setIsFocused(true)}
className="pl-10 pr-20 h-10 rounded-lg border shadow-sm"
/>
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1">
{searchQuery && (
<Button
variant="ghost"
size="sm"
onClick={clearSearch}
className="h-6 w-6 p-0 hover:bg-muted"
>
<X className="h-3 w-3" />
</Button>
)}
<kbd className="hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-xs text-muted-foreground opacity-100 sm:flex">
<span className="text-xs">⌘</span>K
</kbd>
</div>
</div>
{showResults && (
<div className="absolute top-full left-0 right-0 mt-2 bg-background border rounded-lg shadow-xl z-50 max-h-[70vh] overflow-hidden">
<Command className="border-0 shadow-none">
<CommandList className="max-h-[70vh] overflow-y-auto">
{searchResults.length === 0 ? (
<div className="py-8 text-center">
<div className="text-muted-foreground text-sm">
未找到与 &ldquo;<span className="font-medium">{searchQuery}</span>&rdquo; 相关的导航
</div>
<div className="text-xs text-muted-foreground/70 mt-1">
尝试使用不同的关键词搜索
</div>
</div>
) : (
searchResults.map((result) => (
<CommandGroup key={result.category.id} heading={result.category.title}>
{result.items.map((item) => (
<CommandItem
key={item.id}
value={item.title}
onSelect={() => handleItemSelect(item)}
className="flex items-center gap-3 py-3 px-3 cursor-pointer hover:bg-accent/50"
>
<div className="flex-shrink-0 w-8 h-8">
{item.icon && (
<img
src={item.icon}
alt={`${item.title} icon`}
className="w-full h-full object-contain rounded"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
}}
/>
)}
</div>
<div className="flex flex-col flex-1 gap-1">
<span className="text-sm font-medium">
{highlightText(item.title)}
</span>
{item.description && (
<span className="text-xs text-muted-foreground line-clamp-1">
{highlightText(item.description)}
</span>
)}
</div>
</CommandItem>
))}
{result.subCategories.map((sub) => (
<div key={sub.title}>
<div className="px-3 py-2 text-xs font-medium text-muted-foreground bg-muted/30 border-b">
{result.category.title} / {sub.title}
</div>
{sub.items.map((item) => (
<CommandItem
key={item.id}
value={item.title}
onSelect={() => handleItemSelect(item)}
className="flex items-center gap-3 py-3 px-3 cursor-pointer hover:bg-accent/50"
>
<div className="flex-shrink-0 w-8 h-8">
{item.icon && (
<img
src={item.icon}
alt={`${item.title} icon`}
className="w-full h-full object-contain rounded"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
}}
/>
)}
</div>
<div className="flex flex-col flex-1 gap-1">
<span className="text-sm font-medium">
{highlightText(item.title)}
</span>
{item.description && (
<span className="text-xs text-muted-foreground line-clamp-1">
{highlightText(item.description)}
</span>
)}
</div>
</CommandItem>
))}
</div>
))}
</CommandGroup>
))
)}
</CommandList>
</Command>
</div>
)}
</div>
)
}

168
components/sidebar.tsx Normal file
View File

@@ -0,0 +1,168 @@
'use client'
import { useState } from 'react'
import { usePathname } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
import { cn } from '@/lib/utils'
import { Button } from '@/registry/new-york/ui/button'
import { ScrollArea } from '@/registry/new-york/ui/scroll-area'
import type { NavigationData } from '@/types/navigation'
import type { SiteConfig } from '@/types/site'
import * as LucideIcons from 'lucide-react'
import { ChevronDown, ChevronRight, X } from 'lucide-react'
interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> {
navigationData: NavigationData
siteInfo: SiteConfig
onClose?: () => void
}
export function Sidebar({ className, navigationData, siteInfo, onClose }: SidebarProps) {
const pathname = usePathname()
const scrollToSection = (id: string) => {
const element = document.getElementById(id)
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
onClose?.()
}
}
const handleCategoryClick = (categoryId: string) => {
// 先跳转到对应区域
scrollToSection(categoryId)
// 如果有子分类,切换展开/收起状态
const category = navigationData.navigationItems.find(cat => cat.id === categoryId)
if (category?.subCategories && category.subCategories.length > 0) {
toggleCategory(categoryId)
}
}
const renderIcon = (iconName?: string) => {
if (!iconName) return <LucideIcons.Folder className="h-4 w-4" />;
if (iconName.startsWith('/') || iconName.startsWith('http')) {
return (
<Image
src={iconName}
alt="icon"
width={16}
height={16}
className="h-4 w-4"
/>
);
}
// Convert icon name to match Lucide icon component name
const IconComponent = (LucideIcons as any)[iconName] || LucideIcons.Folder;
return <IconComponent className="h-4 w-4" />;
}
// 使用对象存储每个分类的展开状态
const [expandedCategories, setExpandedCategories] = useState<Record<string, boolean>>(() => {
return navigationData.navigationItems.reduce((acc, category) => {
acc[category.id] = false
return acc
}, {} as Record<string, boolean>)
})
const toggleCategory = (categoryId: string) => {
setExpandedCategories(prev => ({
...prev,
[categoryId]: !prev[categoryId]
}))
}
return (
<div className={cn("w-64 bg-background", className)}>
<div className="flex h-14 items-center px-4">
<Link href="/" className="flex items-center gap-2 font-semibold">
{siteInfo.appearance.logo ? (
<Image
src={siteInfo.appearance.logo}
alt={siteInfo.basic.title}
width={24}
height={24}
className="h-6 w-6"
/>
) : (
<LucideIcons.Globe className="h-6 w-6" />
)}
<span>{siteInfo.basic.title}</span>
</Link>
{/* 移动模式下的关闭按钮 */}
{onClose && (
<Button
variant="ghost"
size="icon"
className="ml-auto sm:hidden"
onClick={onClose}
aria-label="关闭侧边栏"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<ScrollArea className="h-[calc(100vh-3.5rem)] px-3 py-2">
<div className="space-y-1">
{navigationData.navigationItems.map((category) => (
<div key={category.id} className="py-2">
<div className="flex items-center">
<Button
variant="ghost"
className="flex-1 justify-start gap-2 font-medium text-muted-foreground hover:text-foreground cursor-pointer"
onClick={() => handleCategoryClick(category.id)}
>
{renderIcon(category.icon)}
<span>{category.title}</span>
</Button>
{category.subCategories && category.subCategories.length > 0 && (
<Button
variant="ghost"
size="sm"
className="px-2 hover:bg-transparent cursor-pointer"
onClick={() => toggleCategory(category.id)}
>
{expandedCategories[category.id] ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</Button>
)}
</div>
{category.subCategories && category.subCategories.length > 0 && (
<div
className={cn(
"mt-1 ml-4 space-y-1 overflow-hidden transition-all duration-200 ease-in-out",
expandedCategories[category.id] ? "max-h-[500px] opacity-100" : "max-h-0 opacity-0"
)}
>
{category.subCategories.map((subCategory) => (
<Button
key={subCategory.id}
variant="ghost"
className="w-full justify-start pl-6 text-sm text-muted-foreground/80 hover:text-foreground cursor-pointer"
onClick={() => {
scrollToSection(subCategory.id)
onClose?.()
}}
>
<span>{subCategory.title}</span>
</Button>
))}
</div>
)}
</div>
))}
</div>
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,65 @@
'use client'
import { createContext, useContext, useEffect, useState } from 'react'
import type { SiteConfig } from '@/types/site'
const SiteContext = createContext<{
siteInfo: SiteConfig | null
isLoading: boolean
error: Error | null
}>({
siteInfo: null,
isLoading: true,
error: null,
})
export function useSiteInfo() {
return useContext(SiteContext)
}
export function SiteProvider({ children }: { children: React.ReactNode }) {
const [siteInfo, setSiteInfo] = useState<SiteConfig | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
async function loadSiteInfo() {
try {
const response = await fetch('/api/site-config')
if (!response.ok) {
throw new Error('Failed to fetch site config')
}
const data = await response.json()
setSiteInfo(data)
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error'))
// 设置默认值
setSiteInfo({
basic: {
title: 'NavSphere',
description: 'A modern navigation platform',
keywords: 'navigation, platform, web, management'
},
appearance: {
logo: '/logo.png',
favicon: '/favicon.ico',
theme: 'system'
},
navigation: {
linkTarget: '_blank'
}
})
} finally {
setIsLoading(false)
}
}
loadSiteInfo()
}, [])
return (
<SiteContext.Provider value={{ siteInfo, isLoading, error }}>
{children}
</SiteContext.Provider>
)
}

View File

@@ -0,0 +1,8 @@
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { type ThemeProviderProps } from "next-themes/dist/types"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@@ -0,0 +1,40 @@
"use client"
import * as React from "react"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/registry/new-york/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/registry/new-york/ui/dropdown-menu"
export function ThemeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

50
components/ui/avatar.tsx Normal file
View File

@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

36
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

56
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

79
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

155
components/ui/command.tsx Normal file
View File

@@ -0,0 +1,155 @@
"use client"
import * as React from "react"
import { DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,11 @@
interface ContainerProps {
children: React.ReactNode
}
export function Container({ children }: ContainerProps) {
return (
<div className="mx-auto w-full max-w-screen-2xl px-2.5 md:px-20">
{children}
</div>
)
}

122
components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

178
components/ui/form.tsx Normal file
View File

@@ -0,0 +1,178 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,162 @@
"use client"
import * as React from "react"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Input } from "@/components/ui/input"
export const icons = [
{ value: "linecons-star", label: "星标", category: "常用" },
{ value: "linecons-desktop", label: "桌面", category: "常用" },
{ value: "linecons-params", label: "工具", category: "常用" },
{ value: "linecons-note", label: "笔记", category: "常用" },
{ value: "linecons-mail", label: "邮件", category: "常用" },
{ value: "linecons-data", label: "数据", category: "常用" },
{ value: "linecons-cloud", label: "云端", category: "常用" },
{ value: "linecons-lab", label: "实验", category: "常用" },
{ value: "linecons-fire", label: "火热", category: "常用" },
{ value: "linecons-clip", label: "剪贴", category: "常用" },
// 开发工具
{ value: "linecons-code", label: "代码", category: "开发" },
{ value: "linecons-git", label: "Git", category: "开发" },
{ value: "linecons-terminal", label: "终端", category: "开发" },
{ value: "linecons-database", label: "数据库", category: "开发" },
{ value: "linecons-api", label: "API", category: "开发" },
// 设计
{ value: "linecons-palette", label: "调色板", category: "设计" },
{ value: "linecons-image", label: "图片", category: "设计" },
{ value: "linecons-vector", label: "矢量", category: "设计" },
{ value: "linecons-brush", label: "画笔", category: "设计" },
// 媒体
{ value: "linecons-video", label: "视频", category: "媒体" },
{ value: "linecons-music", label: "音乐", category: "媒体" },
{ value: "linecons-camera", label: "相机", category: "媒体" },
// 其他
{ value: "linecons-book", label: "书籍", category: "其他" },
{ value: "linecons-link", label: "链接", category: "其他" },
{ value: "linecons-heart", label: "喜欢", category: "其他" },
{ value: "linecons-tag", label: "标签", category: "其他" },
]
interface IconItem {
value: string
label: string
category: string
}
interface IconPickerProps {
value: string
onChange: (value: string) => void
}
export function IconPicker({ value, onChange }: IconPickerProps) {
const [open, setOpen] = React.useState(false)
const [inputValue, setInputValue] = React.useState(value)
const [searchValue, setSearchValue] = React.useState("")
React.useEffect(() => {
setInputValue(value)
}, [value])
// 按分类分组图标
const groupedIcons = React.useMemo(() => {
const filtered = icons.filter((icon) => {
const search = searchValue.toLowerCase()
return (
icon.label.toLowerCase().includes(search) ||
icon.value.toLowerCase().includes(search) ||
icon.category.toLowerCase().includes(search)
)
})
return filtered.reduce<Record<string, IconItem[]>>((groups, icon) => {
const category = icon.category
if (!groups[category]) {
groups[category] = []
}
groups[category].push(icon)
return groups
}, {})
}, [searchValue])
return (
<div className="flex gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[200px] justify-between"
>
<div className="flex items-center gap-2">
<i className={value} />
<span className="truncate">
{icons.find((icon) => icon.value === value)?.label ?? value}
</span>
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput
placeholder="搜索图标..."
value={searchValue}
onValueChange={setSearchValue}
/>
<CommandEmpty>未找到图标</CommandEmpty>
<div className="max-h-[300px] overflow-auto">
{Object.entries(groupedIcons).map(([category, items]) => (
<CommandGroup key={category} heading={category}>
{items.map((icon) => (
<CommandItem
key={icon.value}
value={icon.value}
onSelect={(currentValue) => {
onChange(currentValue)
setOpen(false)
}}
>
<div className="flex items-center gap-2">
<Check
className={cn(
"h-4 w-4",
value === icon.value ? "opacity-100" : "opacity-0"
)}
/>
<i className={icon.value} />
<span>{icon.label}</span>
</div>
</CommandItem>
))}
</CommandGroup>
))}
</div>
</Command>
</PopoverContent>
</Popover>
<Input
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value)
onChange(e.target.value)
}}
placeholder="自定义图标类名"
className="w-[200px]"
/>
</div>
)
}

25
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,198 @@
'use client'
import { useEffect, useRef } from 'react'
import Editor from '@monaco-editor/react'
import { useTheme } from 'next-themes'
interface JsonEditorProps {
value: string
onChange: (value: string) => void
disabled?: boolean
height?: string
onValidate?: (isValid: boolean, errors: string[]) => void
isValid?: boolean
stats?: { categories: number; items: number; size: number }
}
export function JsonEditor({
value,
onChange,
disabled = false,
height = '500px',
onValidate,
isValid = true,
stats
}: JsonEditorProps) {
const { theme } = useTheme()
const editorRef = useRef<any>(null)
const handleEditorDidMount = (editor: any, monaco: any) => {
editorRef.current = editor
// 配置JSON语言特性
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
allowComments: false,
schemas: [],
enableSchemaRequest: false,
})
// 设置编辑器选项
editor.updateOptions({
fontSize: 14,
fontFamily: 'JetBrains Mono, Consolas, Monaco, "Courier New", monospace',
lineNumbers: 'on',
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: 'on',
automaticLayout: true,
tabSize: 2,
insertSpaces: true,
formatOnPaste: true,
formatOnType: true,
folding: true,
bracketPairColorization: { enabled: true },
guides: {
bracketPairs: true,
indentation: true,
},
suggest: {
showKeywords: true,
showSnippets: true,
},
quickSuggestions: {
other: true,
comments: false,
strings: true,
},
})
// 监听内容变化
editor.onDidChangeModelContent(() => {
const currentValue = editor.getValue()
onChange(currentValue)
// 验证JSON格式
if (onValidate) {
try {
JSON.parse(currentValue)
onValidate(true, [])
} catch (error) {
onValidate(false, [(error as Error).message])
}
}
})
// 添加键盘快捷键
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
// 触发保存事件
const event = new CustomEvent('monaco-save')
window.dispatchEvent(event)
})
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyR, () => {
// 触发刷新事件
const event = new CustomEvent('monaco-refresh')
window.dispatchEvent(event)
})
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyD, () => {
// 触发下载事件
const event = new CustomEvent('monaco-download')
window.dispatchEvent(event)
})
// 格式化快捷键
editor.addCommand(monaco.KeyMod.Alt | monaco.KeyMod.Shift | monaco.KeyCode.KeyF, () => {
editor.getAction('editor.action.formatDocument').run()
})
}
// 格式化JSON
const formatJson = () => {
if (editorRef.current) {
editorRef.current.getAction('editor.action.formatDocument').run()
}
}
// 暴露格式化方法
useEffect(() => {
const handleFormat = () => formatJson()
window.addEventListener('monaco-format', handleFormat)
return () => window.removeEventListener('monaco-format', handleFormat)
}, [])
return (
<div className="border rounded-lg overflow-hidden">
{/* 编辑器工具栏 */}
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b">
<div className="flex items-center gap-3 text-sm">
<span className="text-muted-foreground">navigation.json</span>
{value && (
<span className="text-xs text-muted-foreground">
{value.split('\n').length} 行 · {value.length} 字符
</span>
)}
{/* JSON状态显示 */}
<div className="flex items-center gap-1">
{isValid ? (
<>
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="text-xs text-green-600 dark:text-green-400 font-medium">格式正确</span>
{stats && (
<span className="text-xs text-muted-foreground ml-1">
· {stats.categories} 分类 · {stats.items} 站点
</span>
)}
</>
) : (
<>
<div className="w-2 h-2 bg-red-500 rounded-full"></div>
<span className="text-xs text-red-600 dark:text-red-400 font-medium">格式错误</span>
</>
)}
</div>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-background border rounded text-xs">Ctrl+S</kbd>
<span>保存</span>
</div>
<div className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-background border rounded text-xs">Alt+Shift+F</kbd>
<span>格式化</span>
</div>
<div className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-background border rounded text-xs">Ctrl+F</kbd>
<span>查找</span>
</div>
</div>
</div>
{/* Monaco Editor */}
<Editor
height={height}
defaultLanguage="json"
value={value}
theme={theme === 'dark' ? 'vs-dark' : 'vs'}
onMount={handleEditorDidMount}
options={{
readOnly: disabled,
contextmenu: true,
selectOnLineNumbers: true,
roundedSelection: false,
cursorStyle: 'line',
automaticLayout: true,
}}
loading={
<div className="flex items-center justify-center h-full">
<div className="flex items-center gap-2 text-muted-foreground">
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
<span>加载编辑器...</span>
</div>
</div>
}
/>
</div>
)
}

26
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

31
components/ui/popover.tsx Normal file
View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,34 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Icons } from "@/components/icons"
export interface SearchInputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const SearchInput = React.forwardRef<HTMLInputElement, SearchInputProps>(
({ className, type, ...props }, ref) => {
return (
<div className="relative">
<Icons.search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 py-1 text-sm shadow-sm transition-colors",
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
"placeholder:text-muted-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
</div>
)
}
)
SearchInput.displayName = "SearchInput"
export { SearchInput }

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

140
components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

114
components/ui/table.tsx Normal file
View File

@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("bg-primary font-medium text-primary-foreground", className)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

55
components/ui/tabs.tsx Normal file
View File

@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

127
components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,127 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

35
components/ui/toaster.tsx Normal file
View File

@@ -0,0 +1,35 @@
"use client"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
import { useToast } from "@/components/ui/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

36
components/ui/tooltip.tsx Normal file
View File

@@ -0,0 +1,36 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-visible rounded-md bg-gray-100 px-3 py-2 text-sm text-gray-600",
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
>
<TooltipPrimitive.Arrow
className="fill-gray-100"
width={14}
height={12}
/>
{props.children}
</TooltipPrimitive.Content>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

191
components/ui/use-toast.ts Normal file
View File

@@ -0,0 +1,191 @@
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_VALUE
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }