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:
+67
-13
@@ -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
|
||||
|
||||
@@ -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:",
|
||||
|
||||
+40
-18
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -5,6 +5,6 @@ export const bookmarksModule: ModuleMetadata = {
|
||||
name: '书签导航',
|
||||
description: '常用链接和网站的快速导航',
|
||||
icon: 'Compass',
|
||||
adminRoute: '/admin/bookmarks',
|
||||
route: '/bookmarks',
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface ModuleMetadata {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
adminRoute: string
|
||||
route: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user