Files
kairos/apps/server/AGENTS.md
T
imbytecat ba8224e81e feat: 重设计 UI/UX — 展示/管理分离 + shadcn/ui + Admin 后台
- 引入 shadcn/ui(base-nova 风格,Tailwind v4,14 个组件)
- 新增 Admin 后台路由架构:/admin(总览)、/admin/bookmarks(管理)
- 重写首页为纯展示书签导航(BookmarkCard + CategoryGrid)
- 新增 Admin 侧边栏导航(AdminSidebar + SidebarProvider)
- 书签管理页:双栏布局 + Dialog 表单 + DnD 排序 + Toast 通知
- 修复 IconPicker overflow 裁切(改用 Dialog portal)
- 修复嵌套 button hydration 错误(base-ui render prop)
- 删除旧组件(CategorySection/BookmarkItem/IconPicker)和旧路由
- 所有新依赖归入 root catalog
- 更新 AGENTS.md 文档(目录结构、shadcn 模式、render prop 规范)
2026-03-30 22:54:01 +08:00

16 KiB

AGENTS.md - Server App Guidelines

TanStack Start fullstack web app with ORPC (contract-first RPC) and shadcn/ui.

Tech Stack

⚠️ This project uses Bun — NOT Node.js / npm. All commands use bun. Always use bun run <script> (not bun <script>) to avoid conflicts with Bun built-in subcommands. Never use npm, npx, or node.

  • Framework: TanStack Start (React 19 SSR, file-based routing)
  • Runtime: Bun — NOT Node.js
  • Package Manager: Bun — NOT npm / yarn / pnpm
  • Language: TypeScript (strict mode)
  • Styling: Tailwind CSS v4 + shadcn/ui (base-nova style, OKLCH colors)
  • UI Components: shadcn/ui (copy-paste components in src/components/ui/)
  • Database: PostgreSQL + Drizzle ORM v1 beta (drizzle-orm/postgres-js, RQBv2)
  • State: TanStack Query v5
  • RPC: ORPC (contract-first, type-safe)
  • Auth: Better Auth (email+password, self-hosted)
  • DnD: @dnd-kit/react
  • Build: Vite + Nitro

Commands

# Development
bun run dev                    # Vite dev server (localhost:3000)
bun run db:studio              # Drizzle Studio GUI

# Build
bun run build                  # Production build → .output/
bun run compile                # Compile to standalone binary (current platform, depends on build)
bun run compile:darwin         # Compile for macOS (arm64 + x64)
bun run compile:linux          # Compile for Linux (x64 + arm64)
bun run compile:windows        # Compile for Windows (default: x64)

# Code Quality
bun run fix                    # Biome auto-fix
bun run typecheck              # TypeScript check

# Database
bun run db:generate            # Generate migrations from schema
bun run db:migrate             # Run migrations
bun run db:push                # Push schema directly (dev only)

# Testing (not yet configured)
bun test path/to/test.ts       # Run single test
bun test -t "pattern"          # Run tests matching pattern

Architecture Overview

Route Architecture (Display + Admin separation)

