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>
)
}
+111 -17
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={'/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="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<Settings className="h-5 w-5" />
<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>
)}
<div className="mx-auto max-w-2xl py-4">
<SearchBar />
<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={'/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"
>
<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>
<main>
<CategoryGrid categories={categories} />
</main>
<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>
</motion.div>
</motion.div>
)
}
+72
View File
@@ -35,9 +35,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:",
@@ -430,6 +432,40 @@
"@preact/signals-core": ["@preact/signals-core@1.14.0", "", {}, "sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.12", "", { "os": "android", "cpu": "arm64" }, "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg=="],
@@ -634,6 +670,8 @@
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
@@ -700,6 +738,8 @@
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
"code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@@ -762,6 +802,8 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
@@ -858,6 +900,8 @@
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="],
@@ -876,6 +920,8 @@
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"get-own-enumerable-keys": ["get-own-enumerable-keys@1.0.0", "", {}, "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
@@ -1076,6 +1122,12 @@
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="],
"motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="],
"motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"msw": ["msw@2.12.14", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ=="],
@@ -1190,6 +1242,12 @@
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
@@ -1352,6 +1410,10 @@
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
@@ -1418,6 +1480,16 @@
"@noble/curves/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],