Compare commits

..

26 Commits

Author SHA1 Message Date
imbytecat 41f21ec3a9 feat: 新增记账模块与 API Key 管理 — 支持收支记录、账户管理、N8N 外部集成 2026-04-01 02:45:13 +08:00
imbytecat dcccf6675f style: 统一代码风格 — 格式化 auth schema、修复 setup 页面 return 语句、规范 drizzle snapshot 2026-03-31 22:46:47 +08:00
imbytecat 9f39e7f7e8 fix: 修复总览页常用书签加载时的动画抖动 — 移除嵌套 motion 组件的重复 y 偏移 2026-03-31 22:46:07 +08:00
imbytecat a369fe853e 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 文档
2026-03-31 21:36:44 +08:00
imbytecat 588df9f143 fix: 修复后台管理面板 UI 问题 — 移除多余分隔符竖线,用 flex 布局替代 calc() 高度计算 2026-03-31 20:42:42 +08:00
imbytecat c087338009 fix: 添加 shadcn 依赖以解决 tailwind.css 导入错误 2026-03-31 20:24:15 +08:00
imbytecat 6bedc1d60d refactor: 降级 Drizzle ORM 至 0.45.x 稳定版,对齐 Better Auth 兼容性
- drizzle-orm 1.0.0-beta.15 → 0.45.2, drizzle-kit → 0.31.10
- RQBv2 defineRelations() → 旧版 relations() 回调语法
- drizzle-orm/zod → drizzle-zod 独立包
- auth/schema.ts 改由 Better Auth CLI 生成(bun run db:auth)
- db/schema/index.ts 选择性导出表(不导出生成文件中的旧版 relations)
- 删除 db:push script,强制 db:generate → db:migrate 工作流
- 重建迁移基线(删除旧迁移目录,全新生成初始迁移)
2026-03-31 20:18:15 +08:00
imbytecat 5e65c37a26 docs: 同步 AGENTS.md — 单 owner 模型 + 强制 migration workflow + CLI 文档 2026-03-31 19:01:54 +08:00
imbytecat d3f2088fc8 refactor: 移除 Recovery Key 机制,简化单 owner 认证流程
Recovery Key 对自托管场景多余 —— owner 必有服务器访问权限,CLI 重置足够。
- 删除 /recover 路由、systemSettings 表、completeSetup/recoverAccount functions
- /setup 创建完直接跳转,去掉 Recovery Key 步骤
- /login 去掉恢复密钥链接
- 修复跨目录相对路径 → @/ 别名(drizzle schema 链除外)
2026-03-31 18:55:13 +08:00
imbytecat 830714c94f feat: 单 owner 认证模型 — 替换注册为一次性设置向导 + Recovery Key + CLI 密码重置
自托管 Life OS 不应有公开注册。改为:
- /setup 一次性初始化向导(创建唯一 owner + 生成 Recovery Key)
- /recover 通过 Recovery Key 重置密码
- /login 未初始化时重定向到 /setup,去掉注册链接
- Better Auth databaseHooks 阻止额外用户注册
- citty CLI: bun run cli auth reset-password
- 删除 /signup 路由
- 新增 system_settings 表存储 recovery key hash
- 修复 drizzle.config.ts 非空断言 + sidebar.tsx cookieStore API
- 更新 AGENTS.md shadcn/ui 组件编辑规则
2026-03-31 18:33:16 +08:00
imbytecat d67aaa723e docs: 重新梳理 AGENTS.md — 精简去重 + 修正过时模式 + 新增 DnD/Virtual 文档
- 根 AGENTS.md 242→135 行,去掉冗余 compile 命令和重复目录树
- server AGENTS.md 408→220 行,移除与根重复的 Code Style 章节
- 修正 ORPC 契约/路由路径(定义在模块内,非 server/api/)
- 新增 @dnd-kit/helpers move()、@tanstack/react-virtual、MutationCache 模式
2026-03-31 17:42:56 +08:00
imbytecat 001d171111 feat: 重写图标选择器 — 全量 lucide 图标 + 虚拟滚动 + 可清除
- 动态获取所有 ~1500 个 lucide-react 图标,替代硬编码 57 个
- 引入 @tanstack/react-virtual 虚拟滚动,流畅渲染大量图标
- 使用 useState callback ref 解决 Dialog 内 virtualizer 初始化问题
- 新增清除图标按钮,允许将图标置空
- 搜索覆盖全量图标,输入时自动滚回顶部
2026-03-31 17:25:27 +08:00
imbytecat 46e4486d7d fix: 修复拖拽排序持久化 + 恢复 package.json catalog 引用
- 引入 @dnd-kit/helpers,使用 move() 替代手工 splice 排序逻辑
- 恢复 apps/server/package.json 中所有依赖的 catalog: 引用
- 简化 ORPC client,移除 experimental_defaults,改用 MutationCache
- route loaders 改用 fetchQuery 确保数据刷新
2026-03-31 17:01:47 +08:00
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
imbytecat 430c0b0c64 refactor: 统一表命名规范,简化 DB 单例
- 去掉所有 Drizzle 表变量的 Table 后缀(userTable→user 等)
- 修复 Better Auth adapter 找不到 schema model 的问题
- DB 实例从 IIFE 闭包工厂简化为模块级导出
- db middleware 重命名为 dbMiddleware 避免与 db 实例冲突
- 添加 babel-plugin-react-compiler 依赖
2026-03-30 21:53:30 +08:00
imbytecat 8c3425359d chore: 生成初始 Drizzle 迁移和路由树 2026-03-30 21:28:57 +08:00
imbytecat 3ce981a06a feat: 添加 Dashboard 和书签页面 2026-03-30 21:28:44 +08:00
imbytecat 309eb8ac7e feat: 添加书签模块 UI 组件 2026-03-30 21:28:25 +08:00
imbytecat 1494492b95 feat: 添加书签模块 API,移除 Todo 示例 2026-03-30 21:28:10 +08:00
imbytecat 1f3028c25b feat: 添加书签模块 schema 和关联 2026-03-30 21:27:58 +08:00
imbytecat 58b70dd1e8 feat: 添加认证页面和路由守卫 2026-03-30 21:27:26 +08:00
imbytecat df485b54c9 feat: 添加认证 API 路由和中间件 2026-03-30 21:27:10 +08:00
imbytecat 8b754f9fe6 feat: 集成 Better Auth 服务端 2026-03-30 21:26:52 +08:00
imbytecat 50472dbba7 feat: 添加模块注册系统 2026-03-30 21:26:41 +08:00
imbytecat ab713ba5bc feat: 添加 Better Auth 认证 schema 2026-03-30 21:26:25 +08:00
imbytecat da3ce1d2dd chore: 配置数据库和依赖 2026-03-30 21:26:10 +08:00
94 changed files with 10311 additions and 831 deletions
+34 -109
View File
@@ -6,37 +6,16 @@ Guidelines for AI agents working in this Bun monorepo.
> **Kairos 是一个人生操作系统(Personal Life OS)。所有开发决策必须服务于这个愿景。** > **Kairos 是一个人生操作系统(Personal Life OS)。所有开发决策必须服务于这个愿景。**
Kairos 将个人生活的每一个维度 —— 财务、订阅、健康、资产、决策 —— 整合进一个统一的、自托管的平台。它不是又一个效率工具或仪表盘,而是一个完整的操作系统。 Kairos 将个人生活的每一个维度 —— 财务、订阅、健康、资产、决策 —— 整合进一个统一的、自托管的平台。
### 核心模块
| 模块 | 职责 |
|------|------|
| **财务** | 银行账户、交易流水、消费分析、预算追踪 |
| **订阅** | 周期性服务管理 —— 费用、续期、使用频率 |
| **健康** | 身体指标、习惯、睡眠、营养 |
| **资产** | 实体与数字资产、保修、生命周期追踪 |
| **日志** | 决策、复盘、人生事件 |
| **集成** | API 对接、数据导入、自动化 |
每个模块是同一个统一数据层的不同视角,模块之间的数据可以交叉关联和分析。
### 设计信条 ### 设计信条
1. **数据主权** —— 自托管、本地优先。用户数据不离开用户的机器,除非用户自己决定。所有功能必须在无外部云依赖的情况下完整可用。 1. **数据主权** —— 自托管、本地优先。用户数据不离开用户的机器,除非用户自己决定。
2. **关联即洞察** —— 孤立数据是噪音,关联数据是洞察。所有模块共享统一数据层,设计时必须考虑跨模块的数据关联能力 2. **关联即洞察** —— 孤立数据是噪音,关联数据是洞察。所有模块共享统一数据层。
3. **可扩展基座** —— Kairos 是有主见的基座,不是封闭成品。架构必须支持模块的独立扩展和自定义。 3. **可扩展基座** —— 架构必须支持模块的独立扩展和自定义。
### 架构原则
- **契约优先 API** —— 每个端点从类型化契约定义开始,实现紧随其后。一份契约,Web 和 CLI 两个消费者共享。
- **Web + CLI 双入口** —— Web 应用是主界面;CLI 调用同一套 API,支持脚本编排、自动化、快速数据录入。
- **编译为单文件** —— 服务端编译为独立 Bun 二进制,零运行时依赖,部署极简。
### Agent 决策指南 ### Agent 决策指南
在做任何设计或实现决策时,用以下问题自检:
- **这是否让用户更完整地掌控自己的数据?** 如果否,重新考虑。 - **这是否让用户更完整地掌控自己的数据?** 如果否,重新考虑。
- **这个模块的数据是否可以被其他模块关联使用?** 如果不能,说明数据模型设计有问题。 - **这个模块的数据是否可以被其他模块关联使用?** 如果不能,说明数据模型设计有问题。
- **这个功能是否可以在完全离线/自托管环境下工作?** 如果不能,必须提供无外部依赖的替代方案。 - **这个功能是否可以在完全离线/自托管环境下工作?** 如果不能,必须提供无外部依赖的替代方案。
@@ -44,78 +23,49 @@ Kairos 将个人生活的每一个维度 —— 财务、订阅、健康、资
## Project Overview ## Project Overview
> **This project uses [Bun](https://bun.sh) exclusively as both the JavaScript runtime and package manager. Do NOT use Node.js / npm / yarn / pnpm. All commands start with `bun` — use `bun install` for dependencies and `bun run <script>` for scripts. Always prefer `bun run <script>` over `bun <script>` to avoid conflicts with Bun built-in subcommands (e.g. `bun build` invokes Bun's bundler, NOT your package.json script). Never use `npm`, `npx`, or `node`.** > **This project uses [Bun](https://bun.sh) exclusively as both the JavaScript runtime and package manager. Do NOT use Node.js / npm / yarn / pnpm. Always prefer `bun run <script>` over `bun <script>` to avoid conflicts with Bun built-in subcommands. Never use `npm`, `npx`, or `node`.**
- **Monorepo**: Bun workspaces + Turborepo orchestration - **Monorepo**: Bun workspaces + Turborepo orchestration
- **Runtime**: Bun (see `mise.toml` for version) — **NOT Node.js** - **Runtime**: Bun (see `mise.toml` for version) — **NOT Node.js**
- **Package Manager**: Bun — **NOT npm / yarn / pnpm** - **Package Manager**: Bun — **NOT npm / yarn / pnpm**
- **Apps**: - **Apps**: `apps/server` — TanStack Start fullstack web app (see `apps/server/AGENTS.md`)
- `apps/server` - TanStack Start fullstack web app (see `apps/server/AGENTS.md`) - **Packages**: `packages/tsconfig` — shared TS configs
- **Packages**: `packages/tsconfig` (shared TS configs)
## Build / Lint / Test Commands ## Build / Lint / Test Commands
### Root Commands (via Turbo)
```bash ```bash
# Root (via Turbo)
bun run dev # Start all apps in dev mode bun run dev # Start all apps in dev mode
bun run build # Build all apps bun run build # Build all apps
bun run compile # Compile server to standalone binary (current platform) bun run compile # Compile server to standalone binary
bun run compile:darwin # Compile server for macOS (arm64 + x64)
bun run compile:linux # Compile server for Linux (x64 + arm64)
bun run compile:windows # Compile server for Windows x64
bun run fix # Lint + format (Biome auto-fix) bun run fix # Lint + format (Biome auto-fix)
bun run typecheck # TypeScript check across monorepo bun run typecheck # TypeScript check across monorepo
```
### Server App (`apps/server`) # Database (in apps/server) — ALWAYS use migration workflow, never db:push
```bash bun run db:auth # Regenerate Better Auth schema (after upgrading better-auth)
bun run dev # Vite dev server (localhost:3000) bun run db:generate # Generate migrations from schema changes
bun run build # Production build -> .output/ bun run db:migrate # Run pending migrations
bun run compile # Compile to standalone binary (current platform)
bun run compile:darwin # Compile for macOS (arm64 + x64)
bun run compile:darwin:arm64 # Compile for macOS arm64
bun run compile:darwin:x64 # Compile for macOS x64
bun run compile:linux # Compile for Linux (x64 + arm64)
bun run compile:linux:arm64 # Compile for Linux arm64
bun run compile:linux:x64 # Compile for Linux x64
bun run compile:windows # Compile for Windows (default: x64)
bun run compile:windows:x64 # Compile for Windows x64
bun run fix # Biome auto-fix
bun run typecheck # TypeScript check
# Database (Drizzle)
bun run db:generate # Generate migrations from schema
bun run db:migrate # Run migrations
bun run db:push # Push schema (dev only)
bun run db:studio # Open Drizzle Studio bun run db:studio # Open Drizzle Studio
```
### Testing # Server CLI (in apps/server)
No test framework configured yet. When adding tests: bun run cli auth reset-password # Reset owner password
```bash
# Testing (not yet configured)
bun test path/to/test.ts # Run single test file bun test path/to/test.ts # Run single test file
bun test -t "pattern" # Run tests matching pattern
``` ```
## Code Style (TypeScript) ## Code Style (TypeScript)
### Formatting (Biome) ### Formatting (Biome)
- **Indent**: 2 spaces | **Line endings**: LF - **Indent**: 2 spaces | **Line width**: 120 | **Line endings**: LF
- **Quotes**: Single `'` | **Semicolons**: Omit (ASI) - **Quotes**: Single `'` | **Semicolons**: Omit (ASI)
- **Arrow parentheses**: Always `(x) => x` - **Arrow parentheses**: Always `(x) => x`
### Imports ### Imports
Biome auto-organizes. Order: 1) External packages → 2) Internal `@/*` aliases → 3) Type imports (`import type { ... }`) Biome auto-organizes. Order: 1) External packages → 2) Internal `@/*` aliases → 3) Type imports (`import type { ... }`)
```typescript
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
import { db } from '@/server/db'
import type { ReactNode } from 'react'
```
### TypeScript Strictness ### TypeScript Strictness
- `strict: true`, `noUncheckedIndexedAccess: true`, `noImplicitOverride: true`, `verbatimModuleSyntax: true` - `strict: true`, `noUncheckedIndexedAccess: true`, `verbatimModuleSyntax: true`
- Use `@/*` path aliases (maps to `src/*`) - Use `@/*` path aliases (maps to `src/*`)
### Naming Conventions ### Naming Conventions
@@ -127,34 +77,28 @@ import type { ReactNode } from 'react'
| Functions | camelCase | `getUserById` | | Functions | camelCase | `getUserById` |
| Constants | UPPER_SNAKE | `MAX_RETRIES` | | Constants | UPPER_SNAKE | `MAX_RETRIES` |
| Types/Interfaces | PascalCase | `UserProfile` | | Types/Interfaces | PascalCase | `UserProfile` |
| Drizzle tables | camelCase, no suffix | `user`, `category` (NOT `userTable`) |
### React Patterns ### React Patterns
- Components: arrow functions (enforced by Biome) - Components: arrow functions (enforced by Biome)
- Routes: TanStack Router file conventions (`export const Route = createFileRoute(...)`)
- Data fetching: `useSuspenseQuery(orpc.feature.list.queryOptions())` - Data fetching: `useSuspenseQuery(orpc.feature.list.queryOptions())`
- Let React Compiler handle memoization (no manual `useMemo`/`useCallback`) - Let React Compiler handle memoization (no manual `useMemo`/`useCallback`)
### Error Handling ### Error Handling
- Use `try-catch` for async operations; throw descriptive errors - Use `try-catch` for async operations; throw descriptive errors
- ORPC: Use `ORPCError` with proper codes (`NOT_FOUND`, `INPUT_VALIDATION_FAILED`) - ORPC: Use `ORPCError` with proper codes (`NOT_FOUND`, `UNAUTHORIZED`, `INTERNAL_SERVER_ERROR`)
- Never use empty catch blocks - Never use empty catch blocks
## Database (Drizzle ORM v1 beta + postgres-js) ## Database (Drizzle ORM + postgres-js)
- **ORM**: Drizzle ORM `1.0.0-beta` (RQBv2) - **ORM**: Drizzle ORM `0.45.x` (stable)
- **Driver**: `drizzle-orm/postgres-js` (NOT `bun-sql`) - **Driver**: `drizzle-orm/postgres-js` (NOT `bun-sql`)
- **Validation**: `drizzle-orm/zod` (built-in, NOT separate `drizzle-zod` package) - **Validation**: `drizzle-zod` (separate package, NOT `drizzle-orm/zod`)
- **Relations**: Defined via `defineRelations()` in `src/server/db/relations.ts` (contains schema info, so `drizzle()` only needs `{ relations }`) - **Relations**: Defined via `relations()` from `drizzle-orm` — callback syntax
- **Query style**: RQBv2 object syntax (`orderBy: { createdAt: 'desc' }`, `where: { id: 1 }`) - **Query style**: `db.query.tableName.findMany({ where: (t, { eq }) => eq(t.col, val), orderBy: (t, { asc }) => asc(t.col) })`
- **Auth schema**: Generated by Better Auth CLI (`bun run db:auth`), **never hand-edit**
```typescript - **Schema re-export**: `db/schema/index.ts` selectively exports tables only (not relations) from auth schema
export const myTable = pgTable('my_table', { - **Migration workflow**: Always `db:generate``db:migrate`. **Never** use `db:push`.
id: uuid().primaryKey().default(sql`uuidv7()`),
name: text().notNull(),
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow().$onUpdateFn(() => new Date()),
})
```
## Environment Variables ## Environment Variables
@@ -170,11 +114,9 @@ export const myTable = pgTable('my_table', {
## Development Principles ## 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. Never keep deprecated code paths.
2. **Always sync documentation** — When code changes, immediately update `AGENTS.md` and related docs.
1. **No backward compatibility** — This project is in rapid iteration. Always use the latest API and patterns. Never keep deprecated code paths or old API fallbacks "just in case". 3. **Forward-only migration** — When upgrading dependencies, fully adopt the new API.
2. **Always sync documentation** — When code changes, immediately update all related documentation (`AGENTS.md`, `README.md`, inline code examples). Code and docs must never drift apart. This includes updating code snippets in docs when imports, APIs, or patterns change.
3. **Forward-only migration** — When upgrading dependencies, fully adopt the new API. Don't mix old and new patterns in the same codebase.
## Critical Rules ## Critical Rules
@@ -183,7 +125,6 @@ export const myTable = pgTable('my_table', {
- Use `@/*` path aliases (not relative imports) - Use `@/*` path aliases (not relative imports)
- Include `createdAt`/`updatedAt` on all tables - Include `createdAt`/`updatedAt` on all tables
- Use `catalog:` for dependency versions - Use `catalog:` for dependency versions
- Update `AGENTS.md` and other docs whenever code patterns change
**DON'T:** **DON'T:**
- Use `npm`, `npx`, `node`, `yarn`, `pnpm` — always use `bun` / `bunx` - Use `npm`, `npx`, `node`, `yarn`, `pnpm` — always use `bun` / `bunx`
@@ -194,28 +135,12 @@ export const myTable = pgTable('my_table', {
- Hardcode dependency versions in workspace packages - Hardcode dependency versions in workspace packages
- Leave docs out of sync with code changes - Leave docs out of sync with code changes
## Git Workflow
1. Make changes following style guide
2. `bun run fix` - auto-format and lint
3. `bun run typecheck` - verify types
4. `bun run dev` - test locally
5. Commit with descriptive message
## Directory Structure ## Directory Structure
``` ```
. .
├── apps/ ├── apps/
── server/ # TanStack Start fullstack app ── server/ # TanStack Start fullstack app (see apps/server/AGENTS.md)
│ │ ├── src/
│ │ │ ├── client/ # ORPC client + TanStack Query utils
│ │ │ ├── components/
│ │ │ ├── routes/ # File-based routing
│ │ │ └── server/ # API layer + database
│ │ │ ├── api/ # ORPC contracts, routers, middlewares
│ │ │ └── db/ # Drizzle schema
│ │ └── AGENTS.md
├── packages/ ├── packages/
│ └── tsconfig/ # Shared TS configs │ └── tsconfig/ # Shared TS configs
├── biome.json # Linting/formatting config ├── biome.json # Linting/formatting config
@@ -225,4 +150,4 @@ export const myTable = pgTable('my_table', {
## See Also ## See Also
- `apps/server/AGENTS.md` - Detailed TanStack Start / ORPC patterns - `apps/server/AGENTS.md` Detailed server app architecture, ORPC patterns, UI conventions
+3 -2
View File
@@ -114,8 +114,9 @@ bun install
cp apps/server/.env.example apps/server/.env cp apps/server/.env.example apps/server/.env
# 编辑 .env,填入数据库连接信息 # 编辑 .env,填入数据库连接信息
# 初始化数据库 # 初始化数据库(生成迁移 → 应用迁移)
bun run db:push --filter=@furtherverse/server bun run db:generate --filter=@furtherverse/server
bun run db:migrate --filter=@furtherverse/server
# 启动开发环境 # 启动开发环境
bun run dev bun run dev
+3 -1
View File
@@ -1 +1,3 @@
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres DATABASE_URL=postgresql://postgres:postgres@localhost:5432/kairos
BETTER_AUTH_SECRET=your-secret-key-at-least-32-chars
BETTER_AUTH_URL=http://localhost:3000
+362 -208
View File
@@ -1,279 +1,433 @@
# AGENTS.md - Server App Guidelines # AGENTS.md - Server App Guidelines
TanStack Start fullstack web app with ORPC (contract-first RPC). TanStack Start fullstack web app with ORPC (contract-first RPC) and shadcn/ui.
## Tech Stack ## 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`.** > **⚠️ This project uses Bun — NOT Node.js / npm. Always use `bun run <script>` (not `bun <script>`). Never use `npm`, `npx`, or `node`.**
- **Framework**: TanStack Start (React 19 SSR, file-based routing) - **Framework**: TanStack Start (React 19 SSR, file-based routing)
- **Runtime**: Bun — **NOT Node.js** - **Styling**: Tailwind CSS v4 + shadcn/ui (base-nova style, `@base-ui/react`)
- **Package Manager**: Bun — **NOT npm / yarn / pnpm** - **Database**: PostgreSQL + Drizzle ORM 0.45.x (`drizzle-orm/postgres-js`)
- **Language**: TypeScript (strict mode) - **State**: TanStack Query v5 (with MutationCache auto-invalidation)
- **Styling**: Tailwind CSS v4
- **Database**: PostgreSQL + Drizzle ORM v1 beta (`drizzle-orm/postgres-js`, RQBv2)
- **State**: TanStack Query v5
- **RPC**: ORPC (contract-first, type-safe) - **RPC**: ORPC (contract-first, type-safe)
- **Auth**: Better Auth (email+password, single-owner, self-hosted)
- **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)
- **API Key**: @better-auth/api-key (API key auth for external integrations like N8N)
- **Build**: Vite + Nitro - **Build**: Vite + Nitro
## Commands ## Architecture Overview
```bash ### Route Architecture
# 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:darwin:arm64 # Compile for macOS arm64
bun run compile:darwin:x64 # Compile for macOS x64
bun run compile:linux # Compile for Linux (x64 + arm64)
bun run compile:linux:arm64 # Compile for Linux arm64
bun run compile:linux:x64 # Compile for Linux x64
bun run compile:windows # Compile for Windows (default: x64)
bun run compile:windows:x64 # Compile for Windows 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
``` ```
/ → Dashboard overview (greeting, quick bookmarks, module summary)
/bookmarks → Bookmarks page (view mode + edit mode toggle)
/finance → Finance page (transaction list with filters, account/category management)
/settings → Settings page (API key management)
/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.
- **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, route)
- `schema.ts` — Drizzle tables
- `contract.ts` — ORPC contracts (input/output Zod schemas)
- `router.ts` — ORPC handlers (business logic)
- `components/` — React UI components
Contracts and routers are registered centrally:
- `src/server/api/contracts/index.ts` — imports all module contracts
- `src/server/api/routers/index.ts` — imports all module routers
## Directory Structure ## Directory Structure
``` ```
src/ src/
├── client/ # Client-side code ├── client/
│ └── orpc.ts # ORPC client + TanStack Query utils (single entry point) │ └── orpc.ts # ORPC client (isomorphic: SSR direct call / CSR fetch)
├── components/ # React components ├── components/
│ ├── AppSidebar.tsx # Unified sidebar (reads module registry, collapsible, includes Settings link in footer)
│ ├── 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)
├── hooks/
│ └── use-mobile.ts
├── lib/
│ └── utils.ts # cn() utility
├── modules/
│ ├── registry.ts # ModuleMetadata interface + modules[]
│ ├── bookmarks/ # Bookmarks module (schema, contract, router, components/)
│ └── finance/ # Finance module
│ ├── index.ts # Module metadata
│ ├── schema.ts # Drizzle tables (financeAccount, transactionCategory, transaction)
│ ├── contract.ts # ORPC contracts (account, category, transaction CRUD + summary)
│ ├── router.ts # ORPC handlers
│ └── components/ # React UI components
│ ├── AccountFormDialog.tsx
│ ├── AccountManager.tsx
│ ├── CategoryFormDialog.tsx
│ ├── CategoryManager.tsx
│ ├── TransactionFormDialog.tsx
│ └── TransactionList.tsx
├── cli/
│ ├── index.ts # citty CLI entrypoint (bun run cli ...)
│ └── commands/auth.ts # auth reset-password command
├── routes/ # TanStack Router file routes ├── routes/ # TanStack Router file routes
│ ├── __root.tsx # Root layout │ ├── __root.tsx # Root layout (HTML shell, Toaster)
│ ├── index.tsx # Home page │ ├── _protected.tsx # Auth guard + unified shell (SidebarProvider, AppSidebar, CommandPalette)
── api/ ── _protected/
├── $.ts # OpenAPI handler + Scalar docs ├── index.tsx # Dashboard overview (greeting, quick bookmarks, module cards)
├── health.ts # Health check endpoint ├── bookmarks.tsx # Bookmarks page (view/edit toggle, Motion animations)
── rpc.$.ts # ORPC RPC handler ── finance.tsx # Finance page (transaction list, filters, account/category management)
├── server/ # Server-side code │ │ └── settings.tsx # Settings page (API key management)
│ ├── api/ # ORPC layer │ ├── login.tsx, setup.tsx
│ ├── contracts/ # Input/output schemas (Zod) └── api/ # $.ts (OpenAPI), auth.$.ts, health.ts, rpc.$.ts
│ │ ├── middlewares/ # Middleware (db provider, auth) ├── server/
│ ├── routers/ # Handler implementations │ ├── api/
│ │ ├── interceptors.ts # Shared error interceptors │ │ ├── contracts/index.ts # Central contract registry
│ │ ├── context.ts # Request context │ │ ├── routers/index.ts # Central router registry
│ │ ├── server.ts # ORPC server instance │ │ ├── middlewares/ # dbMiddleware, authMiddleware
│ │ ── types.ts # Type exports │ │ ── interceptors.ts # Validation error transform
└── db/ │ ├── context.ts # BaseContext type
├── schema/ # Drizzle table definitions ├── server.ts # `os = implement(contract).$context<BaseContext>()`
── fields.ts # Shared field builders (id, createdAt, updatedAt) ── types.ts # RouterClient type export
├── relations.ts # Drizzle relations (defineRelations, RQBv2) ├── auth/ # Better Auth (schema, instance, client, getSession, checkInitialized)
└── index.ts # Database instance (postgres-js driver) └── db/ # Drizzle (fields, relations, singleton instance)
├── env.ts # Environment variable validation ├── env.ts # @t3-oss/env-core validation
├── router.tsx # Router configuration ├── router.tsx # TanStack Router + QueryClient + MutationCache
├── routeTree.gen.ts # Auto-generated (DO NOT EDIT) ├── routeTree.gen.ts # Auto-generated (DO NOT EDIT)
└── styles.css # Tailwind entry └── styles.css # Tailwind + shadcn CSS variables
``` ```
## ORPC Pattern ## ORPC Pattern
### 1. Define Contract (`src/server/api/contracts/feature.contract.ts`) ### 1. Define Contract (in module: `src/modules/feature/contract.ts`)
```typescript ```typescript
import { oc } from '@orpc/contract' import { oc } from '@orpc/contract'
import { createSelectSchema } from 'drizzle-orm/zod' import { createInsertSchema, createSelectSchema, createUpdateSchema } from 'drizzle-zod'
import { z } from 'zod' import { z } from 'zod'
import { featureTable } from '@/server/db/schema' import * as schema from '@/modules/feature/schema'
import { generatedFieldKeys } from '@/server/db/fields'
const selectSchema = createSelectSchema(featureTable) const selectSchema = createSelectSchema(schema.myTable)
const insertSchema = createInsertSchema(schema.myTable).omit(generatedFieldKeys).omit({ userId: true })
const updateSchema = createUpdateSchema(schema.myTable).omit(generatedFieldKeys).omit({ userId: true })
export const list = oc.input(z.void()).output(z.array(selectSchema)) export const myResource = {
export const create = oc.input(insertSchema).output(selectSchema) list: oc.input(z.void()).output(z.array(selectSchema)),
create: oc.input(insertSchema).output(selectSchema),
update: oc.input(z.object({ id: z.uuid(), data: updateSchema })).output(selectSchema),
remove: oc.input(z.object({ id: z.uuid() })).output(z.void()),
}
``` ```
### 2. Implement Router (`src/server/api/routers/feature.router.ts`) ### 2. Implement Router (in module: `src/modules/feature/router.ts`)
```typescript ```typescript
import { ORPCError } from '@orpc/server' import { ORPCError } from '@orpc/server'
import { db } from '../middlewares' import * as schema from '@/modules/feature/schema'
import { os } from '../server' import { authMiddleware, dbMiddleware } from '@/server/api/middlewares'
import { os } from '@/server/api/server'
export const list = os.feature.list.use(db).handler(async ({ context }) => { export const myResource = {
return await context.db.query.featureTable.findMany({ list: os.feature.myResource.list
orderBy: { createdAt: 'desc' }, .use(dbMiddleware).use(authMiddleware)
.handler(async ({ context }) => {
return await context.db.query.myTable.findMany({
where: (t, { eq }) => eq(t.userId, context.user.id),
orderBy: (t, { desc }) => desc(t.createdAt),
}) })
}) }),
create: os.feature.myResource.create
.use(dbMiddleware).use(authMiddleware)
.handler(async ({ context, input }) => {
const [created] = await context.db.insert(schema.myTable)
.values({ ...input, userId: context.user.id }).returning()
if (!created) throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create' })
return created
}),
}
``` ```
### 3. Register in Index Files ### 3. Register (`src/server/api/contracts/index.ts` + `routers/index.ts`)
```typescript ```typescript
// src/server/api/contracts/index.ts // contracts/index.ts
import * as feature from './feature.contract' import * as feature from '@/modules/feature/contract'
export const contract = { feature } export const contract = { feature }
// src/server/api/routers/index.ts // routers/index.ts
import * as feature from './feature.router' import * as feature from '@/modules/feature/router'
export const router = os.router({ feature }) export const router = os.router({ feature })
``` ```
### 4. Use in Components ### 4. Use in Components
```typescript ```typescript
import { useSuspenseQuery, useMutation } from '@tanstack/react-query' const { data } = useSuspenseQuery(orpc.feature.myResource.list.queryOptions())
import { orpc } from '@/client/orpc' const mutation = useMutation(orpc.feature.myResource.create.mutationOptions())
const { data } = useSuspenseQuery(orpc.feature.list.queryOptions())
const mutation = useMutation(orpc.feature.create.mutationOptions())
``` ```
## Database (Drizzle ORM v1 beta) ### MutationCache Auto-Invalidation
`router.tsx` configures a global `MutationCache` that auto-invalidates queries in the same module when any mutation succeeds. No need for manual `queryClient.invalidateQueries()` in most cases.
## UI Component Patterns
### base-ui `render` Prop (CRITICAL)
shadcn/ui uses `@base-ui/react`. The `render` prop replaces Radix's `asChild`:
```typescript
// ✅ CORRECT
<DialogTrigger render={<Button />} />
<SidebarMenuButton render={<Link to="/bookmarks" />}>
// ❌ WRONG — asChild does NOT exist
<DialogTrigger asChild><Button /></DialogTrigger>
```
### Dialog Forms
```typescript
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={trigger} />
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader><DialogTitle></DialogTitle></DialogHeader>
{/* fields */}
<DialogFooter><Button type="submit"></Button></DialogFooter>
</form>
</DialogContent>
</Dialog>
```
### DnD Sortable (with @dnd-kit/helpers)
```typescript
import { move } from '@dnd-kit/helpers'
import { DragDropProvider } from '@dnd-kit/react'
import { useSortable } from '@dnd-kit/react/sortable'
// In sortable item:
const { ref, handleRef, isDragging } = useSortable({ id, index, group })
// In container — use move() for reordering:
const handleDragEnd = (event) => {
if (event.canceled) return
const reordered = move(items, event) // @dnd-kit/helpers handles index mapping
setItems(reordered)
reorderMutation.mutate(reordered.map((item, i) => ({ id: item.id, orderId: i })))
}
<DragDropProvider onDragEnd={handleDragEnd}>
{items.map((item, index) => <SortableItem key={item.id} index={index} />)}
</DragDropProvider>
```
### Virtual Scrolling in Dialogs
Use `useState` callback ref (NOT `useRef`) for scroll elements inside Dialogs — `useRef` doesn't trigger re-render when Dialog mounts:
```typescript
import { useVirtualizer } from '@tanstack/react-virtual'
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(null)
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => scrollElement, // useState, NOT useRef
estimateSize: () => ROW_HEIGHT,
overscan: 5,
})
<div ref={setScrollElement} className="max-h-80 overflow-y-auto">
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((row) => (
<div key={row.key} style={{ position: 'absolute', transform: `translateY(${row.start}px)` }}>
{/* row content */}
</div>
))}
</div>
</div>
```
### Toast Notifications
```typescript
import { toast } from 'sonner'
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`) - **Driver**: `drizzle-orm/postgres-js` (NOT `bun-sql`)
- **Validation**: `drizzle-orm/zod` (built-in, NOT separate `drizzle-zod` package) - **Validation**: `drizzle-zod` (separate package, NOT `drizzle-orm/zod`)
- **Relations**: Defined via `defineRelations()` in `src/server/db/relations.ts` - **Relations**: `relations()` from `drizzle-orm` in `src/server/db/relations.ts` — callback syntax
- **Query**: RQBv2 — use `db.query.tableName.findMany()` with object-style `orderBy` and `where` - **Table naming**: No `Table` suffix — `user`, `category`, `bookmark`
- **DB instance**: Module-level singleton `export const db = drizzle(client, { schema })` (NOT factory pattern)
- **Shared fields**: Use `...generatedFields` spread for id/createdAt/updatedAt
- **Auth schema**: Generated by Better Auth CLI (`bun run db:auth`), **never hand-edit**
- **Schema re-export**: `db/schema/index.ts` selectively exports tables only (not relations) from auth schema
- **Migration workflow**: Always `db:generate``db:migrate`. **Never** use `db:push`.
- **Path alias exception**: Files in the Drizzle schema chain (`db/schema/index.ts`, module `schema.ts`) MUST use relative imports — `drizzle-kit` does not resolve `@/*` aliases.
### Schema Definition
```typescript ```typescript
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core' // Schema — use generatedFields spread
import { sql } from 'drizzle-orm'
export const myTable = pgTable('my_table', { export const myTable = pgTable('my_table', {
id: uuid().primaryKey().default(sql`uuidv7()`), ...generatedFields, // id (uuid v7), createdAt, updatedAt
name: text().notNull(), name: text('name').notNull(),
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow().$onUpdateFn(() => new Date()),
}) })
```
### Relations (RQBv2) // Relations — callback syntax
```typescript export const myTableRelations = relations(myTable, ({ one }) => ({
// src/server/db/relations.ts user: one(user, {
import { defineRelations } from 'drizzle-orm' fields: [myTable.userId],
import * as schema from './schema' references: [user.id],
}),
export const relations = defineRelations(schema, (r) => ({
// Define relations here using r.one / r.many / r.through
})) }))
``` ```
### DB Instance ## Auth (Better Auth — Single-Owner Model)
```typescript
// src/server/db/index.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import { relations } from '@/server/db/relations'
// In RQBv2, relations already contain schema info — no separate schema import needed
const db = drizzle({ Kairos is a self-hosted single-user app. There is NO public registration. The first visit triggers a one-time setup wizard (`/setup`), and all subsequent signup attempts are blocked at the database level.
connection: env.DATABASE_URL,
relations, - **Instance**: `src/server/auth/index.ts``betterAuth()` with `drizzleAdapter(db, { provider: 'pg', schema })`
}) - **Signup blocking**: `databaseHooks.user.create.before` checks if a user already exists → throws `APIError('FORBIDDEN')` if so
- **Client**: `src/server/auth/client.ts``createAuthClient()` for React
- **Server functions**: `src/server/auth/functions.ts`:
- `getSession()` — get current session via `createServerFn`
- `checkInitialized()` — check if owner account exists (used by `/setup` and `/login` routes)
- **Auth tables**: Use `text` IDs (Better Auth manages its own IDs), NOT project's UUID v7
- **Route guard**: `beforeLoad` in `_protected.tsx` calls `getSession()` → redirect to `/login`
- **ORPC middleware**: `authMiddleware` calls `auth.api.getSession({ headers })` → injects `context.user`
- **Password reset**: Via server CLI only (`bun run cli auth reset-password`) — no web-based password recovery
### API Key Authentication
- **Plugin**: `@better-auth/api-key` with `enableSessionForAPIKeys: true`
- **Auth middleware**: Unchanged — API keys automatically simulate sessions via Better Auth plugin
- **Header**: `x-api-key: kairos_xxxxxxxx` (or `Authorization: Bearer kairos_xxxxxxxx`)
- **Management**: Via `/settings` page UI (create, list, delete)
- **Use case**: N8N workflow automation → parse emails → call ORPC API to create transactions
- **Default prefix**: `kairos_`
## Finance Module
### Data Model
- **financeAccount**: Bank accounts, wallets, credit cards. Fields: name, type (checking/savings/credit/cash/investment/loan), currencyCode (ISO 4217, default CNY), initialBalance (integer, smallest currency unit), icon, isArchived, orderId
- **transactionCategory**: Flat expense/income categories. Fields: name, icon, type (expense/income), orderId
- **transaction**: Financial records. Fields: accountId, categoryId (nullable), type (expense/income), amount (integer, positive, smallest currency unit), description, note, date, source (manual/n8n/import), externalId (unique, for N8N dedup)
### Amount Convention
- Stored as **integer in smallest currency unit** (e.g., ¥120.30 = 12030)
- Always **positive** — type field determines income vs expense
- Display: `(amount / 100).toFixed(2)` with currency symbol
### N8N Integration
External services call ORPC API with API key:
```bash
curl -X POST https://kairos.example/api/rpc/finance.transaction.create \
-H "x-api-key: kairos_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"accountId":"...","type":"expense","amount":3500,"description":"午餐","date":"2026-04-01T12:30:00Z","source":"n8n","externalId":"email-abc123"}'
``` ```
### RQBv2 Query Examples - `externalId` enables idempotent deduplication — duplicate imports return existing transaction
```typescript - `source: "n8n"` marks automated imports
// Object-style orderBy (NOT callback style)
const todos = await db.query.todoTable.findMany({
orderBy: { createdAt: 'desc' },
})
// Object-style where ### ORPC Endpoints
const todo = await db.query.todoTable.findFirst({
where: { id: someId },
})
```
## Code Style - `finance.account.list/create/update/remove/reorder`
- `finance.category.list/create/update/remove/reorder`
### Formatting (Biome) - `finance.transaction.list/create/update/remove/summary`
- **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
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
import { db } from '@/server/db'
import type { ReactNode } from 'react'
```
### 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` |
### React
- Use arrow functions for components (Biome enforced)
- Use `useSuspenseQuery` for guaranteed data
- Let React Compiler handle memoization (no manual `useMemo`/`useCallback`)
## Environment Variables
```typescript
// src/env.ts - using @t3-oss/env-core
import { createEnv } from '@t3-oss/env-core'
import { z } from 'zod'
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
},
clientPrefix: 'VITE_',
client: {
VITE_API_URL: z.string().optional(),
},
})
```
## Development Principles
> **These principles apply to ALL code changes. Agents MUST follow them on every task.**
1. **No backward compatibility** — This project is in rapid iteration. Always use the latest API and patterns. Never keep deprecated code paths or old API fallbacks.
2. **Always sync documentation** — When code changes, immediately update all related documentation (`AGENTS.md`, `README.md`, inline code examples). Code and docs must never drift apart.
3. **Forward-only migration** — When upgrading dependencies, fully adopt the new API. Don't mix old and new patterns.
## Critical Rules ## Critical Rules
**DO:** **DO:**
- Run `bun run fix` before committing - Run `bun run fix` before committing
- Use `@/*` path aliases - Use `@/*` path aliases (not relative imports)
- Include `createdAt`/`updatedAt` on all tables - Use `render` prop (NOT `asChild`) for base-ui component delegation
- Use `ORPCError` with proper codes - Use `ORPCError` with proper codes
- Use `drizzle-orm/zod` (NOT `drizzle-zod`) for schema validation - Use `drizzle-zod` for schema validation (NOT `drizzle-orm/zod`)
- Use RQBv2 object syntax for `orderBy` and `where` - Use callback syntax for `orderBy` and `where` in relational queries
- Update `AGENTS.md` and other docs whenever code patterns change - 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:** **DON'T:**
- Use `npm`, `npx`, `node`, `yarn`, `pnpm` — always use `bun` / `bunx` - Add new `src/components/ui/*.tsx` without CLI (use `bunx shadcn@latest add` to scaffold, then freely customize)
- Edit `src/routeTree.gen.ts` (auto-generated) - Edit `src/routeTree.gen.ts` (auto-generated)
- Use `as any`, `@ts-ignore`, `@ts-expect-error` - Use `asChild` prop (base-ui uses `render`, NOT Radix)
- Commit `.env` files - Import from `drizzle-orm/zod` (use `drizzle-zod`)
- Use empty catch blocks - Use `drizzle-orm/bun-sql` driver
- Import from `drizzle-zod` (use `drizzle-orm/zod` instead) - Hand-edit `src/server/auth/schema.ts` (generated by Better Auth CLI, use `bun run db:auth`)
- Use RQBv1 callback-style `orderBy` / old `relations()` API - Add `Table` suffix to Drizzle table exports
- Use `drizzle-orm/bun-sql` driver (use `drizzle-orm/postgres-js`) - Use `useRef` for scroll elements inside Dialog/conditional rendering
- Pass `schema` to `drizzle()` constructor (only `relations` is needed in RQBv2) - Use `db:push` — always use `db:generate``db:migrate`
- Import `os` from `@orpc/server` in middleware — use `@/server/api/server` (the local typed instance) - Use `@/*` aliases in Drizzle schema files (drizzle-kit can't resolve them)
- Leave docs out of sync with code changes - Add registration/signup functionality (single-owner model, enforced by `databaseHooks`)
- Create separate admin pages — integrate view/edit modes in each module page
- Use `authClient.apiKey` methods for API key management (not ORPC)
- Store amount as float/decimal — amounts MUST be integer (smallest currency unit)
- Duplicate `externalId` on transactions — must be unique (for N8N dedup)
- Try to retrieve API key after creation — keys are shown only once
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+1 -2
View File
@@ -1,11 +1,10 @@
import { defineConfig } from 'drizzle-kit' import { defineConfig } from 'drizzle-kit'
import { env } from '@/env'
export default defineConfig({ export default defineConfig({
out: './drizzle', out: './drizzle',
schema: './src/server/db/schema/index.ts', schema: './src/server/db/schema/index.ts',
dialect: 'postgresql', dialect: 'postgresql',
dbCredentials: { dbCredentials: {
url: env.DATABASE_URL, url: process.env.DATABASE_URL ?? '',
}, },
}) })
+80
View File
@@ -0,0 +1,80 @@
CREATE TABLE "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "bookmark" (
"id" uuid PRIMARY KEY NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"name" text NOT NULL,
"url" text NOT NULL,
"icon" text,
"category_id" uuid NOT NULL,
"is_public" boolean DEFAULT true NOT NULL,
"order_id" integer DEFAULT 0 NOT NULL,
"user_id" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "category" (
"id" uuid PRIMARY KEY NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"name" text NOT NULL,
"is_pinned" boolean DEFAULT false NOT NULL,
"is_public" boolean DEFAULT true NOT NULL,
"order_id" integer DEFAULT 0 NOT NULL,
"user_id" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "bookmark" ADD CONSTRAINT "bookmark_category_id_category_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."category"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "bookmark" ADD CONSTRAINT "bookmark_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "category" ADD CONSTRAINT "category_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");
@@ -0,0 +1,81 @@
CREATE TABLE "apikey" (
"id" text PRIMARY KEY NOT NULL,
"config_id" text DEFAULT 'default' NOT NULL,
"name" text,
"start" text,
"reference_id" text NOT NULL,
"prefix" text,
"key" text NOT NULL,
"refill_interval" integer,
"refill_amount" integer,
"last_refill_at" timestamp,
"enabled" boolean DEFAULT true,
"rate_limit_enabled" boolean DEFAULT true,
"rate_limit_time_window" integer DEFAULT 86400000,
"rate_limit_max" integer DEFAULT 10,
"request_count" integer DEFAULT 0,
"remaining" integer,
"last_request" timestamp,
"expires_at" timestamp,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
"permissions" text,
"metadata" text
);
--> statement-breakpoint
CREATE TABLE "finance_account" (
"id" uuid PRIMARY KEY NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"name" text NOT NULL,
"type" text DEFAULT 'checking' NOT NULL,
"currency_code" text DEFAULT 'CNY' NOT NULL,
"initial_balance" integer DEFAULT 0 NOT NULL,
"icon" text,
"is_archived" boolean DEFAULT false NOT NULL,
"order_id" integer DEFAULT 0 NOT NULL,
"user_id" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "transaction" (
"id" uuid PRIMARY KEY NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"account_id" uuid NOT NULL,
"category_id" uuid,
"type" text DEFAULT 'expense' NOT NULL,
"amount" integer NOT NULL,
"description" text NOT NULL,
"note" text,
"date" timestamp with time zone DEFAULT now() NOT NULL,
"source" text DEFAULT 'manual' NOT NULL,
"external_id" text,
"user_id" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "transaction_category" (
"id" uuid PRIMARY KEY NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"name" text NOT NULL,
"icon" text,
"type" text DEFAULT 'expense' NOT NULL,
"order_id" integer DEFAULT 0 NOT NULL,
"user_id" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "finance_account" ADD CONSTRAINT "finance_account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "transaction" ADD CONSTRAINT "transaction_account_id_finance_account_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."finance_account"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "transaction" ADD CONSTRAINT "transaction_category_id_transaction_category_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."transaction_category"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "transaction" ADD CONSTRAINT "transaction_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "transaction_category" ADD CONSTRAINT "transaction_category_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "apikey_configId_idx" ON "apikey" USING btree ("config_id");--> statement-breakpoint
CREATE INDEX "apikey_referenceId_idx" ON "apikey" USING btree ("reference_id");--> statement-breakpoint
CREATE INDEX "apikey_key_idx" ON "apikey" USING btree ("key");--> statement-breakpoint
CREATE INDEX "finance_account_user_id_idx" ON "finance_account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "transaction_user_id_idx" ON "transaction" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "transaction_account_id_idx" ON "transaction" USING btree ("account_id");--> statement-breakpoint
CREATE INDEX "transaction_category_id_idx" ON "transaction" USING btree ("category_id");--> statement-breakpoint
CREATE INDEX "transaction_date_idx" ON "transaction" USING btree ("date");--> statement-breakpoint
CREATE UNIQUE INDEX "transaction_external_id_idx" ON "transaction" USING btree ("external_id");--> statement-breakpoint
CREATE INDEX "transaction_category_user_id_idx" ON "transaction_category" USING btree ("user_id");
+534
View File
@@ -0,0 +1,534 @@
{
"id": "e52e0416-6f56-4223-9016-44087dd17d11",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.bookmark": {
"name": "bookmark",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false
},
"category_id": {
"name": "category_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"is_public": {
"name": "is_public",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"order_id": {
"name": "order_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"bookmark_category_id_category_id_fk": {
"name": "bookmark_category_id_category_id_fk",
"tableFrom": "bookmark",
"tableTo": "category",
"columnsFrom": ["category_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"bookmark_user_id_user_id_fk": {
"name": "bookmark_user_id_user_id_fk",
"tableFrom": "bookmark",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.category": {
"name": "category",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"is_pinned": {
"name": "is_pinned",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"is_public": {
"name": "is_public",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"order_id": {
"name": "order_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"category_user_id_user_id_fk": {
"name": "category_user_id_user_id_fk",
"tableFrom": "category",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1774958187626,
"tag": "0000_tricky_stick",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1774980527146,
"tag": "0001_special_titanium_man",
"breakpoints": true
}
]
}
+25 -1
View File
@@ -5,6 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "bunx --bun vite build", "build": "bunx --bun vite build",
"cli": "bun run src/cli/index.ts",
"compile": "bun compile.ts", "compile": "bun compile.ts",
"compile:darwin": "bun run compile:darwin:arm64 && bun run compile:darwin:x64", "compile:darwin": "bun run compile:darwin:arm64 && bun run compile:darwin:x64",
"compile:darwin:arm64": "bun compile.ts --target bun-darwin-arm64", "compile:darwin:arm64": "bun compile.ts --target bun-darwin-arm64",
@@ -14,15 +15,21 @@
"compile:linux:x64": "bun compile.ts --target bun-linux-x64", "compile:linux:x64": "bun compile.ts --target bun-linux-x64",
"compile:windows": "bun run compile:windows:x64", "compile:windows": "bun run compile:windows:x64",
"compile:windows:x64": "bun compile.ts --target bun-windows-x64", "compile:windows:x64": "bun compile.ts --target bun-windows-x64",
"db:auth": "bunx auth@latest generate --output src/server/auth/schema.ts --yes",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"dev": "bunx --bun vite dev", "dev": "bunx --bun vite dev",
"fix": "biome check --write", "fix": "biome check --write",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "catalog:",
"@better-auth/api-key": "^1.5.6",
"@dnd-kit/dom": "catalog:",
"@dnd-kit/helpers": "catalog:",
"@dnd-kit/react": "catalog:",
"@fontsource-variable/geist": "catalog:",
"@orpc/client": "catalog:", "@orpc/client": "catalog:",
"@orpc/contract": "catalog:", "@orpc/contract": "catalog:",
"@orpc/openapi": "catalog:", "@orpc/openapi": "catalog:",
@@ -34,10 +41,26 @@
"@tanstack/react-router": "catalog:", "@tanstack/react-router": "catalog:",
"@tanstack/react-router-ssr-query": "catalog:", "@tanstack/react-router-ssr-query": "catalog:",
"@tanstack/react-start": "catalog:", "@tanstack/react-start": "catalog:",
"@tanstack/react-virtual": "catalog:",
"better-auth": "catalog:",
"citty": "catalog:",
"class-variance-authority": "catalog:",
"clsx": "catalog:",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"drizzle-zod": "catalog:",
"lucide-react": "catalog:",
"motion": "^12.38.0",
"next-themes": "catalog:",
"postgres": "catalog:", "postgres": "catalog:",
"react": "catalog:", "react": "catalog:",
"react-day-picker": "^9.14.0",
"react-dom": "catalog:", "react-dom": "catalog:",
"shadcn": "^4.1.1",
"sonner": "catalog:",
"tailwind-merge": "catalog:",
"tw-animate-css": "catalog:",
"uuid": "catalog:", "uuid": "catalog:",
"zod": "catalog:" "zod": "catalog:"
}, },
@@ -51,6 +74,7 @@
"@tanstack/react-router-devtools": "catalog:", "@tanstack/react-router-devtools": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@vitejs/plugin-react": "catalog:", "@vitejs/plugin-react": "catalog:",
"babel-plugin-react-compiler": "catalog:",
"drizzle-kit": "catalog:", "drizzle-kit": "catalog:",
"nitro": "catalog:", "nitro": "catalog:",
"tailwindcss": "catalog:", "tailwindcss": "catalog:",
+71
View File
@@ -0,0 +1,71 @@
import { hashPassword } from 'better-auth/crypto'
import { defineCommand } from 'citty'
import { eq } from 'drizzle-orm'
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as authSchema from '@/server/auth/schema'
export const resetPassword = defineCommand({
meta: {
name: 'reset-password',
description: '重置 owner 密码',
},
args: {
password: {
type: 'string',
description: '新密码(至少 8 个字符)',
required: false,
},
},
run: async ({ args }) => {
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
console.error('错误: 未设置 DATABASE_URL 环境变量')
process.exit(1)
}
const client = postgres(databaseUrl)
const db = drizzle(client)
const owner = await db
.select({ id: authSchema.user.id, email: authSchema.user.email })
.from(authSchema.user)
.limit(1)
if (owner.length === 0 || !owner[0]) {
console.error('错误: 系统尚未初始化,请先通过 Web 界面完成设置')
process.exit(1)
}
let newPassword = args.password
if (!newPassword) {
process.stdout.write('请输入新密码: ')
const reader = Bun.stdin.stream().getReader()
const chunk = await reader.read()
newPassword = new TextDecoder().decode(chunk.value).trim()
}
if (!newPassword || newPassword.length < 8) {
console.error('错误: 密码至少需要 8 个字符')
process.exit(1)
}
const hash = await hashPassword(newPassword)
const result = await db
.update(authSchema.account)
.set({ password: hash })
.where(eq(authSchema.account.userId, owner[0].id))
.returning({ id: authSchema.account.id })
if (result.length === 0) {
console.error('错误: 未找到凭据账户,请确认 owner 使用邮箱密码注册')
process.exit(1)
}
await db.delete(authSchema.session).where(eq(authSchema.session.userId, owner[0].id))
console.log(`✓ 已重置 ${owner[0].email} 的密码,所有会话已失效`)
await client.end()
process.exit(0)
},
})
+22
View File
@@ -0,0 +1,22 @@
import { defineCommand, runMain } from 'citty'
import { resetPassword } from './commands/auth'
const main = defineCommand({
meta: {
name: 'kairos',
description: 'Kairos 服务端管理工具',
},
subCommands: {
auth: defineCommand({
meta: {
name: 'auth',
description: '认证管理',
},
subCommands: {
'reset-password': resetPassword,
},
}),
},
})
runMain(main)
+1 -27
View File
@@ -24,30 +24,4 @@ const getORPCClient = createIsomorphicFn()
const client: RouterClient = getORPCClient() const client: RouterClient = getORPCClient()
export const orpc = createTanstackQueryUtils(client, { export const orpc = createTanstackQueryUtils(client)
experimental_defaults: {
todo: {
create: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
},
},
},
update: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
},
},
},
remove: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
},
},
},
},
},
})
+122
View File
@@ -0,0 +1,122 @@
import { Link, useRouter, useRouterState } from '@tanstack/react-router'
import type { LucideIcon } from 'lucide-react'
import * as LucideIcons from 'lucide-react'
import { Circle, Home, LogOut, Search, Settings } from 'lucide-react'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarSeparator,
} from '@/components/ui/sidebar'
import { modules } from '@/modules/registry'
import { authClient } from '@/server/auth/client'
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 AppSidebar = ({ onOpenCommandPalette }: { onOpenCommandPalette: () => void }) => {
const router = useRouter()
const routerState = useRouterState()
const currentPath = routerState.location.pathname
const handleSignOut = async () => {
await authClient.signOut()
router.navigate({ to: '/login' as never })
}
return (
<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>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton render={<Link to={'/' as never} />} isActive={currentPath === '/'} tooltip="总览">
<Home />
<span></span>
</SidebarMenuButton>
</SidebarMenuItem>
{modules
.filter((mod) => mod.enabled)
.map((mod) => {
const Icon = resolveIcon(mod.icon)
return (
<SidebarMenuItem key={mod.id}>
<SidebarMenuButton
render={<Link to={mod.route as never} />}
isActive={currentPath.startsWith(mod.route)}
tooltip={mod.name}
>
<Icon />
<span>{mod.name}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</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={'/settings' as never} />}
isActive={currentPath === '/settings'}
tooltip="设置"
>
<Settings />
<span></span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton onClick={handleSignOut} tooltip="退出登录">
<LogOut />
<span>退</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
)
}
@@ -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>
)
}
+93
View File
@@ -0,0 +1,93 @@
'use client'
import { Avatar as AvatarPrimitive } from '@base-ui/react/avatar'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Avatar({
className,
size = 'default',
...props
}: AvatarPrimitive.Root.Props & {
size?: 'default' | 'sm' | 'lg'
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
'group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten',
className,
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn('aspect-square size-full rounded-full object-cover', className)}
{...props}
/>
)
}
function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
'flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs',
className,
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="avatar-badge"
className={cn(
'absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none',
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
className,
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="avatar-group"
className={cn(
'group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background',
className,
)}
{...props}
/>
)
}
function AvatarGroupCount({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="avatar-group-count"
className={cn(
'relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3',
className,
)}
{...props}
/>
)
}
export { Avatar, AvatarBadge, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage }
+49
View File
@@ -0,0 +1,49 @@
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
destructive:
'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20',
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
link: 'text-primary underline-offset-4 hover:underline',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function Badge({
className,
variant = 'default',
render,
...props
}: useRender.ComponentProps<'span'> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: 'span',
props: mergeProps<'span'>(
{
className: cn(badgeVariants({ variant }), className),
},
props,
),
render,
state: {
slot: 'badge',
variant,
},
})
}
export { Badge, badgeVariants }
+50
View File
@@ -0,0 +1,50 @@
import { Button as ButtonPrimitive } from '@base-ui/react/button'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
outline:
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost:
'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
destructive:
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
icon: 'size-8',
'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Button({
className,
variant = 'default',
size = 'default',
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return <ButtonPrimitive data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />
}
export { Button, buttonVariants }
+170
View File
@@ -0,0 +1,170 @@
'use client'
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
import * as React from 'react'
import { type DayButton, DayPicker, getDefaultClassNames, type Locale } from 'react-day-picker'
import { Button, buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils'
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = 'label',
buttonVariant = 'ghost',
locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant']
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
'group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) => date.toLocaleString(locale?.code, { month: 'short' }),
...formatters,
}}
classNames={{
root: cn('w-fit', defaultClassNames.root),
months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months),
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
nav: cn('absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', defaultClassNames.nav),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
defaultClassNames.button_next,
),
month_caption: cn(
'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
defaultClassNames.month_caption,
),
dropdowns: cn(
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
defaultClassNames.dropdowns,
),
dropdown_root: cn('relative rounded-(--cell-radius)', defaultClassNames.dropdown_root),
dropdown: cn('absolute inset-0 bg-popover opacity-0', defaultClassNames.dropdown),
caption_label: cn(
'font-medium select-none',
captionLayout === 'label'
? 'text-sm'
: 'flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
defaultClassNames.caption_label,
),
table: 'w-full border-collapse',
weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn(
'flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none',
defaultClassNames.weekday,
),
week: cn('mt-2 flex w-full', defaultClassNames.week),
week_number_header: cn('w-(--cell-size) select-none', defaultClassNames.week_number_header),
week_number: cn('text-[0.8rem] text-muted-foreground select-none', defaultClassNames.week_number),
day: cn(
'group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)',
props.showWeekNumber
? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)'
: '[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)',
defaultClassNames.day,
),
range_start: cn(
'relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted',
defaultClassNames.range_start,
),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn(
'relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted',
defaultClassNames.range_end,
),
today: cn(
'rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none',
defaultClassNames.today,
),
outside: cn('text-muted-foreground aria-selected:text-muted-foreground', defaultClassNames.outside),
disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
hidden: cn('invisible', defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === 'left') {
return <ChevronLeftIcon className={cn('size-4', className)} {...props} />
}
if (orientation === 'right') {
return <ChevronRightIcon className={cn('size-4', className)} {...props} />
}
return <ChevronDownIcon className={cn('size-4', className)} {...props} />
},
DayButton: ({ ...props }) => <CalendarDayButton locale={locale} {...props} />,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">{children}</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
'relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className,
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
+70
View File
@@ -0,0 +1,70 @@
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Card({ className, size = 'default', ...props }: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
'group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
className,
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3',
className,
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm', className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-description" className={cn('text-sm text-muted-foreground', className)} {...props} />
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-4 group-data-[size=sm]/card:px-3', className)} {...props} />
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3', className)}
{...props}
/>
)
}
export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
+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,
}
+131
View File
@@ -0,0 +1,131 @@
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
import { XIcon } from 'lucide-react'
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
'fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
className,
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
'fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />}
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="dialog-header" className={cn('flex flex-col gap-2', className)} {...props} />
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<'div'> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
'-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end',
className,
)}
{...props}
>
{children}
{showCloseButton && <DialogPrimitive.Close render={<Button variant="outline" />}>Close</DialogPrimitive.Close>}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('font-heading text-base leading-none font-medium', className)}
{...props}
/>
)
}
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
'text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
className,
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -0,0 +1,251 @@
import { Menu as MenuPrimitive } from '@base-ui/react/menu'
import { CheckIcon, ChevronRightIcon } from 'lucide-react'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = 'start',
alignOffset = 0,
side = 'bottom',
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props & Pick<MenuPrimitive.Positioner.Props, 'align' | 'alignOffset' | 'side' | 'sideOffset'>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
'z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn('px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7', className)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className,
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = 'start',
alignOffset = -3,
side = 'right',
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
'w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
)
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground',
className,
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
}
@@ -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 }
+20
View File
@@ -0,0 +1,20 @@
import { Input as InputPrimitive } from '@base-ui/react/input'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none 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 { Input }
+71
View File
@@ -0,0 +1,71 @@
'use client'
import { Popover as PopoverPrimitive } from '@base-ui/react/popover'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = 'center',
alignOffset = 0,
side = 'bottom',
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<PopoverPrimitive.Positioner.Props, 'align' | 'alignOffset' | 'side' | 'sideOffset'>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
'z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="popover-header" className={cn('flex flex-col gap-0.5 text-sm', className)} {...props} />
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn('font-heading font-medium', className)}
{...props}
/>
)
}
function PopoverDescription({ className, ...props }: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn('text-muted-foreground', className)}
{...props}
/>
)
}
export { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger }
+164
View File
@@ -0,0 +1,164 @@
import { Select as SelectPrimitive } from '@base-ui/react/select'
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
import type * as React from 'react'
import { cn } from '@/lib/utils'
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return <SelectPrimitive.Group data-slot="select-group" className={cn('scroll-my-1 p-1', className)} {...props} />
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value data-slot="select-value" className={cn('flex flex-1 text-left', className)} {...props} />
)
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: 'sm' | 'default'
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon render={<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />} />
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = 'bottom',
sideOffset = 4,
align = 'center',
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<SelectPrimitive.Positioner.Props, 'align' | 'alignOffset' | 'side' | 'sideOffset' | 'alignItemWithTrigger'>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
'relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn('px-1.5 py-1 text-xs text-muted-foreground', className)}
{...props}
/>
)
}
function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
{...props}
/>
)
}
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
@@ -0,0 +1,19 @@
import { Separator as SeparatorPrimitive } from '@base-ui/react/separator'
import { cn } from '@/lib/utils'
function Separator({ className, orientation = 'horizontal', ...props }: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
'shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
className,
)}
{...props}
/>
)
}
export { Separator }
+103
View File
@@ -0,0 +1,103 @@
'use client'
import { Dialog as SheetPrimitive } from '@base-ui/react/dialog'
import { XIcon } from 'lucide-react'
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
'fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs',
className,
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = 'right',
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: 'top' | 'right' | 'bottom' | 'left'
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
'fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={<Button variant="ghost" className="absolute top-3 right-3" size="icon-sm" />}
>
<XIcon />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="sheet-header" className={cn('flex flex-col gap-0.5 p-4', className)} {...props} />
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="sheet-footer" className={cn('mt-auto flex flex-col gap-2 p-4', className)} {...props} />
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn('font-heading text-base font-medium text-foreground', className)}
{...props}
/>
)
}
function SheetDescription({ className, ...props }: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
)
}
export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger }
+677
View File
@@ -0,0 +1,677 @@
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { cva, type VariantProps } from 'class-variance-authority'
import { PanelLeftIcon } from 'lucide-react'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useIsMobile } from '@/hooks/use-mobile'
import { cn } from '@/lib/utils'
const SIDEBAR_COOKIE_NAME = 'sidebar_state'
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = '16rem'
const SIDEBAR_WIDTH_MOBILE = '18rem'
const SIDEBAR_WIDTH_ICON = '3rem'
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
type SidebarContextProps = {
state: 'expanded' | 'collapsed'
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider.')
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<'div'> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === 'function' ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
cookieStore.set({
name: SIDEBAR_COOKIE_NAME,
value: String(openState),
path: '/',
expires: Date.now() + SIDEBAR_COOKIE_MAX_AGE * 1000,
})
},
[setOpenProp, open],
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed'
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn('group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar', className)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
className,
children,
dir,
...props
}: React.ComponentProps<'div'> & {
side?: 'left' | 'right'
variant?: 'sidebar' | 'floating' | 'inset'
collapsible?: 'offcanvas' | 'icon' | 'none'
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === 'none') {
return (
<div
data-slot="sidebar"
className={cn('flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground', className)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
'absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className,
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
return (
<main
data-slot="sidebar-inset"
className={cn(
'relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
className,
)}
{...props}
/>
)
}
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn('h-8 w-full bg-background shadow-none', className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
)
}
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn('mx-2 w-auto bg-sidebar-border', className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
'no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
className,
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<'div'> & React.ComponentProps<'div'>) {
return useRender({
defaultTagName: 'div',
props: mergeProps<'div'>(
{
className: cn(
'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
className,
),
},
props,
),
render,
state: {
slot: 'sidebar-group-label',
sidebar: 'group-label',
},
})
}
function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<'button'> & React.ComponentProps<'button'>) {
return useRender({
defaultTagName: 'button',
props: mergeProps<'button'>(
{
className: cn(
'absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0',
className,
),
},
props,
),
render,
state: {
slot: 'sidebar-group-action',
sidebar: 'group-action',
},
})
}
function SidebarGroupContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn('w-full text-sm', className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn('flex w-full min-w-0 flex-col gap-0', className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn('group/menu-item relative', className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
'peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate',
{
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function SidebarMenuButton({
render,
isActive = false,
variant = 'default',
size = 'default',
tooltip,
className,
...props
}: useRender.ComponentProps<'button'> &
React.ComponentProps<'button'> & {
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar()
const comp = useRender({
defaultTagName: 'button',
props: mergeProps<'button'>(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
},
props,
),
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: {
slot: 'sidebar-menu-button',
sidebar: 'menu-button',
size,
active: isActive,
},
})
if (!tooltip) {
return comp
}
if (typeof tooltip === 'string') {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
{comp}
<TooltipContent side="right" align="center" hidden={state !== 'collapsed' || isMobile} {...tooltip} />
</Tooltip>
)
}
function SidebarMenuAction({
className,
render,
showOnHover = false,
...props
}: useRender.ComponentProps<'button'> &
React.ComponentProps<'button'> & {
showOnHover?: boolean
}) {
return useRender({
defaultTagName: 'button',
props: mergeProps<'button'>(
{
className: cn(
'absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0',
showOnHover &&
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0',
className,
),
},
props,
),
render,
state: {
slot: 'sidebar-menu-action',
sidebar: 'menu-action',
},
})
}
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
'pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground',
className,
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<'div'> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
{...props}
>
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
'--skeleton-width': width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
)
}
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn('group/menu-sub-item relative', className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
render,
size = 'md',
isActive = false,
className,
...props
}: useRender.ComponentProps<'a'> &
React.ComponentProps<'a'> & {
size?: 'sm' | 'md'
isActive?: boolean
}) {
return useRender({
defaultTagName: 'a',
props: mergeProps<'a'>(
{
className: cn(
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
className,
),
},
props,
),
render,
state: {
slot: 'sidebar-menu-sub-button',
sidebar: 'menu-sub-button',
size,
active: isActive,
},
})
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
@@ -0,0 +1,7 @@
import { cn } from '@/lib/utils'
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="skeleton" className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />
}
export { Skeleton }
+39
View File
@@ -0,0 +1,39 @@
'use client'
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Toaster as Sonner, type ToasterProps } from 'sonner'
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme()
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: 'cn-toast',
},
}}
{...props}
/>
)
}
export { Toaster }
@@ -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 }
+54
View File
@@ -0,0 +1,54 @@
'use client'
import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'
import { cn } from '@/lib/utils'
function TooltipProvider({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delay={delay} {...props} />
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = 'top',
sideOffset = 4,
align = 'center',
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<TooltipPrimitive.Positioner.Props, 'align' | 'alignOffset' | 'side' | 'sideOffset'>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
'z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
+2
View File
@@ -4,6 +4,8 @@ import { z } from 'zod'
export const env = createEnv({ export const env = createEnv({
server: { server: {
DATABASE_URL: z.url(), DATABASE_URL: z.url(),
BETTER_AUTH_SECRET: z.string().min(32),
BETTER_AUTH_URL: z.url(),
}, },
clientPrefix: 'VITE_', clientPrefix: 'VITE_',
client: { client: {
+19
View File
@@ -0,0 +1,19 @@
import * as React from 'react'
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener('change', onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener('change', onChange)
}, [])
return !!isMobile
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
@@ -0,0 +1,37 @@
import * as icons from 'lucide-react'
import { cn } from '@/lib/utils'
const allIcons = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>
interface BookmarkCardProps {
bookmark: {
id: string
name: string
url: string
icon: string | null
}
}
export const BookmarkCard = ({ bookmark }: BookmarkCardProps) => {
const Icon = (bookmark.icon && allIcons[bookmark.icon]) || icons.Globe
return (
<a
href={bookmark.url}
target="_blank"
rel="noopener noreferrer"
className={cn(
'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:border-foreground/10 hover:shadow-md',
)}
>
<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 transition-colors group-hover:text-foreground">
{bookmark.name}
</span>
</a>
)
}
@@ -0,0 +1,149 @@
import { useMutation } from '@tanstack/react-query'
import type { ReactElement } from 'react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { orpc } from '@/client/orpc'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { IconPickerDialog } from './IconPickerDialog'
interface BookmarkFormDialogProps {
bookmark?: {
id: string
name: string
url: string
icon: string | null
}
categoryId: string
orderId?: number
trigger: ReactElement
}
export const BookmarkFormDialog = ({ bookmark, categoryId, orderId = 0, trigger }: BookmarkFormDialogProps) => {
const [open, setOpen] = useState(false)
const [form, setForm] = useState({
name: '',
url: '',
icon: null as string | null,
})
const isEdit = Boolean(bookmark)
const createBookmark = useMutation(orpc.bookmarks.bookmark.create.mutationOptions())
const updateBookmark = useMutation(orpc.bookmarks.bookmark.update.mutationOptions())
useEffect(() => {
if (!open) {
setForm({ name: '', url: '', icon: null })
return
}
setForm({
name: bookmark?.name ?? '',
url: bookmark?.url ?? '',
icon: bookmark?.icon ?? null,
})
}, [bookmark?.icon, bookmark?.name, bookmark?.url, open])
const isPending = createBookmark.isPending || updateBookmark.isPending
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const name = form.name.trim()
const url = form.url.trim()
if (!name || !url) {
return
}
try {
if (bookmark) {
await updateBookmark.mutateAsync({
id: bookmark.id,
data: {
name,
url,
icon: form.icon,
},
})
toast.success('书签已更新')
} else {
await createBookmark.mutateAsync({
name,
url,
icon: form.icon,
categoryId,
orderId,
})
toast.success('书签已创建')
}
setOpen(false)
} catch {
toast.error('操作失败')
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={trigger} />
<DialogContent className="max-w-lg">
<form onSubmit={handleSubmit} className="space-y-4">
<DialogHeader>
<DialogTitle>{isEdit ? '编辑书签' : '添加书签'}</DialogTitle>
<DialogDescription>{isEdit ? '更新书签信息' : '添加一个新的书签'}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label htmlFor="bookmark-name" className="text-sm font-medium">
</label>
<Input
id="bookmark-name"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="例如:GitHub"
required
/>
</div>
<div className="space-y-2">
<label htmlFor="bookmark-url" className="text-sm font-medium">
</label>
<Input
id="bookmark-url"
type="url"
value={form.url}
onChange={(e) => setForm((prev) => ({ ...prev, url: e.target.value }))}
placeholder="https://example.com"
required
/>
</div>
<div className="space-y-2">
<p className="text-sm font-medium"></p>
<IconPickerDialog value={form.icon} onChange={(icon) => setForm((prev) => ({ ...prev, icon }))} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button type="submit" disabled={isPending || !form.name.trim() || !form.url.trim()}>
{isPending ? '提交中...' : isEdit ? '保存修改' : '创建书签'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,185 @@
import { move } from '@dnd-kit/helpers'
import { DragDropProvider } from '@dnd-kit/react'
import { useSortable } from '@dnd-kit/react/sortable'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import * as icons from 'lucide-react'
import { ExternalLink, GripVertical, Pencil, Plus, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { orpc } from '@/client/orpc'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
import { BookmarkFormDialog } from './BookmarkFormDialog'
const allIcons = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>
interface Bookmark {
id: string
name: string
url: string
icon: string | null
orderId: number
}
interface BookmarkManagerProps {
category: {
id: string
name: string
bookmarks: Bookmark[]
}
}
const SortableBookmarkItem = ({
bookmark,
index,
categoryId,
}: {
bookmark: Bookmark
index: number
categoryId: string
}) => {
const { ref, handleRef, isDragging } = useSortable({
id: bookmark.id,
index,
group: categoryId,
})
const removeBookmark = useMutation(orpc.bookmarks.bookmark.remove.mutationOptions())
const Icon = (bookmark.icon && allIcons[bookmark.icon]) || icons.Globe
const handleDelete = async () => {
try {
await removeBookmark.mutateAsync({ id: bookmark.id })
toast.success('书签已删除')
} catch {
toast.error('操作失败')
}
}
return (
<div
ref={ref}
className={cn(
'group flex items-center gap-3 rounded-lg border px-3 py-2 transition-colors hover:bg-muted/30',
isDragging && 'opacity-60',
)}
>
<button
type="button"
ref={handleRef}
className="text-muted-foreground transition-colors hover:text-foreground active:cursor-grabbing"
>
<GripVertical className="size-4" />
</button>
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted">
<Icon className="size-4 text-muted-foreground" />
</div>
<a href={bookmark.url} target="_blank" rel="noreferrer" className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{bookmark.name}</p>
<p className="truncate text-xs text-muted-foreground">{bookmark.url}</p>
</a>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
title="打开链接"
onClick={() => window.open(bookmark.url, '_blank', 'noopener,noreferrer')}
>
<ExternalLink className="size-4" />
</Button>
<BookmarkFormDialog
categoryId={categoryId}
bookmark={bookmark}
trigger={
<Button type="button" variant="ghost" size="icon-sm">
<Pencil className="size-4" />
</Button>
}
/>
<Button type="button" variant="ghost" size="icon-sm" onClick={handleDelete}>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</div>
)
}
export const BookmarkManager = ({ category }: BookmarkManagerProps) => {
const [items, setItems] = useState(category.bookmarks)
const queryClient = useQueryClient()
const reorderBookmarks = useMutation(orpc.bookmarks.bookmark.reorder.mutationOptions())
useEffect(() => {
setItems(category.bookmarks)
}, [category.bookmarks])
const handleDragEnd: NonNullable<React.ComponentProps<typeof DragDropProvider>['onDragEnd']> = (event) => {
if (event.canceled) return
const reordered = move(items, event)
const previousItems = items
setItems(reordered)
reorderBookmarks.mutate(
reordered.map((item, index) => ({ id: item.id, orderId: index })),
{
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: orpc.bookmarks.category.list.queryOptions().queryKey,
})
toast.success('书签顺序已更新')
},
onError: () => {
setItems(previousItems)
toast.error('操作失败')
},
},
)
}
return (
<Card className="h-full">
<CardHeader className="gap-2">
<CardTitle className="text-base">{category.name}</CardTitle>
<p className="text-sm text-muted-foreground"> {items.length} </p>
</CardHeader>
<Separator />
<CardContent className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
{items.length === 0 ? (
<div className="rounded-lg border border-dashed px-4 py-12 text-center text-sm text-muted-foreground">
</div>
) : (
<DragDropProvider onDragEnd={handleDragEnd}>
{items.map((bookmark, index) => (
<SortableBookmarkItem key={bookmark.id} bookmark={bookmark} index={index} categoryId={category.id} />
))}
</DragDropProvider>
)}
</CardContent>
<CardFooter className="justify-end">
<BookmarkFormDialog
categoryId={category.id}
orderId={items.length}
trigger={
<Button type="button">
<Plus className="size-4" />
</Button>
}
/>
</CardFooter>
</Card>
)
}
@@ -0,0 +1,109 @@
import { useMutation } from '@tanstack/react-query'
import type { ReactElement } from 'react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { orpc } from '@/client/orpc'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
interface CategoryFormDialogProps {
category?: {
id: string
name: string
}
orderId?: number
trigger: ReactElement
}
export const CategoryFormDialog = ({ category, orderId = 0, trigger }: CategoryFormDialogProps) => {
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const isEdit = Boolean(category)
const createCategory = useMutation(orpc.bookmarks.category.create.mutationOptions())
const updateCategory = useMutation(orpc.bookmarks.category.update.mutationOptions())
useEffect(() => {
if (!open) {
setName('')
return
}
setName(category?.name ?? '')
}, [category?.name, open])
const isPending = createCategory.isPending || updateCategory.isPending
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const trimmedName = name.trim()
if (!trimmedName) {
return
}
try {
if (category) {
await updateCategory.mutateAsync({
id: category.id,
data: { name: trimmedName },
})
toast.success('分类已更新')
} else {
await createCategory.mutateAsync({
name: trimmedName,
orderId,
})
toast.success('分类已创建')
}
setOpen(false)
} catch {
toast.error('操作失败')
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={trigger} />
<DialogContent>
<form onSubmit={handleSubmit} className="space-y-4">
<DialogHeader>
<DialogTitle>{isEdit ? '重命名分类' : '添加分类'}</DialogTitle>
<DialogDescription>{isEdit ? '更新当前分类名称' : '创建一个新的书签分类'}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label htmlFor="category-name" className="text-sm font-medium">
</label>
<Input
id="category-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如:开发工具"
required
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button type="submit" disabled={isPending || !name.trim()}>
{isPending ? '提交中...' : isEdit ? '保存修改' : '创建分类'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,44 @@
import { BookmarkCard } from './BookmarkCard'
interface Bookmark {
id: string
name: string
url: string
icon: string | null
orderId: number
}
interface Category {
id: string
name: string
orderId: number
bookmarks: Bookmark[]
}
interface CategoryGridProps {
categories: Category[]
}
export const CategoryGrid = ({ categories }: CategoryGridProps) => {
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 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>
)}
</div>
))}
</div>
)
}
@@ -0,0 +1,185 @@
import { move } from '@dnd-kit/helpers'
import { DragDropProvider } from '@dnd-kit/react'
import { useSortable } from '@dnd-kit/react/sortable'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { GripVertical, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { orpc } from '@/client/orpc'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { CategoryFormDialog } from './CategoryFormDialog'
interface Category {
id: string
name: string
orderId: number
bookmarks: Array<{ id: string }>
}
interface CategoryManagerProps {
categories: Category[]
selectedCategoryId: string | null
onSelectCategory: (id: string) => void
}
const SortableCategoryItem = ({
category,
index,
isActive,
onSelect,
}: {
category: Category
index: number
isActive: boolean
onSelect: () => void
}) => {
const { ref, handleRef, isDragging } = useSortable({
id: category.id,
index,
group: 'bookmark-categories',
})
const removeCategory = useMutation(orpc.bookmarks.category.remove.mutationOptions())
const handleDelete = async () => {
try {
await removeCategory.mutateAsync({ id: category.id })
toast.success('分类已删除')
} catch {
toast.error('操作失败')
}
}
return (
<div
ref={ref}
className={cn(
'group flex items-center gap-2 rounded-lg border px-2 py-1.5 transition-colors',
isActive && 'border-primary bg-primary/5',
isDragging && 'opacity-60',
)}
>
<button
type="button"
ref={handleRef}
className="text-muted-foreground transition-colors hover:text-foreground active:cursor-grabbing"
>
<GripVertical className="size-4" />
</button>
<button
type="button"
onClick={onSelect}
className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left"
>
<span className="truncate text-sm font-medium">{category.name}</span>
<span className="shrink-0 text-xs text-muted-foreground">{category.bookmarks.length}</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon-sm" />}>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<CategoryFormDialog
category={{ id: category.id, name: category.name }}
trigger={
<DropdownMenuItem>
<Pencil className="size-4" />
</DropdownMenuItem>
}
/>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
export const CategoryManager = ({ categories, selectedCategoryId, onSelectCategory }: CategoryManagerProps) => {
const [items, setItems] = useState(categories)
const queryClient = useQueryClient()
const reorderCategories = useMutation(orpc.bookmarks.category.reorder.mutationOptions())
useEffect(() => {
setItems(categories)
}, [categories])
const handleDragEnd: NonNullable<React.ComponentProps<typeof DragDropProvider>['onDragEnd']> = (event) => {
if (event.canceled) return
const reordered = move(items, event)
const previousItems = items
setItems(reordered)
reorderCategories.mutate(
reordered.map((item, index) => ({ id: item.id, orderId: index })),
{
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: orpc.bookmarks.category.list.queryOptions().queryKey,
})
toast.success('分类顺序已更新')
},
onError: () => {
setItems(previousItems)
toast.error('操作失败')
},
},
)
}
return (
<Card className="h-full">
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
<DragDropProvider onDragEnd={handleDragEnd}>
{items.map((category, index) => (
<SortableCategoryItem
key={category.id}
category={category}
index={index}
isActive={selectedCategoryId === category.id}
onSelect={() => onSelectCategory(category.id)}
/>
))}
</DragDropProvider>
{items.length === 0 && (
<div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
</div>
)}
</CardContent>
<CardFooter className="justify-end">
<CategoryFormDialog
orderId={items.length}
trigger={
<Button type="button">
<Plus className="size-4" />
</Button>
}
/>
</CardFooter>
</Card>
)
}
@@ -0,0 +1,33 @@
import { useEffect, useState } from 'react'
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}`
}
export const GreetingHeader = () => {
const [now, setNow] = useState(() => new Date())
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 60_000)
return () => clearInterval(timer)
}, [])
return (
<div className="space-y-1">
<h2 className="text-3xl font-bold text-slate-900 tracking-tight">{getGreeting(now.getHours())}</h2>
<p className="text-slate-500">{formatDate(now)}</p>
</div>
)
}
@@ -0,0 +1,151 @@
import { useVirtualizer } from '@tanstack/react-virtual'
import * as icons from 'lucide-react'
import { Search, X } from 'lucide-react'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
const allIcons = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>
const ALL_ICON_NAMES: string[] = Object.keys(icons)
.filter((key) => /^[A-Z]/.test(key) && !key.endsWith('Icon'))
.sort()
const COLUMNS = 8
const ROW_HEIGHT = 40
interface IconPickerDialogProps {
value: string | null
onChange: (iconName: string | null) => void
}
export const IconPickerDialog = ({ value, onChange }: IconPickerDialogProps) => {
const [open, setOpen] = useState(false)
const [filter, setFilter] = useState('')
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(null)
const filteredIcons = filter
? ALL_ICON_NAMES.filter((name) => name.toLowerCase().includes(filter.toLowerCase()))
: ALL_ICON_NAMES
const rowCount = Math.ceil(filteredIcons.length / COLUMNS)
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => scrollElement,
estimateSize: () => ROW_HEIGHT,
overscan: 5,
})
const CurrentIcon = (value && allIcons[value]) || null
return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen)
if (!isOpen) setFilter('')
}}
>
<DialogTrigger render={<Button type="button" variant="outline" className="w-full justify-start gap-2" />}>
{CurrentIcon ? <CurrentIcon className="size-4" /> : <Search className="size-4 text-muted-foreground" />}
<span className={cn('truncate', !value && 'text-muted-foreground')}>{value ?? '选择图标'}</span>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription> · {ALL_ICON_NAMES.length} </DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input
value={filter}
onChange={(e) => {
setFilter(e.target.value)
scrollElement?.scrollTo({ top: 0 })
}}
placeholder="搜索图标..."
className="flex-1"
/>
{value && (
<Button
type="button"
variant="outline"
size="icon"
title="清除图标"
onClick={() => {
onChange(null)
setOpen(false)
}}
>
<X className="size-4" />
</Button>
)}
</div>
<div ref={setScrollElement} className="max-h-80 overflow-y-auto rounded-lg border p-2">
<div
style={{
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const startIndex = virtualRow.index * COLUMNS
const rowIcons = filteredIcons.slice(startIndex, startIndex + COLUMNS)
return (
<div
key={virtualRow.key}
className="grid grid-cols-8 gap-1"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: ROW_HEIGHT,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{rowIcons.map((name) => {
const Icon = allIcons[name]
if (!Icon) return null
return (
<Button
key={name}
type="button"
size="icon"
variant={value === name ? 'secondary' : 'ghost'}
title={name}
onClick={() => {
onChange(name)
setOpen(false)
}}
>
<Icon className="size-4" />
</Button>
)
})}
</div>
)
})}
</div>
</div>
{filteredIcons.length === 0 && <p className="text-center text-sm text-muted-foreground"></p>}
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,59 @@
import { Search } from 'lucide-react'
import type { FormEvent } from 'react'
import { useState } from 'react'
const SEARCH_ENGINES: Record<string, string> = {
g: 'https://google.com/search?q=',
d: 'https://duckduckgo.com/?q=',
b: 'https://bing.com/search?q=',
gh: 'https://github.com/search?q=',
yt: 'https://youtube.com/results?search_query=',
}
const DEFAULT_ENGINE = 'https://google.com/search?q='
const parseSearchQuery = (raw: string): string => {
const trimmed = raw.trim()
if (!trimmed) return ''
if (/^https?:\/\//i.test(trimmed) || /^[^\s]+\.[^\s]+$/.test(trimmed)) {
return trimmed.startsWith('http') ? trimmed : `https://${trimmed}`
}
const match = trimmed.match(/^\/(\w+)\s+(.+)$/)
if (match) {
const prefix = match[1]?.toLowerCase() ?? ''
const query = match[2] ?? ''
const engine = SEARCH_ENGINES[prefix]
if (engine) return `${engine}${encodeURIComponent(query)}`
}
return `${DEFAULT_ENGINE}${encodeURIComponent(trimmed)}`
}
export const SearchBar = () => {
const [query, setQuery] = useState('')
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
const url = parseSearchQuery(query)
if (url) {
window.location.href = url
}
}
return (
<form onSubmit={handleSubmit} className="w-full">
<div className="relative">
<Search className="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索或输入 URL · /g Google · /gh GitHub · /yt YouTube"
className="w-full rounded-2xl bg-white py-4 pl-12 pr-6 shadow-[0_8px_30px_rgb(0,0,0,0.04)] ring-1 ring-slate-100 outline-none transition-all placeholder:text-slate-400 text-lg text-slate-700 focus:ring-2 focus:ring-indigo-500/50"
/>
</div>
</form>
)
}
@@ -0,0 +1,35 @@
import { oc } from '@orpc/contract'
import { createInsertSchema, createSelectSchema, createUpdateSchema } from 'drizzle-zod'
import { z } from 'zod'
import * as schema from '@/modules/bookmarks/schema'
import { generatedFieldKeys } from '@/server/db/fields'
const categorySelect = createSelectSchema(schema.category)
const categoryInsert = createInsertSchema(schema.category).omit(generatedFieldKeys).omit({ userId: true })
const categoryUpdate = createUpdateSchema(schema.category).omit(generatedFieldKeys).omit({ userId: true })
const bookmarkSelect = createSelectSchema(schema.bookmark)
const bookmarkInsert = createInsertSchema(schema.bookmark).omit(generatedFieldKeys).omit({ userId: true })
const bookmarkUpdate = createUpdateSchema(schema.bookmark).omit(generatedFieldKeys).omit({ userId: true })
export const category = {
list: oc.input(z.void()).output(z.array(categorySelect.extend({ bookmarks: z.array(bookmarkSelect) }))),
create: oc.input(categoryInsert).output(categorySelect),
update: oc.input(z.object({ id: z.uuid(), data: categoryUpdate })).output(categorySelect),
remove: oc.input(z.object({ id: z.uuid() })).output(z.void()),
reorder: oc.input(z.array(z.object({ id: z.uuid(), orderId: z.number().int() }))).output(z.void()),
}
export const bookmark = {
create: oc.input(bookmarkInsert).output(bookmarkSelect),
update: oc.input(z.object({ id: z.uuid(), data: bookmarkUpdate })).output(bookmarkSelect),
remove: oc.input(z.object({ id: z.uuid() })).output(z.void()),
reorder: oc.input(z.array(z.object({ id: z.uuid(), orderId: z.number().int() }))).output(z.void()),
}
@@ -0,0 +1,10 @@
import type { ModuleMetadata } from '@/modules/registry'
export const bookmarksModule: ModuleMetadata = {
id: 'bookmarks',
name: '书签导航',
description: '常用链接和网站的快速导航',
icon: 'Compass',
route: '/bookmarks',
enabled: true,
}
+124
View File
@@ -0,0 +1,124 @@
import { ORPCError } from '@orpc/server'
import { and, eq } from 'drizzle-orm'
import * as schema from '@/modules/bookmarks/schema'
import { authMiddleware, dbMiddleware } from '@/server/api/middlewares'
import { os } from '@/server/api/server'
export const category = {
list: os.bookmarks.category.list
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context }) => {
return await context.db.query.category.findMany({
where: (category, { eq }) => eq(category.userId, context.user.id),
orderBy: (category, { asc }) => asc(category.orderId),
with: {
bookmarks: {
orderBy: (bookmark, { asc }) => asc(bookmark.orderId),
},
},
})
}),
create: os.bookmarks.category.create
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [created] = await context.db
.insert(schema.category)
.values({ ...input, userId: context.user.id })
.returning()
if (!created) throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create category' })
return created
}),
update: os.bookmarks.category.update
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [updated] = await context.db
.update(schema.category)
.set(input.data)
.where(and(eq(schema.category.id, input.id), eq(schema.category.userId, context.user.id)))
.returning()
if (!updated) throw new ORPCError('NOT_FOUND')
return updated
}),
remove: os.bookmarks.category.remove
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [deleted] = await context.db
.delete(schema.category)
.where(and(eq(schema.category.id, input.id), eq(schema.category.userId, context.user.id)))
.returning({ id: schema.category.id })
if (!deleted) throw new ORPCError('NOT_FOUND')
}),
reorder: os.bookmarks.category.reorder
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
await context.db.transaction(async (tx) => {
for (const item of input) {
await tx
.update(schema.category)
.set({ orderId: item.orderId })
.where(and(eq(schema.category.id, item.id), eq(schema.category.userId, context.user.id)))
}
})
}),
}
export const bookmark = {
create: os.bookmarks.bookmark.create
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [created] = await context.db
.insert(schema.bookmark)
.values({ ...input, userId: context.user.id })
.returning()
if (!created) throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create bookmark' })
return created
}),
update: os.bookmarks.bookmark.update
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [updated] = await context.db
.update(schema.bookmark)
.set(input.data)
.where(and(eq(schema.bookmark.id, input.id), eq(schema.bookmark.userId, context.user.id)))
.returning()
if (!updated) throw new ORPCError('NOT_FOUND')
return updated
}),
remove: os.bookmarks.bookmark.remove
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [deleted] = await context.db
.delete(schema.bookmark)
.where(and(eq(schema.bookmark.id, input.id), eq(schema.bookmark.userId, context.user.id)))
.returning({ id: schema.bookmark.id })
if (!deleted) throw new ORPCError('NOT_FOUND')
}),
reorder: os.bookmarks.bookmark.reorder
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
await context.db.transaction(async (tx) => {
for (const item of input) {
await tx
.update(schema.bookmark)
.set({ orderId: item.orderId })
.where(and(eq(schema.bookmark.id, item.id), eq(schema.bookmark.userId, context.user.id)))
}
})
}),
}
@@ -0,0 +1,29 @@
import { boolean, integer, pgTable, text, uuid } from 'drizzle-orm/pg-core'
import { user } from '../../server/auth/schema'
import { generatedFields } from '../../server/db/fields'
export const category = pgTable('category', {
...generatedFields,
name: text('name').notNull(),
isPinned: boolean('is_pinned').notNull().default(false),
isPublic: boolean('is_public').notNull().default(true),
orderId: integer('order_id').notNull().default(0),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
})
export const bookmark = pgTable('bookmark', {
...generatedFields,
name: text('name').notNull(),
url: text('url').notNull(),
icon: text('icon'),
categoryId: uuid('category_id')
.notNull()
.references(() => category.id, { onDelete: 'cascade' }),
isPublic: boolean('is_public').notNull().default(true),
orderId: integer('order_id').notNull().default(0),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
})
@@ -0,0 +1,194 @@
import { useMutation } from '@tanstack/react-query'
import type { ReactElement } from 'react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { orpc } from '@/client/orpc'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { IconPickerDialog } from '@/modules/bookmarks/components/IconPickerDialog'
interface AccountFormDialogProps {
account?: {
id: string
name: string
type: string
currencyCode: string
initialBalance: number
icon: string | null
}
orderId?: number
trigger: ReactElement
}
const ACCOUNT_TYPES = [
{ value: 'checking', label: '储蓄卡' },
{ value: 'savings', label: '存款' },
{ value: 'credit', label: '信用卡' },
{ value: 'cash', label: '现金' },
{ value: 'investment', label: '投资' },
{ value: 'loan', label: '贷款' },
] as const
export const AccountFormDialog = ({ account, orderId = 0, trigger }: AccountFormDialogProps) => {
const [open, setOpen] = useState(false)
const [form, setForm] = useState({
name: '',
type: 'checking' as 'checking' | 'savings' | 'credit' | 'cash' | 'investment' | 'loan',
currencyCode: 'CNY',
initialBalance: '0',
icon: null as string | null,
})
const isEdit = Boolean(account)
const createAccount = useMutation(orpc.finance.account.create.mutationOptions())
const updateAccount = useMutation(orpc.finance.account.update.mutationOptions())
useEffect(() => {
if (!open) {
setForm({ name: '', type: 'checking', currencyCode: 'CNY', initialBalance: '0', icon: null })
return
}
if (account) {
setForm({
name: account.name,
type: account.type as 'checking' | 'savings' | 'credit' | 'cash' | 'investment' | 'loan',
currencyCode: account.currencyCode,
initialBalance: (account.initialBalance / 100).toFixed(2),
icon: account.icon,
})
}
}, [account, open])
const isPending = createAccount.isPending || updateAccount.isPending
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const name = form.name.trim()
if (!name) return
const initialBalanceCents = Math.round(Number.parseFloat(form.initialBalance || '0') * 100)
try {
if (account) {
await updateAccount.mutateAsync({
id: account.id,
data: {
name,
type: form.type,
currencyCode: form.currencyCode,
initialBalance: initialBalanceCents,
icon: form.icon,
},
})
toast.success('账户已更新')
} else {
await createAccount.mutateAsync({
name,
type: form.type,
currencyCode: form.currencyCode,
initialBalance: initialBalanceCents,
icon: form.icon,
orderId,
})
toast.success('账户已创建')
}
setOpen(false)
} catch {
toast.error('操作失败')
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={trigger} />
<DialogContent className="max-w-md">
<form onSubmit={handleSubmit} className="space-y-4">
<DialogHeader>
<DialogTitle>{isEdit ? '编辑账户' : '添加账户'}</DialogTitle>
<DialogDescription>{isEdit ? '更新账户信息' : '添加一个新的财务账户'}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label htmlFor="account-name" className="text-sm font-medium">
</label>
<Input
id="account-name"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="例如:招商银行"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<span className="text-sm font-medium"></span>
<Select
value={form.type}
onValueChange={(value) =>
setForm((prev) => ({
...prev,
type: (value as 'checking' | 'savings' | 'credit' | 'cash' | 'investment' | 'loan') || 'checking',
}))
}
>
<SelectTrigger>
<SelectValue placeholder="选择类型" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label htmlFor="account-balance" className="text-sm font-medium">
</label>
<Input
id="account-balance"
type="number"
step="0.01"
value={form.initialBalance}
onChange={(e) => setForm((prev) => ({ ...prev, initialBalance: e.target.value }))}
placeholder="0.00"
/>
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium"></p>
<IconPickerDialog value={form.icon} onChange={(icon) => setForm((prev) => ({ ...prev, icon }))} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button type="submit" disabled={isPending || !form.name.trim()}>
{isPending ? '提交中...' : isEdit ? '保存修改' : '创建账户'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,177 @@
import { move } from '@dnd-kit/helpers'
import { DragDropProvider } from '@dnd-kit/react'
import { useSortable } from '@dnd-kit/react/sortable'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { GripVertical, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { orpc } from '@/client/orpc'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { AccountFormDialog } from './AccountFormDialog'
interface Account {
id: string
name: string
type: string
currencyCode: string
initialBalance: number
icon: string | null
orderId: number
}
interface AccountManagerProps {
accounts: Account[]
}
const ACCOUNT_TYPE_LABELS: Record<string, string> = {
checking: '储蓄卡',
savings: '存款',
credit: '信用卡',
cash: '现金',
investment: '投资',
loan: '贷款',
}
const SortableAccountItem = ({ account, index }: { account: Account; index: number }) => {
const { ref, handleRef, isDragging } = useSortable({
id: account.id,
index,
group: 'finance-accounts',
})
const removeAccount = useMutation(orpc.finance.account.remove.mutationOptions())
const handleDelete = async () => {
try {
await removeAccount.mutateAsync({ id: account.id })
toast.success('账户已删除')
} catch {
toast.error('操作失败')
}
}
return (
<div
ref={ref}
className={cn(
'group flex items-center gap-2 rounded-lg border px-2 py-1.5 transition-colors',
isDragging && 'opacity-60',
)}
>
<button
type="button"
ref={handleRef}
className="text-muted-foreground transition-colors hover:text-foreground active:cursor-grabbing"
>
<GripVertical className="size-4" />
</button>
<div className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left">
<div className="flex items-center gap-2 truncate">
{account.icon && <span className="text-sm">{account.icon}</span>}
<span className="truncate text-sm font-medium">{account.name}</span>
</div>
<span className="shrink-0 text-xs text-muted-foreground">{ACCOUNT_TYPE_LABELS[account.type]}</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon-sm" />}>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<AccountFormDialog
account={account}
trigger={
<DropdownMenuItem>
<Pencil className="size-4" />
</DropdownMenuItem>
}
/>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
export const AccountManager = ({ accounts }: AccountManagerProps) => {
const [items, setItems] = useState(accounts)
const queryClient = useQueryClient()
const reorderAccounts = useMutation(orpc.finance.account.reorder.mutationOptions())
useEffect(() => {
setItems(accounts)
}, [accounts])
const handleDragEnd: NonNullable<React.ComponentProps<typeof DragDropProvider>['onDragEnd']> = (event) => {
if (event.canceled) return
const reordered = move(items, event)
const previousItems = items
setItems(reordered)
reorderAccounts.mutate(
reordered.map((item, index) => ({ id: item.id, orderId: index })),
{
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: orpc.finance.account.list.queryOptions().queryKey,
})
toast.success('账户顺序已更新')
},
onError: () => {
setItems(previousItems)
toast.error('操作失败')
},
},
)
}
return (
<Card className="h-full">
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
<DragDropProvider onDragEnd={handleDragEnd}>
{items.map((account, index) => (
<SortableAccountItem key={account.id} account={account} index={index} />
))}
</DragDropProvider>
{items.length === 0 && (
<div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
</div>
)}
</CardContent>
<CardFooter className="justify-end">
<AccountFormDialog
orderId={items.length}
trigger={
<Button type="button">
<Plus className="size-4" />
</Button>
}
/>
</CardFooter>
</Card>
)
}
@@ -0,0 +1,151 @@
import { useMutation } from '@tanstack/react-query'
import type { ReactElement } from 'react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { orpc } from '@/client/orpc'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { IconPickerDialog } from '@/modules/bookmarks/components/IconPickerDialog'
interface CategoryFormDialogProps {
category?: {
id: string
name: string
type: string
icon: string | null
}
orderId?: number
trigger: ReactElement
}
export const CategoryFormDialog = ({ category, orderId = 0, trigger }: CategoryFormDialogProps) => {
const [open, setOpen] = useState(false)
const [form, setForm] = useState({
name: '',
type: 'expense' as 'expense' | 'income',
icon: null as string | null,
})
const isEdit = Boolean(category)
const createCategory = useMutation(orpc.finance.category.create.mutationOptions())
const updateCategory = useMutation(orpc.finance.category.update.mutationOptions())
useEffect(() => {
if (!open) {
setForm({ name: '', type: 'expense', icon: null })
return
}
if (category) {
setForm({
name: category.name,
type: category.type as 'expense' | 'income',
icon: category.icon,
})
}
}, [category, open])
const isPending = createCategory.isPending || updateCategory.isPending
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const name = form.name.trim()
if (!name) return
try {
if (category) {
await updateCategory.mutateAsync({
id: category.id,
data: {
name,
type: form.type,
icon: form.icon,
},
})
toast.success('分类已更新')
} else {
await createCategory.mutateAsync({
name,
type: form.type,
icon: form.icon,
orderId,
})
toast.success('分类已创建')
}
setOpen(false)
} catch {
toast.error('操作失败')
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={trigger} />
<DialogContent className="max-w-md">
<form onSubmit={handleSubmit} className="space-y-4">
<DialogHeader>
<DialogTitle>{isEdit ? '编辑分类' : '添加分类'}</DialogTitle>
<DialogDescription>{isEdit ? '更新分类信息' : '添加一个新的交易分类'}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label htmlFor="category-name" className="text-sm font-medium">
</label>
<Input
id="category-name"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="例如:餐饮美食"
required
/>
</div>
<div className="space-y-2">
<span className="text-sm font-medium"></span>
<Select
value={form.type}
onValueChange={(value) =>
setForm((prev) => ({ ...prev, type: (value as 'expense' | 'income') || 'expense' }))
}
>
<SelectTrigger>
<SelectValue placeholder="选择类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="expense"></SelectItem>
<SelectItem value="income"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<p className="text-sm font-medium"></p>
<IconPickerDialog value={form.icon} onChange={(icon) => setForm((prev) => ({ ...prev, icon }))} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button type="submit" disabled={isPending || !form.name.trim()}>
{isPending ? '提交中...' : isEdit ? '保存修改' : '创建分类'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,168 @@
import { move } from '@dnd-kit/helpers'
import { DragDropProvider } from '@dnd-kit/react'
import { useSortable } from '@dnd-kit/react/sortable'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { GripVertical, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { orpc } from '@/client/orpc'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { CategoryFormDialog } from './CategoryFormDialog'
interface Category {
id: string
name: string
type: string
icon: string | null
orderId: number
}
interface CategoryManagerProps {
categories: Category[]
}
const SortableCategoryItem = ({ category, index }: { category: Category; index: number }) => {
const { ref, handleRef, isDragging } = useSortable({
id: category.id,
index,
group: 'finance-categories',
})
const removeCategory = useMutation(orpc.finance.category.remove.mutationOptions())
const handleDelete = async () => {
try {
await removeCategory.mutateAsync({ id: category.id })
toast.success('分类已删除')
} catch {
toast.error('操作失败')
}
}
return (
<div
ref={ref}
className={cn(
'group flex items-center gap-2 rounded-lg border px-2 py-1.5 transition-colors',
isDragging && 'opacity-60',
)}
>
<button
type="button"
ref={handleRef}
className="text-muted-foreground transition-colors hover:text-foreground active:cursor-grabbing"
>
<GripVertical className="size-4" />
</button>
<div className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left">
<div className="flex items-center gap-2 truncate">
{category.icon && <span className="text-sm">{category.icon}</span>}
<span className="truncate text-sm font-medium">{category.name}</span>
</div>
<span className={cn('shrink-0 text-xs', category.type === 'income' ? 'text-emerald-500' : 'text-rose-500')}>
{category.type === 'income' ? '收入' : '支出'}
</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon-sm" />}>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<CategoryFormDialog
category={category}
trigger={
<DropdownMenuItem>
<Pencil className="size-4" />
</DropdownMenuItem>
}
/>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
export const CategoryManager = ({ categories }: CategoryManagerProps) => {
const [items, setItems] = useState(categories)
const queryClient = useQueryClient()
const reorderCategories = useMutation(orpc.finance.category.reorder.mutationOptions())
useEffect(() => {
setItems(categories)
}, [categories])
const handleDragEnd: NonNullable<React.ComponentProps<typeof DragDropProvider>['onDragEnd']> = (event) => {
if (event.canceled) return
const reordered = move(items, event)
const previousItems = items
setItems(reordered)
reorderCategories.mutate(
reordered.map((item, index) => ({ id: item.id, orderId: index })),
{
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: orpc.finance.category.list.queryOptions().queryKey,
})
toast.success('分类顺序已更新')
},
onError: () => {
setItems(previousItems)
toast.error('操作失败')
},
},
)
}
return (
<Card className="h-full">
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
<DragDropProvider onDragEnd={handleDragEnd}>
{items.map((category, index) => (
<SortableCategoryItem key={category.id} category={category} index={index} />
))}
</DragDropProvider>
{items.length === 0 && (
<div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
</div>
)}
</CardContent>
<CardFooter className="justify-end">
<CategoryFormDialog
orderId={items.length}
trigger={
<Button type="button">
<Plus className="size-4" />
</Button>
}
/>
</CardFooter>
</Card>
)
}
@@ -0,0 +1,296 @@
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import { format } from 'date-fns'
import { CalendarIcon } from 'lucide-react'
import type { ReactElement } from 'react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { orpc } from '@/client/orpc'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { cn } from '@/lib/utils'
interface TransactionFormDialogProps {
transaction?: {
id: string
accountId: string
categoryId: string | null
type: string
amount: number
description: string
note: string | null
date: Date
}
trigger: ReactElement
}
export const TransactionFormDialog = ({ transaction, trigger }: TransactionFormDialogProps) => {
const [open, setOpen] = useState(false)
const { data: accounts } = useSuspenseQuery(orpc.finance.account.list.queryOptions())
const { data: categories } = useSuspenseQuery(orpc.finance.category.list.queryOptions())
const [form, setForm] = useState<{
type: 'expense' | 'income'
amount: string
description: string
accountId: string
categoryId: string
date: Date
note: string
}>({
type: 'expense',
amount: '',
description: '',
accountId: accounts[0]?.id ?? '',
categoryId: 'none',
date: new Date(),
note: '',
})
const isEdit = Boolean(transaction)
const createTransaction = useMutation(orpc.finance.transaction.create.mutationOptions())
const updateTransaction = useMutation(orpc.finance.transaction.update.mutationOptions())
useEffect(() => {
if (!open) {
setForm({
type: 'expense',
amount: '',
description: '',
accountId: accounts[0]?.id ?? '',
categoryId: 'none',
date: new Date(),
note: '',
})
return
}
if (transaction) {
setForm({
type: transaction.type as 'expense' | 'income',
amount: (transaction.amount / 100).toFixed(2),
description: transaction.description,
accountId: transaction.accountId,
categoryId: transaction.categoryId ?? 'none',
date: new Date(transaction.date),
note: transaction.note ?? '',
})
}
}, [transaction, open, accounts])
const isPending = createTransaction.isPending || updateTransaction.isPending
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const description = form.description.trim()
if (!description || !form.accountId || !form.amount) return
const amountCents = Math.round(Number.parseFloat(form.amount) * 100)
if (amountCents <= 0) {
toast.error('金额必须大于 0')
return
}
try {
const data = {
type: form.type,
amount: amountCents,
description,
accountId: form.accountId,
categoryId: form.categoryId === 'none' ? undefined : form.categoryId,
date: form.date,
note: form.note.trim() || undefined,
}
if (transaction) {
await updateTransaction.mutateAsync({
id: transaction.id,
data,
})
toast.success('交易已更新')
} else {
await createTransaction.mutateAsync(data)
toast.success('交易已记录')
}
setOpen(false)
} catch {
toast.error('操作失败')
}
}
const filteredCategories = categories.filter((c) => c.type === form.type)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={trigger} />
<DialogContent className="max-w-md">
<form onSubmit={handleSubmit} className="space-y-4">
<DialogHeader>
<DialogTitle>{isEdit ? '编辑交易' : '记录交易'}</DialogTitle>
<DialogDescription>{isEdit ? '更新交易信息' : '添加一笔新的收入或支出'}</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<span className="text-sm font-medium"></span>
<Select
value={form.type}
onValueChange={(value) => {
setForm((prev) => ({
...prev,
type: (value as 'expense' | 'income') || 'expense',
categoryId: 'none',
}))
}}
>
<SelectTrigger>
<SelectValue placeholder="选择类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="expense"></SelectItem>
<SelectItem value="income"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label htmlFor="tx-amount" className="text-sm font-medium">
</label>
<Input
id="tx-amount"
type="number"
step="0.01"
min="0.01"
value={form.amount}
onChange={(e) => setForm((prev) => ({ ...prev, amount: e.target.value }))}
placeholder="0.00"
required
/>
</div>
</div>
<div className="space-y-2">
<label htmlFor="tx-desc" className="text-sm font-medium">
</label>
<Input
id="tx-desc"
value={form.description}
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
placeholder="例如:午餐、工资"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<span className="text-sm font-medium"></span>
<Select
value={form.accountId}
onValueChange={(value) => setForm((prev) => ({ ...prev, accountId: value || '' }))}
>
<SelectTrigger>
<SelectValue placeholder="选择账户" />
</SelectTrigger>
<SelectContent>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
{account.icon && <span className="mr-2">{account.icon}</span>}
{account.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<span className="text-sm font-medium"></span>
<Select
value={form.categoryId}
onValueChange={(value) => setForm((prev) => ({ ...prev, categoryId: value || 'none' }))}
>
<SelectTrigger>
<SelectValue placeholder="选择分类" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{filteredCategories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.icon && <span className="mr-2">{category.icon}</span>}
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2 flex flex-col">
<span className="text-sm font-medium"></span>
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
className={cn(
'w-full justify-start text-left font-normal',
!form.date && 'text-muted-foreground',
)}
>
<CalendarIcon className="mr-2 size-4" />
{form.date ? format(form.date, 'yyyy-MM-dd') : <span></span>}
</Button>
}
/>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={form.date}
onSelect={(date) => date && setForm((prev) => ({ ...prev, date }))}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<label htmlFor="tx-note" className="text-sm font-medium">
()
</label>
<Input
id="tx-note"
value={form.note}
onChange={(e) => setForm((prev) => ({ ...prev, note: e.target.value }))}
placeholder="添加备注..."
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button type="submit" disabled={isPending || !form.description.trim() || !form.amount || !form.accountId}>
{isPending ? '提交中...' : isEdit ? '保存修改' : '记录交易'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,173 @@
import { useMutation } from '@tanstack/react-query'
import { format } from 'date-fns'
import { MoreHorizontal, Pencil, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { orpc } from '@/client/orpc'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { TransactionFormDialog } from './TransactionFormDialog'
interface Transaction {
id: string
accountId: string
categoryId: string | null
type: string
amount: number
description: string
note: string | null
date: Date
source: string
account: { id: string; name: string; type: string; currencyCode: string; icon: string | null }
category: { id: string; name: string; icon: string | null; type: string } | null
createdAt: Date
updatedAt: Date
}
interface TransactionListProps {
transactions: Transaction[]
}
export const TransactionList = ({ transactions }: TransactionListProps) => {
const removeTransaction = useMutation(orpc.finance.transaction.remove.mutationOptions())
const handleDelete = async (id: string) => {
if (!confirm('确定要删除这笔交易吗?')) return
try {
await removeTransaction.mutateAsync({ id })
toast.success('交易已删除')
} catch {
toast.error('操作失败')
}
}
if (transactions.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-24 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="text-sm text-muted-foreground"></p>
</div>
)
}
const grouped = transactions.reduce(
(acc, tx) => {
const date = format(new Date(tx.date), 'yyyy-MM-dd')
if (!acc[date]) acc[date] = []
acc[date].push(tx)
return acc
},
{} as Record<string, Transaction[]>,
)
const sortedDates = Object.keys(grouped).sort((a, b) => new Date(b).getTime() - new Date(a).getTime())
return (
<div className="space-y-8">
{sortedDates.map((date) => {
const dayTransactions = grouped[date] || []
const dayTotal = dayTransactions.reduce((sum, tx) => sum + (tx.type === 'income' ? tx.amount : -tx.amount), 0)
return (
<div key={date} className="space-y-3">
<div className="flex items-center justify-between px-1">
<h3 className="text-sm font-medium text-muted-foreground">{date}</h3>
<span
className={cn(
'text-sm font-medium',
dayTotal > 0 ? 'text-emerald-500' : dayTotal < 0 ? 'text-rose-500' : 'text-muted-foreground',
)}
>
{dayTotal > 0 ? '+' : ''}
{(dayTotal / 100).toFixed(2)}
</span>
</div>
<div className="overflow-hidden rounded-xl border bg-card">
{dayTransactions.map((tx, index) => (
<div
key={tx.id}
className={cn(
'flex items-center justify-between p-4 transition-colors hover:bg-muted/50',
index !== dayTransactions.length - 1 && 'border-b',
)}
>
<div className="flex items-center gap-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted">
{tx.category?.icon ? (
<span className="text-lg">{tx.category.icon}</span>
) : (
<span className="text-lg">💰</span>
)}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{tx.description}</span>
{tx.category && (
<Badge variant="secondary" className="text-[10px] font-normal">
{tx.category.name}
</Badge>
)}
</div>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
{tx.account.icon && <span>{tx.account.icon}</span>}
{tx.account.name}
</span>
{tx.note && (
<>
<span></span>
<span className="truncate max-w-[200px]">{tx.note}</span>
</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-4">
<span className={cn('font-medium', tx.type === 'income' ? 'text-emerald-500' : 'text-foreground')}>
{tx.type === 'income' ? '+' : '-'}¥{(tx.amount / 100).toFixed(2)}
</span>
<DropdownMenu>
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon-sm" />}>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<TransactionFormDialog
transaction={tx}
trigger={
<DropdownMenuItem>
<Pencil className="size-4" />
</DropdownMenuItem>
}
/>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => handleDelete(tx.id)}>
<Trash2 className="size-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,94 @@
import { oc } from '@orpc/contract'
import { createInsertSchema, createSelectSchema, createUpdateSchema } from 'drizzle-zod'
import { z } from 'zod'
import * as schema from '@/modules/finance/schema'
import { generatedFieldKeys } from '@/server/db/fields'
const financeAccountSelect = createSelectSchema(schema.financeAccount)
const financeAccountInsert = createInsertSchema(schema.financeAccount).omit(generatedFieldKeys).omit({ userId: true })
const financeAccountUpdate = createUpdateSchema(schema.financeAccount).omit(generatedFieldKeys).omit({ userId: true })
const transactionCategorySelect = createSelectSchema(schema.transactionCategory)
const transactionCategoryInsert = createInsertSchema(schema.transactionCategory)
.omit(generatedFieldKeys)
.omit({ userId: true })
const transactionCategoryUpdate = createUpdateSchema(schema.transactionCategory)
.omit(generatedFieldKeys)
.omit({ userId: true })
const transactionSelect = createSelectSchema(schema.transaction)
const transactionInsert = createInsertSchema(schema.transaction).omit(generatedFieldKeys).omit({ userId: true })
const transactionUpdate = createUpdateSchema(schema.transaction).omit(generatedFieldKeys).omit({ userId: true })
export const account = {
list: oc.input(z.void()).output(z.array(financeAccountSelect)),
create: oc.input(financeAccountInsert).output(financeAccountSelect),
update: oc.input(z.object({ id: z.uuid(), data: financeAccountUpdate })).output(financeAccountSelect),
remove: oc.input(z.object({ id: z.uuid() })).output(z.void()),
reorder: oc.input(z.array(z.object({ id: z.uuid(), orderId: z.number().int() }))).output(z.void()),
}
export const category = {
list: oc.input(z.void()).output(z.array(transactionCategorySelect)),
create: oc.input(transactionCategoryInsert).output(transactionCategorySelect),
update: oc.input(z.object({ id: z.uuid(), data: transactionCategoryUpdate })).output(transactionCategorySelect),
remove: oc.input(z.object({ id: z.uuid() })).output(z.void()),
reorder: oc.input(z.array(z.object({ id: z.uuid(), orderId: z.number().int() }))).output(z.void()),
}
export const transaction = {
list: oc
.input(
z.object({
accountId: z.uuid().optional(),
categoryId: z.uuid().optional(),
type: z.enum(['expense', 'income']).optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
limit: z.number().int().min(1).max(100).default(50),
offset: z.number().int().min(0).default(0),
}),
)
.output(
z.object({
items: z.array(
transactionSelect.extend({
account: financeAccountSelect,
category: transactionCategorySelect.nullable(),
}),
),
total: z.number().int(),
}),
),
create: oc.input(transactionInsert).output(transactionSelect),
update: oc.input(z.object({ id: z.uuid(), data: transactionUpdate })).output(transactionSelect),
remove: oc.input(z.object({ id: z.uuid() })).output(z.void()),
summary: oc
.input(
z.object({
startDate: z.string().optional(),
endDate: z.string().optional(),
accountId: z.uuid().optional(),
}),
)
.output(
z.object({
totalIncome: z.number().int(),
totalExpense: z.number().int(),
balance: z.number().int(),
transactionCount: z.number().int(),
}),
),
}
+10
View File
@@ -0,0 +1,10 @@
import type { ModuleMetadata } from '@/modules/registry'
export const financeModule: ModuleMetadata = {
id: 'finance',
name: '记账',
description: '收支记录、账户管理与财务分析',
icon: 'Wallet',
route: '/finance',
enabled: true,
}
+232
View File
@@ -0,0 +1,232 @@
import { ORPCError } from '@orpc/server'
import { and, eq, gte, lte, sql } from 'drizzle-orm'
import * as schema from '@/modules/finance/schema'
import { authMiddleware, dbMiddleware } from '@/server/api/middlewares'
import { os } from '@/server/api/server'
export const account = {
list: os.finance.account.list
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context }) => {
return await context.db.query.financeAccount.findMany({
where: (t, { eq }) => eq(t.userId, context.user.id),
orderBy: (t, { asc }) => asc(t.orderId),
})
}),
create: os.finance.account.create
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [created] = await context.db
.insert(schema.financeAccount)
.values({ ...input, userId: context.user.id })
.returning()
if (!created) throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create account' })
return created
}),
update: os.finance.account.update
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [updated] = await context.db
.update(schema.financeAccount)
.set(input.data)
.where(and(eq(schema.financeAccount.id, input.id), eq(schema.financeAccount.userId, context.user.id)))
.returning()
if (!updated) throw new ORPCError('NOT_FOUND')
return updated
}),
remove: os.finance.account.remove
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [deleted] = await context.db
.delete(schema.financeAccount)
.where(and(eq(schema.financeAccount.id, input.id), eq(schema.financeAccount.userId, context.user.id)))
.returning({ id: schema.financeAccount.id })
if (!deleted) throw new ORPCError('NOT_FOUND')
}),
reorder: os.finance.account.reorder
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
await context.db.transaction(async (tx) => {
for (const item of input) {
await tx
.update(schema.financeAccount)
.set({ orderId: item.orderId })
.where(and(eq(schema.financeAccount.id, item.id), eq(schema.financeAccount.userId, context.user.id)))
}
})
}),
}
export const category = {
list: os.finance.category.list
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context }) => {
return await context.db.query.transactionCategory.findMany({
where: (t, { eq }) => eq(t.userId, context.user.id),
orderBy: (t, { asc }) => asc(t.orderId),
})
}),
create: os.finance.category.create
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [created] = await context.db
.insert(schema.transactionCategory)
.values({ ...input, userId: context.user.id })
.returning()
if (!created) throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create category' })
return created
}),
update: os.finance.category.update
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [updated] = await context.db
.update(schema.transactionCategory)
.set(input.data)
.where(and(eq(schema.transactionCategory.id, input.id), eq(schema.transactionCategory.userId, context.user.id)))
.returning()
if (!updated) throw new ORPCError('NOT_FOUND')
return updated
}),
remove: os.finance.category.remove
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [deleted] = await context.db
.delete(schema.transactionCategory)
.where(and(eq(schema.transactionCategory.id, input.id), eq(schema.transactionCategory.userId, context.user.id)))
.returning({ id: schema.transactionCategory.id })
if (!deleted) throw new ORPCError('NOT_FOUND')
}),
reorder: os.finance.category.reorder
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
await context.db.transaction(async (tx) => {
for (const item of input) {
await tx
.update(schema.transactionCategory)
.set({ orderId: item.orderId })
.where(
and(eq(schema.transactionCategory.id, item.id), eq(schema.transactionCategory.userId, context.user.id)),
)
}
})
}),
}
export const transaction = {
list: os.finance.transaction.list
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const conditions = [eq(schema.transaction.userId, context.user.id)]
if (input.accountId) conditions.push(eq(schema.transaction.accountId, input.accountId))
if (input.categoryId) conditions.push(eq(schema.transaction.categoryId, input.categoryId))
if (input.type) conditions.push(eq(schema.transaction.type, input.type))
if (input.startDate) conditions.push(gte(schema.transaction.date, new Date(input.startDate)))
if (input.endDate) conditions.push(lte(schema.transaction.date, new Date(input.endDate)))
const where = and(...conditions)
const items = await context.db.query.transaction.findMany({
where: () => where,
with: { account: true, category: true },
orderBy: (t, { desc }) => desc(t.date),
limit: input.limit,
offset: input.offset,
})
const [countResult] = await context.db
.select({ count: sql<number>`COUNT(*)::int` })
.from(schema.transaction)
.where(where)
return { items, total: countResult?.count ?? 0 }
}),
create: os.finance.transaction.create
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
if (input.externalId) {
const externalId = input.externalId
const existing = await context.db.query.transaction.findFirst({
where: (t, { eq, and }) => and(eq(t.externalId, externalId), eq(t.userId, context.user.id)),
})
if (existing) return existing
}
const [created] = await context.db
.insert(schema.transaction)
.values({ ...input, userId: context.user.id })
.returning()
if (!created) throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create transaction' })
return created
}),
update: os.finance.transaction.update
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [updated] = await context.db
.update(schema.transaction)
.set(input.data)
.where(and(eq(schema.transaction.id, input.id), eq(schema.transaction.userId, context.user.id)))
.returning()
if (!updated) throw new ORPCError('NOT_FOUND')
return updated
}),
remove: os.finance.transaction.remove
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [deleted] = await context.db
.delete(schema.transaction)
.where(and(eq(schema.transaction.id, input.id), eq(schema.transaction.userId, context.user.id)))
.returning({ id: schema.transaction.id })
if (!deleted) throw new ORPCError('NOT_FOUND')
}),
summary: os.finance.transaction.summary
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const conditions = [eq(schema.transaction.userId, context.user.id)]
if (input.accountId) conditions.push(eq(schema.transaction.accountId, input.accountId))
if (input.startDate) conditions.push(gte(schema.transaction.date, new Date(input.startDate)))
if (input.endDate) conditions.push(lte(schema.transaction.date, new Date(input.endDate)))
const [result] = await context.db
.select({
totalIncome: sql<number>`COALESCE(SUM(CASE WHEN ${schema.transaction.type} = 'income' THEN ${schema.transaction.amount} ELSE 0 END), 0)::int`,
totalExpense: sql<number>`COALESCE(SUM(CASE WHEN ${schema.transaction.type} = 'expense' THEN ${schema.transaction.amount} ELSE 0 END), 0)::int`,
transactionCount: sql<number>`COUNT(*)::int`,
})
.from(schema.transaction)
.where(and(...conditions))
return {
totalIncome: result?.totalIncome ?? 0,
totalExpense: result?.totalExpense ?? 0,
balance: (result?.totalIncome ?? 0) - (result?.totalExpense ?? 0),
transactionCount: result?.transactionCount ?? 0,
}
}),
}
+64
View File
@@ -0,0 +1,64 @@
import { boolean, index, integer, pgTable, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core'
import { user } from '../../server/auth/schema'
import { generatedFields } from '../../server/db/fields'
export const financeAccount = pgTable(
'finance_account',
{
...generatedFields,
name: text('name').notNull(),
type: text('type').notNull().default('checking'),
currencyCode: text('currency_code').notNull().default('CNY'),
initialBalance: integer('initial_balance').notNull().default(0),
icon: text('icon'),
isArchived: boolean('is_archived').notNull().default(false),
orderId: integer('order_id').notNull().default(0),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
},
(table) => [index('finance_account_user_id_idx').on(table.userId)],
)
export const transactionCategory = pgTable(
'transaction_category',
{
...generatedFields,
name: text('name').notNull(),
icon: text('icon'),
type: text('type').notNull().default('expense'),
orderId: integer('order_id').notNull().default(0),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
},
(table) => [index('transaction_category_user_id_idx').on(table.userId)],
)
export const transaction = pgTable(
'transaction',
{
...generatedFields,
accountId: uuid('account_id')
.notNull()
.references(() => financeAccount.id, { onDelete: 'cascade' }),
categoryId: uuid('category_id').references(() => transactionCategory.id, { onDelete: 'set null' }),
type: text('type').notNull().default('expense'),
amount: integer('amount').notNull(),
description: text('description').notNull(),
note: text('note'),
date: timestamp('date', { withTimezone: true }).notNull().defaultNow(),
source: text('source').notNull().default('manual'),
externalId: text('external_id'),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
},
(table) => [
index('transaction_user_id_idx').on(table.userId),
index('transaction_account_id_idx').on(table.accountId),
index('transaction_category_id_idx').on(table.categoryId),
index('transaction_date_idx').on(table.date),
uniqueIndex('transaction_external_id_idx').on(table.externalId),
],
)
+13
View File
@@ -0,0 +1,13 @@
import { bookmarksModule } from './bookmarks'
import { financeModule } from './finance'
export interface ModuleMetadata {
id: string
name: string
description: string
icon: string
route: string
enabled: boolean
}
export const modules: ModuleMetadata[] = [bookmarksModule, financeModule]
+180 -15
View File
@@ -9,15 +9,36 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index' import { Route as SetupRouteImport } from './routes/setup'
import { Route as LoginRouteImport } from './routes/login'
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 ApiHealthRouteImport } from './routes/api/health'
import { Route as ApiSplatRouteImport } from './routes/api/$' import { Route as ApiSplatRouteImport } from './routes/api/$'
import { Route as ProtectedSettingsRouteImport } from './routes/_protected/settings'
import { Route as ProtectedFinanceRouteImport } from './routes/_protected/finance'
import { Route as ProtectedBookmarksRouteImport } from './routes/_protected/bookmarks'
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$' import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
const IndexRoute = IndexRouteImport.update({ const SetupRoute = SetupRouteImport.update({
id: '/setup',
path: '/setup',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const ProtectedRoute = ProtectedRouteImport.update({
id: '/_protected',
getParentRoute: () => rootRouteImport,
} as any)
const ProtectedIndexRoute = ProtectedIndexRouteImport.update({
id: '/', id: '/',
path: '/', path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => ProtectedRoute,
} as any) } as any)
const ApiHealthRoute = ApiHealthRouteImport.update({ const ApiHealthRoute = ApiHealthRouteImport.update({
id: '/api/health', id: '/api/health',
@@ -29,54 +50,149 @@ const ApiSplatRoute = ApiSplatRouteImport.update({
path: '/api/$', path: '/api/$',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ProtectedSettingsRoute = ProtectedSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => ProtectedRoute,
} as any)
const ProtectedFinanceRoute = ProtectedFinanceRouteImport.update({
id: '/finance',
path: '/finance',
getParentRoute: () => ProtectedRoute,
} as any)
const ProtectedBookmarksRoute = ProtectedBookmarksRouteImport.update({
id: '/bookmarks',
path: '/bookmarks',
getParentRoute: () => ProtectedRoute,
} as any)
const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({ const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
id: '/api/rpc/$', id: '/api/rpc/$',
path: '/api/rpc/$', path: '/api/rpc/$',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
id: '/api/auth/$',
path: '/api/auth/$',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof ProtectedIndexRoute
'/login': typeof LoginRoute
'/setup': typeof SetupRoute
'/bookmarks': typeof ProtectedBookmarksRoute
'/finance': typeof ProtectedFinanceRoute
'/settings': typeof ProtectedSettingsRoute
'/api/$': typeof ApiSplatRoute '/api/$': typeof ApiSplatRoute
'/api/health': typeof ApiHealthRoute '/api/health': typeof ApiHealthRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/login': typeof LoginRoute
'/setup': typeof SetupRoute
'/bookmarks': typeof ProtectedBookmarksRoute
'/finance': typeof ProtectedFinanceRoute
'/settings': typeof ProtectedSettingsRoute
'/api/$': typeof ApiSplatRoute '/api/$': typeof ApiSplatRoute
'/api/health': typeof ApiHealthRoute '/api/health': typeof ApiHealthRoute
'/': typeof ProtectedIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/_protected': typeof ProtectedRouteWithChildren
'/login': typeof LoginRoute
'/setup': typeof SetupRoute
'/_protected/bookmarks': typeof ProtectedBookmarksRoute
'/_protected/finance': typeof ProtectedFinanceRoute
'/_protected/settings': typeof ProtectedSettingsRoute
'/api/$': typeof ApiSplatRoute '/api/$': typeof ApiSplatRoute
'/api/health': typeof ApiHealthRoute '/api/health': typeof ApiHealthRoute
'/_protected/': typeof ProtectedIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/api/$' | '/api/health' | '/api/rpc/$' fullPaths:
| '/'
| '/login'
| '/setup'
| '/bookmarks'
| '/finance'
| '/settings'
| '/api/$'
| '/api/health'
| '/api/auth/$'
| '/api/rpc/$'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/api/$' | '/api/health' | '/api/rpc/$' to:
id: '__root__' | '/' | '/api/$' | '/api/health' | '/api/rpc/$' | '/login'
| '/setup'
| '/bookmarks'
| '/finance'
| '/settings'
| '/api/$'
| '/api/health'
| '/'
| '/api/auth/$'
| '/api/rpc/$'
id:
| '__root__'
| '/_protected'
| '/login'
| '/setup'
| '/_protected/bookmarks'
| '/_protected/finance'
| '/_protected/settings'
| '/api/$'
| '/api/health'
| '/_protected/'
| '/api/auth/$'
| '/api/rpc/$'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute ProtectedRoute: typeof ProtectedRouteWithChildren
LoginRoute: typeof LoginRoute
SetupRoute: typeof SetupRoute
ApiSplatRoute: typeof ApiSplatRoute ApiSplatRoute: typeof ApiSplatRoute
ApiHealthRoute: typeof ApiHealthRoute ApiHealthRoute: typeof ApiHealthRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiRpcSplatRoute: typeof ApiRpcSplatRoute ApiRpcSplatRoute: typeof ApiRpcSplatRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
'/': { '/setup': {
id: '/' id: '/setup'
path: '/setup'
fullPath: '/setup'
preLoaderRoute: typeof SetupRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/_protected': {
id: '/_protected'
path: ''
fullPath: '/'
preLoaderRoute: typeof ProtectedRouteImport
parentRoute: typeof rootRouteImport
}
'/_protected/': {
id: '/_protected/'
path: '/' path: '/'
fullPath: '/' fullPath: '/'
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof ProtectedIndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof ProtectedRoute
} }
'/api/health': { '/api/health': {
id: '/api/health' id: '/api/health'
@@ -92,6 +208,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiSplatRouteImport preLoaderRoute: typeof ApiSplatRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/_protected/settings': {
id: '/_protected/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof ProtectedSettingsRouteImport
parentRoute: typeof ProtectedRoute
}
'/_protected/finance': {
id: '/_protected/finance'
path: '/finance'
fullPath: '/finance'
preLoaderRoute: typeof ProtectedFinanceRouteImport
parentRoute: typeof ProtectedRoute
}
'/_protected/bookmarks': {
id: '/_protected/bookmarks'
path: '/bookmarks'
fullPath: '/bookmarks'
preLoaderRoute: typeof ProtectedBookmarksRouteImport
parentRoute: typeof ProtectedRoute
}
'/api/rpc/$': { '/api/rpc/$': {
id: '/api/rpc/$' id: '/api/rpc/$'
path: '/api/rpc/$' path: '/api/rpc/$'
@@ -99,13 +236,41 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiRpcSplatRouteImport preLoaderRoute: typeof ApiRpcSplatRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/api/auth/$': {
id: '/api/auth/$'
path: '/api/auth/$'
fullPath: '/api/auth/$'
preLoaderRoute: typeof ApiAuthSplatRouteImport
parentRoute: typeof rootRouteImport
}
} }
} }
interface ProtectedRouteChildren {
ProtectedBookmarksRoute: typeof ProtectedBookmarksRoute
ProtectedFinanceRoute: typeof ProtectedFinanceRoute
ProtectedSettingsRoute: typeof ProtectedSettingsRoute
ProtectedIndexRoute: typeof ProtectedIndexRoute
}
const ProtectedRouteChildren: ProtectedRouteChildren = {
ProtectedBookmarksRoute: ProtectedBookmarksRoute,
ProtectedFinanceRoute: ProtectedFinanceRoute,
ProtectedSettingsRoute: ProtectedSettingsRoute,
ProtectedIndexRoute: ProtectedIndexRoute,
}
const ProtectedRouteWithChildren = ProtectedRoute._addFileChildren(
ProtectedRouteChildren,
)
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, ProtectedRoute: ProtectedRouteWithChildren,
LoginRoute: LoginRoute,
SetupRoute: SetupRoute,
ApiSplatRoute: ApiSplatRoute, ApiSplatRoute: ApiSplatRoute,
ApiHealthRoute: ApiHealthRoute, ApiHealthRoute: ApiHealthRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiRpcSplatRoute: ApiRpcSplatRoute, ApiRpcSplatRoute: ApiRpcSplatRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
+15 -1
View File
@@ -1,4 +1,4 @@
import { QueryClient } from '@tanstack/react-query' import { MutationCache, QueryClient } from '@tanstack/react-query'
import { createRouter } from '@tanstack/react-router' import { createRouter } from '@tanstack/react-router'
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query' import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
import type { RouterContext } from './routes/__root' import type { RouterContext } from './routes/__root'
@@ -6,6 +6,20 @@ import { routeTree } from './routeTree.gen'
export const getRouter = () => { export const getRouter = () => {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
mutationCache: new MutationCache({
onSuccess: (_data, _variables, _context, mutation) => {
const key = mutation.options.mutationKey
if (Array.isArray(key) && Array.isArray(key[0]) && key[0].length > 0) {
const module = key[0][0]
queryClient.invalidateQueries({
predicate: (query) => {
const qk = query.queryKey
return Array.isArray(qk) && Array.isArray(qk[0]) && qk[0][0] === module
},
})
}
},
}),
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 30 * 1000, staleTime: 30 * 1000,
+4 -1
View File
@@ -6,6 +6,8 @@ import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { ErrorComponent } from '@/components/Error' import { ErrorComponent } from '@/components/Error'
import { NotFoundComponent } from '@/components/NotFound' import { NotFoundComponent } from '@/components/NotFound'
import { Toaster } from '@/components/ui/sonner'
import { TooltipProvider } from '@/components/ui/tooltip'
import appCss from '@/styles.css?url' import appCss from '@/styles.css?url'
export interface RouterContext { export interface RouterContext {
@@ -45,7 +47,8 @@ function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
<HeadContent /> <HeadContent />
</head> </head>
<body> <body>
{children} <TooltipProvider>{children}</TooltipProvider>
<Toaster richColors position="top-right" />
{import.meta.env.DEV && ( {import.meta.env.DEV && (
<TanStackDevtools <TanStackDevtools
config={{ config={{
+36
View File
@@ -0,0 +1,36 @@
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)({
beforeLoad: async () => {
const session = await getSession()
if (!session) {
throw redirect({ to: '/login' as never })
}
return { user: session.user, session: session.session }
},
component: ProtectedLayout,
})
function ProtectedLayout() {
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>
)
}
@@ -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>
)
}
@@ -0,0 +1,240 @@
import type { QueryClient } from '@tanstack/react-query'
import { useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { Pencil, Plus, X } from 'lucide-react'
import { AnimatePresence } from 'motion/react'
import * as motion from 'motion/react-client'
import { useState } from 'react'
import { orpc } from '@/client/orpc'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { AccountManager } from '@/modules/finance/components/AccountManager'
import { CategoryManager } from '@/modules/finance/components/CategoryManager'
import { TransactionFormDialog } from '@/modules/finance/components/TransactionFormDialog'
import { TransactionList } from '@/modules/finance/components/TransactionList'
export const Route = createFileRoute('/_protected/finance' as never)({
loader: async ({ context }: { context: { queryClient: QueryClient } }) => {
await Promise.all([
context.queryClient.fetchQuery(orpc.finance.account.list.queryOptions()),
context.queryClient.fetchQuery(orpc.finance.category.list.queryOptions()),
context.queryClient.fetchQuery(orpc.finance.transaction.list.queryOptions({ input: { limit: 50, offset: 0 } })),
context.queryClient.fetchQuery(orpc.finance.transaction.summary.queryOptions({ input: {} })),
])
},
component: FinancePage,
})
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 FinancePage() {
const [editing, setEditing] = useState(false)
const [filterAccountId, setFilterAccountId] = useState<string>('all')
const [filterCategoryId, setFilterCategoryId] = useState<string>('all')
const [filterType, setFilterType] = useState<'all' | 'expense' | 'income'>('all')
const { data: accounts } = useSuspenseQuery(orpc.finance.account.list.queryOptions())
const { data: categories } = useSuspenseQuery(orpc.finance.category.list.queryOptions())
const listParams = {
limit: 50,
offset: 0,
...(filterAccountId !== 'all' && { accountId: filterAccountId }),
...(filterCategoryId !== 'all' && { categoryId: filterCategoryId }),
...(filterType !== 'all' && { type: filterType }),
}
const { data: result } = useSuspenseQuery(orpc.finance.transaction.list.queryOptions({ input: listParams }))
const { data: summary } = useSuspenseQuery(
orpc.finance.transaction.summary.queryOptions({
input: {
...(filterAccountId !== 'all' && { accountId: filterAccountId }),
},
}),
)
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>
<div className="flex items-center gap-3">
{!editing && (
<TransactionFormDialog
trigger={
<Button size="sm">
<Plus className="size-4" />
</Button>
}
/>
)}
<Button variant={editing ? 'default' : 'outline'} size="sm" onClick={() => setEditing(!editing)}>
{editing ? (
<>
<X className="size-4" />
</>
) : (
<>
<Pencil className="size-4" />
</>
)}
</Button>
</div>
</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">
<AccountManager accounts={accounts} />
</div>
<div className="min-w-0 flex-1">
<CategoryManager categories={categories} />
</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"
>
{accounts.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="space-y-8">
<motion.div variants={itemVariants} className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-emerald-500">
¥{(summary.totalIncome / 100).toFixed(2)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-rose-500">¥{(summary.totalExpense / 100).toFixed(2)}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">¥{(summary.balance / 100).toFixed(2)}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{summary.transactionCount}</div>
</CardContent>
</Card>
</motion.div>
<motion.div variants={itemVariants} className="flex items-center gap-4">
<Select value={filterAccountId} onValueChange={(v) => setFilterAccountId(v || 'all')}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="所有账户" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
{account.icon && <span className="mr-2">{account.icon}</span>}
{account.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterCategoryId} onValueChange={(v) => setFilterCategoryId(v || 'all')}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="所有分类" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.icon && <span className="mr-2">{category.icon}</span>}
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filterType}
onValueChange={(v) => setFilterType((v as 'all' | 'expense' | 'income') || 'all')}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="所有类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="expense"></SelectItem>
<SelectItem value="income"></SelectItem>
</SelectContent>
</Select>
</motion.div>
<motion.div variants={itemVariants}>
<TransactionList transactions={result.items} />
</motion.div>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
)
}
+169
View File
@@ -0,0 +1,169 @@
import type { QueryClient } from '@tanstack/react-query'
import { useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute, Link } from '@tanstack/react-router'
import * as icons from 'lucide-react'
import { ArrowRight, Compass, TrendingDown, TrendingUp, Wallet } from 'lucide-react'
import * as motion from 'motion/react-client'
import { orpc } from '@/client/orpc'
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 } }) => {
await Promise.all([
context.queryClient.fetchQuery(orpc.bookmarks.category.list.queryOptions()),
context.queryClient.fetchQuery(orpc.finance.transaction.summary.queryOptions({ input: {} })),
context.queryClient.fetchQuery(orpc.finance.account.list.queryOptions()),
])
},
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 { data: summary } = useSuspenseQuery(orpc.finance.transaction.summary.queryOptions({ input: {} }))
const { data: accounts } = useSuspenseQuery(orpc.finance.account.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 (
<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={'/bookmarks' as never}
className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<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 (
<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"
>
<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>
</a>
)
})}
</div>
</motion.div>
)}
<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>
<Link
to={'/finance' 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">
<Wallet 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">
{accounts.length} · {summary.transactionCount}
</p>
</div>
<ArrowRight className="size-4 text-muted-foreground opacity-0 transition-all group-hover:opacity-100" />
</Link>
{summary.transactionCount > 0 && (
<div className="flex items-center gap-4 rounded-xl border bg-card p-5">
<div className="min-w-0 flex-1 space-y-2">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<TrendingUp className="size-3 text-emerald-500" />
</span>
<span className="text-xs font-medium text-emerald-600">
¥{(summary.totalIncome / 100).toFixed(2)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<TrendingDown className="size-3 text-red-500" />
</span>
<span className="text-xs font-medium text-red-600">¥{(summary.totalExpense / 100).toFixed(2)}</span>
</div>
</div>
</div>
)}
</div>
</motion.div>
</motion.div>
)
}
@@ -0,0 +1,204 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { Check, Copy, Key, Plus, Trash2 } from 'lucide-react'
import * as motion from 'motion/react-client'
import { useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { authClient } from '@/server/auth/client'
export const Route = createFileRoute('/_protected/settings' as never)({
component: SettingsPage,
})
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 SettingsPage() {
const queryClient = useQueryClient()
const [createOpen, setCreateOpen] = useState(false)
const [keyName, setKeyName] = useState('')
const [newKey, setNewKey] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const { data: apiKeys, isLoading } = useQuery({
queryKey: ['api-keys'],
queryFn: async () => {
const result = await authClient.apiKey.list({ query: { limit: 100, sortBy: 'createdAt', sortDirection: 'desc' } })
return result.data
},
})
const createMutation = useMutation({
mutationFn: async (name: string) => {
const result = await authClient.apiKey.create({ name })
return result.data
},
onSuccess: (data) => {
if (data?.key) {
setNewKey(data.key)
}
setKeyName('')
setCreateOpen(false)
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
toast.success('API 密钥已创建')
},
onError: () => {
toast.error('创建失败')
},
})
const deleteMutation = useMutation({
mutationFn: async (keyId: string) => {
await authClient.apiKey.delete({ keyId })
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
toast.success('API 密钥已删除')
},
onError: () => {
toast.error('删除失败')
},
})
const handleCopyKey = async () => {
if (!newKey) return
await navigator.clipboard.writeText(newKey)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleCreateSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!keyName.trim()) return
createMutation.mutate(keyName.trim())
}
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
return (
<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-2xl font-bold tracking-tight"></h1>
<p className="mt-1 text-sm text-muted-foreground"> API </p>
</motion.div>
<motion.div variants={itemVariants}>
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">API </h2>
<p className="text-sm text-muted-foreground"> N8N Kairos API</p>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger render={<Button size="sm" />}>
<Plus className="size-4" />
</DialogTrigger>
<DialogContent>
<form onSubmit={handleCreateSubmit}>
<DialogHeader>
<DialogTitle> API </DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
placeholder="密钥名称(如 N8N Integration"
value={keyName}
onChange={(e) => setKeyName(e.target.value)}
autoFocus
/>
</div>
<DialogFooter>
<Button type="submit" disabled={!keyName.trim() || createMutation.isPending}>
{createMutation.isPending ? '创建中...' : '创建'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
{isLoading ? (
<div className="space-y-3">
{[1, 2].map((i) => (
<div key={i} className="h-16 animate-pulse rounded-xl border bg-muted/30" />
))}
</div>
) : apiKeys && apiKeys.length > 0 ? (
<div className="space-y-2">
{apiKeys.map((key: { id: string; name: string | null; start: string | null; createdAt: string }) => (
<div
key={key.id}
className="group flex items-center gap-4 rounded-xl border bg-card px-4 py-3 transition-colors hover:bg-muted/30"
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted/60">
<Key className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{key.name ?? '未命名密钥'}</p>
<p className="font-mono text-xs text-muted-foreground">{key.start ?? '***'}</p>
</div>
<span className="text-xs text-muted-foreground">{formatDate(key.createdAt)}</span>
<Button
variant="ghost"
size="icon"
className="size-8 opacity-0 transition-opacity group-hover:opacity-100"
onClick={() => deleteMutation.mutate(key.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="size-3.5 text-muted-foreground" />
</Button>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed py-12 text-center">
<div className="mb-3 flex size-12 items-center justify-center rounded-xl bg-muted/60">
<Key className="size-5 text-muted-foreground" />
</div>
<p className="mb-1 text-sm font-medium"> API </p>
<p className="mb-4 text-xs text-muted-foreground"> N8N </p>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
</Button>
</div>
)}
</motion.div>
<Dialog open={!!newKey} onOpenChange={(open) => !open && setNewKey(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="mb-3 text-sm text-muted-foreground"></p>
<div className="flex items-center gap-2 rounded-lg border bg-muted/30 p-3">
<code className="flex-1 break-all font-mono text-xs">{newKey}</code>
<Button variant="ghost" size="icon" className="size-8 shrink-0" onClick={handleCopyKey}>
{copied ? <Check className="size-3.5 text-green-500" /> : <Copy className="size-3.5" />}
</Button>
</div>
</div>
<DialogFooter>
<Button onClick={() => setNewKey(null)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</motion.div>
)
}
+11
View File
@@ -0,0 +1,11 @@
import { createFileRoute } from '@tanstack/react-router'
import { auth } from '@/server/auth'
export const Route = createFileRoute('/api/auth/$' as never)({
server: {
handlers: {
GET: ({ request }) => auth.handler(request),
POST: ({ request }) => auth.handler(request),
},
},
})
-193
View File
@@ -1,193 +0,0 @@
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import type { ChangeEventHandler, SubmitEventHandler } from 'react'
import { useState } from 'react'
import { orpc } from '@/client/orpc'
export const Route = createFileRoute('/')({
component: Todos,
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(orpc.todo.list.queryOptions())
},
})
function Todos() {
const [newTodoTitle, setNewTodoTitle] = useState('')
const listQuery = useSuspenseQuery(orpc.todo.list.queryOptions())
const createMutation = useMutation(orpc.todo.create.mutationOptions())
const updateMutation = useMutation(orpc.todo.update.mutationOptions())
const deleteMutation = useMutation(orpc.todo.remove.mutationOptions())
const handleCreateTodo: SubmitEventHandler<HTMLFormElement> = (e) => {
e.preventDefault()
if (newTodoTitle.trim()) {
createMutation.mutate({ title: newTodoTitle.trim() })
setNewTodoTitle('')
}
}
const handleInputChange: ChangeEventHandler<HTMLInputElement> = (e) => {
setNewTodoTitle(e.target.value)
}
const handleToggleTodo = (id: string, currentCompleted: boolean) => {
updateMutation.mutate({
id,
data: { completed: !currentCompleted },
})
}
const handleDeleteTodo = (id: string) => {
deleteMutation.mutate({ id })
}
const todos = listQuery.data
const completedCount = todos.filter((todo) => todo.completed).length
const totalCount = todos.length
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0
return (
<div className="min-h-screen bg-slate-50 py-12 px-4 sm:px-6 font-sans">
<div className="max-w-2xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-end justify-between">
<div>
<h1 className="text-3xl font-bold text-slate-900 tracking-tight"></h1>
<p className="text-slate-500 mt-1"></p>
</div>
<div className="text-right">
<div className="text-2xl font-semibold text-slate-900">
{completedCount}
<span className="text-slate-400 text-lg">/{totalCount}</span>
</div>
<div className="text-xs font-medium text-slate-400 uppercase tracking-wider"></div>
</div>
</div>
{/* Add Todo Form */}
<form onSubmit={handleCreateTodo} className="relative group z-10">
<div className="relative transform transition-all duration-200 focus-within:-translate-y-1">
<input
type="text"
value={newTodoTitle}
onChange={handleInputChange}
placeholder="添加新任务..."
className="w-full pl-6 pr-32 py-5 bg-white rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] border-0 ring-1 ring-slate-100 focus:ring-2 focus:ring-indigo-500/50 outline-none transition-all placeholder:text-slate-400 text-lg text-slate-700"
disabled={createMutation.isPending}
/>
<button
type="submit"
disabled={createMutation.isPending || !newTodoTitle.trim()}
className="absolute right-3 top-3 bottom-3 px-6 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-medium transition-all shadow-md shadow-indigo-200 disabled:opacity-50 disabled:shadow-none hover:shadow-lg hover:shadow-indigo-300 active:scale-95"
>
{createMutation.isPending ? '添加中' : '添加'}
</button>
</div>
</form>
{/* Progress Bar (Only visible when there are tasks) */}
{totalCount > 0 && (
<div className="h-1.5 w-full bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 transition-all duration-500 ease-out rounded-full"
style={{ width: `${progress}%` }}
/>
</div>
)}
{/* Todo List */}
<div className="space-y-3">
{todos.length === 0 ? (
<div className="py-20 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-100 mb-4">
<svg
className="w-8 h-8 text-slate-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
<p className="text-slate-500 text-lg font-medium"></p>
<p className="text-slate-400 text-sm mt-1"></p>
</div>
) : (
todos.map((todo) => (
<div
key={todo.id}
className={`group relative flex items-center p-4 bg-white rounded-xl border border-slate-100 shadow-sm transition-all duration-200 hover:shadow-md hover:border-slate-200 ${
todo.completed ? 'bg-slate-50/50' : ''
}`}
>
<button
type="button"
onClick={() => handleToggleTodo(todo.id, todo.completed)}
className={`flex-shrink-0 w-6 h-6 rounded-full border-2 transition-all duration-200 flex items-center justify-center mr-4 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ${
todo.completed
? 'bg-indigo-500 border-indigo-500'
: 'border-slate-300 hover:border-indigo-500 bg-white'
}`}
>
{todo.completed && (
<svg
className="w-3.5 h-3.5 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</button>
<div className="flex-1 min-w-0">
<p
className={`text-lg transition-all duration-200 truncate ${
todo.completed
? 'text-slate-400 line-through decoration-slate-300 decoration-2'
: 'text-slate-700'
}`}
>
{todo.title}
</p>
</div>
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 absolute right-4 pl-4 bg-gradient-to-l from-white via-white to-transparent sm:static sm:bg-none">
<span className="text-xs text-slate-400 mr-3 hidden sm:inline-block">
{new Date(todo.createdAt).toLocaleDateString('zh-CN')}
</span>
<button
type="button"
onClick={() => handleDeleteTodo(todo.id)}
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors focus:outline-none"
title="删除"
>
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
))
)}
</div>
</div>
</div>
)
}
+102
View File
@@ -0,0 +1,102 @@
import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
import { useState } from 'react'
import { authClient } from '@/server/auth/client'
import { checkInitialized, getSession } from '@/server/auth/functions'
export const Route = createFileRoute('/login' as never)({
beforeLoad: async () => {
const initialized = await checkInitialized()
if (!initialized) {
throw redirect({ to: '/setup' as never })
}
const session = await getSession()
if (session) {
throw redirect({ to: '/' as never })
}
},
component: LoginPage,
})
function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setError('')
setLoading(true)
const { error: signInError } = await authClient.signIn.email({
email,
password,
})
if (signInError) {
setError(signInError.message ?? '登录失败,请重试')
setLoading(false)
return
}
router.navigate({ to: '/' as never })
}
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-slate-900 tracking-tight">Kairos</h1>
<p className="text-slate-500 mt-2"></p>
</div>
<div className="bg-white rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] ring-1 ring-slate-100 p-8">
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="email" className="block text-sm font-medium text-slate-700 mb-1.5">
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 rounded-xl bg-slate-50 border-0 ring-1 ring-slate-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all text-slate-700 placeholder:text-slate-400"
placeholder="your@email.com"
disabled={loading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-slate-700 mb-1.5">
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-3 rounded-xl bg-slate-50 border-0 ring-1 ring-slate-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all text-slate-700 placeholder:text-slate-400"
placeholder="••••••••"
disabled={loading}
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-medium transition-all shadow-md shadow-indigo-200 disabled:opacity-50 disabled:shadow-none hover:shadow-lg hover:shadow-indigo-300 active:scale-[0.98]"
>
{loading ? '登录中...' : '登录'}
</button>
</form>
</div>
</div>
</div>
)
}
+144
View File
@@ -0,0 +1,144 @@
import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
import { useState } from 'react'
import { authClient } from '@/server/auth/client'
import { checkInitialized } from '@/server/auth/functions'
export const Route = createFileRoute('/setup' as never)({
beforeLoad: async () => {
const initialized = await checkInitialized()
if (initialized) {
throw redirect({ to: '/login' as never })
}
},
component: SetupPage,
})
function SetupPage() {
const router = useRouter()
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setError('')
if (password !== confirmPassword) {
setError('两次输入的密码不一致')
return
}
if (password.length < 8) {
setError('密码至少需要 8 个字符')
return
}
setLoading(true)
const { error: signUpError } = await authClient.signUp.email({
name,
email,
password,
})
if (signUpError) {
setError(signUpError.message ?? '创建账号失败,请重试')
setLoading(false)
return
}
router.navigate({ to: '/' as never })
}
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-slate-900 tracking-tight">Kairos</h1>
<p className="text-slate-500 mt-2"></p>
</div>
<div className="bg-white rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] ring-1 ring-slate-100 p-8">
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="name" className="block text-sm font-medium text-slate-700 mb-1.5">
</label>
<input
id="name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-3 rounded-xl bg-slate-50 border-0 ring-1 ring-slate-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all text-slate-700 placeholder:text-slate-400"
placeholder="你的名字"
disabled={loading}
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-slate-700 mb-1.5">
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 rounded-xl bg-slate-50 border-0 ring-1 ring-slate-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all text-slate-700 placeholder:text-slate-400"
placeholder="your@email.com"
disabled={loading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-slate-700 mb-1.5">
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-3 rounded-xl bg-slate-50 border-0 ring-1 ring-slate-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all text-slate-700 placeholder:text-slate-400"
placeholder="至少 8 个字符"
disabled={loading}
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-slate-700 mb-1.5">
</label>
<input
id="confirmPassword"
type="password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-4 py-3 rounded-xl bg-slate-50 border-0 ring-1 ring-slate-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all text-slate-700 placeholder:text-slate-400"
placeholder="再次输入密码"
disabled={loading}
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-medium transition-all shadow-md shadow-indigo-200 disabled:opacity-50 disabled:shadow-none hover:shadow-lg hover:shadow-indigo-300 active:scale-[0.98]"
>
{loading ? '初始化中...' : '开始使用 Kairos'}
</button>
</form>
</div>
</div>
</div>
)
}
+5 -15
View File
@@ -1,25 +1,15 @@
import type { auth } from '@/server/auth'
import type { DB } from '@/server/db' import type { DB } from '@/server/db'
/**
* 基础 Context - 所有请求都包含的上下文
*/
export interface BaseContext { export interface BaseContext {
headers: Headers headers: Headers
} }
/**
* 数据库 Context - 通过 db middleware 扩展
*/
export interface DBContext extends BaseContext { export interface DBContext extends BaseContext {
db: DB db: DB
} }
/** export interface AuthContext extends DBContext {
* 认证 Context - 通过 auth middleware 扩展(未来使用) user: typeof auth.$Infer.Session.user
* session: typeof auth.$Infer.Session.session
* @example }
* export interface AuthContext extends DBContext {
* userId: string
* user: User
* }
*/
@@ -1,7 +1,9 @@
import * as todo from './todo.contract' import * as bookmarks from '@/modules/bookmarks/contract'
import * as finance from '@/modules/finance/contract'
export const contract = { export const contract = {
todo, bookmarks,
finance,
} }
export type Contract = typeof contract export type Contract = typeof contract
@@ -1,32 +0,0 @@
import { oc } from '@orpc/contract'
import { createInsertSchema, createSelectSchema, createUpdateSchema } from 'drizzle-orm/zod'
import { z } from 'zod'
import { generatedFieldKeys } from '@/server/db/fields'
import { todoTable } from '@/server/db/schema'
const selectSchema = createSelectSchema(todoTable)
const insertSchema = createInsertSchema(todoTable).omit(generatedFieldKeys)
const updateSchema = createUpdateSchema(todoTable).omit(generatedFieldKeys)
export const list = oc.input(z.void()).output(z.array(selectSchema))
export const create = oc.input(insertSchema).output(selectSchema)
export const update = oc
.input(
z.object({
id: z.uuid(),
data: updateSchema,
}),
)
.output(selectSchema)
export const remove = oc
.input(
z.object({
id: z.uuid(),
}),
)
.output(z.void())
@@ -0,0 +1,19 @@
import { ORPCError } from '@orpc/server'
import { os } from '@/server/api/server'
import { auth } from '@/server/auth'
export const authMiddleware = os.middleware(async ({ context, next }) => {
const sessionData = await auth.api.getSession({ headers: context.headers })
if (!sessionData?.session || !sessionData?.user) {
throw new ORPCError('UNAUTHORIZED')
}
return next({
context: {
...context,
session: sessionData.session,
user: sessionData.user,
},
})
})
@@ -1,11 +1,11 @@
import { os } from '@/server/api/server' import { os } from '@/server/api/server'
import { getDB } from '@/server/db' import { db } from '@/server/db'
export const db = os.middleware(async ({ context, next }) => { export const dbMiddleware = os.middleware(async ({ context, next }) => {
return next({ return next({
context: { context: {
...context, ...context,
db: getDB(), db,
}, },
}) })
}) })
@@ -1 +1,2 @@
export * from './auth.middleware'
export * from './db.middleware' export * from './db.middleware'
+5 -3
View File
@@ -1,6 +1,8 @@
import { os } from '../server' import * as bookmarks from '@/modules/bookmarks/router'
import * as todo from './todo.router' import * as finance from '@/modules/finance/router'
import { os } from '@/server/api/server'
export const router = os.router({ export const router = os.router({
todo, bookmarks,
finance,
}) })
@@ -1,40 +0,0 @@
import { ORPCError } from '@orpc/server'
import { eq } from 'drizzle-orm'
import { todoTable } from '@/server/db/schema'
import { db } from '../middlewares'
import { os } from '../server'
export const list = os.todo.list.use(db).handler(async ({ context }) => {
const todos = await context.db.query.todoTable.findMany({
orderBy: { createdAt: 'desc' },
})
return todos
})
export const create = os.todo.create.use(db).handler(async ({ context, input }) => {
const [newTodo] = await context.db.insert(todoTable).values(input).returning()
if (!newTodo) {
throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create todo' })
}
return newTodo
})
export const update = os.todo.update.use(db).handler(async ({ context, input }) => {
const [updatedTodo] = await context.db.update(todoTable).set(input.data).where(eq(todoTable.id, input.id)).returning()
if (!updatedTodo) {
throw new ORPCError('NOT_FOUND')
}
return updatedTodo
})
export const remove = os.todo.remove.use(db).handler(async ({ context, input }) => {
const [deleted] = await context.db.delete(todoTable).where(eq(todoTable.id, input.id)).returning({ id: todoTable.id })
if (!deleted) {
throw new ORPCError('NOT_FOUND')
}
})
+6
View File
@@ -0,0 +1,6 @@
import { apiKeyClient } from '@better-auth/api-key/client'
import { createAuthClient } from 'better-auth/react'
export const authClient = createAuthClient({
plugins: [apiKeyClient()],
})
+15
View File
@@ -0,0 +1,15 @@
import { createServerFn } from '@tanstack/react-start'
import { getRequestHeaders } from '@tanstack/react-start/server'
import { auth } from '@/server/auth'
import * as authSchema from '@/server/auth/schema'
import { db } from '@/server/db'
export const getSession = createServerFn({ method: 'GET' }).handler(async () => {
const headers = getRequestHeaders()
return await auth.api.getSession({ headers })
})
export const checkInitialized = createServerFn({ method: 'GET' }).handler(async () => {
const users = await db.select({ id: authSchema.user.id }).from(authSchema.user).limit(1)
return users.length > 0
})
+45
View File
@@ -0,0 +1,45 @@
import { apiKey } from '@better-auth/api-key'
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { APIError } from 'better-auth/api'
import { tanstackStartCookies } from 'better-auth/tanstack-start'
import { env } from '@/env'
import * as authSchema from '@/server/auth/schema'
import { db } from '@/server/db'
export const auth = betterAuth({
baseURL: env.BETTER_AUTH_URL,
secret: env.BETTER_AUTH_SECRET,
database: drizzleAdapter(db, {
provider: 'pg',
schema: authSchema,
}),
emailAndPassword: { enabled: true },
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
cookieCache: {
enabled: true,
maxAge: 5 * 60,
},
},
databaseHooks: {
user: {
create: {
before: async () => {
const existingUsers = await db.select({ id: authSchema.user.id }).from(authSchema.user).limit(1)
if (existingUsers.length > 0) {
throw new APIError('FORBIDDEN', { message: 'System already has an owner. Registration is disabled.' })
}
},
},
},
},
plugins: [
tanstackStartCookies(),
apiKey({
defaultPrefix: 'kairos_',
enableSessionForAPIKeys: true,
}),
],
})
+126
View File
@@ -0,0 +1,126 @@
import { relations } from 'drizzle-orm'
import { boolean, index, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
export const user = pgTable('user', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').default(false).notNull(),
image: text('image'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
})
export const session = pgTable(
'session',
{
id: text('id').primaryKey(),
expiresAt: timestamp('expires_at').notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
},
(table) => [index('session_userId_idx').on(table.userId)],
)
export const account = pgTable(
'account',
{
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index('account_userId_idx').on(table.userId)],
)
export const verification = pgTable(
'verification',
{
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index('verification_identifier_idx').on(table.identifier)],
)
export const apikey = pgTable(
'apikey',
{
id: text('id').primaryKey(),
configId: text('config_id').default('default').notNull(),
name: text('name'),
start: text('start'),
referenceId: text('reference_id').notNull(),
prefix: text('prefix'),
key: text('key').notNull(),
refillInterval: integer('refill_interval'),
refillAmount: integer('refill_amount'),
lastRefillAt: timestamp('last_refill_at'),
enabled: boolean('enabled').default(true),
rateLimitEnabled: boolean('rate_limit_enabled').default(true),
rateLimitTimeWindow: integer('rate_limit_time_window').default(86400000),
rateLimitMax: integer('rate_limit_max').default(10),
requestCount: integer('request_count').default(0),
remaining: integer('remaining'),
lastRequest: timestamp('last_request'),
expiresAt: timestamp('expires_at'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
permissions: text('permissions'),
metadata: text('metadata'),
},
(table) => [
index('apikey_configId_idx').on(table.configId),
index('apikey_referenceId_idx').on(table.referenceId),
index('apikey_key_idx').on(table.key),
],
)
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
}))
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}))
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
}))
+6 -19
View File
@@ -1,24 +1,11 @@
import { drizzle } from 'drizzle-orm/postgres-js' import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import { env } from '@/env' import { env } from '@/env'
import { relations } from '@/server/db/relations' import * as relations from '@/server/db/relations'
import * as schema from '@/server/db/schema'
export const createDB = () => const client = postgres(env.DATABASE_URL)
drizzle({
connection: env.DATABASE_URL,
relations,
})
export type DB = ReturnType<typeof createDB> export const db = drizzle(client, { schema: { ...schema, ...relations } })
export const getDB = (() => { export type DB = typeof db
let db: DB | null = null
return (singleton = true): DB => {
if (!singleton) {
return createDB()
}
db ??= createDB()
return db
}
})()
+61 -3
View File
@@ -1,4 +1,62 @@
import { defineRelations } from 'drizzle-orm' import { relations } from 'drizzle-orm'
import * as schema from './schema' import { bookmark, category } from '../../modules/bookmarks/schema'
import { financeAccount, transaction, transactionCategory } from '../../modules/finance/schema'
import { user } from '../auth/schema'
export const relations = defineRelations(schema, (_r) => ({})) export const userRelations = relations(user, ({ many }) => ({
categories: many(category),
bookmarks: many(bookmark),
financeAccounts: many(financeAccount),
transactionCategories: many(transactionCategory),
transactions: many(transaction),
}))
export const categoryRelations = relations(category, ({ one, many }) => ({
user: one(user, {
fields: [category.userId],
references: [user.id],
}),
bookmarks: many(bookmark),
}))
export const bookmarkRelations = relations(bookmark, ({ one }) => ({
user: one(user, {
fields: [bookmark.userId],
references: [user.id],
}),
category: one(category, {
fields: [bookmark.categoryId],
references: [category.id],
}),
}))
export const financeAccountRelations = relations(financeAccount, ({ one, many }) => ({
user: one(user, {
fields: [financeAccount.userId],
references: [user.id],
}),
transactions: many(transaction),
}))
export const transactionCategoryRelations = relations(transactionCategory, ({ one, many }) => ({
user: one(user, {
fields: [transactionCategory.userId],
references: [user.id],
}),
transactions: many(transaction),
}))
export const transactionRelations = relations(transaction, ({ one }) => ({
user: one(user, {
fields: [transaction.userId],
references: [user.id],
}),
account: one(financeAccount, {
fields: [transaction.accountId],
references: [financeAccount.id],
}),
category: one(transactionCategory, {
fields: [transaction.categoryId],
references: [transactionCategory.id],
}),
}))
+3 -1
View File
@@ -1 +1,3 @@
export * from './todo' export * from '../../../modules/bookmarks/schema'
export * from '../../../modules/finance/schema'
export { account, apikey, session, user, verification } from '../../auth/schema'
-8
View File
@@ -1,8 +0,0 @@
import { boolean, pgTable, text } from 'drizzle-orm/pg-core'
import { generatedFields } from '../fields'
export const todoTable = pgTable('todo', {
...generatedFields,
title: text('title').notNull(),
completed: boolean('completed').notNull().default(false),
})
+129
View File
@@ -1 +1,130 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: "Geist Variable", sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+884 -132
View File
File diff suppressed because it is too large Load Diff
+22 -5
View File
@@ -18,8 +18,8 @@
"typecheck": "turbo run typecheck" "typecheck": "turbo run typecheck"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.9", "@biomejs/biome": "^2.4.10",
"turbo": "^2.8.20", "turbo": "^2.9.1",
"typescript": "^6.0.2" "typescript": "^6.0.2"
}, },
"catalog": { "catalog": {
@@ -36,21 +36,38 @@
"@tanstack/react-devtools": "^0.10.0", "@tanstack/react-devtools": "^0.10.0",
"@tanstack/react-query": "^5.95.2", "@tanstack/react-query": "^5.95.2",
"@tanstack/react-query-devtools": "^5.95.2", "@tanstack/react-query-devtools": "^5.95.2",
"@tanstack/react-virtual": "^3.13.6",
"@tanstack/react-router": "^1.168.3", "@tanstack/react-router": "^1.168.3",
"@tanstack/react-router-devtools": "^1.166.11", "@tanstack/react-router-devtools": "^1.166.11",
"@tanstack/react-router-ssr-query": "^1.166.10", "@tanstack/react-router-ssr-query": "^1.166.10",
"@tanstack/react-start": "^1.167.6", "@tanstack/react-start": "^1.167.6",
"@types/bun": "^1.3.11", "@types/bun": "^1.3.11",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"drizzle-kit": "1.0.0-beta.15-859cf75", "drizzle-kit": "^0.31.10",
"drizzle-orm": "1.0.0-beta.15-859cf75", "drizzle-orm": "^0.45.2",
"drizzle-zod": "^0.7.1",
"nitro": "npm:nitro-nightly@3.0.1-20260324-103046-9ce219ca", "nitro": "npm:nitro-nightly@3.0.1-20260324-103046-9ce219ca",
"postgres": "^3.4.8", "postgres": "^3.4.8",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"@dnd-kit/dom": "^0.3.2",
"@dnd-kit/helpers": "^0.3.2",
"@dnd-kit/react": "^0.3.2",
"better-auth": "^1.2.8",
"lucide-react": "^1.7.0",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"vite": "^8.0.2", "vite": "^8.0.2",
"zod": "^4.3.6" "zod": "^4.3.6",
"@base-ui/react": "^1.3.0",
"@fontsource-variable/geist": "^5.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"next-themes": "^0.4.6",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"babel-plugin-react-compiler": "^1.0.0",
"citty": "^0.2.1"
} }
} }