/               → Dashboard homepage (bookmark display, daily use)
/admin          → Admin panel overview (module cards)
/admin/bookmarks → Bookmark management (CRUD, DnD, Dialog forms)
/login          → Login page
/signup         → Signup page
  • Display pages (/): Clean, no management UI. What users see daily.
  • Admin pages (/admin/*): Full CRUD, management, configuration. Sidebar navigation.
  • All authenticated routes are under _protected layout (auth guard → redirect to /login).

Module System

Modules are directory-based under src/modules/. Each module is independent and pluggable.

// src/modules/registry.ts
export interface ModuleMetadata {
  id: string
  name: string
  description: string
  icon: string         // lucide-react icon name
  adminRoute: string   // Admin management route (e.g., '/admin/bookmarks')
  enabled: boolean
}

Each module provides: schema.ts (Drizzle tables), contract.ts (ORPC contracts), router.ts (ORPC handlers), components/ (UI), and index.ts (metadata).

Directory Structure

src/
├── client/                   # Client-side code
│   └── orpc.ts               # ORPC client + TanStack Query utils
├── components/               # Shared React components
│   ├── AdminSidebar.tsx      # Admin panel sidebar navigation
│   ├── Error.tsx             # Error boundary fallback
│   ├── NotFound.tsx          # 404 fallback
│   └── ui/                   # shadcn/ui components (DO NOT manually edit)
│       ├── button.tsx
│       ├── card.tsx
│       ├── dialog.tsx
│       ├── dropdown-menu.tsx
│       ├── input.tsx
│       ├── separator.tsx
│       ├── sheet.tsx
│       ├── sidebar.tsx
│       ├── skeleton.tsx
│       ├── sonner.tsx
│       ├── tooltip.tsx
│       ├── avatar.tsx
│       └── badge.tsx
├── hooks/                    # Custom React hooks
│   └── use-mobile.ts         # Mobile detection (shadcn sidebar dep)
├── lib/                      # Utility functions
│   └── utils.ts              # cn() utility (clsx + tailwind-merge)
├── modules/                  # Feature modules
│   ├── registry.ts           # Module metadata registry
│   └── bookmarks/            # Bookmarks module
│       ├── index.ts          # Module metadata
│       ├── schema.ts         # Drizzle tables (category, bookmark)
│       ├── contract.ts       # ORPC contracts
│       ├── router.ts         # ORPC handlers
│       └── components/       # Module UI
│           ├── BookmarkCard.tsx        # Display: clean card (icon + name)
│           ├── CategoryGrid.tsx        # Display: category grid for homepage
│           ├── BookmarkFormDialog.tsx   # Admin: create/edit bookmark dialog
│           ├── BookmarkManager.tsx      # Admin: bookmark list with DnD
│           ├── CategoryFormDialog.tsx   # Admin: create/edit category dialog
│           ├── CategoryManager.tsx      # Admin: category list with DnD
│           ├── IconPickerDialog.tsx     # Admin: icon picker in dialog
│           ├── GreetingHeader.tsx       # Display: time-based greeting
│           └── SearchBar.tsx           # Display: multi-engine search
├── routes/                   # TanStack Router file routes
│   ├── __root.tsx            # Root layout (HTML shell, Toaster, TooltipProvider)
│   ├── _protected.tsx        # Auth guard layout (redirect to /login)
│   ├── _protected/
│   │   ├── index.tsx         # Dashboard homepage (bookmark display)
│   │   ├── admin.tsx         # Admin layout (SidebarProvider + Outlet)
│   │   └── admin/
│   │       ├── index.tsx     # Admin overview (module cards)
│   │       └── bookmarks.tsx # Bookmark management page
│   ├── login.tsx             # Login page
│   ├── signup.tsx            # Signup page
│   └── api/
│       ├── $.ts              # OpenAPI handler + Scalar docs
│       ├── auth.$.ts         # Better Auth handler
│       ├── health.ts         # Health check endpoint
│       └── rpc.$.ts          # ORPC RPC handler
├── server/                   # Server-side code
│   ├── api/                  # ORPC layer
│   │   ├── contracts/        # Input/output schemas (Zod)
│   │   ├── middlewares/      # Middleware (dbMiddleware, authMiddleware)
│   │   ├── routers/          # Handler implementations
│   │   ├── interceptors.ts   # Shared error interceptors
│   │   ├── context.ts        # Request context types
│   │   ├── server.ts         # ORPC server instance
│   │   └── types.ts          # Type exports
│   ├── auth/                 # Better Auth
│   │   ├── schema.ts         # Auth tables (user, session, account, verification)
│   │   ├── index.ts          # Auth instance (betterAuth + drizzleAdapter)
│   │   ├── client.ts         # Auth client (createAuthClient)
│   │   └── functions.ts      # Server functions (getSession)
│   └── db/
│       ├── schema/           # Drizzle table re-exports
│       ├── fields.ts         # Shared field builders (id, createdAt, updatedAt)
│       ├── relations.ts      # Drizzle relations (defineRelations, RQBv2)
│       └── index.ts          # Database singleton (module-level export)
├── env.ts                    # Environment variable validation
├── router.tsx                # Router configuration
├── routeTree.gen.ts          # Auto-generated (DO NOT EDIT)
└── styles.css                # Tailwind + shadcn CSS variables

ORPC Pattern

1. Define Contract (src/server/api/contracts/feature.contract.ts)

import { oc } from '@orpc/contract'
import { createSelectSchema } from 'drizzle-orm/zod'
import { z } from 'zod'
import * as schema from '@/modules/feature/schema'

const selectSchema = createSelectSchema(schema.myTable)

export const list = oc.input(z.void()).output(z.array(selectSchema))
export const create = oc.input(insertSchema).output(selectSchema)

2. Implement Router (src/server/api/routers/feature.router.ts)

import { ORPCError } from '@orpc/server'
import * as schema from '@/modules/feature/schema'
import { authMiddleware, dbMiddleware } from '@/server/api/middlewares'
import { os } from '@/server/api/server'

export const list = os.feature.list
  .use(dbMiddleware)
  .use(authMiddleware)
  .handler(async ({ context }) => {
    return await context.db.query.myTable.findMany({
      orderBy: { createdAt: 'desc' },
    })
  })

3. Register in Index Files

// src/server/api/contracts/index.ts
import * as feature from '@/modules/feature/contract'
export const contract = { feature }

// src/server/api/routers/index.ts
import * as feature from '@/modules/feature/router'
export const router = os.router({ feature })

4. Use in Components

import { useSuspenseQuery, useMutation } from '@tanstack/react-query'
import { orpc } from '@/client/orpc'

const { data } = useSuspenseQuery(orpc.feature.list.queryOptions())
const mutation = useMutation(orpc.feature.create.mutationOptions())

UI Component Patterns (shadcn/ui)

Dialog Forms (for CRUD operations)

import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'

// Use render prop on DialogTrigger to avoid nested <button>
<Dialog open={open} onOpenChange={setOpen}>
  <DialogTrigger render={<Button>打开</Button>} />
  <DialogContent>
    {/* Form content */}
  </DialogContent>
