ba8224e81e
- 引入 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 规范)
16 KiB
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 usebun run <script>(notbun <script>) to avoid conflicts with Bun built-in subcommands. Never usenpm,npx, ornode.
- 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
_protectedlayout (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+SidebarInsetpattern - Navigation items from module registry
SidebarMenuButtonusesrenderprop forLinkintegration (NOTasChild)
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(NOTbun-sql) - Validation:
drizzle-orm/zod(built-in, NOT separatedrizzle-zodpackage) - Relations: Defined via
defineRelations()insrc/server/db/relations.ts - Query: RQBv2 — use
db.query.tableName.findMany()with object-styleorderByandwhere - Table naming: No
Tablesuffix — useuser,category,bookmark(NOTuserTable) - 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.ts—betterAuth()withdrizzleAdapter(db, { provider: 'pg', schema: authSchema }) - Client:
src/server/auth/client.ts—createAuthClient()for React - Server function:
src/server/auth/functions.ts—getSession()viacreateServerFn - Auth tables: Use
textIDs (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:
- External packages
- Internal
@/*aliases - Type imports (
import type { ... })
TypeScript
strict: truenoUncheckedIndexedAccess: true— array access returnsT | undefined- Use
@/*path aliases (maps tosrc/*)
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
useSuspenseQueryfor 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.
- No backward compatibility — Always use the latest API and patterns.
- Always sync documentation — When code changes, immediately update all related documentation.
- Forward-only migration — When upgrading dependencies, fully adopt the new API.
Critical Rules
DO:
- Run
bun run fixbefore committing - Use
@/*path aliases - Include
createdAt/updatedAton all tables - Use
ORPCErrorwith proper codes - Use
drizzle-orm/zod(NOTdrizzle-zod) for schema validation - Use RQBv2 object syntax for
orderByandwhere - Use
renderprop (NOTasChild) for base-ui component delegation - Use Dialog modals for forms (NOT inline forms)
- Use
toastfromsonnerfor CRUD notifications - Update
AGENTS.mdand other docs whenever code patterns change
DON'T:
- Use
npm,npx,node,yarn,pnpm— always usebun/bunx - Edit
src/routeTree.gen.ts(auto-generated) - Manually edit
src/components/ui/*.tsx(usebunx shadcn@latest add) - Use
as any,@ts-ignore,@ts-expect-error - Use
asChildprop (userenderprop instead — base-ui, NOT Radix) - Commit
.envfiles - Use empty catch blocks
- Import from
drizzle-zod(usedrizzle-orm/zodinstead) - Use RQBv1 callback-style
orderBy/ oldrelations()API - Use
drizzle-orm/bun-sqldriver (usedrizzle-orm/postgres-js) - Pass
schematodrizzle()constructor (onlyrelationsis needed in RQBv2) - Use
getDB()factory pattern (use module-leveldbexport instead) - Add
Tablesuffix to Drizzle table exports - Leave docs out of sync with code changes