feat: 添加 Dashboard 和书签页面
This commit is contained in:
@@ -0,0 +1,92 @@
|
|||||||
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
|
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { Plus, X } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { orpc } from '@/client/orpc'
|
||||||
|
import { CategorySection } from '@/modules/bookmarks/components/CategorySection'
|
||||||
|
import { GreetingHeader } from '@/modules/bookmarks/components/GreetingHeader'
|
||||||
|
import { SearchBar } from '@/modules/bookmarks/components/SearchBar'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_protected/bookmarks/' as never)({
|
||||||
|
component: BookmarksPage,
|
||||||
|
loader: async ({ context }: { context: { queryClient: QueryClient } }) => {
|
||||||
|
await context.queryClient.ensureQueryData(orpc.bookmarks.category.list.queryOptions())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function BookmarksPage() {
|
||||||
|
const categoriesQuery = useSuspenseQuery(orpc.bookmarks.category.list.queryOptions())
|
||||||
|
const createCategory = useMutation(orpc.bookmarks.category.create.mutationOptions())
|
||||||
|
const [showAddCategory, setShowAddCategory] = useState(false)
|
||||||
|
const [newCategoryName, setNewCategoryName] = useState('')
|
||||||
|
|
||||||
|
const handleAddCategory = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (newCategoryName.trim()) {
|
||||||
|
createCategory.mutate({
|
||||||
|
name: newCategoryName.trim(),
|
||||||
|
orderId: categoriesQuery.data.length,
|
||||||
|
})
|
||||||
|
setNewCategoryName('')
|
||||||
|
setShowAddCategory(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 py-8 px-4 sm:px-6">
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
|
<SearchBar />
|
||||||
|
|
||||||
|
<GreetingHeader />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{categoriesQuery.data.map((category) => (
|
||||||
|
<CategorySection key={category.id} category={category} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{categoriesQuery.data.length === 0 && !showAddCategory && (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<p className="text-slate-400 text-lg">还没有任何分类</p>
|
||||||
|
<p className="text-slate-400 text-sm mt-1">创建一个分类来开始添加书签</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAddCategory ? (
|
||||||
|
<form onSubmit={handleAddCategory} className="flex items-center gap-2 max-w-md mx-auto">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newCategoryName}
|
||||||
|
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||||
|
placeholder="分类名称"
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl bg-white ring-1 ring-slate-200 outline-none focus:ring-2 focus:ring-indigo-500/50 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createCategory.isPending || !newCategoryName.trim()}
|
||||||
|
className="px-4 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{createCategory.isPending ? '创建中...' : '创建'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAddCategory(false)}
|
||||||
|
className="p-2.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAddCategory(true)}
|
||||||
|
className="flex items-center justify-center gap-2 w-full py-3 rounded-xl border-2 border-dashed border-slate-200 text-slate-400 hover:border-slate-300 hover:text-slate-500 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" /> 添加分类
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
|
||||||
|
import * as icons from 'lucide-react'
|
||||||
|
import { modules } from '@/modules/registry'
|
||||||
|
import { authClient } from '@/server/auth/client'
|
||||||
|
|
||||||
|
const iconComponents = icons as unknown as Record<string, typeof icons.Box>
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_protected' as never)({
|
||||||
|
component: DashboardPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function DashboardPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { user } = Route.useRouteContext() as {
|
||||||
|
user: {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const enabledModules = modules.filter((mod) => mod.enabled)
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
await authClient.signOut()
|
||||||
|
router.navigate({ to: '/login' as never })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 px-4 py-12 sm:px-6">
|
||||||
|
<div className="mx-auto max-w-4xl space-y-8">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-slate-900">Kairos</h1>
|
||||||
|
<p className="mt-1 text-slate-500">
|
||||||
|
欢迎回来,{user.name} · {user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSignOut}
|
||||||
|
className="rounded-lg px-4 py-2 text-sm text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{enabledModules.length === 0 ? (
|
||||||
|
<div className="py-20 text-center text-slate-400">暂无可用模块</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{enabledModules.map((mod) => {
|
||||||
|
const IconComponent = iconComponents[mod.icon] ?? icons.Box
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={mod.id}
|
||||||
|
to={mod.route as never}
|
||||||
|
className="group block rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-100 transition-all hover:shadow-md hover:ring-slate-200"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-indigo-50 transition-colors group-hover:bg-indigo-100">
|
||||||
|
<IconComponent className="h-6 w-6 text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-900">{mod.name}</h3>
|
||||||
|
<p className="mt-0.5 text-sm text-slate-500">{mod.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user