</Dialog>

DropdownMenu (for action menus)

import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'

// Use render prop on trigger to avoid nested <button>
<DropdownMenu>
  <DropdownMenuTrigger render={<Button variant="ghost" size="icon-sm" />}>
    <MoreHorizontal className="size-4" />
  </DropdownMenuTrigger>
  <DropdownMenuContent>
    <DropdownMenuItem>操作</DropdownMenuItem>
  </DropdownMenuContent>
</DropdownMenu>

Toast Notifications

import { toast } from 'sonner'

toast.success('操作成功')
toast.error('操作失败')

Admin Sidebar

  • Uses shadcn SidebarProvider + Sidebar + SidebarInset pattern
  • Navigation items from module registry
  • SidebarMenuButton uses render prop for Link integration (NOT asChild)

base-ui render Prop (IMPORTANT)

shadcn/ui v4 uses @base-ui/react instead of Radix primitives. The render prop replaces asChild:

// CORRECT: render prop delegates element rendering
<DialogTrigger render={<Button />} />
<SidebarMenuButton render={<Link to="/admin" />}>

// WRONG: asChild does NOT exist in base-ui components
<DialogTrigger asChild><Button /></DialogTrigger>  // ❌ asChild is NOT supported

Database (Drizzle ORM v1 beta)

  • Driver: drizzle-orm/postgres-js (NOT bun-sql)
  • Validation: drizzle-orm/zod (built-in, NOT separate drizzle-zod package)
  • Relations: Defined via defineRelations() in src/server/db/relations.ts
  • Query: RQBv2 — use db.query.tableName.findMany() with object-style orderBy and where
  • Table naming: No Table suffix — use user, category, bookmark (NOT userTable)
  • DB instance: Module-level singleton export const db = drizzle(...) (NOT factory/closure pattern)

Schema Definition

import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
import { sql } from 'drizzle-orm'

