a369fe853e
- 移除独立的 /admin 路由层,路由扁平化为 /bookmarks - 用 AppSidebar 替代 AdminSidebar,SidebarProvider 提升至 _protected 全局布局 - 新增 ⌘K 命令面板(cmdk + @tanstack/react-hotkeys),支持书签搜索、搜索引擎跳转和页面导航 - 书签页查看/管理一体化,通过编辑模式开关切换,AnimatePresence 平滑过渡 - 总览驾驶舱:Motion stagger 入场动画、常用书签快捷区、模块概览卡片 - 统一设计语言:BookmarkCard/CategoryGrid 用 design token 替代硬编码 stone 色 - ModuleMetadata.adminRoute 重命名为 route - 同步更新 AGENTS.md 文档
162 lines
6.3 KiB
TypeScript
162 lines
6.3 KiB
TypeScript
import type { QueryClient } from '@tanstack/react-query'
|
|
import { useSuspenseQuery } from '@tanstack/react-query'
|
|
import { createFileRoute } from '@tanstack/react-router'
|
|
import { Pencil, X } from 'lucide-react'
|
|
import { AnimatePresence } from 'motion/react'
|
|
import * as motion from 'motion/react-client'
|
|
import { useEffect, useState } from 'react'
|
|
import { orpc } from '@/client/orpc'
|
|
import { Button } from '@/components/ui/button'
|
|
import { BookmarkCard } from '@/modules/bookmarks/components/BookmarkCard'
|
|
import { BookmarkManager } from '@/modules/bookmarks/components/BookmarkManager'
|
|
import { CategoryManager } from '@/modules/bookmarks/components/CategoryManager'
|
|
|
|
export const Route = createFileRoute('/_protected/bookmarks' as never)({
|
|
loader: async ({ context }: { context: { queryClient: QueryClient } }) => {
|
|
await context.queryClient.fetchQuery(orpc.bookmarks.category.list.queryOptions())
|
|
},
|
|
component: BookmarksPage,
|
|
})
|
|
|
|
const containerVariants = {
|
|
hidden: { opacity: 0 },
|
|
visible: {
|
|
opacity: 1,
|
|
transition: { staggerChildren: 0.05, delayChildren: 0.08 },
|
|
},
|
|
}
|
|
|
|
const itemVariants = {
|
|
hidden: { opacity: 0, y: 10 },
|
|
visible: { opacity: 1, y: 0, transition: { duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] as const } },
|
|
}
|
|
|
|
function BookmarksPage() {
|
|
const { data: categories } = useSuspenseQuery(orpc.bookmarks.category.list.queryOptions())
|
|
const [editing, setEditing] = useState(false)
|
|
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (selectedCategoryId === null && categories.length > 0) {
|
|
setSelectedCategoryId(categories[0]?.id ?? null)
|
|
}
|
|
}, [categories, selectedCategoryId])
|
|
|
|
useEffect(() => {
|
|
if (selectedCategoryId && !categories.some((c: { id: string }) => c.id === selectedCategoryId)) {
|
|
setSelectedCategoryId(categories[0]?.id ?? null)
|
|
}
|
|
}, [categories, selectedCategoryId])
|
|
|
|
const selectedCategory = categories.find((c: { id: string }) => c.id === selectedCategoryId)
|
|
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col px-6 pb-6">
|
|
<div className="mb-6 flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight">{editing ? '书签管理' : '书签导航'}</h1>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
{editing ? '管理你的书签分类、图标和排序' : '常用链接和网站的快速导航'}
|
|
</p>
|
|
</div>
|
|
<Button variant={editing ? 'default' : 'outline'} size="sm" onClick={() => setEditing(!editing)}>
|
|
{editing ? (
|
|
<>
|
|
<X className="size-4" />
|
|
完成
|
|
</>
|
|
) : (
|
|
<>
|
|
<Pencil className="size-4" />
|
|
编辑
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
<AnimatePresence mode="wait">
|
|
{editing ? (
|
|
<motion.div
|
|
key="edit"
|
|
initial={{ opacity: 0, scale: 0.98 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.98 }}
|
|
transition={{ duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] as const }}
|
|
className="flex min-h-0 flex-1 gap-6"
|
|
>
|
|
<div className="w-80 shrink-0">
|
|
<CategoryManager
|
|
categories={categories}
|
|
selectedCategoryId={selectedCategoryId}
|
|
onSelectCategory={setSelectedCategoryId}
|
|
/>
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
{selectedCategory ? (
|
|
<BookmarkManager category={selectedCategory} />
|
|
) : (
|
|
<div className="flex h-full items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground">
|
|
请选择一个分类开始管理书签
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
) : (
|
|
<motion.div
|
|
key="view"
|
|
variants={containerVariants}
|
|
initial="hidden"
|
|
animate="visible"
|
|
exit={{ opacity: 0 }}
|
|
className="min-h-0 flex-1 overflow-y-auto"
|
|
>
|
|
{categories.length === 0 ? (
|
|
<motion.div
|
|
variants={itemVariants}
|
|
className="flex flex-col items-center justify-center py-32 text-center"
|
|
>
|
|
<div className="mb-4 flex size-16 items-center justify-center rounded-2xl bg-muted">
|
|
<span className="text-2xl">✨</span>
|
|
</div>
|
|
<h3 className="mb-2 text-lg font-medium">还没有任何书签</h3>
|
|
<p className="mb-6 text-sm text-muted-foreground">点击右上角「编辑」按钮添加你的第一个书签</p>
|
|
<Button onClick={() => setEditing(true)}>
|
|
<Pencil className="size-4" />
|
|
开始添加
|
|
</Button>
|
|
</motion.div>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-10 md:grid-cols-2 lg:grid-cols-3">
|
|
{categories.map(
|
|
(category: {
|
|
id: string
|
|
name: string
|
|
bookmarks: Array<{ id: string; name: string; url: string; icon: string | null }>
|
|
}) => (
|
|
<motion.div key={category.id} variants={itemVariants} className="flex flex-col gap-3.5">
|
|
<h2 className="px-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
{category.name}
|
|
</h2>
|
|
{category.bookmarks.length === 0 ? (
|
|
<div className="rounded-xl border border-dashed py-6 text-center">
|
|
<span className="text-sm text-muted-foreground">暂无书签</span>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 md:grid-cols-1 xl:grid-cols-2">
|
|
{category.bookmarks.map((bookmark) => (
|
|
<BookmarkCard key={bookmark.id} bookmark={bookmark} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
),
|
|
)}
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
)
|
|
}
|