refactor: 统一应用架构 — 消除前后台割裂,引入全局侧边栏、命令面板和 Motion 动效

- 移除独立的 /admin 路由层,路由扁平化为 /bookmarks
- 用 AppSidebar 替代 AdminSidebar,SidebarProvider 提升至 _protected 全局布局
- 新增 ⌘K 命令面板(cmdk + @tanstack/react-hotkeys),支持书签搜索、搜索引擎跳转和页面导航
- 书签页查看/管理一体化,通过编辑模式开关切换,AnimatePresence 平滑过渡
- 总览驾驶舱:Motion stagger 入场动画、常用书签快捷区、模块概览卡片
- 统一设计语言:BookmarkCard/CategoryGrid 用 design token 替代硬编码 stone 色
- ModuleMetadata.adminRoute 重命名为 route
- 同步更新 AGENTS.md 文档
This commit is contained in:
2026-03-31 21:36:44 +08:00
parent 588df9f143
commit a369fe853e
19 changed files with 973 additions and 283 deletions
+67 -13
View File
@@ -15,6 +15,9 @@ TanStack Start fullstack web app with ORPC (contract-first RPC) and shadcn/ui.
- **CLI**: citty (server-side admin commands)
- **DnD**: @dnd-kit/react + @dnd-kit/helpers (`move()` for sortable)
- **Virtualization**: @tanstack/react-virtual (`useVirtualizer`)
- **Hotkeys**: @tanstack/react-hotkeys (`useHotkey` for type-safe keyboard shortcuts)
- **Animation**: motion (page transitions, staggered entrance, layout animation)
- **Command Palette**: cmdk (via shadcn Command component, triggered by ⌘K)
- **Build**: Vite + Nitro
## Architecture Overview
@@ -22,22 +25,21 @@ TanStack Start fullstack web app with ORPC (contract-first RPC) and shadcn/ui.
### Route Architecture
```
/ → Dashboard homepage (bookmark display, daily use)
/admin → Admin panel overview (module cards)
/admin/bookmarks → Bookmark management (CRUD, DnD, Dialog forms)
/ → Dashboard overview (greeting, quick bookmarks, module summary)
/bookmarks → Bookmarks page (view mode + edit mode toggle)
/setup → One-time owner setup (first visit only, redirects to /login after)
/login → Login page (redirects to /setup if no owner exists)
```
- **Unified shell**: All authenticated pages share a global sidebar (`AppSidebar`) and command palette (`⌘K`). There is NO separate admin panel — view and management are integrated in each module page.
- **Single-owner model**: Kairos is a self-hosted Life OS. Only ONE user (the owner) exists. There is NO registration page — `/setup` is a one-time wizard shown on first visit.
- **Display pages** (`/`): Clean, no management UI. What users see daily.
- **Admin pages** (`/admin/*`): Full CRUD, management, configuration. Sidebar navigation.
- All authenticated routes under `_protected` layout (auth guard → redirect to `/login`).
- **Module pages** (`/bookmarks`, etc.): Each module page has a view mode (clean display) and an edit mode (CRUD, DnD). Toggle via a button in the page header.
- All authenticated routes under `_protected` layout (auth guard + SidebarProvider + CommandPalette → redirect to `/login`).
### Module System
Modules are directory-based under `src/modules/`. Each module provides:
- `index.ts``ModuleMetadata` (id, name, icon, adminRoute)
- `index.ts``ModuleMetadata` (id, name, icon, route)
- `schema.ts` — Drizzle tables
- `contract.ts` — ORPC contracts (input/output Zod schemas)
- `router.ts` — ORPC handlers (business logic)
@@ -54,7 +56,8 @@ src/
├── client/
│ └── orpc.ts # ORPC client (isomorphic: SSR direct call / CSR fetch)
├── components/
│ ├── AdminSidebar.tsx # Admin sidebar (reads module registry)
│ ├── AppSidebar.tsx # Unified sidebar (reads module registry, collapsible)
│ ├── CommandPalette.tsx # ⌘K command palette (search bookmarks, engines, navigation)
│ ├── Error.tsx # Error boundary fallback
│ ├── NotFound.tsx # 404 fallback
│ └── ui/ # shadcn/ui components (可自由修改,添加新组件用 bunx shadcn@latest add)
@@ -70,11 +73,10 @@ src/
│ └── commands/auth.ts # auth reset-password command
├── routes/ # TanStack Router file routes
│ ├── __root.tsx # Root layout (HTML shell, Toaster)
│ ├── _protected.tsx # Auth guard layout
│ ├── _protected.tsx # Auth guard + unified shell (SidebarProvider, AppSidebar, CommandPalette)
│ ├── _protected/
│ │ ├── index.tsx # Dashboard homepage
│ │ ── admin.tsx # Admin layout (SidebarProvider)
│ │ └── admin/bookmarks.tsx
│ │ ├── index.tsx # Dashboard overview (greeting, quick bookmarks, module cards)
│ │ ── bookmarks.tsx # Bookmarks page (view/edit toggle, Motion animations)
│ ├── login.tsx, setup.tsx
│ └── api/ # $.ts (OpenAPI), auth.$.ts, health.ts, rpc.$.ts
├── server/
@@ -171,7 +173,7 @@ shadcn/ui uses `@base-ui/react`. The `render` prop replaces Radix's `asChild`:
```typescript
// ✅ CORRECT
<DialogTrigger render={<Button />} />
<SidebarMenuButton render={<Link to="/admin" />}>
<SidebarMenuButton render={<Link to="/bookmarks" />}>
// ❌ WRONG — asChild does NOT exist
<DialogTrigger asChild><Button /></DialogTrigger>
@@ -244,6 +246,55 @@ toast.success('操作成功')
toast.error('操作失败')
```
### Motion Animations
Use `motion` for page transitions, staggered entrance, and layout animations:
```typescript
import { AnimatePresence } from 'motion/react'
import * as motion from 'motion/react-client'
const containerVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { staggerChildren: 0.06, delayChildren: 0.1 } },
}
const itemVariants = {
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] as const } },
}
<motion.div variants={containerVariants} initial="hidden" animate="visible">
{items.map((item) => (
<motion.div key={item.id} variants={itemVariants}>...</motion.div>
))}
</motion.div>
// AnimatePresence for mode switching (e.g., view ↔ edit)
<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 }}>
...
</motion.div>
) : (
<motion.div key="view" ...>...</motion.div>
)}
</AnimatePresence>
```
### Keyboard Shortcuts (TanStack Hotkeys)
```typescript
import { useHotkey } from '@tanstack/react-hotkeys'
useHotkey('Mod+K', () => openCommandPalette()) // ⌘K on Mac, Ctrl+K on Windows
useHotkey('Mod+S', () => save())
```
### Command Palette (⌘K)
Global command palette in `_protected` layout. Uses shadcn `CommandDialog` + `@tanstack/react-hotkeys`:
- Search bookmarks by name/URL/category
- Search engine shortcuts: `/g query`, `/gh query`, `/yt query`
- Page navigation: 总览, 书签导航
- Quick actions: 管理书签
## Database (Drizzle ORM 0.45.x)
- **Driver**: `drizzle-orm/postgres-js` (NOT `bun-sql`)
@@ -300,6 +351,8 @@ Kairos is a self-hosted single-user app. There is NO public registration. The fi
- Use callback syntax for `orderBy` and `where` in relational queries
- Use `move()` from `@dnd-kit/helpers` for DnD reordering
- Use `useState` callback ref for virtualizer scroll elements inside Dialogs
- Use `motion` for page transitions and staggered entrance animations
- Use `useHotkey` from `@tanstack/react-hotkeys` for keyboard shortcuts
**DON'T:**
- Add new `src/components/ui/*.tsx` without CLI (use `bunx shadcn@latest add` to scaffold, then freely customize)
@@ -313,3 +366,4 @@ Kairos is a self-hosted single-user app. There is NO public registration. The fi
- Use `db:push` — always use `db:generate``db:migrate`
- Use `@/*` aliases in Drizzle schema files (drizzle-kit can't resolve them)
- Add registration/signup functionality (single-owner model, enforced by `databaseHooks`)
- Create separate admin pages — integrate view/edit modes in each module page
+2
View File
@@ -45,9 +45,11 @@
"citty": "catalog:",
"class-variance-authority": "catalog:",
"clsx": "catalog:",
"cmdk": "^1.1.1",
"drizzle-orm": "catalog:",
"drizzle-zod": "catalog:",
"lucide-react": "catalog:",
"motion": "^12.38.0",
"next-themes": "catalog:",
"postgres": "catalog:",
"react": "catalog:",
@@ -1,18 +1,18 @@
import { Link, useRouter, useRouterState } from '@tanstack/react-router'
import type { LucideIcon } from 'lucide-react'
import * as LucideIcons from 'lucide-react'
import { Circle, Home, LayoutDashboard, LogOut } from 'lucide-react'
import { Circle, Home, LogOut, Search } from 'lucide-react'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarSeparator,
} from '@/components/ui/sidebar'
import { modules } from '@/modules/registry'
import { authClient } from '@/server/auth/client'
@@ -23,7 +23,7 @@ const resolveIcon = (name: string): LucideIcon => {
return (typeof icon === 'function' ? icon : Circle) as LucideIcon
}
export const AdminSidebar = () => {
export const AppSidebar = ({ onOpenCommandPalette }: { onOpenCommandPalette: () => void }) => {
const router = useRouter()
const routerState = useRouterState()
const currentPath = routerState.location.pathname
@@ -34,21 +34,29 @@ export const AdminSidebar = () => {
}
return (
<Sidebar>
<SidebarHeader className="px-4 py-6">
<h2 className="text-lg font-semibold tracking-tight">Kairos</h2>
<Sidebar collapsible="icon">
<SidebarHeader className="px-4 py-4">
<Link to={'/' as never} className="flex items-center gap-2">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-foreground text-background">
<span className="text-sm font-bold">K</span>
</div>
<span className="truncate text-lg font-semibold tracking-tight group-data-[collapsible=icon]:hidden">
Kairos
</span>
</Link>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel></SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton render={<Link to={'/admin' as never} />} isActive={currentPath === '/admin'}>
<LayoutDashboard />
<SidebarMenuButton render={<Link to={'/' as never} />} isActive={currentPath === '/'} tooltip="总览">
<Home />
<span></span>
</SidebarMenuButton>
</SidebarMenuItem>
{modules
.filter((mod) => mod.enabled)
.map((mod) => {
@@ -56,8 +64,9 @@ export const AdminSidebar = () => {
return (
<SidebarMenuItem key={mod.id}>
<SidebarMenuButton
render={<Link to={mod.adminRoute as never} />}
isActive={currentPath.startsWith(mod.adminRoute)}
render={<Link to={mod.route as never} />}
isActive={currentPath.startsWith(mod.route)}
tooltip={mod.name}
>
<Icon />
<span>{mod.name}</span>
@@ -68,17 +77,30 @@ export const AdminSidebar = () => {
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarSeparator />
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton onClick={onOpenCommandPalette} tooltip="搜索 ⌘K">
<Search />
<span></span>
<kbd className="ml-auto rounded border bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground group-data-[collapsible=icon]:hidden">
K
</kbd>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton render={<Link to={'/' as never} />}>
<Home />
<span></span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton onClick={handleSignOut}>
<SidebarMenuButton onClick={handleSignOut} tooltip="退出登录">
<LogOut />
<span>退</span>
</SidebarMenuButton>
@@ -0,0 +1,163 @@
import { useHotkey } from '@tanstack/react-hotkeys'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import * as icons from 'lucide-react'
import { Compass, ExternalLink, Home, Plus, Search } from 'lucide-react'
import { useState } from 'react'
import { orpc } from '@/client/orpc'
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
const allIcons = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>
const SEARCH_ENGINES: Record<string, { name: string; url: string }> = {
g: { name: 'Google', url: 'https://google.com/search?q=' },
d: { name: 'DuckDuckGo', url: 'https://duckduckgo.com/?q=' },
b: { name: 'Bing', url: 'https://bing.com/search?q=' },
gh: { name: 'GitHub', url: 'https://github.com/search?q=' },
yt: { name: 'YouTube', url: 'https://youtube.com/results?search_query=' },
}
interface CommandPaletteProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export const CommandPalette = ({ open, onOpenChange }: CommandPaletteProps) => {
const navigate = useNavigate()
const [search, setSearch] = useState('')
useHotkey('Mod+K', () => {
onOpenChange(!open)
})
const { data: categories } = useQuery(orpc.bookmarks.category.list.queryOptions())
const allBookmarks =
categories?.flatMap((cat) =>
cat.bookmarks.map((b: { id: string; name: string; url: string; icon: string | null }) => ({
...b,
categoryName: cat.name,
})),
) ?? []
const handleSelect = (callback: () => void) => {
setSearch('')
onOpenChange(false)
callback()
}
const engineMatch = search.match(/^\/(\w+)\s+(.+)$/)
const engineKey = engineMatch?.[1]?.toLowerCase() ?? ''
const matchedEngine = engineMatch ? SEARCH_ENGINES[engineKey] : null
const engineQuery = engineMatch?.[2] ?? ''
const isUrl = /^https?:\/\//i.test(search.trim()) || /^[^\s]+\.[^\s]+$/.test(search.trim())
return (
<CommandDialog open={open} onOpenChange={onOpenChange} title="命令面板" description="搜索书签、页面或执行操作">
<Command shouldFilter={!matchedEngine}>
<CommandInput placeholder="搜索书签、页面,或 /g 搜索 Google..." value={search} onValueChange={setSearch} />
<CommandList>
<CommandEmpty>
{search.trim() ? (
<button
type="button"
className="inline-flex items-center gap-2 text-muted-foreground transition-colors hover:text-foreground"
onClick={() =>
handleSelect(() => {
const url = isUrl
? search.trim().startsWith('http')
? search.trim()
: `https://${search.trim()}`
: `https://google.com/search?q=${encodeURIComponent(search.trim())}`
window.open(url, '_blank', 'noopener,noreferrer')
})
}
>
<ExternalLink className="size-3.5" />
{isUrl ? `打开 ${search.trim()}` : `用 Google 搜索「${search.trim()}`}
</button>
) : (
<span className="text-muted-foreground"></span>
)}
</CommandEmpty>
{matchedEngine && (
<CommandGroup heading="搜索引擎">
<CommandItem
onSelect={() =>
handleSelect(() => {
window.open(
`${matchedEngine.url}${encodeURIComponent(engineQuery)}`,
'_blank',
'noopener,noreferrer',
)
})
}
>
<Search className="size-4" />
<span>
{matchedEngine.name} {engineQuery}
</span>
</CommandItem>
</CommandGroup>
)}
{allBookmarks.length > 0 && !matchedEngine && (
<CommandGroup heading="书签">
{allBookmarks.map(
(bookmark: { id: string; name: string; url: string; icon: string | null; categoryName: string }) => {
const Icon = (bookmark.icon && allIcons[bookmark.icon]) || icons.Globe
return (
<CommandItem
key={bookmark.id}
value={`${bookmark.name} ${bookmark.url} ${bookmark.categoryName}`}
onSelect={() =>
handleSelect(() => {
window.open(bookmark.url, '_blank', 'noopener,noreferrer')
})
}
>
<Icon className="size-4" />
<span>{bookmark.name}</span>
<span className="ml-auto truncate text-xs text-muted-foreground">{bookmark.categoryName}</span>
</CommandItem>
)
},
)}
</CommandGroup>
)}
<CommandSeparator />
<CommandGroup heading="导航">
<CommandItem onSelect={() => handleSelect(() => navigate({ to: '/' as never }))}>
<Home className="size-4" />
<span></span>
</CommandItem>
<CommandItem onSelect={() => handleSelect(() => navigate({ to: '/bookmarks' as never }))}>
<Compass className="size-4" />
<span></span>
</CommandItem>
</CommandGroup>
<CommandGroup heading="快捷操作">
<CommandItem onSelect={() => handleSelect(() => navigate({ to: '/bookmarks' as never }))}>
<Plus className="size-4" />
<span></span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
)
}
+152
View File
@@ -0,0 +1,152 @@
'use client'
import { Command as CommandPrimitive } from 'cmdk'
import { CheckIcon, SearchIcon } from 'lucide-react'
import type * as React from 'react'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { InputGroup, InputGroupAddon } from '@/components/ui/input-group'
import { cn } from '@/lib/utils'
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
'flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground',
className,
)}
{...props}
/>
)
}
function CommandDialog({
title = 'Command Palette',
description = 'Search for a command to run...',
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, 'children'> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
children: React.ReactNode
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn('top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0', className)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
)
}
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn('w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50', className)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn('no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none', className)}
{...props}
/>
)
}
function CommandEmpty({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn('py-6 text-center text-sm', className)}
{...props}
/>
)
}
function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
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}
/>
)
}
function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
)
}
function CommandItem({ className, children, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className,
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="command-shortcut"
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground',
className,
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
}
@@ -0,0 +1,135 @@
import { cva, type VariantProps } from 'class-variance-authority'
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
'group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5',
className,
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
'inline-start': 'order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]',
'inline-end': 'order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]',
'block-start':
'order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2',
'block-end': 'order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2',
},
},
defaultVariants: {
align: 'inline-start',
},
},
)
function InputGroupAddon({
className,
align = 'inline-start',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button')) {
return
}
e.currentTarget.parentElement?.querySelector('input')?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva('flex items-center gap-2 text-sm shadow-none', {
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: '',
'icon-xs': 'size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
},
},
defaultVariants: {
size: 'xs',
},
})
function InputGroupButton({
className,
type = 'button',
variant = 'ghost',
size = 'xs',
...props
}: Omit<React.ComponentProps<typeof Button>, 'size' | 'type'> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: 'button' | 'submit' | 'reset'
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>) {
return (
<Input
data-slot="input-group-control"
className={cn(
'flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent',
className,
)}
{...props}
/>
)
}
function InputGroupTextarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
'flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent',
className,
)}
{...props}
/>
)
}
export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText, InputGroupTextarea }
@@ -0,0 +1,18 @@
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className,
)}
{...props}
/>
)
}
export { Textarea }
@@ -21,16 +21,15 @@ export const BookmarkCard = ({ bookmark }: BookmarkCardProps) => {
target="_blank"
rel="noopener noreferrer"
className={cn(
'group flex items-center gap-3.5 rounded-xl bg-white px-4 py-3.5',
'ring-1 ring-stone-100 shadow-sm',
'group flex items-center gap-3.5 rounded-xl border bg-card px-4 py-3.5',
'transition-all duration-200 ease-out',
'hover:-translate-y-0.5 hover:shadow-lg hover:ring-stone-200',
'hover:-translate-y-0.5 hover:border-foreground/10 hover:shadow-md',
)}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-stone-50/80 transition-colors group-hover:bg-stone-100">
<Icon className="h-[18px] w-[18px] text-stone-500 transition-colors group-hover:text-stone-800" />
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted/60 transition-colors group-hover:bg-muted">
<Icon className="size-[18px] text-muted-foreground transition-colors group-hover:text-foreground" />
</div>
<span className="min-w-0 truncate text-sm font-medium text-stone-600 transition-colors group-hover:text-stone-900">
<span className="min-w-0 truncate text-sm font-medium transition-colors group-hover:text-foreground">
{bookmark.name}
</span>
</a>
@@ -1,4 +1,3 @@
import { Link } from '@tanstack/react-router'
import { BookmarkCard } from './BookmarkCard'
interface Bookmark {
@@ -21,33 +20,15 @@ interface CategoryGridProps {
}
export const CategoryGrid = ({ categories }: CategoryGridProps) => {
if (categories.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-32 text-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-stone-50 ring-1 ring-stone-100">
<span className="text-2xl"></span>
</div>
<h3 className="mb-2 text-lg font-medium text-stone-800"></h3>
<p className="mb-6 text-sm text-stone-400"></p>
<Link
to={'/admin/bookmarks' as never}
className="rounded-lg bg-stone-800 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-stone-700"
>
</Link>
</div>
)
}
return (
<div className="grid grid-cols-1 gap-10 md:grid-cols-2 lg:grid-cols-3">
{categories.map((category) => (
<div key={category.id} className="flex flex-col gap-3.5">
<h2 className="px-1 text-xs font-semibold tracking-wide text-stone-400 uppercase">{category.name}</h2>
<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 border-stone-200 py-6 text-center">
<span className="text-sm text-stone-400"></span>
<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">
+1 -1
View File
@@ -5,6 +5,6 @@ export const bookmarksModule: ModuleMetadata = {
name: '书签导航',
description: '常用链接和网站的快速导航',
icon: 'Compass',
adminRoute: '/admin/bookmarks',
route: '/bookmarks',
enabled: true,
}
+1 -1
View File
@@ -5,7 +5,7 @@ export interface ModuleMetadata {
name: string
description: string
icon: string
adminRoute: string
route: string
enabled: boolean
}
+17 -67
View File
@@ -15,11 +15,9 @@ import { Route as ProtectedRouteImport } from './routes/_protected'
import { Route as ProtectedIndexRouteImport } from './routes/_protected/index'
import { Route as ApiHealthRouteImport } from './routes/api/health'
import { Route as ApiSplatRouteImport } from './routes/api/$'
import { Route as ProtectedAdminRouteImport } from './routes/_protected/admin'
import { Route as ProtectedAdminIndexRouteImport } from './routes/_protected/admin/index'
import { Route as ProtectedBookmarksRouteImport } from './routes/_protected/bookmarks'
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
import { Route as ProtectedAdminBookmarksRouteImport } from './routes/_protected/admin/bookmarks'
const SetupRoute = SetupRouteImport.update({
id: '/setup',
@@ -50,16 +48,11 @@ const ApiSplatRoute = ApiSplatRouteImport.update({
path: '/api/$',
getParentRoute: () => rootRouteImport,
} as any)
const ProtectedAdminRoute = ProtectedAdminRouteImport.update({
id: '/admin',
path: '/admin',
const ProtectedBookmarksRoute = ProtectedBookmarksRouteImport.update({
id: '/bookmarks',
path: '/bookmarks',
getParentRoute: () => ProtectedRoute,
} as any)
const ProtectedAdminIndexRoute = ProtectedAdminIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => ProtectedAdminRoute,
} as any)
const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
id: '/api/rpc/$',
path: '/api/rpc/$',
@@ -70,48 +63,38 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
path: '/api/auth/$',
getParentRoute: () => rootRouteImport,
} as any)
const ProtectedAdminBookmarksRoute = ProtectedAdminBookmarksRouteImport.update({
id: '/bookmarks',
path: '/bookmarks',
getParentRoute: () => ProtectedAdminRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof ProtectedIndexRoute
'/login': typeof LoginRoute
'/setup': typeof SetupRoute
'/admin': typeof ProtectedAdminRouteWithChildren
'/bookmarks': typeof ProtectedBookmarksRoute
'/api/$': typeof ApiSplatRoute
'/api/health': typeof ApiHealthRoute
'/admin/bookmarks': typeof ProtectedAdminBookmarksRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
'/admin/': typeof ProtectedAdminIndexRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/setup': typeof SetupRoute
'/bookmarks': typeof ProtectedBookmarksRoute
'/api/$': typeof ApiSplatRoute
'/api/health': typeof ApiHealthRoute
'/': typeof ProtectedIndexRoute
'/admin/bookmarks': typeof ProtectedAdminBookmarksRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
'/admin': typeof ProtectedAdminIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_protected': typeof ProtectedRouteWithChildren
'/login': typeof LoginRoute
'/setup': typeof SetupRoute
'/_protected/admin': typeof ProtectedAdminRouteWithChildren
'/_protected/bookmarks': typeof ProtectedBookmarksRoute
'/api/$': typeof ApiSplatRoute
'/api/health': typeof ApiHealthRoute
'/_protected/': typeof ProtectedIndexRoute
'/_protected/admin/bookmarks': typeof ProtectedAdminBookmarksRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
'/_protected/admin/': typeof ProtectedAdminIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -119,37 +102,32 @@ export interface FileRouteTypes {
| '/'
| '/login'
| '/setup'
| '/admin'
| '/bookmarks'
| '/api/$'
| '/api/health'
| '/admin/bookmarks'
| '/api/auth/$'
| '/api/rpc/$'
| '/admin/'
fileRoutesByTo: FileRoutesByTo
to:
| '/login'
| '/setup'
| '/bookmarks'
| '/api/$'
| '/api/health'
| '/'
| '/admin/bookmarks'
| '/api/auth/$'
| '/api/rpc/$'
| '/admin'
id:
| '__root__'
| '/_protected'
| '/login'
| '/setup'
| '/_protected/admin'
| '/_protected/bookmarks'
| '/api/$'
| '/api/health'
| '/_protected/'
| '/_protected/admin/bookmarks'
| '/api/auth/$'
| '/api/rpc/$'
| '/_protected/admin/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -206,20 +184,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiSplatRouteImport
parentRoute: typeof rootRouteImport
}
'/_protected/admin': {
id: '/_protected/admin'
path: '/admin'
fullPath: '/admin'
preLoaderRoute: typeof ProtectedAdminRouteImport
'/_protected/bookmarks': {
id: '/_protected/bookmarks'
path: '/bookmarks'
fullPath: '/bookmarks'
preLoaderRoute: typeof ProtectedBookmarksRouteImport
parentRoute: typeof ProtectedRoute
}
'/_protected/admin/': {
id: '/_protected/admin/'
path: '/'
fullPath: '/admin/'
preLoaderRoute: typeof ProtectedAdminIndexRouteImport
parentRoute: typeof ProtectedAdminRoute
}
'/api/rpc/$': {
id: '/api/rpc/$'
path: '/api/rpc/$'
@@ -234,37 +205,16 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiAuthSplatRouteImport
parentRoute: typeof rootRouteImport
}
'/_protected/admin/bookmarks': {
id: '/_protected/admin/bookmarks'
path: '/bookmarks'
fullPath: '/admin/bookmarks'
preLoaderRoute: typeof ProtectedAdminBookmarksRouteImport
parentRoute: typeof ProtectedAdminRoute
}
}
}
interface ProtectedAdminRouteChildren {
ProtectedAdminBookmarksRoute: typeof ProtectedAdminBookmarksRoute
ProtectedAdminIndexRoute: typeof ProtectedAdminIndexRoute
}
const ProtectedAdminRouteChildren: ProtectedAdminRouteChildren = {
ProtectedAdminBookmarksRoute: ProtectedAdminBookmarksRoute,
ProtectedAdminIndexRoute: ProtectedAdminIndexRoute,
}
const ProtectedAdminRouteWithChildren = ProtectedAdminRoute._addFileChildren(
ProtectedAdminRouteChildren,
)
interface ProtectedRouteChildren {
ProtectedAdminRoute: typeof ProtectedAdminRouteWithChildren
ProtectedBookmarksRoute: typeof ProtectedBookmarksRoute
ProtectedIndexRoute: typeof ProtectedIndexRoute
}
const ProtectedRouteChildren: ProtectedRouteChildren = {
ProtectedAdminRoute: ProtectedAdminRouteWithChildren,
ProtectedBookmarksRoute: ProtectedBookmarksRoute,
ProtectedIndexRoute: ProtectedIndexRoute,
}
+20 -1
View File
@@ -1,4 +1,8 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import { useState } from 'react'
import { AppSidebar } from '@/components/AppSidebar'
import { CommandPalette } from '@/components/CommandPalette'
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
import { getSession } from '@/server/auth/functions'
export const Route = createFileRoute('/_protected' as never)({
@@ -13,5 +17,20 @@ export const Route = createFileRoute('/_protected' as never)({
})
function ProtectedLayout() {
return <Outlet />
const [commandOpen, setCommandOpen] = useState(false)
return (
<SidebarProvider>
<AppSidebar onOpenCommandPalette={() => setCommandOpen(true)} />
<SidebarInset>
<header className="flex h-12 shrink-0 items-center px-4">
<SidebarTrigger className="-ml-1" />
</header>
<div className="flex min-h-0 flex-1 flex-col">
<Outlet />
</div>
</SidebarInset>
<CommandPalette open={commandOpen} onOpenChange={setCommandOpen} />
</SidebarProvider>
)
}
@@ -1,23 +0,0 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
import { AdminSidebar } from '@/components/AdminSidebar'
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
export const Route = createFileRoute('/_protected/admin' as never)({
component: AdminLayout,
})
function AdminLayout() {
return (
<SidebarProvider>
<AdminSidebar />
<SidebarInset>
<header className="flex h-14 items-center gap-2 border-b px-6">
<SidebarTrigger />
</header>
<div className="flex min-h-0 flex-1 flex-col p-6">
<Outlet />
</div>
</SidebarInset>
</SidebarProvider>
)
}
@@ -1,61 +0,0 @@
import type { QueryClient } from '@tanstack/react-query'
import { useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { orpc } from '@/client/orpc'
import { BookmarkManager } from '@/modules/bookmarks/components/BookmarkManager'
import { CategoryManager } from '@/modules/bookmarks/components/CategoryManager'
export const Route = createFileRoute('/_protected/admin/bookmarks' as never)({
loader: async ({ context }: { context: { queryClient: QueryClient } }) => {
await context.queryClient.fetchQuery(orpc.bookmarks.category.list.queryOptions())
},
component: BookmarksAdmin,
})
function BookmarksAdmin() {
const { data } = useSuspenseQuery(orpc.bookmarks.category.list.queryOptions())
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null)
useEffect(() => {
if (selectedCategoryId === null && data.length > 0) {
setSelectedCategoryId(data[0]?.id ?? null)
}
}, [data, selectedCategoryId])
useEffect(() => {
if (selectedCategoryId && !data.some((category: { id: string }) => category.id === selectedCategoryId)) {
setSelectedCategoryId(data[0]?.id ?? null)
}
}, [data, selectedCategoryId])
const selectedCategory = data.find((category: { id: string }) => category.id === selectedCategoryId)
return (
<div className="flex min-h-0 flex-1 flex-col gap-6">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground"></p>
</div>
<div className="flex min-h-0 flex-1 gap-6">
<div className="w-80 shrink-0">
<CategoryManager
categories={data}
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>
</div>
</div>
)
}
@@ -1,48 +0,0 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import type { LucideIcon } from 'lucide-react'
import * as LucideIcons from 'lucide-react'
import { Circle } from 'lucide-react'
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { modules } from '@/modules/registry'
const resolveIcon = (name: string): LucideIcon => {
const icons = LucideIcons as Record<string, unknown>
const icon = icons[name]
return (typeof icon === 'function' ? icon : Circle) as LucideIcon
}
export const Route = createFileRoute('/_protected/admin/' as never)({
component: AdminOverview,
})
function AdminOverview() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground"> Kairos </p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{modules
.filter((mod) => mod.enabled)
.map((mod) => {
const Icon = resolveIcon(mod.icon)
return (
<Link key={mod.id} to={mod.adminRoute as never} className="block">
<Card className="h-full transition-colors hover:bg-muted/50">
<CardHeader>
<div className="flex items-center gap-2">
<Icon className="h-5 w-5" />
<CardTitle className="text-lg">{mod.name}</CardTitle>
</div>
<CardDescription>{mod.description}</CardDescription>
</CardHeader>
</Card>
</Link>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,161 @@
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>
)
}
+116 -22
View File
@@ -1,11 +1,12 @@
import type { QueryClient } from '@tanstack/react-query'
import { useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute, Link } from '@tanstack/react-router'
import { Settings } from 'lucide-react'
import * as icons from 'lucide-react'
import { ArrowRight, Compass, Plus } from 'lucide-react'
import * as motion from 'motion/react-client'
import { orpc } from '@/client/orpc'
import { CategoryGrid } from '@/modules/bookmarks/components/CategoryGrid'
import { GreetingHeader } from '@/modules/bookmarks/components/GreetingHeader'
import { SearchBar } from '@/modules/bookmarks/components/SearchBar'
const allIcons = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>
export const Route = createFileRoute('/_protected/' as never)({
loader: async ({ context }: { context: { queryClient: QueryClient } }) => {
@@ -14,31 +15,124 @@ export const Route = createFileRoute('/_protected/' as never)({
component: DashboardPage,
})
const getGreeting = (hour: number): string => {
if (hour >= 5 && hour < 12) return '早上好'
if (hour >= 12 && hour < 14) return '中午好'
if (hour >= 14 && hour < 18) return '下午好'
return '晚上好'
}
const formatDate = (date: Date): string => {
const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
const weekday = weekdays[date.getDay()]
return `${year}${month}${day}${weekday}`
}
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.06, delayChildren: 0.1 },
},
}
const itemVariants = {
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] as const } },
}
function DashboardPage() {
const { data: categories } = useSuspenseQuery(orpc.bookmarks.category.list.queryOptions())
const now = new Date()
const totalBookmarks = categories.reduce(
(sum: number, cat: { bookmarks: Array<{ id: string }> }) => sum + cat.bookmarks.length,
0,
)
const topBookmarks = categories
.flatMap((cat: { bookmarks: Array<{ id: string; name: string; url: string; icon: string | null }> }) =>
cat.bookmarks.slice(0, 4),
)
.slice(0, 8)
return (
<div className="min-h-screen bg-stone-50/50 px-4 py-12 font-sans sm:px-6">
<div className="mx-auto max-w-5xl space-y-12">
<div className="flex items-center justify-between">
<GreetingHeader />
<motion.div className="flex-1 px-6 pb-8" variants={containerVariants} initial="hidden" animate="visible">
<motion.div variants={itemVariants} className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">{getGreeting(now.getHours())}</h1>
<p className="mt-1 text-muted-foreground">{formatDate(now)}</p>
</motion.div>
{topBookmarks.length > 0 && (
<motion.div variants={itemVariants} className="mb-8">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-medium text-muted-foreground"></h2>
<Link
to={'/bookmarks' as never}
className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowRight className="size-3" />
</Link>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{topBookmarks.map((bookmark: { id: string; name: string; url: string; icon: string | null }) => {
const Icon = (bookmark.icon && allIcons[bookmark.icon]) || icons.Globe
return (
<motion.a
key={bookmark.id}
href={bookmark.url}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-3 rounded-xl border bg-card px-3.5 py-3 transition-all duration-200 hover:-translate-y-0.5 hover:border-foreground/10 hover:shadow-md"
variants={itemVariants}
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted/60 transition-colors group-hover:bg-muted">
<Icon className="size-4 text-muted-foreground transition-colors group-hover:text-foreground" />
</div>
<span className="min-w-0 truncate text-sm font-medium">{bookmark.name}</span>
</motion.a>
)
})}
</div>
</motion.div>
)}
<motion.div variants={itemVariants}>
<h2 className="mb-4 text-sm font-medium text-muted-foreground"></h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Link
to={'/admin' as never}
className="rounded-full p-2.5 text-stone-400 transition-colors hover:bg-stone-100 hover:text-stone-600"
title="管理后台"
to={'/bookmarks' as never}
className="group flex items-center gap-4 rounded-xl border bg-card p-5 transition-all duration-200 hover:-translate-y-0.5 hover:border-foreground/10 hover:shadow-md"
>
<Settings className="h-5 w-5" />
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted/60 transition-colors group-hover:bg-muted">
<Compass className="size-5 text-muted-foreground transition-colors group-hover:text-foreground" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium"></p>
<p className="text-xs text-muted-foreground">
{categories.length} · {totalBookmarks}
</p>
</div>
<ArrowRight className="size-4 text-muted-foreground opacity-0 transition-all group-hover:opacity-100" />
</Link>
<Link
to={'/bookmarks' as never}
className="group flex items-center gap-4 rounded-xl border border-dashed bg-card p-5 transition-all duration-200 hover:-translate-y-0.5 hover:border-foreground/10 hover:shadow-md"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted/60 transition-colors group-hover:bg-muted">
<Plus className="size-5 text-muted-foreground transition-colors group-hover:text-foreground" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium"></p>
<p className="text-xs text-muted-foreground"></p>
</div>
</Link>
</div>
<div className="mx-auto max-w-2xl py-4">
<SearchBar />
</div>
<main>
<CategoryGrid categories={categories} />
</main>
</div>
</div>
</motion.div>
</motion.div>
)
}