Compare commits
15 Commits
8c3425359d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 41f21ec3a9 | |||
| dcccf6675f | |||
| 9f39e7f7e8 | |||
| a369fe853e | |||
| 588df9f143 | |||
| c087338009 | |||
| 6bedc1d60d | |||
| 5e65c37a26 | |||
| d3f2088fc8 | |||
| 830714c94f | |||
| d67aaa723e | |||
| 001d171111 | |||
| 46e4486d7d | |||
| ba8224e81e | |||
| 430c0b0c64 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+362
-208
@@ -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
|
||||||
|
|||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -5,6 +5,6 @@ export default defineConfig({
|
|||||||
schema: './src/server/db/schema/index.ts',
|
schema: './src/server/db/schema/index.ts',
|
||||||
dialect: 'postgresql',
|
dialect: 'postgresql',
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DATABASE_URL!,
|
url: process.env.DATABASE_URL ?? '',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
CREATE TABLE "bookmark" (
|
|
||||||
"id" uuid PRIMARY KEY,
|
|
||||||
"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,
|
|
||||||
"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
|
|
||||||
CREATE TABLE "account" (
|
|
||||||
"id" text PRIMARY KEY,
|
|
||||||
"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 with time zone,
|
|
||||||
"refresh_token_expires_at" timestamp with time zone,
|
|
||||||
"scope" text,
|
|
||||||
"password" text,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "session" (
|
|
||||||
"id" text PRIMARY KEY,
|
|
||||||
"expires_at" timestamp with time zone NOT NULL,
|
|
||||||
"token" text NOT NULL UNIQUE,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"ip_address" text,
|
|
||||||
"user_agent" text,
|
|
||||||
"user_id" text NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "user" (
|
|
||||||
"id" text PRIMARY KEY,
|
|
||||||
"name" text NOT NULL,
|
|
||||||
"email" text NOT NULL UNIQUE,
|
|
||||||
"email_verified" boolean DEFAULT false NOT NULL,
|
|
||||||
"image" text,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "verification" (
|
|
||||||
"id" text PRIMARY KEY,
|
|
||||||
"identifier" text NOT NULL,
|
|
||||||
"value" text NOT NULL,
|
|
||||||
"expires_at" timestamp with time zone NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "bookmark" ADD CONSTRAINT "bookmark_category_id_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "category"("id") ON DELETE CASCADE;--> statement-breakpoint
|
|
||||||
ALTER TABLE "bookmark" ADD CONSTRAINT "bookmark_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
|
||||||
ALTER TABLE "category" ADD CONSTRAINT "category_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
|
||||||
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
|
||||||
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;
|
|
||||||
@@ -1,852 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "8",
|
|
||||||
"dialect": "postgres",
|
|
||||||
"id": "1521f139-4e9c-4d3f-965b-c9cbe21f80ff",
|
|
||||||
"prevIds": ["00000000-0000-0000-0000-000000000000"],
|
|
||||||
"ddl": [
|
|
||||||
{
|
|
||||||
"isRlsEnabled": false,
|
|
||||||
"name": "bookmark",
|
|
||||||
"entityType": "tables",
|
|
||||||
"schema": "public"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"isRlsEnabled": false,
|
|
||||||
"name": "category",
|
|
||||||
"entityType": "tables",
|
|
||||||
"schema": "public"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"isRlsEnabled": false,
|
|
||||||
"name": "account",
|
|
||||||
"entityType": "tables",
|
|
||||||
"schema": "public"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"isRlsEnabled": false,
|
|
||||||
"name": "session",
|
|
||||||
"entityType": "tables",
|
|
||||||
"schema": "public"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"isRlsEnabled": false,
|
|
||||||
"name": "user",
|
|
||||||
"entityType": "tables",
|
|
||||||
"schema": "public"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"isRlsEnabled": false,
|
|
||||||
"name": "verification",
|
|
||||||
"entityType": "tables",
|
|
||||||
"schema": "public"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "uuid",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "id",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "bookmark"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": "now()",
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "created_at",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "bookmark"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": "now()",
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "updated_at",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "bookmark"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "name",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "bookmark"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "url",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "bookmark"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": false,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "icon",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "bookmark"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "uuid",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "category_id",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "bookmark"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "boolean",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": "true",
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "is_public",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "bookmark"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "integer",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": "0",
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "order_id",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "bookmark"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "user_id",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "bookmark"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "uuid",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "id",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "category"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": "now()",
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "created_at",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "category"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": "now()",
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "updated_at",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "category"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "name",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "category"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "boolean",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": "false",
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "is_pinned",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "category"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "boolean",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": "true",
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "is_public",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "category"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "integer",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": "0",
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "order_id",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "category"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "user_id",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "category"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "id",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "account"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "account_id",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "account"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "provider_id",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "account"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "user_id",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "account"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": false,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "access_token",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "account"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": false,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "refresh_token",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "account"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": false,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "id_token",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "account"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": false,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "access_token_expires_at",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "account"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": false,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "refresh_token_expires_at",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "account"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": false,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "scope",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "account"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": false,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "password",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "account"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": "now()",
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "created_at",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "account"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": "now()",
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "updated_at",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "account"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "id",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "session"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "expires_at",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "session"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "token",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "session"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": "now()",
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "created_at",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "session"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": "now()",
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "updated_at",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "session"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": false,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "ip_address",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "session"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": false,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "user_agent",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "session"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "user_id",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "session"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "id",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "user"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "name",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "user"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "email",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "user"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "boolean",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": "false",
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "email_verified",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "user"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": false,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "image",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "user"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": "now()",
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "created_at",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "user"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": "now()",
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "updated_at",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "user"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "id",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "verification"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "identifier",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "verification"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "value",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "verification"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "expires_at",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "verification"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": "now()",
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "created_at",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "verification"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"typeSchema": null,
|
|
||||||
"notNull": true,
|
|
||||||
"dimensions": 0,
|
|
||||||
"default": "now()",
|
|
||||||
"generated": null,
|
|
||||||
"identity": null,
|
|
||||||
"name": "updated_at",
|
|
||||||
"entityType": "columns",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "verification"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nameExplicit": false,
|
|
||||||
"columns": ["category_id"],
|
|
||||||
"schemaTo": "public",
|
|
||||||
"tableTo": "category",
|
|
||||||
"columnsTo": ["id"],
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"onDelete": "CASCADE",
|
|
||||||
"name": "bookmark_category_id_category_id_fkey",
|
|
||||||
"entityType": "fks",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "bookmark"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nameExplicit": false,
|
|
||||||
"columns": ["user_id"],
|
|
||||||
"schemaTo": "public",
|
|
||||||
"tableTo": "user",
|
|
||||||
"columnsTo": ["id"],
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"onDelete": "CASCADE",
|
|
||||||
"name": "bookmark_user_id_user_id_fkey",
|
|
||||||
"entityType": "fks",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "bookmark"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nameExplicit": false,
|
|
||||||
"columns": ["user_id"],
|
|
||||||
"schemaTo": "public",
|
|
||||||
"tableTo": "user",
|
|
||||||
"columnsTo": ["id"],
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"onDelete": "CASCADE",
|
|
||||||
"name": "category_user_id_user_id_fkey",
|
|
||||||
"entityType": "fks",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "category"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nameExplicit": false,
|
|
||||||
"columns": ["user_id"],
|
|
||||||
"schemaTo": "public",
|
|
||||||
"tableTo": "user",
|
|
||||||
"columnsTo": ["id"],
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"onDelete": "CASCADE",
|
|
||||||
"name": "account_user_id_user_id_fkey",
|
|
||||||
"entityType": "fks",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "account"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nameExplicit": false,
|
|
||||||
"columns": ["user_id"],
|
|
||||||
"schemaTo": "public",
|
|
||||||
"tableTo": "user",
|
|
||||||
"columnsTo": ["id"],
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"onDelete": "CASCADE",
|
|
||||||
"name": "session_user_id_user_id_fkey",
|
|
||||||
"entityType": "fks",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "session"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"columns": ["id"],
|
|
||||||
"nameExplicit": false,
|
|
||||||
"name": "bookmark_pkey",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "bookmark",
|
|
||||||
"entityType": "pks"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"columns": ["id"],
|
|
||||||
"nameExplicit": false,
|
|
||||||
"name": "category_pkey",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "category",
|
|
||||||
"entityType": "pks"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"columns": ["id"],
|
|
||||||
"nameExplicit": false,
|
|
||||||
"name": "account_pkey",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "account",
|
|
||||||
"entityType": "pks"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"columns": ["id"],
|
|
||||||
"nameExplicit": false,
|
|
||||||
"name": "session_pkey",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "session",
|
|
||||||
"entityType": "pks"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"columns": ["id"],
|
|
||||||
"nameExplicit": false,
|
|
||||||
"name": "user_pkey",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "user",
|
|
||||||
"entityType": "pks"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"columns": ["id"],
|
|
||||||
"nameExplicit": false,
|
|
||||||
"name": "verification_pkey",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "verification",
|
|
||||||
"entityType": "pks"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nameExplicit": false,
|
|
||||||
"columns": ["token"],
|
|
||||||
"nullsNotDistinct": false,
|
|
||||||
"name": "session_token_key",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "session",
|
|
||||||
"entityType": "uniques"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nameExplicit": false,
|
|
||||||
"columns": ["email"],
|
|
||||||
"nullsNotDistinct": false,
|
|
||||||
"name": "user_email_key",
|
|
||||||
"schema": "public",
|
|
||||||
"table": "user",
|
|
||||||
"entityType": "uniques"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"renames": []
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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,14 +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:",
|
||||||
"@dnd-kit/dom": "catalog:",
|
"shadcn": "^4.1.1",
|
||||||
"@dnd-kit/react": "catalog:",
|
"sonner": "catalog:",
|
||||||
"better-auth": "catalog:",
|
"tailwind-merge": "catalog:",
|
||||||
"lucide-react": "catalog:",
|
"tw-animate-css": "catalog:",
|
||||||
"uuid": "catalog:",
|
"uuid": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
@@ -55,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:",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
@@ -24,69 +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: {
|
|
||||||
bookmarks: {
|
|
||||||
category: {
|
|
||||||
create: {
|
|
||||||
mutationOptions: {
|
|
||||||
onSuccess: (_, __, ___, ctx) => {
|
|
||||||
ctx.client.invalidateQueries({ queryKey: orpc.bookmarks.category.list.key() })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
mutationOptions: {
|
|
||||||
onSuccess: (_, __, ___, ctx) => {
|
|
||||||
ctx.client.invalidateQueries({ queryKey: orpc.bookmarks.category.list.key() })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
remove: {
|
|
||||||
mutationOptions: {
|
|
||||||
onSuccess: (_, __, ___, ctx) => {
|
|
||||||
ctx.client.invalidateQueries({ queryKey: orpc.bookmarks.category.list.key() })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
reorder: {
|
|
||||||
mutationOptions: {
|
|
||||||
onSuccess: (_, __, ___, ctx) => {
|
|
||||||
ctx.client.invalidateQueries({ queryKey: orpc.bookmarks.category.list.key() })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
bookmark: {
|
|
||||||
create: {
|
|
||||||
mutationOptions: {
|
|
||||||
onSuccess: (_, __, ___, ctx) => {
|
|
||||||
ctx.client.invalidateQueries({ queryKey: orpc.bookmarks.category.list.key() })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
mutationOptions: {
|
|
||||||
onSuccess: (_, __, ___, ctx) => {
|
|
||||||
ctx.client.invalidateQueries({ queryKey: orpc.bookmarks.category.list.key() })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
remove: {
|
|
||||||
mutationOptions: {
|
|
||||||
onSuccess: (_, __, ___, ctx) => {
|
|
||||||
ctx.client.invalidateQueries({ queryKey: orpc.bookmarks.category.list.key() })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
reorder: {
|
|
||||||
mutationOptions: {
|
|
||||||
onSuccess: (_, __, ___, ctx) => {
|
|
||||||
ctx.client.invalidateQueries({ queryKey: orpc.bookmarks.category.list.key() })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import * as icons from 'lucide-react'
|
|
||||||
import { GripVertical, Pencil, Trash2 } from 'lucide-react'
|
|
||||||
|
|
||||||
const allIcons = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>
|
|
||||||
|
|
||||||
interface BookmarkItemProps {
|
|
||||||
bookmark: {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
url: string
|
|
||||||
icon: string | null
|
|
||||||
}
|
|
||||||
onEdit: () => void
|
|
||||||
onDelete: () => void
|
|
||||||
handleRef?: (el: Element | null) => void
|
|
||||||
sortableRef?: (el: Element | null) => void
|
|
||||||
isDragging?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BookmarkItem = ({ bookmark, onEdit, onDelete, handleRef, sortableRef, isDragging }: BookmarkItemProps) => {
|
|
||||||
const Icon = (bookmark.icon && allIcons[bookmark.icon]) || icons.Globe
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={sortableRef}
|
|
||||||
className={`group flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:bg-slate-50 ${
|
|
||||||
isDragging ? 'opacity-50' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div ref={handleRef} className="cursor-grab text-slate-300 hover:text-slate-500 active:cursor-grabbing">
|
|
||||||
<GripVertical className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-slate-100">
|
|
||||||
<Icon className="h-4 w-4 text-slate-600" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a href={bookmark.url} target="_blank" rel="noopener noreferrer" className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-slate-700 truncate">{bookmark.name}</p>
|
|
||||||
<p className="text-xs text-slate-400 truncate">{bookmark.url}</p>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onEdit}
|
|
||||||
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onDelete}
|
|
||||||
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</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 * 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
import { DragDropProvider } from '@dnd-kit/react'
|
|
||||||
import { useSortable } from '@dnd-kit/react/sortable'
|
|
||||||
import { useMutation } from '@tanstack/react-query'
|
|
||||||
import { FolderOpen, MoreHorizontal, Pencil, Plus, Trash2, X } from 'lucide-react'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { orpc } from '@/client/orpc'
|
|
||||||
import { BookmarkItem } from './BookmarkItem'
|
|
||||||
import { IconPicker } from './IconPicker'
|
|
||||||
|
|
||||||
interface Bookmark {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
url: string
|
|
||||||
icon: string | null
|
|
||||||
orderId: number
|
|
||||||
categoryId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Category {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
orderId: number
|
|
||||||
bookmarks: Bookmark[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CategorySectionProps {
|
|
||||||
category: Category
|
|
||||||
}
|
|
||||||
|
|
||||||
const SortableBookmark = ({
|
|
||||||
bookmark,
|
|
||||||
index,
|
|
||||||
groupId,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
}: {
|
|
||||||
bookmark: Bookmark
|
|
||||||
index: number
|
|
||||||
groupId: string
|
|
||||||
onEdit: () => void
|
|
||||||
onDelete: () => void
|
|
||||||
}) => {
|
|
||||||
const { ref, handleRef, isDragging } = useSortable({
|
|
||||||
id: bookmark.id,
|
|
||||||
index,
|
|
||||||
group: groupId,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BookmarkItem
|
|
||||||
bookmark={bookmark}
|
|
||||||
onEdit={onEdit}
|
|
||||||
onDelete={onDelete}
|
|
||||||
sortableRef={ref}
|
|
||||||
handleRef={handleRef}
|
|
||||||
isDragging={isDragging}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CategorySection = ({ category }: CategorySectionProps) => {
|
|
||||||
const [showAddForm, setShowAddForm] = useState(false)
|
|
||||||
const [editingName, setEditingName] = useState(false)
|
|
||||||
const [categoryName, setCategoryName] = useState(category.name)
|
|
||||||
const [newBookmark, setNewBookmark] = useState({ name: '', url: '', icon: '' })
|
|
||||||
const [showIconPicker, setShowIconPicker] = useState(false)
|
|
||||||
const [showMenu, setShowMenu] = useState(false)
|
|
||||||
const [editingBookmark, setEditingBookmark] = useState<Bookmark | null>(null)
|
|
||||||
const [editForm, setEditForm] = useState({ name: '', url: '', icon: '' })
|
|
||||||
const [showEditIconPicker, setShowEditIconPicker] = useState(false)
|
|
||||||
const [items, setItems] = useState(category.bookmarks)
|
|
||||||
|
|
||||||
const updateCategory = useMutation(orpc.bookmarks.category.update.mutationOptions())
|
|
||||||
const deleteCategory = useMutation(orpc.bookmarks.category.remove.mutationOptions())
|
|
||||||
const createBookmark = useMutation(orpc.bookmarks.bookmark.create.mutationOptions())
|
|
||||||
const updateBookmark = useMutation(orpc.bookmarks.bookmark.update.mutationOptions())
|
|
||||||
const deleteBookmark = useMutation(orpc.bookmarks.bookmark.remove.mutationOptions())
|
|
||||||
const reorderBookmarks = useMutation(orpc.bookmarks.bookmark.reorder.mutationOptions())
|
|
||||||
|
|
||||||
if (items !== category.bookmarks && !reorderBookmarks.isPending) {
|
|
||||||
setItems(category.bookmarks)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSaveCategoryName = () => {
|
|
||||||
if (categoryName.trim() && categoryName !== category.name) {
|
|
||||||
updateCategory.mutate({ id: category.id, data: { name: categoryName.trim() } })
|
|
||||||
}
|
|
||||||
setEditingName(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddBookmark = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (newBookmark.name.trim() && newBookmark.url.trim()) {
|
|
||||||
createBookmark.mutate({
|
|
||||||
name: newBookmark.name.trim(),
|
|
||||||
url: newBookmark.url.trim(),
|
|
||||||
icon: newBookmark.icon || null,
|
|
||||||
categoryId: category.id,
|
|
||||||
orderId: category.bookmarks.length,
|
|
||||||
})
|
|
||||||
setNewBookmark({ name: '', url: '', icon: '' })
|
|
||||||
setShowAddForm(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEditBookmark = (bm: Bookmark) => {
|
|
||||||
setEditingBookmark(bm)
|
|
||||||
setEditForm({ name: bm.name, url: bm.url, icon: bm.icon ?? '' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSaveEditBookmark = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (editingBookmark && editForm.name.trim() && editForm.url.trim()) {
|
|
||||||
updateBookmark.mutate({
|
|
||||||
id: editingBookmark.id,
|
|
||||||
data: {
|
|
||||||
name: editForm.name.trim(),
|
|
||||||
url: editForm.url.trim(),
|
|
||||||
icon: editForm.icon || null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
setEditingBookmark(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragEnd: NonNullable<React.ComponentProps<typeof DragDropProvider>['onDragEnd']> = (event) => {
|
|
||||||
if (event.canceled) return
|
|
||||||
const sourceId = event.operation.source?.id
|
|
||||||
const targetId = event.operation.target?.id
|
|
||||||
if (!sourceId || !targetId || sourceId === targetId) return
|
|
||||||
|
|
||||||
const oldIndex = items.findIndex((b) => b.id === sourceId)
|
|
||||||
const newIndex = items.findIndex((b) => b.id === targetId)
|
|
||||||
if (oldIndex === -1 || newIndex === -1) return
|
|
||||||
|
|
||||||
const reordered = [...items]
|
|
||||||
const [moved] = reordered.splice(oldIndex, 1)
|
|
||||||
if (!moved) return
|
|
||||||
reordered.splice(newIndex, 0, moved)
|
|
||||||
setItems(reordered)
|
|
||||||
|
|
||||||
reorderBookmarks.mutate(reordered.map((b, i) => ({ id: b.id, orderId: i })))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl bg-white ring-1 ring-slate-100 shadow-sm overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FolderOpen className="h-4 w-4 text-indigo-500" />
|
|
||||||
{editingName ? (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={categoryName}
|
|
||||||
onChange={(e) => setCategoryName(e.target.value)}
|
|
||||||
onBlur={handleSaveCategoryName}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSaveCategoryName()}
|
|
||||||
className="px-2 py-0.5 text-sm font-semibold text-slate-900 bg-slate-50 rounded-md ring-1 ring-indigo-300 outline-none"
|
|
||||||
// biome-ignore lint/a11y/noAutofocus: inline edit needs immediate focus
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<h3 className="text-sm font-semibold text-slate-900">{category.name}</h3>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowMenu(!showMenu)}
|
|
||||||
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
{showMenu && (
|
|
||||||
<div className="absolute right-0 z-40 mt-1 w-32 rounded-lg bg-white py-1 shadow-lg ring-1 ring-slate-200">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingName(true)
|
|
||||||
setShowMenu(false)
|
|
||||||
}}
|
|
||||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
<Pencil className="h-3.5 w-3.5" /> 重命名
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
deleteCategory.mutate({ id: category.id })
|
|
||||||
setShowMenu(false)
|
|
||||||
}}
|
|
||||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-red-600 hover:bg-red-50"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" /> 删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-2">
|
|
||||||
<DragDropProvider onDragEnd={handleDragEnd}>
|
|
||||||
{items.map((bm, idx) => (
|
|
||||||
<SortableBookmark
|
|
||||||
key={bm.id}
|
|
||||||
bookmark={bm}
|
|
||||||
index={idx}
|
|
||||||
groupId={category.id}
|
|
||||||
onEdit={() => handleEditBookmark(bm)}
|
|
||||||
onDelete={() => deleteBookmark.mutate({ id: bm.id })}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</DragDropProvider>
|
|
||||||
|
|
||||||
{items.length === 0 && !showAddForm && <p className="py-4 text-center text-sm text-slate-400">暂无书签</p>}
|
|
||||||
|
|
||||||
{editingBookmark && (
|
|
||||||
<form onSubmit={handleSaveEditBookmark} className="mx-2 mt-2 space-y-2 rounded-lg bg-slate-50 p-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs font-medium text-slate-500">编辑书签</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEditingBookmark(null)}
|
|
||||||
className="text-slate-400 hover:text-slate-600"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editForm.name}
|
|
||||||
onChange={(e) => setEditForm((p) => ({ ...p, name: e.target.value }))}
|
|
||||||
placeholder="名称"
|
|
||||||
className="w-full rounded-lg bg-white px-3 py-2 text-sm ring-1 ring-slate-200 outline-none focus:ring-2 focus:ring-indigo-500/50"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={editForm.url}
|
|
||||||
onChange={(e) => setEditForm((p) => ({ ...p, url: e.target.value }))}
|
|
||||||
placeholder="URL"
|
|
||||||
className="w-full rounded-lg bg-white px-3 py-2 text-sm ring-1 ring-slate-200 outline-none focus:ring-2 focus:ring-indigo-500/50"
|
|
||||||
/>
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowEditIconPicker(!showEditIconPicker)}
|
|
||||||
className="rounded-lg bg-white px-3 py-2 text-sm ring-1 ring-slate-200 hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
{editForm.icon || '选择图标'}
|
|
||||||
</button>
|
|
||||||
{showEditIconPicker && (
|
|
||||||
<IconPicker
|
|
||||||
value={editForm.icon}
|
|
||||||
onChange={(icon) => setEditForm((p) => ({ ...p, icon }))}
|
|
||||||
onClose={() => setShowEditIconPicker(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="w-full rounded-lg bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition-colors"
|
|
||||||
>
|
|
||||||
保存
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showAddForm ? (
|
|
||||||
<form onSubmit={handleAddBookmark} className="mx-2 mt-2 space-y-2 rounded-lg bg-slate-50 p-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs font-medium text-slate-500">添加书签</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAddForm(false)}
|
|
||||||
className="text-slate-400 hover:text-slate-600"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newBookmark.name}
|
|
||||||
onChange={(e) => setNewBookmark((p) => ({ ...p, name: e.target.value }))}
|
|
||||||
placeholder="名称"
|
|
||||||
required
|
|
||||||
className="w-full rounded-lg bg-white px-3 py-2 text-sm ring-1 ring-slate-200 outline-none focus:ring-2 focus:ring-indigo-500/50"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={newBookmark.url}
|
|
||||||
onChange={(e) => setNewBookmark((p) => ({ ...p, url: e.target.value }))}
|
|
||||||
placeholder="https://example.com"
|
|
||||||
required
|
|
||||||
className="w-full rounded-lg bg-white px-3 py-2 text-sm ring-1 ring-slate-200 outline-none focus:ring-2 focus:ring-indigo-500/50"
|
|
||||||
/>
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowIconPicker(!showIconPicker)}
|
|
||||||
className="rounded-lg bg-white px-3 py-2 text-sm ring-1 ring-slate-200 hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
{newBookmark.icon || '选择图标'}
|
|
||||||
</button>
|
|
||||||
{showIconPicker && (
|
|
||||||
<IconPicker
|
|
||||||
value={newBookmark.icon}
|
|
||||||
onChange={(icon) => setNewBookmark((p) => ({ ...p, icon }))}
|
|
||||||
onClose={() => setShowIconPicker(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={createBookmark.isPending}
|
|
||||||
className="w-full rounded-lg bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{createBookmark.isPending ? '添加中...' : '添加'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAddForm(true)}
|
|
||||||
className="mx-2 mt-1 flex w-[calc(100%-1rem)] items-center justify-center gap-1 rounded-lg py-2 text-sm text-slate-400 hover:bg-slate-50 hover:text-slate-600 transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" /> 添加书签
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import * as icons from 'lucide-react'
|
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
const ICON_NAMES = [
|
|
||||||
'Globe',
|
|
||||||
'Home',
|
|
||||||
'Code',
|
|
||||||
'Database',
|
|
||||||
'Mail',
|
|
||||||
'MessageSquare',
|
|
||||||
'Music',
|
|
||||||
'Video',
|
|
||||||
'Image',
|
|
||||||
'FileText',
|
|
||||||
'Folder',
|
|
||||||
'Star',
|
|
||||||
'Heart',
|
|
||||||
'Bookmark',
|
|
||||||
'Search',
|
|
||||||
'Settings',
|
|
||||||
'User',
|
|
||||||
'Shield',
|
|
||||||
'Key',
|
|
||||||
'Terminal',
|
|
||||||
'Github',
|
|
||||||
'Chrome',
|
|
||||||
'Cpu',
|
|
||||||
'Server',
|
|
||||||
'Cloud',
|
|
||||||
'Wifi',
|
|
||||||
'Zap',
|
|
||||||
'Coffee',
|
|
||||||
'BookOpen',
|
|
||||||
'Briefcase',
|
|
||||||
'Calendar',
|
|
||||||
'Clock',
|
|
||||||
'Download',
|
|
||||||
'Edit',
|
|
||||||
'ExternalLink',
|
|
||||||
'Eye',
|
|
||||||
'Film',
|
|
||||||
'Gift',
|
|
||||||
'Headphones',
|
|
||||||
'Layout',
|
|
||||||
'Link',
|
|
||||||
'Map',
|
|
||||||
'Monitor',
|
|
||||||
'Package',
|
|
||||||
'Phone',
|
|
||||||
'ShoppingCart',
|
|
||||||
'Smartphone',
|
|
||||||
'Tv',
|
|
||||||
'Upload',
|
|
||||||
'Box',
|
|
||||||
'Compass',
|
|
||||||
'Rss',
|
|
||||||
'Camera',
|
|
||||||
'Printer',
|
|
||||||
'Layers',
|
|
||||||
'Activity',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const allIcons = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>
|
|
||||||
|
|
||||||
interface IconPickerProps {
|
|
||||||
value?: string | null
|
|
||||||
onChange: (iconName: string) => void
|
|
||||||
onClose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const IconPicker = ({ value, onChange, onClose }: IconPickerProps) => {
|
|
||||||
const [filter, setFilter] = useState('')
|
|
||||||
|
|
||||||
const filtered = ICON_NAMES.filter((name) => name.toLowerCase().includes(filter.toLowerCase()))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute z-50 mt-1 w-72 rounded-xl bg-white p-3 shadow-lg ring-1 ring-slate-200">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={filter}
|
|
||||||
onChange={(e) => setFilter(e.target.value)}
|
|
||||||
placeholder="搜索图标..."
|
|
||||||
className="mb-2 w-full rounded-lg bg-slate-50 px-3 py-2 text-sm ring-1 ring-slate-200 outline-none focus:ring-2 focus:ring-indigo-500/50"
|
|
||||||
// biome-ignore lint/a11y/noAutofocus: icon picker search needs immediate focus
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div className="grid max-h-48 grid-cols-8 gap-1 overflow-y-auto">
|
|
||||||
{filtered.map((name) => {
|
|
||||||
const Icon = allIcons[name]
|
|
||||||
if (!Icon) return null
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={name}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onChange(name)
|
|
||||||
onClose()
|
|
||||||
}}
|
|
||||||
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-colors ${
|
|
||||||
value === name ? 'bg-indigo-100 text-indigo-600' : 'hover:bg-slate-100 text-slate-600'
|
|
||||||
}`}
|
|
||||||
title={name}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{filtered.length === 0 && <p className="py-4 text-center text-sm text-slate-400">未找到匹配图标</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import { oc } from '@orpc/contract'
|
import { oc } from '@orpc/contract'
|
||||||
import { createInsertSchema, createSelectSchema, createUpdateSchema } from 'drizzle-orm/zod'
|
import { createInsertSchema, createSelectSchema, createUpdateSchema } from 'drizzle-zod'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { bookmarkTable, categoryTable } from '@/modules/bookmarks/schema'
|
import * as schema from '@/modules/bookmarks/schema'
|
||||||
import { generatedFieldKeys } from '@/server/db/fields'
|
import { generatedFieldKeys } from '@/server/db/fields'
|
||||||
|
|
||||||
const categorySelect = createSelectSchema(categoryTable)
|
const categorySelect = createSelectSchema(schema.category)
|
||||||
const categoryInsert = createInsertSchema(categoryTable).omit(generatedFieldKeys).omit({ userId: true })
|
const categoryInsert = createInsertSchema(schema.category).omit(generatedFieldKeys).omit({ userId: true })
|
||||||
const categoryUpdate = createUpdateSchema(categoryTable).omit(generatedFieldKeys).omit({ userId: true })
|
const categoryUpdate = createUpdateSchema(schema.category).omit(generatedFieldKeys).omit({ userId: true })
|
||||||
|
|
||||||
const bookmarkSelect = createSelectSchema(bookmarkTable)
|
const bookmarkSelect = createSelectSchema(schema.bookmark)
|
||||||
const bookmarkInsert = createInsertSchema(bookmarkTable).omit(generatedFieldKeys).omit({ userId: true })
|
const bookmarkInsert = createInsertSchema(schema.bookmark).omit(generatedFieldKeys).omit({ userId: true })
|
||||||
const bookmarkUpdate = createUpdateSchema(bookmarkTable).omit(generatedFieldKeys).omit({ userId: true })
|
const bookmarkUpdate = createUpdateSchema(schema.bookmark).omit(generatedFieldKeys).omit({ userId: true })
|
||||||
|
|
||||||
export const category = {
|
export const category = {
|
||||||
list: oc.input(z.void()).output(z.array(categorySelect.extend({ bookmarks: z.array(bookmarkSelect) }))),
|
list: oc.input(z.void()).output(z.array(categorySelect.extend({ bookmarks: z.array(bookmarkSelect) }))),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ModuleMetadata } from '../registry'
|
import type { ModuleMetadata } from '@/modules/registry'
|
||||||
|
|
||||||
export const bookmarksModule: ModuleMetadata = {
|
export const bookmarksModule: ModuleMetadata = {
|
||||||
id: 'bookmarks',
|
id: 'bookmarks',
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
import { ORPCError } from '@orpc/server'
|
import { ORPCError } from '@orpc/server'
|
||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { bookmarkTable, categoryTable } from '@/modules/bookmarks/schema'
|
import * as schema from '@/modules/bookmarks/schema'
|
||||||
import { authMiddleware, db } from '@/server/api/middlewares'
|
import { authMiddleware, dbMiddleware } from '@/server/api/middlewares'
|
||||||
import { os } from '@/server/api/server'
|
import { os } from '@/server/api/server'
|
||||||
|
|
||||||
export const category = {
|
export const category = {
|
||||||
list: os.bookmarks.category.list
|
list: os.bookmarks.category.list
|
||||||
.use(db)
|
.use(dbMiddleware)
|
||||||
.use(authMiddleware)
|
.use(authMiddleware)
|
||||||
.handler(async ({ context }) => {
|
.handler(async ({ context }) => {
|
||||||
return await context.db.query.categoryTable.findMany({
|
return await context.db.query.category.findMany({
|
||||||
where: { userId: context.user.id },
|
where: (category, { eq }) => eq(category.userId, context.user.id),
|
||||||
orderBy: { orderId: 'asc' },
|
orderBy: (category, { asc }) => asc(category.orderId),
|
||||||
with: {
|
with: {
|
||||||
bookmarks: {
|
bookmarks: {
|
||||||
orderBy: { orderId: 'asc' },
|
orderBy: (bookmark, { asc }) => asc(bookmark.orderId),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
||||||
create: os.bookmarks.category.create
|
create: os.bookmarks.category.create
|
||||||
.use(db)
|
.use(dbMiddleware)
|
||||||
.use(authMiddleware)
|
.use(authMiddleware)
|
||||||
.handler(async ({ context, input }) => {
|
.handler(async ({ context, input }) => {
|
||||||
const [created] = await context.db
|
const [created] = await context.db
|
||||||
.insert(categoryTable)
|
.insert(schema.category)
|
||||||
.values({ ...input, userId: context.user.id })
|
.values({ ...input, userId: context.user.id })
|
||||||
.returning()
|
.returning()
|
||||||
if (!created) throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create category' })
|
if (!created) throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create category' })
|
||||||
@@ -33,39 +33,39 @@ export const category = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
update: os.bookmarks.category.update
|
update: os.bookmarks.category.update
|
||||||
.use(db)
|
.use(dbMiddleware)
|
||||||
.use(authMiddleware)
|
.use(authMiddleware)
|
||||||
.handler(async ({ context, input }) => {
|
.handler(async ({ context, input }) => {
|
||||||
const [updated] = await context.db
|
const [updated] = await context.db
|
||||||
.update(categoryTable)
|
.update(schema.category)
|
||||||
.set(input.data)
|
.set(input.data)
|
||||||
.where(and(eq(categoryTable.id, input.id), eq(categoryTable.userId, context.user.id)))
|
.where(and(eq(schema.category.id, input.id), eq(schema.category.userId, context.user.id)))
|
||||||
.returning()
|
.returning()
|
||||||
if (!updated) throw new ORPCError('NOT_FOUND')
|
if (!updated) throw new ORPCError('NOT_FOUND')
|
||||||
return updated
|
return updated
|
||||||
}),
|
}),
|
||||||
|
|
||||||
remove: os.bookmarks.category.remove
|
remove: os.bookmarks.category.remove
|
||||||
.use(db)
|
.use(dbMiddleware)
|
||||||
.use(authMiddleware)
|
.use(authMiddleware)
|
||||||
.handler(async ({ context, input }) => {
|
.handler(async ({ context, input }) => {
|
||||||
const [deleted] = await context.db
|
const [deleted] = await context.db
|
||||||
.delete(categoryTable)
|
.delete(schema.category)
|
||||||
.where(and(eq(categoryTable.id, input.id), eq(categoryTable.userId, context.user.id)))
|
.where(and(eq(schema.category.id, input.id), eq(schema.category.userId, context.user.id)))
|
||||||
.returning({ id: categoryTable.id })
|
.returning({ id: schema.category.id })
|
||||||
if (!deleted) throw new ORPCError('NOT_FOUND')
|
if (!deleted) throw new ORPCError('NOT_FOUND')
|
||||||
}),
|
}),
|
||||||
|
|
||||||
reorder: os.bookmarks.category.reorder
|
reorder: os.bookmarks.category.reorder
|
||||||
.use(db)
|
.use(dbMiddleware)
|
||||||
.use(authMiddleware)
|
.use(authMiddleware)
|
||||||
.handler(async ({ context, input }) => {
|
.handler(async ({ context, input }) => {
|
||||||
await context.db.transaction(async (tx) => {
|
await context.db.transaction(async (tx) => {
|
||||||
for (const item of input) {
|
for (const item of input) {
|
||||||
await tx
|
await tx
|
||||||
.update(categoryTable)
|
.update(schema.category)
|
||||||
.set({ orderId: item.orderId })
|
.set({ orderId: item.orderId })
|
||||||
.where(and(eq(categoryTable.id, item.id), eq(categoryTable.userId, context.user.id)))
|
.where(and(eq(schema.category.id, item.id), eq(schema.category.userId, context.user.id)))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
@@ -73,11 +73,11 @@ export const category = {
|
|||||||
|
|
||||||
export const bookmark = {
|
export const bookmark = {
|
||||||
create: os.bookmarks.bookmark.create
|
create: os.bookmarks.bookmark.create
|
||||||
.use(db)
|
.use(dbMiddleware)
|
||||||
.use(authMiddleware)
|
.use(authMiddleware)
|
||||||
.handler(async ({ context, input }) => {
|
.handler(async ({ context, input }) => {
|
||||||
const [created] = await context.db
|
const [created] = await context.db
|
||||||
.insert(bookmarkTable)
|
.insert(schema.bookmark)
|
||||||
.values({ ...input, userId: context.user.id })
|
.values({ ...input, userId: context.user.id })
|
||||||
.returning()
|
.returning()
|
||||||
if (!created) throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create bookmark' })
|
if (!created) throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create bookmark' })
|
||||||
@@ -85,39 +85,39 @@ export const bookmark = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
update: os.bookmarks.bookmark.update
|
update: os.bookmarks.bookmark.update
|
||||||
.use(db)
|
.use(dbMiddleware)
|
||||||
.use(authMiddleware)
|
.use(authMiddleware)
|
||||||
.handler(async ({ context, input }) => {
|
.handler(async ({ context, input }) => {
|
||||||
const [updated] = await context.db
|
const [updated] = await context.db
|
||||||
.update(bookmarkTable)
|
.update(schema.bookmark)
|
||||||
.set(input.data)
|
.set(input.data)
|
||||||
.where(and(eq(bookmarkTable.id, input.id), eq(bookmarkTable.userId, context.user.id)))
|
.where(and(eq(schema.bookmark.id, input.id), eq(schema.bookmark.userId, context.user.id)))
|
||||||
.returning()
|
.returning()
|
||||||
if (!updated) throw new ORPCError('NOT_FOUND')
|
if (!updated) throw new ORPCError('NOT_FOUND')
|
||||||
return updated
|
return updated
|
||||||
}),
|
}),
|
||||||
|
|
||||||
remove: os.bookmarks.bookmark.remove
|
remove: os.bookmarks.bookmark.remove
|
||||||
.use(db)
|
.use(dbMiddleware)
|
||||||
.use(authMiddleware)
|
.use(authMiddleware)
|
||||||
.handler(async ({ context, input }) => {
|
.handler(async ({ context, input }) => {
|
||||||
const [deleted] = await context.db
|
const [deleted] = await context.db
|
||||||
.delete(bookmarkTable)
|
.delete(schema.bookmark)
|
||||||
.where(and(eq(bookmarkTable.id, input.id), eq(bookmarkTable.userId, context.user.id)))
|
.where(and(eq(schema.bookmark.id, input.id), eq(schema.bookmark.userId, context.user.id)))
|
||||||
.returning({ id: bookmarkTable.id })
|
.returning({ id: schema.bookmark.id })
|
||||||
if (!deleted) throw new ORPCError('NOT_FOUND')
|
if (!deleted) throw new ORPCError('NOT_FOUND')
|
||||||
}),
|
}),
|
||||||
|
|
||||||
reorder: os.bookmarks.bookmark.reorder
|
reorder: os.bookmarks.bookmark.reorder
|
||||||
.use(db)
|
.use(dbMiddleware)
|
||||||
.use(authMiddleware)
|
.use(authMiddleware)
|
||||||
.handler(async ({ context, input }) => {
|
.handler(async ({ context, input }) => {
|
||||||
await context.db.transaction(async (tx) => {
|
await context.db.transaction(async (tx) => {
|
||||||
for (const item of input) {
|
for (const item of input) {
|
||||||
await tx
|
await tx
|
||||||
.update(bookmarkTable)
|
.update(schema.bookmark)
|
||||||
.set({ orderId: item.orderId })
|
.set({ orderId: item.orderId })
|
||||||
.where(and(eq(bookmarkTable.id, item.id), eq(bookmarkTable.userId, context.user.id)))
|
.where(and(eq(schema.bookmark.id, item.id), eq(schema.bookmark.userId, context.user.id)))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { boolean, integer, pgTable, text, uuid } from 'drizzle-orm/pg-core'
|
import { boolean, integer, pgTable, text, uuid } from 'drizzle-orm/pg-core'
|
||||||
import { userTable } from '../../server/auth/schema'
|
import { user } from '../../server/auth/schema'
|
||||||
import { generatedFields } from '../../server/db/fields'
|
import { generatedFields } from '../../server/db/fields'
|
||||||
|
|
||||||
export const categoryTable = pgTable('category', {
|
export const category = pgTable('category', {
|
||||||
...generatedFields,
|
...generatedFields,
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
isPinned: boolean('is_pinned').notNull().default(false),
|
isPinned: boolean('is_pinned').notNull().default(false),
|
||||||
@@ -10,20 +10,20 @@ export const categoryTable = pgTable('category', {
|
|||||||
orderId: integer('order_id').notNull().default(0),
|
orderId: integer('order_id').notNull().default(0),
|
||||||
userId: text('user_id')
|
userId: text('user_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => userTable.id, { onDelete: 'cascade' }),
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const bookmarkTable = pgTable('bookmark', {
|
export const bookmark = pgTable('bookmark', {
|
||||||
...generatedFields,
|
...generatedFields,
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
url: text('url').notNull(),
|
url: text('url').notNull(),
|
||||||
icon: text('icon'),
|
icon: text('icon'),
|
||||||
categoryId: uuid('category_id')
|
categoryId: uuid('category_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => categoryTable.id, { onDelete: 'cascade' }),
|
.references(() => category.id, { onDelete: 'cascade' }),
|
||||||
isPublic: boolean('is_public').notNull().default(true),
|
isPublic: boolean('is_public').notNull().default(true),
|
||||||
orderId: integer('order_id').notNull().default(0),
|
orderId: integer('order_id').notNull().default(0),
|
||||||
userId: text('user_id')
|
userId: text('user_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => userTable.id, { onDelete: 'cascade' }),
|
.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(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { ModuleMetadata } from '@/modules/registry'
|
||||||
|
|
||||||
|
export const financeModule: ModuleMetadata = {
|
||||||
|
id: 'finance',
|
||||||
|
name: '记账',
|
||||||
|
description: '收支记录、账户管理与财务分析',
|
||||||
|
icon: 'Wallet',
|
||||||
|
route: '/finance',
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { bookmarksModule } from './bookmarks'
|
import { bookmarksModule } from './bookmarks'
|
||||||
|
import { financeModule } from './finance'
|
||||||
|
|
||||||
export interface ModuleMetadata {
|
export interface ModuleMetadata {
|
||||||
id: string
|
id: string
|
||||||
@@ -9,4 +10,4 @@ export interface ModuleMetadata {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const modules: ModuleMetadata[] = [bookmarksModule]
|
export const modules: ModuleMetadata[] = [bookmarksModule, financeModule]
|
||||||
|
|||||||
@@ -9,19 +9,21 @@
|
|||||||
// 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 SignupRouteImport } from './routes/signup'
|
import { Route as SetupRouteImport } from './routes/setup'
|
||||||
import { Route as LoginRouteImport } from './routes/login'
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as ProtectedRouteImport } from './routes/_protected'
|
import { Route as ProtectedRouteImport } from './routes/_protected'
|
||||||
import { Route as ProtectedIndexRouteImport } from './routes/_protected/index'
|
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 ProtectedBookmarksIndexRouteImport } from './routes/_protected/bookmarks/index'
|
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.$'
|
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
|
||||||
|
|
||||||
const SignupRoute = SignupRouteImport.update({
|
const SetupRoute = SetupRouteImport.update({
|
||||||
id: '/signup',
|
id: '/setup',
|
||||||
path: '/signup',
|
path: '/setup',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const LoginRoute = LoginRouteImport.update({
|
const LoginRoute = LoginRouteImport.update({
|
||||||
@@ -48,9 +50,19 @@ const ApiSplatRoute = ApiSplatRouteImport.update({
|
|||||||
path: '/api/$',
|
path: '/api/$',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const ProtectedBookmarksIndexRoute = ProtectedBookmarksIndexRouteImport.update({
|
const ProtectedSettingsRoute = ProtectedSettingsRouteImport.update({
|
||||||
id: '/bookmarks/',
|
id: '/settings',
|
||||||
path: '/bookmarks/',
|
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,
|
getParentRoute: () => ProtectedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
|
const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
|
||||||
@@ -67,73 +79,85 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
|||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof ProtectedIndexRoute
|
'/': typeof ProtectedIndexRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/signup': typeof SignupRoute
|
'/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/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||||
'/bookmarks/': typeof ProtectedBookmarksIndexRoute
|
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/signup': typeof SignupRoute
|
'/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
|
'/': typeof ProtectedIndexRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||||
'/bookmarks': typeof ProtectedBookmarksIndexRoute
|
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/_protected': typeof ProtectedRouteWithChildren
|
'/_protected': typeof ProtectedRouteWithChildren
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/signup': typeof SignupRoute
|
'/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
|
'/_protected/': typeof ProtectedIndexRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||||
'/_protected/bookmarks/': typeof ProtectedBookmarksIndexRoute
|
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/signup'
|
| '/setup'
|
||||||
|
| '/bookmarks'
|
||||||
|
| '/finance'
|
||||||
|
| '/settings'
|
||||||
| '/api/$'
|
| '/api/$'
|
||||||
| '/api/health'
|
| '/api/health'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/rpc/$'
|
| '/api/rpc/$'
|
||||||
| '/bookmarks/'
|
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/signup'
|
| '/setup'
|
||||||
|
| '/bookmarks'
|
||||||
|
| '/finance'
|
||||||
|
| '/settings'
|
||||||
| '/api/$'
|
| '/api/$'
|
||||||
| '/api/health'
|
| '/api/health'
|
||||||
| '/'
|
| '/'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/rpc/$'
|
| '/api/rpc/$'
|
||||||
| '/bookmarks'
|
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/_protected'
|
| '/_protected'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/signup'
|
| '/setup'
|
||||||
|
| '/_protected/bookmarks'
|
||||||
|
| '/_protected/finance'
|
||||||
|
| '/_protected/settings'
|
||||||
| '/api/$'
|
| '/api/$'
|
||||||
| '/api/health'
|
| '/api/health'
|
||||||
| '/_protected/'
|
| '/_protected/'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/rpc/$'
|
| '/api/rpc/$'
|
||||||
| '/_protected/bookmarks/'
|
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
ProtectedRoute: typeof ProtectedRouteWithChildren
|
ProtectedRoute: typeof ProtectedRouteWithChildren
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
SignupRoute: typeof SignupRoute
|
SetupRoute: typeof SetupRoute
|
||||||
ApiSplatRoute: typeof ApiSplatRoute
|
ApiSplatRoute: typeof ApiSplatRoute
|
||||||
ApiHealthRoute: typeof ApiHealthRoute
|
ApiHealthRoute: typeof ApiHealthRoute
|
||||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||||
@@ -142,11 +166,11 @@ export interface RootRouteChildren {
|
|||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
'/signup': {
|
'/setup': {
|
||||||
id: '/signup'
|
id: '/setup'
|
||||||
path: '/signup'
|
path: '/setup'
|
||||||
fullPath: '/signup'
|
fullPath: '/setup'
|
||||||
preLoaderRoute: typeof SignupRouteImport
|
preLoaderRoute: typeof SetupRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/login': {
|
'/login': {
|
||||||
@@ -184,11 +208,25 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiSplatRouteImport
|
preLoaderRoute: typeof ApiSplatRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/_protected/bookmarks/': {
|
'/_protected/settings': {
|
||||||
id: '/_protected/bookmarks/'
|
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'
|
path: '/bookmarks'
|
||||||
fullPath: '/bookmarks/'
|
fullPath: '/bookmarks'
|
||||||
preLoaderRoute: typeof ProtectedBookmarksIndexRouteImport
|
preLoaderRoute: typeof ProtectedBookmarksRouteImport
|
||||||
parentRoute: typeof ProtectedRoute
|
parentRoute: typeof ProtectedRoute
|
||||||
}
|
}
|
||||||
'/api/rpc/$': {
|
'/api/rpc/$': {
|
||||||
@@ -209,13 +247,17 @@ declare module '@tanstack/react-router' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ProtectedRouteChildren {
|
interface ProtectedRouteChildren {
|
||||||
|
ProtectedBookmarksRoute: typeof ProtectedBookmarksRoute
|
||||||
|
ProtectedFinanceRoute: typeof ProtectedFinanceRoute
|
||||||
|
ProtectedSettingsRoute: typeof ProtectedSettingsRoute
|
||||||
ProtectedIndexRoute: typeof ProtectedIndexRoute
|
ProtectedIndexRoute: typeof ProtectedIndexRoute
|
||||||
ProtectedBookmarksIndexRoute: typeof ProtectedBookmarksIndexRoute
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProtectedRouteChildren: ProtectedRouteChildren = {
|
const ProtectedRouteChildren: ProtectedRouteChildren = {
|
||||||
|
ProtectedBookmarksRoute: ProtectedBookmarksRoute,
|
||||||
|
ProtectedFinanceRoute: ProtectedFinanceRoute,
|
||||||
|
ProtectedSettingsRoute: ProtectedSettingsRoute,
|
||||||
ProtectedIndexRoute: ProtectedIndexRoute,
|
ProtectedIndexRoute: ProtectedIndexRoute,
|
||||||
ProtectedBookmarksIndexRoute: ProtectedBookmarksIndexRoute,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProtectedRouteWithChildren = ProtectedRoute._addFileChildren(
|
const ProtectedRouteWithChildren = ProtectedRoute._addFileChildren(
|
||||||
@@ -225,7 +267,7 @@ const ProtectedRouteWithChildren = ProtectedRoute._addFileChildren(
|
|||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
ProtectedRoute: ProtectedRouteWithChildren,
|
ProtectedRoute: ProtectedRouteWithChildren,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
SignupRoute: SignupRoute,
|
SetupRoute: SetupRoute,
|
||||||
ApiSplatRoute: ApiSplatRoute,
|
ApiSplatRoute: ApiSplatRoute,
|
||||||
ApiHealthRoute: ApiHealthRoute,
|
ApiHealthRoute: ApiHealthRoute,
|
||||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
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'
|
import { getSession } from '@/server/auth/functions'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_protected' as never)({
|
export const Route = createFileRoute('/_protected' as never)({
|
||||||
@@ -13,5 +17,20 @@ export const Route = createFileRoute('/_protected' as never)({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function ProtectedLayout() {
|
function ProtectedLayout() {
|
||||||
return <Outlet />
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import type { QueryClient } from '@tanstack/react-query'
|
|
||||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
|
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
|
||||||
import { Plus, X } from 'lucide-react'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { orpc } from '@/client/orpc'
|
|
||||||
import { CategorySection } from '@/modules/bookmarks/components/CategorySection'
|
|
||||||
import { GreetingHeader } from '@/modules/bookmarks/components/GreetingHeader'
|
|
||||||
import { SearchBar } from '@/modules/bookmarks/components/SearchBar'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_protected/bookmarks/' as never)({
|
|
||||||
component: BookmarksPage,
|
|
||||||
loader: async ({ context }: { context: { queryClient: QueryClient } }) => {
|
|
||||||
await context.queryClient.ensureQueryData(orpc.bookmarks.category.list.queryOptions())
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
function BookmarksPage() {
|
|
||||||
const categoriesQuery = useSuspenseQuery(orpc.bookmarks.category.list.queryOptions())
|
|
||||||
const createCategory = useMutation(orpc.bookmarks.category.create.mutationOptions())
|
|
||||||
const [showAddCategory, setShowAddCategory] = useState(false)
|
|
||||||
const [newCategoryName, setNewCategoryName] = useState('')
|
|
||||||
|
|
||||||
const handleAddCategory = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (newCategoryName.trim()) {
|
|
||||||
createCategory.mutate({
|
|
||||||
name: newCategoryName.trim(),
|
|
||||||
orderId: categoriesQuery.data.length,
|
|
||||||
})
|
|
||||||
setNewCategoryName('')
|
|
||||||
setShowAddCategory(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-slate-50 py-8 px-4 sm:px-6">
|
|
||||||
<div className="max-w-4xl mx-auto space-y-8">
|
|
||||||
<SearchBar />
|
|
||||||
|
|
||||||
<GreetingHeader />
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{categoriesQuery.data.map((category) => (
|
|
||||||
<CategorySection key={category.id} category={category} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{categoriesQuery.data.length === 0 && !showAddCategory && (
|
|
||||||
<div className="text-center py-16">
|
|
||||||
<p className="text-slate-400 text-lg">还没有任何分类</p>
|
|
||||||
<p className="text-slate-400 text-sm mt-1">创建一个分类来开始添加书签</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showAddCategory ? (
|
|
||||||
<form onSubmit={handleAddCategory} className="flex items-center gap-2 max-w-md mx-auto">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newCategoryName}
|
|
||||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
|
||||||
placeholder="分类名称"
|
|
||||||
className="flex-1 px-4 py-2.5 rounded-xl bg-white ring-1 ring-slate-200 outline-none focus:ring-2 focus:ring-indigo-500/50 text-sm"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={createCategory.isPending || !newCategoryName.trim()}
|
|
||||||
className="px-4 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-medium transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{createCategory.isPending ? '创建中...' : '创建'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAddCategory(false)}
|
|
||||||
className="p-2.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-xl transition-colors"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAddCategory(true)}
|
|
||||||
className="flex items-center justify-center gap-2 w-full py-3 rounded-xl border-2 border-dashed border-slate-200 text-slate-400 hover:border-slate-300 hover:text-slate-500 transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" /> 添加分类
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,76 +1,169 @@
|
|||||||
import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
|
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 * as icons from 'lucide-react'
|
||||||
import { modules } from '@/modules/registry'
|
import { ArrowRight, Compass, TrendingDown, TrendingUp, Wallet } from 'lucide-react'
|
||||||
import { authClient } from '@/server/auth/client'
|
import * as motion from 'motion/react-client'
|
||||||
|
import { orpc } from '@/client/orpc'
|
||||||
|
|
||||||
const iconComponents = icons as unknown as Record<string, typeof icons.Box>
|
const allIcons = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>
|
||||||
|
|
||||||
export const Route = createFileRoute('/_protected' as never)({
|
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,
|
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() {
|
function DashboardPage() {
|
||||||
const router = useRouter()
|
const { data: categories } = useSuspenseQuery(orpc.bookmarks.category.list.queryOptions())
|
||||||
const { user } = Route.useRouteContext() as {
|
const { data: summary } = useSuspenseQuery(orpc.finance.transaction.summary.queryOptions({ input: {} }))
|
||||||
user: {
|
const { data: accounts } = useSuspenseQuery(orpc.finance.account.list.queryOptions())
|
||||||
name: string
|
const now = new Date()
|
||||||
email: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const enabledModules = modules.filter((mod) => mod.enabled)
|
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const totalBookmarks = categories.reduce(
|
||||||
await authClient.signOut()
|
(sum: number, cat: { bookmarks: Array<{ id: string }> }) => sum + cat.bookmarks.length,
|
||||||
router.navigate({ to: '/login' as never })
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 px-4 py-12 sm:px-6">
|
<motion.div className="flex-1 px-6 pb-8" variants={containerVariants} initial="hidden" animate="visible">
|
||||||
<div className="mx-auto max-w-4xl space-y-8">
|
<motion.div variants={itemVariants} className="mb-8">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<h1 className="text-3xl font-bold tracking-tight">{getGreeting(now.getHours())}</h1>
|
||||||
<div>
|
<p className="mt-1 text-muted-foreground">{formatDate(now)}</p>
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">Kairos</h1>
|
</motion.div>
|
||||||
<p className="mt-1 text-slate-500">
|
|
||||||
欢迎回来,{user.name} · {user.email}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSignOut}
|
|
||||||
className="rounded-lg px-4 py-2 text-sm text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
|
||||||
>
|
|
||||||
退出登录
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{enabledModules.length === 0 ? (
|
{topBookmarks.length > 0 && (
|
||||||
<div className="py-20 text-center text-slate-400">暂无可用模块</div>
|
<motion.div variants={itemVariants} className="mb-8">
|
||||||
) : (
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<h2 className="text-sm font-medium text-muted-foreground">常用书签</h2>
|
||||||
{enabledModules.map((mod) => {
|
|
||||||
const IconComponent = iconComponents[mod.icon] ?? icons.Box
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
<Link
|
||||||
key={mod.id}
|
to={'/bookmarks' as never}
|
||||||
to={mod.route as never}
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||||
className="group block rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-100 transition-all hover:shadow-md hover:ring-slate-200"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
查看全部
|
||||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-indigo-50 transition-colors group-hover:bg-indigo-100">
|
<ArrowRight className="size-3" />
|
||||||
<IconComponent className="h-6 w-6 text-indigo-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-slate-900">{mod.name}</h3>
|
|
||||||
<p className="mt-0.5 text-sm text-slate-500">{mod.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
</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>
|
</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>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
|
import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { authClient } from '@/server/auth/client'
|
import { authClient } from '@/server/auth/client'
|
||||||
import { getSession } from '@/server/auth/functions'
|
import { checkInitialized, getSession } from '@/server/auth/functions'
|
||||||
|
|
||||||
export const Route = createFileRoute('/login' as never)({
|
export const Route = createFileRoute('/login' as never)({
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
|
const initialized = await checkInitialized()
|
||||||
|
if (!initialized) {
|
||||||
|
throw redirect({ to: '/setup' as never })
|
||||||
|
}
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (session) {
|
if (session) {
|
||||||
throw redirect({ to: '/' as never })
|
throw redirect({ to: '/' as never })
|
||||||
@@ -91,13 +95,6 @@ function LoginPage() {
|
|||||||
{loading ? '登录中...' : '登录'}
|
{loading ? '登录中...' : '登录'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="text-center text-sm text-slate-500 mt-6">
|
|
||||||
还没有账号?{' '}
|
|
||||||
<Link to={'/signup' as never} className="text-indigo-600 hover:text-indigo-700 font-medium">
|
|
||||||
注册
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
|
import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { authClient } from '@/server/auth/client'
|
import { authClient } from '@/server/auth/client'
|
||||||
import { getSession } from '@/server/auth/functions'
|
import { checkInitialized } from '@/server/auth/functions'
|
||||||
|
|
||||||
export const Route = createFileRoute('/signup' as never)({
|
export const Route = createFileRoute('/setup' as never)({
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
const session = await getSession()
|
const initialized = await checkInitialized()
|
||||||
if (session) {
|
if (initialized) {
|
||||||
throw redirect({ to: '/' as never })
|
throw redirect({ to: '/login' as never })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
component: SignupPage,
|
component: SetupPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
function SignupPage() {
|
function SetupPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
@@ -45,7 +45,7 @@ function SignupPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (signUpError) {
|
if (signUpError) {
|
||||||
setError(signUpError.message ?? '注册失败,请重试')
|
setError(signUpError.message ?? '创建账号失败,请重试')
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -58,7 +58,7 @@ function SignupPage() {
|
|||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-4xl font-bold text-slate-900 tracking-tight">Kairos</h1>
|
<h1 className="text-4xl font-bold text-slate-900 tracking-tight">Kairos</h1>
|
||||||
<p className="text-slate-500 mt-2">创建你的账号</p>
|
<p className="text-slate-500 mt-2">初始化你的人生操作系统</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] ring-1 ring-slate-100 p-8">
|
<div className="bg-white rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] ring-1 ring-slate-100 p-8">
|
||||||
@@ -134,16 +134,9 @@ function SignupPage() {
|
|||||||
disabled={loading}
|
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]"
|
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 ? '注册中...' : '注册'}
|
{loading ? '初始化中...' : '开始使用 Kairos'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="text-center text-sm text-slate-500 mt-6">
|
|
||||||
已有账号?{' '}
|
|
||||||
<Link to={'/login' as never} className="text-indigo-600 hover:text-indigo-700 font-medium">
|
|
||||||
登录
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import * as bookmarks from '@/modules/bookmarks/contract'
|
import * as bookmarks from '@/modules/bookmarks/contract'
|
||||||
|
import * as finance from '@/modules/finance/contract'
|
||||||
|
|
||||||
export const contract = {
|
export const contract = {
|
||||||
bookmarks,
|
bookmarks,
|
||||||
|
finance,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Contract = typeof contract
|
export type Contract = typeof contract
|
||||||
|
|||||||
@@ -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,6 +1,8 @@
|
|||||||
import * as bookmarks from '@/modules/bookmarks/router'
|
import * as bookmarks from '@/modules/bookmarks/router'
|
||||||
import { os } from '../server'
|
import * as finance from '@/modules/finance/router'
|
||||||
|
import { os } from '@/server/api/server'
|
||||||
|
|
||||||
export const router = os.router({
|
export const router = os.router({
|
||||||
bookmarks,
|
bookmarks,
|
||||||
|
finance,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { apiKeyClient } from '@better-auth/api-key/client'
|
||||||
import { createAuthClient } from 'better-auth/react'
|
import { createAuthClient } from 'better-auth/react'
|
||||||
|
|
||||||
export const authClient = createAuthClient()
|
export const authClient = createAuthClient({
|
||||||
|
plugins: [apiKeyClient()],
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { createServerFn } from '@tanstack/react-start'
|
import { createServerFn } from '@tanstack/react-start'
|
||||||
import { getRequestHeaders } from '@tanstack/react-start/server'
|
import { getRequestHeaders } from '@tanstack/react-start/server'
|
||||||
import { auth } from '@/server/auth'
|
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 () => {
|
export const getSession = createServerFn({ method: 'GET' }).handler(async () => {
|
||||||
const headers = getRequestHeaders()
|
const headers = getRequestHeaders()
|
||||||
return await auth.api.getSession({ headers })
|
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
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
|
import { apiKey } from '@better-auth/api-key'
|
||||||
import { betterAuth } from 'better-auth'
|
import { betterAuth } from 'better-auth'
|
||||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
||||||
|
import { APIError } from 'better-auth/api'
|
||||||
import { tanstackStartCookies } from 'better-auth/tanstack-start'
|
import { tanstackStartCookies } from 'better-auth/tanstack-start'
|
||||||
import { env } from '@/env'
|
import { env } from '@/env'
|
||||||
import * as authSchema from '@/server/auth/schema'
|
import * as authSchema from '@/server/auth/schema'
|
||||||
import { getDB } from '@/server/db'
|
import { db } from '@/server/db'
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
baseURL: env.BETTER_AUTH_URL,
|
baseURL: env.BETTER_AUTH_URL,
|
||||||
secret: env.BETTER_AUTH_SECRET,
|
secret: env.BETTER_AUTH_SECRET,
|
||||||
database: drizzleAdapter(getDB(), {
|
database: drizzleAdapter(db, {
|
||||||
provider: 'pg',
|
provider: 'pg',
|
||||||
schema: authSchema,
|
schema: authSchema,
|
||||||
}),
|
}),
|
||||||
@@ -21,5 +23,23 @@ export const auth = betterAuth({
|
|||||||
maxAge: 5 * 60,
|
maxAge: 5 * 60,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [tanstackStartCookies()],
|
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,
|
||||||
|
}),
|
||||||
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,70 +1,126 @@
|
|||||||
import { boolean, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
|
import { relations } from 'drizzle-orm'
|
||||||
|
import { boolean, index, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
|
||||||
|
|
||||||
/**
|
export const user = pgTable('user', {
|
||||||
* Better Auth 认证表
|
|
||||||
*
|
|
||||||
* 注意:所有 ID 使用 text 类型(Better Auth 自管 ID 生成),
|
|
||||||
* 不使用项目的 generatedFields(UUID v7)。
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const userTable = pgTable('user', {
|
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
email: text('email').notNull().unique(),
|
email: text('email').notNull().unique(),
|
||||||
emailVerified: boolean('email_verified').notNull().default(false),
|
emailVerified: boolean('email_verified').default(false).notNull(),
|
||||||
image: text('image'),
|
image: text('image'),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
updatedAt: timestamp('updated_at')
|
||||||
.notNull()
|
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.$onUpdateFn(() => new Date()),
|
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||||
|
.notNull(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const sessionTable = pgTable('session', {
|
export const session = pgTable(
|
||||||
|
'session',
|
||||||
|
{
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
expiresAt: timestamp('expires_at').notNull(),
|
||||||
token: text('token').notNull().unique(),
|
token: text('token').notNull().unique(),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
updatedAt: timestamp('updated_at')
|
||||||
.notNull()
|
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||||
.defaultNow()
|
.notNull(),
|
||||||
.$onUpdateFn(() => new Date()),
|
|
||||||
ipAddress: text('ip_address'),
|
ipAddress: text('ip_address'),
|
||||||
userAgent: text('user_agent'),
|
userAgent: text('user_agent'),
|
||||||
userId: text('user_id')
|
userId: text('user_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => userTable.id, { onDelete: 'cascade' }),
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
})
|
},
|
||||||
|
(table) => [index('session_userId_idx').on(table.userId)],
|
||||||
|
)
|
||||||
|
|
||||||
export const accountTable = pgTable('account', {
|
export const account = pgTable(
|
||||||
|
'account',
|
||||||
|
{
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
accountId: text('account_id').notNull(),
|
accountId: text('account_id').notNull(),
|
||||||
providerId: text('provider_id').notNull(),
|
providerId: text('provider_id').notNull(),
|
||||||
userId: text('user_id')
|
userId: text('user_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => userTable.id, { onDelete: 'cascade' }),
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
accessToken: text('access_token'),
|
accessToken: text('access_token'),
|
||||||
refreshToken: text('refresh_token'),
|
refreshToken: text('refresh_token'),
|
||||||
idToken: text('id_token'),
|
idToken: text('id_token'),
|
||||||
accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }),
|
accessTokenExpiresAt: timestamp('access_token_expires_at'),
|
||||||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }),
|
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
|
||||||
scope: text('scope'),
|
scope: text('scope'),
|
||||||
password: text('password'),
|
password: text('password'),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
updatedAt: timestamp('updated_at')
|
||||||
.notNull()
|
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||||
.defaultNow()
|
.notNull(),
|
||||||
.$onUpdateFn(() => new Date()),
|
},
|
||||||
})
|
(table) => [index('account_userId_idx').on(table.userId)],
|
||||||
|
)
|
||||||
|
|
||||||
export const verificationTable = pgTable('verification', {
|
export const verification = pgTable(
|
||||||
|
'verification',
|
||||||
|
{
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
identifier: text('identifier').notNull(),
|
identifier: text('identifier').notNull(),
|
||||||
value: text('value').notNull(),
|
value: text('value').notNull(),
|
||||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
expiresAt: timestamp('expires_at').notNull(),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
updatedAt: timestamp('updated_at')
|
||||||
.notNull()
|
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.$onUpdateFn(() => new Date()),
|
.$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],
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|||||||
@@ -1,26 +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 }) => ({
|
||||||
userTable: {
|
categories: many(category),
|
||||||
categories: r.many.categoryTable(),
|
bookmarks: many(bookmark),
|
||||||
bookmarks: r.many.bookmarkTable(),
|
financeAccounts: many(financeAccount),
|
||||||
},
|
transactionCategories: many(transactionCategory),
|
||||||
categoryTable: {
|
transactions: many(transaction),
|
||||||
user: r.one.userTable({
|
}))
|
||||||
from: r.categoryTable.userId,
|
|
||||||
to: r.userTable.id,
|
export const categoryRelations = relations(category, ({ one, many }) => ({
|
||||||
}),
|
user: one(user, {
|
||||||
bookmarks: r.many.bookmarkTable(),
|
fields: [category.userId],
|
||||||
},
|
references: [user.id],
|
||||||
bookmarkTable: {
|
}),
|
||||||
user: r.one.userTable({
|
bookmarks: many(bookmark),
|
||||||
from: r.bookmarkTable.userId,
|
}))
|
||||||
to: r.userTable.id,
|
|
||||||
}),
|
export const bookmarkRelations = relations(bookmark, ({ one }) => ({
|
||||||
category: r.one.categoryTable({
|
user: one(user, {
|
||||||
from: r.bookmarkTable.categoryId,
|
fields: [bookmark.userId],
|
||||||
to: r.categoryTable.id,
|
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],
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from '../../../modules/bookmarks/schema'
|
export * from '../../../modules/bookmarks/schema'
|
||||||
export * from '../../auth/schema'
|
export * from '../../../modules/finance/schema'
|
||||||
|
export { account, apikey, session, user, verification } from '../../auth/schema'
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+19
-6
@@ -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,25 +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/dom": "^0.3.2",
|
||||||
|
"@dnd-kit/helpers": "^0.3.2",
|
||||||
"@dnd-kit/react": "^0.3.2",
|
"@dnd-kit/react": "^0.3.2",
|
||||||
"better-auth": "^1.2.8",
|
"better-auth": "^1.2.8",
|
||||||
"lucide-react": "^0.513.0",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user