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 规范)
409 lines
16 KiB
Markdown
409 lines
16 KiB
Markdown
# 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
|
|
|
|
```bash
|
|
# 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.
|
|
|
|
```typescript
|
|
// 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`)
|
|
```typescript
|
|
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`)
|
|
```typescript
|
|
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
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
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)
|
|
```typescript
|
|
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)
|
|
```typescript
|
|
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
|
|
```typescript
|
|
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`:
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
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)
|
|
```typescript
|
|
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
|
|
```typescript
|
|
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()` with `drizzleAdapter(db, { provider: 'pg', schema: authSchema })`
|
|
- **Client**: `src/server/auth/client.ts` — `createAuthClient()` for React
|
|
- **Server function**: `src/server/auth/functions.ts` — `getSession()` 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)
|
|
```typescript
|
|
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
|