export const myTable = pgTable('my_table', {
  id: uuid().primaryKey().default(sql`uuidv7()`),
  name: text().notNull(),
  createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow().$onUpdateFn(() => new Date()),
})

Relations (RQBv2)

import { defineRelations } from 'drizzle-orm'
import * as schema from './schema'

export const relations = defineRelations(schema, (r) => ({
  user: {
    categories: r.many.category(),
  },
  category: {
    user: r.one.user({ from: r.category.userId, to: r.user.id }),
    bookmarks: r.many.bookmark(),
  },
}))

DB Instance

import { drizzle } from 'drizzle-orm/postgres-js'
import { relations } from '@/server/db/relations'

export const db = drizzle({ connection: env.DATABASE_URL, relations })
export type DB = typeof db

Auth (Better Auth)

  • Instance: src/server/auth/index.tsbetterAuth() with drizzleAdapter(db, { provider: 'pg', schema: authSchema })
  • Client: src/server/auth/client.tscreateAuthClient() for React
  • Server function: src/server/auth/functions.tsgetSession() via createServerFn
  • Auth tables: Use text IDs (Better Auth manages its own IDs), NOT project's UUID v7
  • Schema key naming: Export names must match Better Auth model names exactly (user, session, account, verification)

Auth Middleware (ORPC)

const sessionData = await auth.api.getSession({ headers: context.headers })
// Injects context.user and context.session

Code Style

Formatting (Biome)

  • Indent: 2 spaces
  • Quotes: Single '
  • Semicolons: Omit (ASI)
  • Arrow parens: Always (x) => x

Imports

Biome auto-organizes:

  1. External packages
  2. Internal @/* aliases
  3. Type imports (import type { ... })

TypeScript

  • strict: true
  • noUncheckedIndexedAccess: true — array access returns T | undefined
  • Use @/* path aliases (maps to src/*)

Naming

Type Convention Example
Files (utils) kebab-case auth-utils.ts
Files (components) PascalCase UserProfile.tsx
Components PascalCase arrow const Button = () => {}
Functions camelCase getUserById
Types PascalCase UserProfile
Drizzle tables camelCase, no suffix user, category (NOT userTable)

React

  • Use arrow functions for components (Biome enforced)
  • Use useSuspenseQuery for guaranteed data
  • Let React Compiler handle memoization (no manual useMemo/useCallback)

Development Principles

These principles apply to ALL code changes. Agents MUST follow them on every task.

  1. No backward compatibility — Always use the latest API and patterns.
  2. Always sync documentation — When code changes, immediately update all related documentation.
  3. Forward-only migration — When upgrading dependencies, fully adopt the new API.

Critical Rules

DO:

  • Run bun run fix before committing
  • Use @/* path aliases
  • Include createdAt/updatedAt on all tables
  • Use ORPCError with proper codes
  • Use drizzle-orm/zod (NOT drizzle-zod) for schema validation
  • Use RQBv2 object syntax for orderBy and where
  • Use render prop (NOT asChild) for base-ui component delegation
  • Use Dialog modals for forms (NOT inline forms)
  • Use toast from sonner for CRUD notifications
  • Update AGENTS.md and other docs whenever code patterns change

DON'T:

  • Use npm, npx, node, yarn, pnpm — always use bun / bunx
  • Edit src/routeTree.gen.ts (auto-generated)
  • Manually edit src/components/ui/*.tsx (use bunx shadcn@latest add)
  • Use as any, @ts-ignore, @ts-expect-error
  • Use asChild prop (use render prop instead — base-ui, NOT Radix)
  • Commit .env files
  • Use empty catch blocks
  • Import from drizzle-zod (use drizzle-orm/zod instead)
  • Use RQBv1 callback-style orderBy / old relations() API
  • Use drizzle-orm/bun-sql driver (use drizzle-orm/postgres-js)
  • Pass schema to drizzle() constructor (only relations is needed in RQBv2)
  • Use getDB() factory pattern (use module-level db export instead)
  • Add Table suffix to Drizzle table exports
  • Leave docs out of sync with code changes