feat: 重设计 UI/UX — 展示/管理分离 + shadcn/ui + Admin 后台

- 引入 shadcn/ui(base-nova 风格,Tailwind v4,14 个组件)
- 新增 Admin 后台路由架构:/admin(总览)、/admin/bookmarks(管理)
- 重写首页为纯展示书签导航(BookmarkCard + CategoryGrid)
- 新增 Admin 侧边栏导航(AdminSidebar + SidebarProvider)
- 书签管理页:双栏布局 + Dialog 表单 + DnD 排序 + Toast 通知
- 修复 IconPicker overflow 裁切(改用 Dialog portal)
- 修复嵌套 button hydration 错误(base-ui render prop)
- 删除旧组件(CategorySection/BookmarkItem/IconPicker)和旧路由
- 所有新依赖归入 root catalog
- 更新 AGENTS.md 文档(目录结构、shadcn 模式、render prop 规范)
This commit is contained in:
2026-03-30 22:54:01 +08:00
parent 430c0b0c64
commit ba8224e81e
42 changed files with 3261 additions and 781 deletions
@@ -0,0 +1,135 @@
import * as icons from 'lucide-react'
import { Search } from 'lucide-react'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
const ICON_NAMES = [
'Globe',
'Home',
'Code',
'Database',
'Mail',
'MessageSquare',
'Music',
'Video',
'Image',
'FileText',
'Folder',
'Star',
'Heart',
'Bookmark',
'Search',
'Settings',
'User',
'Shield',
'Key',
'Terminal',
'Github',
'Chrome',
'Cpu',
'Server',
'Cloud',
'Wifi',
'Zap',
'Coffee',
'BookOpen',
'Briefcase',
'Calendar',
'Clock',
'Download',
'Edit',
'ExternalLink',
'Eye',
'Film',
'Gift',
'Headphones',
'Layout',
'Link',
'Map',
'Monitor',
'Package',
'Phone',
'ShoppingCart',
'Smartphone',
'Tv',
'Upload',
'Box',
'Compass',
'Rss',
'Camera',
'Printer',
'Layers',
'Activity',
] as const
const allIcons = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>
interface IconPickerDialogProps {
value: string | null
onChange: (iconName: string) => void
}
export const IconPickerDialog = ({ value, onChange }: IconPickerDialogProps) => {
const [open, setOpen] = useState(false)
const [filter, setFilter] = useState('')
const filteredIcons = ICON_NAMES.filter((name) => name.toLowerCase().includes(filter.toLowerCase()))
const CurrentIcon = (value && allIcons[value]) || null
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger>
<Button variant="outline" className="w-full justify-start gap-2">
{CurrentIcon ? <CurrentIcon className="size-4" /> : <Search className="size-4 text-muted-foreground" />}
<span className={cn('truncate', !value && 'text-muted-foreground')}>{value ?? '选择图标'}</span>
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription> lucide </DialogDescription>
</DialogHeader>
<Input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="搜索图标..." />
<div className="grid max-h-80 grid-cols-8 gap-2 overflow-y-auto rounded-lg border p-2">
{filteredIcons.map((name) => {
const Icon = allIcons[name]
if (!Icon) {
return null
}
return (
<Button
key={name}
type="button"
size="icon"
variant={value === name ? 'secondary' : 'ghost'}
title={name}
onClick={() => {
onChange(name)
setOpen(false)
}}
>
<Icon className="size-4" />
</Button>
)
})}
</div>
{filteredIcons.length === 0 && <p className="text-center text-sm text-muted-foreground"></p>}
</DialogContent>
</Dialog>
)
}