feat: 重设计 UI/UX — 展示/管理分离 + shadcn/ui + Admin 后台
- 引入 shadcn/ui(base-nova 风格,Tailwind v4,14 个组件) - 新增 Admin 后台路由架构:/admin(总览)、/admin/bookmarks(管理) - 重写首页为纯展示书签导航(BookmarkCard + CategoryGrid) - 新增 Admin 侧边栏导航(AdminSidebar + SidebarProvider) - 书签管理页:双栏布局 + Dialog 表单 + DnD 排序 + Toast 通知 - 修复 IconPicker overflow 裁切(改用 Dialog portal) - 修复嵌套 button hydration 错误(base-ui render prop) - 删除旧组件(CategorySection/BookmarkItem/IconPicker)和旧路由 - 所有新依赖归入 root catalog - 更新 AGENTS.md 文档(目录结构、shadcn 模式、render prop 规范)
This commit is contained in:
@@ -208,13 +208,27 @@ export const myTable = pgTable('my_table', {
|
||||
.
|
||||
├── apps/
|
||||
│ ├── server/ # TanStack Start fullstack app
|
||||
│ │ ├── components.json # shadcn/ui configuration
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── client/ # ORPC client + TanStack Query utils
|
||||
│ │ │ ├── components/
|
||||
│ │ │ │ ├── ui/ # shadcn/ui components (auto-managed)
|
||||
│ │ │ │ └── AdminSidebar.tsx # Admin panel sidebar
|
||||
│ │ │ ├── hooks/ # Custom React hooks
|
||||
│ │ │ ├── lib/ # Utilities (cn, etc.)
|
||||
│ │ │ ├── modules/ # Feature modules (bookmarks, etc.)
|
||||
│ │ │ ├── routes/ # File-based routing
|
||||
│ │ │ └── server/ # API layer + database
|
||||
│ │ │ │ ├── _protected/ # Auth guard
|
||||
│ │ │ │ │ ├── index.tsx # Dashboard homepage
|
||||
│ │ │ │ │ ├── admin.tsx # Admin layout (sidebar)
|
||||
│ │ │ │ │ └── admin/ # Admin pages
|
||||
│ │ │ │ │ ├── index.tsx # Admin overview
|
||||
│ │ │ │ │ └── bookmarks.tsx # Bookmark management
|
||||
│ │ │ │ └── api/ # API routes
|
||||
│ │ │ └── server/ # API layer + database + auth
|
||||
│ │ │ ├── api/ # ORPC contracts, routers, middlewares
|
||||
│ │ │ └── db/ # Drizzle schema
|
||||
│ │ │ ├── auth/ # Better Auth (schema, instance, client)
|
||||
│ │ │ └── db/ # Drizzle schema + relations
|
||||
│ │ └── AGENTS.md
|
||||
├── packages/
|
||||
│ └── tsconfig/ # Shared TS configs
|
||||
|
||||
+202
-73
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -10,10 +10,13 @@ TanStack Start fullstack web app with ORPC (contract-first RPC).
|
||||
- **Runtime**: Bun — **NOT Node.js**
|
||||
- **Package Manager**: Bun — **NOT npm / yarn / pnpm**
|
||||
- **Language**: TypeScript (strict mode)
|
||||
- **Styling**: Tailwind CSS v4
|
||||
- **Styling**: Tailwind CSS v4 + shadcn/ui (base-nova style, OKLCH colors)
|
||||
- **UI Components**: shadcn/ui (copy-paste components in `src/components/ui/`)
|
||||
- **Database**: PostgreSQL + Drizzle ORM v1 beta (`drizzle-orm/postgres-js`, RQBv2)
|
||||
- **State**: TanStack Query v5
|
||||
- **RPC**: ORPC (contract-first, type-safe)
|
||||
- **Auth**: Better Auth (email+password, self-hosted)
|
||||
- **DnD**: @dnd-kit/react
|
||||
- **Build**: Vite + Nitro
|
||||
|
||||
## Commands
|
||||
@@ -27,13 +30,8 @@ bun run db:studio # Drizzle Studio GUI
|
||||
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
|
||||
@@ -49,38 +47,124 @@ bun test path/to/test.ts # Run single test
|
||||
bun test -t "pattern" # Run tests matching pattern
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Route Architecture (Display + Admin separation)
|
||||
|
||||
```
|
||||
/ → Dashboard homepage (bookmark display, daily use)
|
||||
/admin → Admin panel overview (module cards)
|
||||
/admin/bookmarks → Bookmark management (CRUD, DnD, Dialog forms)
|
||||
/login → Login page
|
||||
/signup → Signup page
|
||||
```
|
||||
|
||||
- **Display pages** (`/`): Clean, no management UI. What users see daily.
|
||||
- **Admin pages** (`/admin/*`): Full CRUD, management, configuration. Sidebar navigation.
|
||||
- All authenticated routes are under `_protected` layout (auth guard → redirect to `/login`).
|
||||
|
||||
### Module System
|
||||
|
||||
Modules are directory-based under `src/modules/`. Each module is independent and pluggable.
|
||||
|
||||
```typescript
|
||||
// src/modules/registry.ts
|
||||
export interface ModuleMetadata {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string // lucide-react icon name
|
||||
adminRoute: string // Admin management route (e.g., '/admin/bookmarks')
|
||||
enabled: boolean
|
||||
}
|
||||
```
|
||||
|
||||
Each module provides: `schema.ts` (Drizzle tables), `contract.ts` (ORPC contracts), `router.ts` (ORPC handlers), `components/` (UI), and `index.ts` (metadata).
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── client/ # Client-side code
|
||||
│ └── orpc.ts # ORPC client + TanStack Query utils (single entry point)
|
||||
├── components/ # React components
|
||||
│ └── orpc.ts # ORPC client + TanStack Query utils
|
||||
├── components/ # Shared React components
|
||||
│ ├── AdminSidebar.tsx # Admin panel sidebar navigation
|
||||
│ ├── Error.tsx # Error boundary fallback
|
||||
│ ├── NotFound.tsx # 404 fallback
|
||||
│ └── ui/ # shadcn/ui components (DO NOT manually edit)
|
||||
│ ├── button.tsx
|
||||
│ ├── card.tsx
|
||||
│ ├── dialog.tsx
|
||||
│ ├── dropdown-menu.tsx
|
||||
│ ├── input.tsx
|
||||
│ ├── separator.tsx
|
||||
│ ├── sheet.tsx
|
||||
│ ├── sidebar.tsx
|
||||
│ ├── skeleton.tsx
|
||||
│ ├── sonner.tsx
|
||||
│ ├── tooltip.tsx
|
||||
│ ├── avatar.tsx
|
||||
│ └── badge.tsx
|
||||
├── hooks/ # Custom React hooks
|
||||
│ └── use-mobile.ts # Mobile detection (shadcn sidebar dep)
|
||||
├── lib/ # Utility functions
|
||||
│ └── utils.ts # cn() utility (clsx + tailwind-merge)
|
||||
├── modules/ # Feature modules
|
||||
│ ├── registry.ts # Module metadata registry
|
||||
│ └── bookmarks/ # Bookmarks module
|
||||
│ ├── index.ts # Module metadata
|
||||
│ ├── schema.ts # Drizzle tables (category, bookmark)
|
||||
│ ├── contract.ts # ORPC contracts
|
||||
│ ├── router.ts # ORPC handlers
|
||||
│ └── components/ # Module UI
|
||||
│ ├── BookmarkCard.tsx # Display: clean card (icon + name)
|
||||
│ ├── CategoryGrid.tsx # Display: category grid for homepage
|
||||
│ ├── BookmarkFormDialog.tsx # Admin: create/edit bookmark dialog
|
||||
│ ├── BookmarkManager.tsx # Admin: bookmark list with DnD
|
||||
│ ├── CategoryFormDialog.tsx # Admin: create/edit category dialog
|
||||
│ ├── CategoryManager.tsx # Admin: category list with DnD
|
||||
│ ├── IconPickerDialog.tsx # Admin: icon picker in dialog
|
||||
│ ├── GreetingHeader.tsx # Display: time-based greeting
|
||||
│ └── SearchBar.tsx # Display: multi-engine search
|
||||
├── routes/ # TanStack Router file routes
|
||||
│ ├── __root.tsx # Root layout
|
||||
│ ├── index.tsx # Home page
|
||||
│ ├── __root.tsx # Root layout (HTML shell, Toaster, TooltipProvider)
|
||||
│ ├── _protected.tsx # Auth guard layout (redirect to /login)
|
||||
│ ├── _protected/
|
||||
│ │ ├── index.tsx # Dashboard homepage (bookmark display)
|
||||
│ │ ├── admin.tsx # Admin layout (SidebarProvider + Outlet)
|
||||
│ │ └── admin/
|
||||
│ │ ├── index.tsx # Admin overview (module cards)
|
||||
│ │ └── bookmarks.tsx # Bookmark management page
|
||||
│ ├── login.tsx # Login page
|
||||
│ ├── signup.tsx # Signup page
|
||||
│ └── api/
|
||||
│ ├── $.ts # OpenAPI handler + Scalar docs
|
||||
│ ├── auth.$.ts # Better Auth handler
|
||||
│ ├── health.ts # Health check endpoint
|
||||
│ └── rpc.$.ts # ORPC RPC handler
|
||||
├── server/ # Server-side code
|
||||
│ ├── api/ # ORPC layer
|
||||
│ │ ├── contracts/ # Input/output schemas (Zod)
|
||||
│ │ ├── middlewares/ # Middleware (db provider, auth)
|
||||
│ │ ├── middlewares/ # Middleware (dbMiddleware, authMiddleware)
|
||||
│ │ ├── routers/ # Handler implementations
|
||||
│ │ ├── interceptors.ts # Shared error interceptors
|
||||
│ │ ├── context.ts # Request context
|
||||
│ │ ├── context.ts # Request context types
|
||||
│ │ ├── server.ts # ORPC server instance
|
||||
│ │ └── types.ts # Type exports
|
||||
│ ├── auth/ # Better Auth
|
||||
│ │ ├── schema.ts # Auth tables (user, session, account, verification)
|
||||
│ │ ├── index.ts # Auth instance (betterAuth + drizzleAdapter)
|
||||
│ │ ├── client.ts # Auth client (createAuthClient)
|
||||
│ │ └── functions.ts # Server functions (getSession)
|
||||
│ └── db/
|
||||
│ ├── schema/ # Drizzle table definitions
|
||||
│ ├── schema/ # Drizzle table re-exports
|
||||
│ ├── fields.ts # Shared field builders (id, createdAt, updatedAt)
|
||||
│ ├── relations.ts # Drizzle relations (defineRelations, RQBv2)
|
||||
│ └── index.ts # Database instance (postgres-js driver)
|
||||
│ └── index.ts # Database singleton (module-level export)
|
||||
├── env.ts # Environment variable validation
|
||||
├── router.tsx # Router configuration
|
||||
├── routeTree.gen.ts # Auto-generated (DO NOT EDIT)
|
||||
└── styles.css # Tailwind entry
|
||||
└── styles.css # Tailwind + shadcn CSS variables
|
||||
```
|
||||
|
||||
## ORPC Pattern
|
||||
@@ -90,9 +174,9 @@ src/
|
||||
import { oc } from '@orpc/contract'
|
||||
import { createSelectSchema } from 'drizzle-orm/zod'
|
||||
import { z } from 'zod'
|
||||
import { featureTable } from '@/server/db/schema'
|
||||
import * as schema from '@/modules/feature/schema'
|
||||
|
||||
const selectSchema = createSelectSchema(featureTable)
|
||||
const selectSchema = createSelectSchema(schema.myTable)
|
||||
|
||||
export const list = oc.input(z.void()).output(z.array(selectSchema))
|
||||
export const create = oc.input(insertSchema).output(selectSchema)
|
||||
@@ -101,24 +185,28 @@ export const create = oc.input(insertSchema).output(selectSchema)
|
||||
### 2. Implement Router (`src/server/api/routers/feature.router.ts`)
|
||||
```typescript
|
||||
import { ORPCError } from '@orpc/server'
|
||||
import { db } from '../middlewares'
|
||||
import { os } from '../server'
|
||||
import * as schema from '@/modules/feature/schema'
|
||||
import { authMiddleware, dbMiddleware } from '@/server/api/middlewares'
|
||||
import { os } from '@/server/api/server'
|
||||
|
||||
export const list = os.feature.list.use(db).handler(async ({ context }) => {
|
||||
return await context.db.query.featureTable.findMany({
|
||||
export const list = os.feature.list
|
||||
.use(dbMiddleware)
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
return await context.db.query.myTable.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 3. Register in Index Files
|
||||
```typescript
|
||||
// src/server/api/contracts/index.ts
|
||||
import * as feature from './feature.contract'
|
||||
import * as feature from '@/modules/feature/contract'
|
||||
export const contract = { feature }
|
||||
|
||||
// src/server/api/routers/index.ts
|
||||
import * as feature from './feature.router'
|
||||
import * as feature from '@/modules/feature/router'
|
||||
export const router = os.router({ feature })
|
||||
```
|
||||
|
||||
@@ -131,12 +219,69 @@ const { data } = useSuspenseQuery(orpc.feature.list.queryOptions())
|
||||
const mutation = useMutation(orpc.feature.create.mutationOptions())
|
||||
```
|
||||
|
||||
## UI Component Patterns (shadcn/ui)
|
||||
|
||||
### Dialog Forms (for CRUD operations)
|
||||
```typescript
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
// Use render prop on DialogTrigger to avoid nested <button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button>打开</Button>} />
|
||||
<DialogContent>
|
||||
{/* Form content */}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
### DropdownMenu (for action menus)
|
||||
```typescript
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
|
||||
// Use render prop on trigger to avoid nested <button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-sm" />}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>操作</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
```
|
||||
|
||||
### Toast Notifications
|
||||
```typescript
|
||||
import { toast } from 'sonner'
|
||||
|
||||
toast.success('操作成功')
|
||||
toast.error('操作失败')
|
||||
```
|
||||
|
||||
### Admin Sidebar
|
||||
- Uses shadcn `SidebarProvider` + `Sidebar` + `SidebarInset` pattern
|
||||
- Navigation items from module registry
|
||||
- `SidebarMenuButton` uses `render` prop for `Link` integration (NOT `asChild`)
|
||||
|
||||
### base-ui `render` Prop (IMPORTANT)
|
||||
shadcn/ui v4 uses `@base-ui/react` instead of Radix primitives. The `render` prop replaces `asChild`:
|
||||
```typescript
|
||||
// CORRECT: render prop delegates element rendering
|
||||
<DialogTrigger render={<Button />} />
|
||||
<SidebarMenuButton render={<Link to="/admin" />}>
|
||||
|
||||
// WRONG: asChild does NOT exist in base-ui components
|
||||
<DialogTrigger asChild><Button /></DialogTrigger> // ❌ asChild is NOT supported
|
||||
```
|
||||
|
||||
## Database (Drizzle ORM v1 beta)
|
||||
|
||||
- **Driver**: `drizzle-orm/postgres-js` (NOT `bun-sql`)
|
||||
- **Validation**: `drizzle-orm/zod` (built-in, NOT separate `drizzle-zod` package)
|
||||
- **Relations**: Defined via `defineRelations()` in `src/server/db/relations.ts`
|
||||
- **Query**: RQBv2 — use `db.query.tableName.findMany()` with object-style `orderBy` and `where`
|
||||
- **Table naming**: No `Table` suffix — use `user`, `category`, `bookmark` (NOT `userTable`)
|
||||
- **DB instance**: Module-level singleton `export const db = drizzle(...)` (NOT factory/closure pattern)
|
||||
|
||||
### Schema Definition
|
||||
```typescript
|
||||
@@ -153,39 +298,41 @@ export const myTable = pgTable('my_table', {
|
||||
|
||||
### Relations (RQBv2)
|
||||
```typescript
|
||||
// src/server/db/relations.ts
|
||||
import { defineRelations } from 'drizzle-orm'
|
||||
import * as schema from './schema'
|
||||
|
||||
export const relations = defineRelations(schema, (r) => ({
|
||||
// Define relations here using r.one / r.many / r.through
|
||||
user: {
|
||||
categories: r.many.category(),
|
||||
},
|
||||
category: {
|
||||
user: r.one.user({ from: r.category.userId, to: r.user.id }),
|
||||
bookmarks: r.many.bookmark(),
|
||||
},
|
||||
}))
|
||||
```
|
||||
|
||||
### DB Instance
|
||||
```typescript
|
||||
// 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({
|
||||
connection: env.DATABASE_URL,
|
||||
relations,
|
||||
})
|
||||
export const db = drizzle({ connection: env.DATABASE_URL, relations })
|
||||
export type DB = typeof db
|
||||
```
|
||||
|
||||
### RQBv2 Query Examples
|
||||
```typescript
|
||||
// Object-style orderBy (NOT callback style)
|
||||
const todos = await db.query.todoTable.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
## Auth (Better Auth)
|
||||
|
||||
// Object-style where
|
||||
const todo = await db.query.todoTable.findFirst({
|
||||
where: { id: someId },
|
||||
})
|
||||
- **Instance**: `src/server/auth/index.ts` — `betterAuth()` with `drizzleAdapter(db, { provider: 'pg', schema: authSchema })`
|
||||
- **Client**: `src/server/auth/client.ts` — `createAuthClient()` for React
|
||||
- **Server function**: `src/server/auth/functions.ts` — `getSession()` via `createServerFn`
|
||||
- **Auth tables**: Use `text` IDs (Better Auth manages its own IDs), NOT project's UUID v7
|
||||
- **Schema key naming**: Export names must match Better Auth model names exactly (`user`, `session`, `account`, `verification`)
|
||||
|
||||
### Auth Middleware (ORPC)
|
||||
```typescript
|
||||
const sessionData = await auth.api.getSession({ headers: context.headers })
|
||||
// Injects context.user and context.session
|
||||
```
|
||||
|
||||
## Code Style
|
||||
@@ -202,16 +349,9 @@ Biome auto-organizes:
|
||||
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`
|
||||
- `noUncheckedIndexedAccess: true` — array access returns `T | undefined`
|
||||
- Use `@/*` path aliases (maps to `src/*`)
|
||||
|
||||
### Naming
|
||||
@@ -222,37 +362,20 @@ import type { ReactNode } from 'react'
|
||||
| Components | PascalCase arrow | `const Button = () => {}` |
|
||||
| Functions | camelCase | `getUserById` |
|
||||
| Types | PascalCase | `UserProfile` |
|
||||
| Drizzle tables | camelCase, no suffix | `user`, `category` (NOT `userTable`) |
|
||||
|
||||
### React
|
||||
- Use arrow functions for components (Biome enforced)
|
||||
- Use `useSuspenseQuery` for guaranteed data
|
||||
- Let React Compiler handle memoization (no manual `useMemo`/`useCallback`)
|
||||
|
||||
## 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.
|
||||
1. **No backward compatibility** — Always use the latest API and patterns.
|
||||
2. **Always sync documentation** — When code changes, immediately update all related documentation.
|
||||
3. **Forward-only migration** — When upgrading dependencies, fully adopt the new API.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
@@ -263,17 +386,23 @@ export const env = createEnv({
|
||||
- Use `ORPCError` with proper codes
|
||||
- Use `drizzle-orm/zod` (NOT `drizzle-zod`) for schema validation
|
||||
- Use RQBv2 object syntax for `orderBy` and `where`
|
||||
- Use `render` prop (NOT `asChild`) for base-ui component delegation
|
||||
- Use Dialog modals for forms (NOT inline forms)
|
||||
- Use `toast` from `sonner` for CRUD notifications
|
||||
- Update `AGENTS.md` and other docs whenever code patterns change
|
||||
|
||||
**DON'T:**
|
||||
- Use `npm`, `npx`, `node`, `yarn`, `pnpm` — always use `bun` / `bunx`
|
||||
- Edit `src/routeTree.gen.ts` (auto-generated)
|
||||
- Manually edit `src/components/ui/*.tsx` (use `bunx shadcn@latest add`)
|
||||
- Use `as any`, `@ts-ignore`, `@ts-expect-error`
|
||||
- Use `asChild` prop (use `render` prop instead — base-ui, NOT Radix)
|
||||
- Commit `.env` files
|
||||
- Use empty catch blocks
|
||||
- Import from `drizzle-zod` (use `drizzle-orm/zod` instead)
|
||||
- Use RQBv1 callback-style `orderBy` / old `relations()` API
|
||||
- Use `drizzle-orm/bun-sql` driver (use `drizzle-orm/postgres-js`)
|
||||
- Pass `schema` to `drizzle()` constructor (only `relations` is needed in RQBv2)
|
||||
- Import `os` from `@orpc/server` in middleware — use `@/server/api/server` (the local typed instance)
|
||||
- Use `getDB()` factory pattern (use module-level `db` export instead)
|
||||
- Add `Table` suffix to Drizzle table exports
|
||||
- Leave docs out of sync with code changes
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -23,6 +23,10 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "catalog:",
|
||||
"@dnd-kit/dom": "catalog:",
|
||||
"@dnd-kit/react": "catalog:",
|
||||
"@fontsource-variable/geist": "catalog:",
|
||||
"@orpc/client": "catalog:",
|
||||
"@orpc/contract": "catalog:",
|
||||
"@orpc/openapi": "catalog:",
|
||||
@@ -34,14 +38,18 @@
|
||||
"@tanstack/react-router": "catalog:",
|
||||
"@tanstack/react-router-ssr-query": "catalog:",
|
||||
"@tanstack/react-start": "catalog:",
|
||||
"better-auth": "catalog:",
|
||||
"class-variance-authority": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"next-themes": "catalog:",
|
||||
"postgres": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"@dnd-kit/dom": "catalog:",
|
||||
"@dnd-kit/react": "catalog:",
|
||||
"better-auth": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"sonner": "catalog:",
|
||||
"tailwind-merge": "catalog:",
|
||||
"tw-animate-css": "catalog:",
|
||||
"uuid": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
@@ -55,7 +63,7 @@
|
||||
"@tanstack/react-router-devtools": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"babel-plugin-react-compiler": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
"nitro": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Link, useRouter, useRouterState } from '@tanstack/react-router'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import * as LucideIcons from 'lucide-react'
|
||||
import { Circle, Home, LayoutDashboard, LogOut } from 'lucide-react'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} 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 AdminSidebar = () => {
|
||||
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>
|
||||
<SidebarHeader className="px-4 py-6">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Kairos</h2>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>管理</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton render={<Link to={'/admin' as never} />} isActive={currentPath === '/admin'}>
|
||||
<LayoutDashboard />
|
||||
<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.adminRoute as never} />}
|
||||
isActive={currentPath.startsWith(mod.adminRoute)}
|
||||
>
|
||||
<Icon />
|
||||
<span>{mod.name}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton render={<Link to={'/' as never} />}>
|
||||
<Home />
|
||||
<span>返回首页</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton onClick={handleSignOut}>
|
||||
<LogOut />
|
||||
<span>退出登录</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
@@ -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,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,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,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,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,672 @@
|
||||
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.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[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,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,38 @@
|
||||
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 bg-white px-4 py-3.5',
|
||||
'ring-1 ring-stone-100 shadow-sm',
|
||||
'transition-all duration-200 ease-out',
|
||||
'hover:-translate-y-0.5 hover:shadow-lg hover:ring-stone-200',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-stone-50/80 transition-colors group-hover:bg-stone-100">
|
||||
<Icon className="h-[18px] w-[18px] text-stone-500 transition-colors group-hover:text-stone-800" />
|
||||
</div>
|
||||
<span className="min-w-0 truncate text-sm font-medium text-stone-600 transition-colors group-hover:text-stone-900">
|
||||
{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,196 @@
|
||||
import { DragDropProvider } from '@dnd-kit/react'
|
||||
import { useSortable } from '@dnd-kit/react/sortable'
|
||||
import { useMutation } 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 reorderBookmarks = useMutation(orpc.bookmarks.bookmark.reorder.mutationOptions())
|
||||
|
||||
useEffect(() => {
|
||||
if (!reorderBookmarks.isPending) {
|
||||
setItems(category.bookmarks)
|
||||
}
|
||||
}, [category.bookmarks, reorderBookmarks.isPending])
|
||||
|
||||
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 sourceIndex = items.findIndex((item) => item.id === sourceId)
|
||||
const targetIndex = items.findIndex((item) => item.id === targetId)
|
||||
if (sourceIndex === -1 || targetIndex === -1) {
|
||||
return
|
||||
}
|
||||
|
||||
const reordered = [...items]
|
||||
const [moved] = reordered.splice(sourceIndex, 1)
|
||||
if (!moved) {
|
||||
return
|
||||
}
|
||||
|
||||
reordered.splice(targetIndex, 0, moved)
|
||||
setItems(reordered)
|
||||
|
||||
reorderBookmarks.mutate(
|
||||
reordered.map((item, index) => ({ id: item.id, orderId: index })),
|
||||
{
|
||||
onSuccess: () => toast.success('书签顺序已更新'),
|
||||
onError: () => 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,63 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
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) => {
|
||||
if (categories.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-32 text-center">
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-stone-50 ring-1 ring-stone-100">
|
||||
<span className="text-2xl">✨</span>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-medium text-stone-800">还没有任何书签</h3>
|
||||
<p className="mb-6 text-sm text-stone-400">前往管理后台添加你的第一个书签,打造你的数字主页。</p>
|
||||
<Link
|
||||
to={'/admin/bookmarks' as never}
|
||||
className="rounded-lg bg-stone-800 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-stone-700"
|
||||
>
|
||||
前往管理后台
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 tracking-wide text-stone-400 uppercase">{category.name}</h2>
|
||||
|
||||
{category.bookmarks.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-stone-200 py-6 text-center">
|
||||
<span className="text-sm text-stone-400">暂无书签</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,196 @@
|
||||
import { DragDropProvider } from '@dnd-kit/react'
|
||||
import { useSortable } from '@dnd-kit/react/sortable'
|
||||
import { useMutation } 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 reorderCategories = useMutation(orpc.bookmarks.category.reorder.mutationOptions())
|
||||
|
||||
useEffect(() => {
|
||||
if (!reorderCategories.isPending) {
|
||||
setItems(categories)
|
||||
}
|
||||
}, [categories, reorderCategories.isPending])
|
||||
|
||||
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 sourceIndex = items.findIndex((item) => item.id === sourceId)
|
||||
const targetIndex = items.findIndex((item) => item.id === targetId)
|
||||
if (sourceIndex === -1 || targetIndex === -1) {
|
||||
return
|
||||
}
|
||||
|
||||
const reordered = [...items]
|
||||
const [moved] = reordered.splice(sourceIndex, 1)
|
||||
if (!moved) {
|
||||
return
|
||||
}
|
||||
|
||||
reordered.splice(targetIndex, 0, moved)
|
||||
setItems(reordered)
|
||||
|
||||
reorderCategories.mutate(
|
||||
reordered.map((item, index) => ({ id: item.id, orderId: index })),
|
||||
{
|
||||
onSuccess: () => toast.success('分类顺序已更新'),
|
||||
onError: () => 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,135 @@
|
||||
import * as icons from 'lucide-react'
|
||||
import { Search } 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 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 IconPickerDialogProps {
|
||||
value: string | null
|
||||
onChange: (iconName: string) => void
|
||||
}
|
||||
|
||||
export const IconPickerDialog = ({ value, onChange }: IconPickerDialogProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
const filteredIcons = ICON_NAMES.filter((name) => name.toLowerCase().includes(filter.toLowerCase()))
|
||||
const CurrentIcon = (value && allIcons[value]) || null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger>
|
||||
<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>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择图标</DialogTitle>
|
||||
<DialogDescription>搜索并选择一个 lucide 图标作为书签图标</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="搜索图标..." />
|
||||
|
||||
<div className="grid max-h-80 grid-cols-8 gap-2 overflow-y-auto rounded-lg border p-2">
|
||||
{filteredIcons.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>
|
||||
|
||||
{filteredIcons.length === 0 && <p className="text-center text-sm text-muted-foreground">未找到匹配图标</p>}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,6 @@ export const bookmarksModule: ModuleMetadata = {
|
||||
name: '书签导航',
|
||||
description: '常用链接和网站的快速导航',
|
||||
icon: 'Compass',
|
||||
route: '/bookmarks',
|
||||
adminRoute: '/admin/bookmarks',
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface ModuleMetadata {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
route: string
|
||||
adminRoute: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,11 @@ import { Route as ProtectedRouteImport } from './routes/_protected'
|
||||
import { Route as ProtectedIndexRouteImport } from './routes/_protected/index'
|
||||
import { Route as ApiHealthRouteImport } from './routes/api/health'
|
||||
import { Route as ApiSplatRouteImport } from './routes/api/$'
|
||||
import { Route as ProtectedBookmarksIndexRouteImport } from './routes/_protected/bookmarks/index'
|
||||
import { Route as ProtectedAdminRouteImport } from './routes/_protected/admin'
|
||||
import { Route as ProtectedAdminIndexRouteImport } from './routes/_protected/admin/index'
|
||||
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$'
|
||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
|
||||
import { Route as ProtectedAdminBookmarksRouteImport } from './routes/_protected/admin/bookmarks'
|
||||
|
||||
const SignupRoute = SignupRouteImport.update({
|
||||
id: '/signup',
|
||||
@@ -48,11 +50,16 @@ const ApiSplatRoute = ApiSplatRouteImport.update({
|
||||
path: '/api/$',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ProtectedBookmarksIndexRoute = ProtectedBookmarksIndexRouteImport.update({
|
||||
id: '/bookmarks/',
|
||||
path: '/bookmarks/',
|
||||
const ProtectedAdminRoute = ProtectedAdminRouteImport.update({
|
||||
id: '/admin',
|
||||
path: '/admin',
|
||||
getParentRoute: () => ProtectedRoute,
|
||||
} as any)
|
||||
const ProtectedAdminIndexRoute = ProtectedAdminIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => ProtectedAdminRoute,
|
||||
} as any)
|
||||
const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
|
||||
id: '/api/rpc/$',
|
||||
path: '/api/rpc/$',
|
||||
@@ -63,16 +70,23 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
||||
path: '/api/auth/$',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ProtectedAdminBookmarksRoute = ProtectedAdminBookmarksRouteImport.update({
|
||||
id: '/bookmarks',
|
||||
path: '/bookmarks',
|
||||
getParentRoute: () => ProtectedAdminRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof ProtectedIndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/admin': typeof ProtectedAdminRouteWithChildren
|
||||
'/api/$': typeof ApiSplatRoute
|
||||
'/api/health': typeof ApiHealthRoute
|
||||
'/admin/bookmarks': typeof ProtectedAdminBookmarksRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||
'/bookmarks/': typeof ProtectedBookmarksIndexRoute
|
||||
'/admin/': typeof ProtectedAdminIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/login': typeof LoginRoute
|
||||
@@ -80,21 +94,24 @@ export interface FileRoutesByTo {
|
||||
'/api/$': typeof ApiSplatRoute
|
||||
'/api/health': typeof ApiHealthRoute
|
||||
'/': typeof ProtectedIndexRoute
|
||||
'/admin/bookmarks': typeof ProtectedAdminBookmarksRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||
'/bookmarks': typeof ProtectedBookmarksIndexRoute
|
||||
'/admin': typeof ProtectedAdminIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/_protected': typeof ProtectedRouteWithChildren
|
||||
'/login': typeof LoginRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/_protected/admin': typeof ProtectedAdminRouteWithChildren
|
||||
'/api/$': typeof ApiSplatRoute
|
||||
'/api/health': typeof ApiHealthRoute
|
||||
'/_protected/': typeof ProtectedIndexRoute
|
||||
'/_protected/admin/bookmarks': typeof ProtectedAdminBookmarksRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||
'/_protected/bookmarks/': typeof ProtectedBookmarksIndexRoute
|
||||
'/_protected/admin/': typeof ProtectedAdminIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
@@ -102,11 +119,13 @@ export interface FileRouteTypes {
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/signup'
|
||||
| '/admin'
|
||||
| '/api/$'
|
||||
| '/api/health'
|
||||
| '/admin/bookmarks'
|
||||
| '/api/auth/$'
|
||||
| '/api/rpc/$'
|
||||
| '/bookmarks/'
|
||||
| '/admin/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/login'
|
||||
@@ -114,20 +133,23 @@ export interface FileRouteTypes {
|
||||
| '/api/$'
|
||||
| '/api/health'
|
||||
| '/'
|
||||
| '/admin/bookmarks'
|
||||
| '/api/auth/$'
|
||||
| '/api/rpc/$'
|
||||
| '/bookmarks'
|
||||
| '/admin'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/_protected'
|
||||
| '/login'
|
||||
| '/signup'
|
||||
| '/_protected/admin'
|
||||
| '/api/$'
|
||||
| '/api/health'
|
||||
| '/_protected/'
|
||||
| '/_protected/admin/bookmarks'
|
||||
| '/api/auth/$'
|
||||
| '/api/rpc/$'
|
||||
| '/_protected/bookmarks/'
|
||||
| '/_protected/admin/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -184,13 +206,20 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiSplatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_protected/bookmarks/': {
|
||||
id: '/_protected/bookmarks/'
|
||||
path: '/bookmarks'
|
||||
fullPath: '/bookmarks/'
|
||||
preLoaderRoute: typeof ProtectedBookmarksIndexRouteImport
|
||||
'/_protected/admin': {
|
||||
id: '/_protected/admin'
|
||||
path: '/admin'
|
||||
fullPath: '/admin'
|
||||
preLoaderRoute: typeof ProtectedAdminRouteImport
|
||||
parentRoute: typeof ProtectedRoute
|
||||
}
|
||||
'/_protected/admin/': {
|
||||
id: '/_protected/admin/'
|
||||
path: '/'
|
||||
fullPath: '/admin/'
|
||||
preLoaderRoute: typeof ProtectedAdminIndexRouteImport
|
||||
parentRoute: typeof ProtectedAdminRoute
|
||||
}
|
||||
'/api/rpc/$': {
|
||||
id: '/api/rpc/$'
|
||||
path: '/api/rpc/$'
|
||||
@@ -205,17 +234,38 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiAuthSplatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_protected/admin/bookmarks': {
|
||||
id: '/_protected/admin/bookmarks'
|
||||
path: '/bookmarks'
|
||||
fullPath: '/admin/bookmarks'
|
||||
preLoaderRoute: typeof ProtectedAdminBookmarksRouteImport
|
||||
parentRoute: typeof ProtectedAdminRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ProtectedAdminRouteChildren {
|
||||
ProtectedAdminBookmarksRoute: typeof ProtectedAdminBookmarksRoute
|
||||
ProtectedAdminIndexRoute: typeof ProtectedAdminIndexRoute
|
||||
}
|
||||
|
||||
const ProtectedAdminRouteChildren: ProtectedAdminRouteChildren = {
|
||||
ProtectedAdminBookmarksRoute: ProtectedAdminBookmarksRoute,
|
||||
ProtectedAdminIndexRoute: ProtectedAdminIndexRoute,
|
||||
}
|
||||
|
||||
const ProtectedAdminRouteWithChildren = ProtectedAdminRoute._addFileChildren(
|
||||
ProtectedAdminRouteChildren,
|
||||
)
|
||||
|
||||
interface ProtectedRouteChildren {
|
||||
ProtectedAdminRoute: typeof ProtectedAdminRouteWithChildren
|
||||
ProtectedIndexRoute: typeof ProtectedIndexRoute
|
||||
ProtectedBookmarksIndexRoute: typeof ProtectedBookmarksIndexRoute
|
||||
}
|
||||
|
||||
const ProtectedRouteChildren: ProtectedRouteChildren = {
|
||||
ProtectedAdminRoute: ProtectedAdminRouteWithChildren,
|
||||
ProtectedIndexRoute: ProtectedIndexRoute,
|
||||
ProtectedBookmarksIndexRoute: ProtectedBookmarksIndexRoute,
|
||||
}
|
||||
|
||||
const ProtectedRouteWithChildren = ProtectedRoute._addFileChildren(
|
||||
|
||||
@@ -6,6 +6,8 @@ import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
||||
import type { ReactNode } from 'react'
|
||||
import { ErrorComponent } from '@/components/Error'
|
||||
import { NotFoundComponent } from '@/components/NotFound'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import appCss from '@/styles.css?url'
|
||||
|
||||
export interface RouterContext {
|
||||
@@ -45,7 +47,8 @@ function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
<Toaster richColors position="top-right" />
|
||||
{import.meta.env.DEV && (
|
||||
<TanStackDevtools
|
||||
config={{
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||
import { AdminSidebar } from '@/components/AdminSidebar'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
|
||||
|
||||
export const Route = createFileRoute('/_protected/admin' as never)({
|
||||
component: AdminLayout,
|
||||
})
|
||||
|
||||
function AdminLayout() {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AdminSidebar />
|
||||
<SidebarInset>
|
||||
<header className="flex h-14 items-center gap-2 border-b px-6">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
</header>
|
||||
<main className="flex-1 p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { orpc } from '@/client/orpc'
|
||||
import { BookmarkManager } from '@/modules/bookmarks/components/BookmarkManager'
|
||||
import { CategoryManager } from '@/modules/bookmarks/components/CategoryManager'
|
||||
|
||||
export const Route = createFileRoute('/_protected/admin/bookmarks' as never)({
|
||||
loader: async ({ context }: { context: { queryClient: QueryClient } }) => {
|
||||
await context.queryClient.ensureQueryData(orpc.bookmarks.category.list.queryOptions())
|
||||
},
|
||||
component: BookmarksAdmin,
|
||||
})
|
||||
|
||||
function BookmarksAdmin() {
|
||||
const { data } = useSuspenseQuery(orpc.bookmarks.category.list.queryOptions())
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCategoryId === null && data.length > 0) {
|
||||
setSelectedCategoryId(data[0]?.id ?? null)
|
||||
}
|
||||
}, [data, selectedCategoryId])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCategoryId && !data.some((category: { id: string }) => category.id === selectedCategoryId)) {
|
||||
setSelectedCategoryId(data[0]?.id ?? null)
|
||||
}
|
||||
}, [data, selectedCategoryId])
|
||||
|
||||
const selectedCategory = data.find((category: { id: string }) => category.id === selectedCategoryId)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">书签管理</h1>
|
||||
<p className="text-muted-foreground">管理你的书签分类、图标和排序</p>
|
||||
</div>
|
||||
|
||||
<div className="flex h-[calc(100vh-8rem)] gap-6">
|
||||
<div className="w-80 shrink-0">
|
||||
<CategoryManager
|
||||
categories={data}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import * as LucideIcons from 'lucide-react'
|
||||
import { Circle } from 'lucide-react'
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { modules } from '@/modules/registry'
|
||||
|
||||
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 Route = createFileRoute('/_protected/admin/' as never)({
|
||||
component: AdminOverview,
|
||||
})
|
||||
|
||||
function AdminOverview() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">管理总览</h1>
|
||||
<p className="text-muted-foreground">配置和管理你的 Kairos 模块</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{modules
|
||||
.filter((mod) => mod.enabled)
|
||||
.map((mod) => {
|
||||
const Icon = resolveIcon(mod.icon)
|
||||
return (
|
||||
<Link key={mod.id} to={mod.adminRoute as never} className="block">
|
||||
<Card className="h-full transition-colors hover:bg-muted/50">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-5 w-5" />
|
||||
<CardTitle className="text-lg">{mod.name}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{mod.description}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,75 +1,43 @@
|
||||
import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
|
||||
import * as icons from 'lucide-react'
|
||||
import { modules } from '@/modules/registry'
|
||||
import { authClient } from '@/server/auth/client'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { Settings } from 'lucide-react'
|
||||
import { orpc } from '@/client/orpc'
|
||||
import { CategoryGrid } from '@/modules/bookmarks/components/CategoryGrid'
|
||||
import { GreetingHeader } from '@/modules/bookmarks/components/GreetingHeader'
|
||||
import { SearchBar } from '@/modules/bookmarks/components/SearchBar'
|
||||
|
||||
const iconComponents = icons as unknown as Record<string, typeof icons.Box>
|
||||
|
||||
export const Route = createFileRoute('/_protected' as never)({
|
||||
export const Route = createFileRoute('/_protected/' as never)({
|
||||
loader: async ({ context }: { context: { queryClient: QueryClient } }) => {
|
||||
await context.queryClient.ensureQueryData(orpc.bookmarks.category.list.queryOptions())
|
||||
},
|
||||
component: DashboardPage,
|
||||
})
|
||||
|
||||
function DashboardPage() {
|
||||
const router = useRouter()
|
||||
const { user } = Route.useRouteContext() as {
|
||||
user: {
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
}
|
||||
const enabledModules = modules.filter((mod) => mod.enabled)
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await authClient.signOut()
|
||||
router.navigate({ to: '/login' as never })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 px-4 py-12 sm:px-6">
|
||||
<div className="mx-auto max-w-4xl space-y-8">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">Kairos</h1>
|
||||
<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 ? (
|
||||
<div className="py-20 text-center text-slate-400">暂无可用模块</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{enabledModules.map((mod) => {
|
||||
const IconComponent = iconComponents[mod.icon] ?? icons.Box
|
||||
const { data: categories } = useSuspenseQuery(orpc.bookmarks.category.list.queryOptions())
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50/50 px-4 py-12 font-sans sm:px-6">
|
||||
<div className="mx-auto max-w-5xl space-y-12">
|
||||
<div className="flex items-center justify-between">
|
||||
<GreetingHeader />
|
||||
<Link
|
||||
key={mod.id}
|
||||
to={mod.route as never}
|
||||
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"
|
||||
to={'/admin' as never}
|
||||
className="rounded-full p-2.5 text-stone-400 transition-colors hover:bg-stone-100 hover:text-stone-600"
|
||||
title="管理后台"
|
||||
>
|
||||
<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">
|
||||
<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>
|
||||
<Settings className="h-5 w-5" />
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-auto max-w-2xl py-4">
|
||||
<SearchBar />
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<CategoryGrid categories={categories} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1 +1,130 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
"name": "@furtherverse/server",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "catalog:",
|
||||
"@dnd-kit/dom": "catalog:",
|
||||
"@dnd-kit/react": "catalog:",
|
||||
"@fontsource-variable/geist": "catalog:",
|
||||
"@orpc/client": "catalog:",
|
||||
"@orpc/contract": "catalog:",
|
||||
"@orpc/openapi": "catalog:",
|
||||
@@ -28,11 +30,17 @@
|
||||
"@tanstack/react-router-ssr-query": "catalog:",
|
||||
"@tanstack/react-start": "catalog:",
|
||||
"better-auth": "catalog:",
|
||||
"class-variance-authority": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"next-themes": "catalog:",
|
||||
"postgres": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"sonner": "catalog:",
|
||||
"tailwind-merge": "catalog:",
|
||||
"tw-animate-css": "catalog:",
|
||||
"uuid": "catalog:",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -46,7 +54,7 @@
|
||||
"@tanstack/react-router-devtools": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"babel-plugin-react-compiler": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
"nitro": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
@@ -59,8 +67,10 @@
|
||||
},
|
||||
},
|
||||
"catalog": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@dnd-kit/dom": "^0.3.2",
|
||||
"@dnd-kit/react": "^0.3.2",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@orpc/client": "^1.13.11",
|
||||
"@orpc/contract": "^1.13.11",
|
||||
"@orpc/openapi": "^1.13.11",
|
||||
@@ -80,15 +90,22 @@
|
||||
"@tanstack/react-start": "^1.167.6",
|
||||
"@types/bun": "^1.3.11",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"better-auth": "^1.2.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-kit": "1.0.0-beta.15-859cf75",
|
||||
"drizzle-orm": "1.0.0-beta.15-859cf75",
|
||||
"lucide-react": "^0.513.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"nitro": "npm:nitro-nightly@3.0.1-20260324-103046-9ce219ca",
|
||||
"postgres": "^3.4.8",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"uuid": "^13.0.0",
|
||||
"vite": "^8.0.2",
|
||||
"zod": "^4.3.6",
|
||||
@@ -160,12 +177,18 @@
|
||||
|
||||
"@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@base-ui/react": ["@base-ui/react@1.3.0", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@base-ui/utils": "0.2.6", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "tabbable": "^6.4.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA=="],
|
||||
|
||||
"@base-ui/utils": ["@base-ui/utils@0.2.6", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw=="],
|
||||
|
||||
"@better-auth/core": ["@better-auth/core@1.5.6", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-Ez9DZdIMFyxHremmoLz1emFPGNQomDC1jqqBPnZ6Ci+6TiGN3R9w/Y03cJn6I8r1ycKgOzeVMZtJ/erOZ27Gsw=="],
|
||||
|
||||
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.5.6", "", { "peerDependencies": { "@better-auth/core": "1.5.6", "@better-auth/utils": "^0.3.0", "drizzle-orm": ">=0.41.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-VfFFmaoFw3ug12SiSuIwzrMoHyIVmkMGWm9gZ4sXdYYVX4HboCL4m3fjzOhppcmK5OGatRuU+N1UX6wxCITcXw=="],
|
||||
@@ -274,6 +297,16 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
|
||||
|
||||
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
||||
|
||||
"@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.8", "", {}, "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="],
|
||||
|
||||
"@furtherverse/server": ["@furtherverse/server@workspace:apps/server"],
|
||||
|
||||
"@furtherverse/tsconfig": ["@furtherverse/tsconfig@workspace:packages/tsconfig"],
|
||||
@@ -576,6 +609,8 @@
|
||||
|
||||
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
||||
@@ -622,6 +657,8 @@
|
||||
|
||||
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||
|
||||
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@1.0.0-beta.15-859cf75", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-Y36s1XQGVb1PgU3aRNgufp1K3D2VkIifu8kv4Ubsmxi+Dq+N7KMklnpp7Knu/XC4FZi2MHPPG3v3o097r0/TcQ=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@1.0.0-beta.15-859cf75", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-dGVb2Q70H2AV6513hkOXR3Ud0FeGXLdugVq3YehoqkGIVTJrkuo0gRnCcW/dfI00O07t3T4HSh4clF/D/o/IsQ=="],
|
||||
@@ -772,7 +809,7 @@
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.513.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-CJZKq2g8Y8yN4Aq002GahSXbG2JpFv9kXwyiOAMvUBv7pxeOFHUWKB0mO7MiY4ZVFCV4aNjv2BJFq/z3DgKPQg=="],
|
||||
"lucide-react": ["lucide-react@1.7.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
@@ -786,6 +823,8 @@
|
||||
|
||||
"native-duplexpair": ["native-duplexpair@1.0.0", "", {}, "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA=="],
|
||||
|
||||
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
|
||||
|
||||
"nf3": ["nf3@0.3.14", "", {}, "sha512-MjG9u/IlvSq5txxY0oug1sjrGZ2l37IuhExI1iPuwV4S3RcyRNGoy6xLwznH3ATK6PUAM4fbQVb4Rzy1L1nlzw=="],
|
||||
|
||||
"nitro": ["nitro-nightly@3.0.1-20260324-103046-9ce219ca", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.4", "db0": "^0.3.4", "env-runner": "^0.1.6", "h3": "^2.0.1-rc.19", "hookable": "^6.1.0", "nf3": "^0.3.13", "ocache": "^0.1.4", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.0.0-rc.11", "srvx": "^0.11.13", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.7" }, "peerDependencies": { "dotenv": "*", "giget": "*", "jiti": "^2.6.1", "rollup": "^4.60.0", "vite": "^7 || ^8", "xml2js": "^0.6.2", "zephyr-agent": "^0.1.15" }, "optionalPeers": ["dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-N9ZennO7ZNNZT0/mBwfA288zGC5l+fp2MVu8QPjCwpyNSS+yvZacQrgVzWJsrl2UAAPVRgTroKbAo+m1Fds/sw=="],
|
||||
@@ -838,6 +877,8 @@
|
||||
|
||||
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
|
||||
|
||||
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||
@@ -866,6 +907,8 @@
|
||||
|
||||
"solid-js": ["solid-js@1.9.12", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw=="],
|
||||
|
||||
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
|
||||
|
||||
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
@@ -876,8 +919,12 @@
|
||||
|
||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="],
|
||||
|
||||
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
|
||||
@@ -898,6 +945,8 @@
|
||||
|
||||
"turbo": ["turbo@2.8.21", "", { "optionalDependencies": { "@turbo/darwin-64": "2.8.21", "@turbo/darwin-arm64": "2.8.21", "@turbo/linux-64": "2.8.21", "@turbo/linux-arm64": "2.8.21", "@turbo/windows-64": "2.8.21", "@turbo/windows-arm64": "2.8.21" }, "bin": { "turbo": "bin/turbo" } }, "sha512-FlJ8OD5Qcp0jTAM7E4a/RhUzRNds2GzKlyxHKA6N247VLy628rrxAGlMpIXSz6VB430+TiQDJ/SMl6PL1lu6wQ=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"type-fest": ["type-fest@5.5.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g=="],
|
||||
|
||||
"typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
|
||||
|
||||
+11
-2
@@ -52,9 +52,18 @@
|
||||
"@dnd-kit/dom": "^0.3.2",
|
||||
"@dnd-kit/react": "^0.3.2",
|
||||
"better-auth": "^1.2.8",
|
||||
"lucide-react": "^0.513.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"uuid": "^13.0.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user