diff --git a/apps/server/AGENTS.md b/apps/server/AGENTS.md index 2a0d4e8..7515fa7 100644 --- a/apps/server/AGENTS.md +++ b/apps/server/AGENTS.md @@ -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 } /> -}> +}> // ❌ WRONG — asChild does NOT exist + ) : ( + 输入关键词开始搜索 + )} + + + {matchedEngine && ( + + + handleSelect(() => { + window.open( + `${matchedEngine.url}${encodeURIComponent(engineQuery)}`, + '_blank', + 'noopener,noreferrer', + ) + }) + } + > + + + 用 {matchedEngine.name} 搜索「{engineQuery}」 + + + + )} + + {allBookmarks.length > 0 && !matchedEngine && ( + + {allBookmarks.map( + (bookmark: { id: string; name: string; url: string; icon: string | null; categoryName: string }) => { + const Icon = (bookmark.icon && allIcons[bookmark.icon]) || icons.Globe + return ( + + handleSelect(() => { + window.open(bookmark.url, '_blank', 'noopener,noreferrer') + }) + } + > + + {bookmark.name} + {bookmark.categoryName} + + ) + }, + )} + + )} + + + + + handleSelect(() => navigate({ to: '/' as never }))}> + + 总览 + + handleSelect(() => navigate({ to: '/bookmarks' as never }))}> + + 书签导航 + + + + + handleSelect(() => navigate({ to: '/bookmarks' as never }))}> + + 管理书签 + + + + + + ) +} diff --git a/apps/server/src/components/ui/command.tsx b/apps/server/src/components/ui/command.tsx new file mode 100644 index 0000000..214f4d2 --- /dev/null +++ b/apps/server/src/components/ui/command.tsx @@ -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) { + return ( + + ) +} + +function CommandDialog({ + title = 'Command Palette', + description = 'Search for a command to run...', + children, + className, + showCloseButton = false, + ...props +}: Omit, 'children'> & { + title?: string + description?: string + className?: string + showCloseButton?: boolean + children: React.ReactNode +}) { + return ( + + + {title} + {description} + + + {children} + + + ) +} + +function CommandInput({ className, ...props }: React.ComponentProps) { + return ( +
+ + + + + + +
+ ) +} + +function CommandList({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function CommandEmpty({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function CommandGroup({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function CommandSeparator({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function CommandItem({ className, children, ...props }: React.ComponentProps) { + return ( + + {children} + + + ) +} + +function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + ) +} + +export { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} diff --git a/apps/server/src/components/ui/input-group.tsx b/apps/server/src/components/ui/input-group.tsx new file mode 100644 index 0000000..55f4562 --- /dev/null +++ b/apps/server/src/components/ui/input-group.tsx @@ -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 ( +
[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) { + return ( +
{ + 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, 'size' | 'type'> & + VariantProps & { + type?: 'button' | 'submit' | 'reset' + }) { + return ( +