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:
2026-03-30 22:54:01 +08:00
parent 430c0b0c64
commit ba8224e81e
42 changed files with 3261 additions and 781 deletions
+220 -91
View File
@@ -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
├── routes/ # TanStack Router file routes
│ ├── __root.tsx # Root layout
│ ├── index.tsx # Home page
├── client/ # Client-side code
│ └── orpc.ts # ORPC client + TanStack Query utils
├── components/ # Shared React components
│ ├── AdminSidebar.tsx # Admin panel sidebar navigation
│ ├── Error.tsx # Error boundary fallback
│ ├── NotFound.tsx # 404 fallback
│ └── ui/ # shadcn/ui components (DO NOT manually edit)
│ ├── button.tsx
│ ├── card.tsx
│ ├── dialog.tsx
│ ├── dropdown-menu.tsx
│ ├── input.tsx
│ ├── separator.tsx
│ ├── sheet.tsx
│ ├── sidebar.tsx
│ ├── skeleton.tsx
│ ├── sonner.tsx
│ ├── tooltip.tsx
│ ├── avatar.tsx
│ └── badge.tsx
├── hooks/ # Custom React hooks
│ └── use-mobile.ts # Mobile detection (shadcn sidebar dep)
├── lib/ # Utility functions
│ └── utils.ts # cn() utility (clsx + tailwind-merge)
├── modules/ # Feature modules
│ ├── registry.ts # Module metadata registry
│ └── bookmarks/ # Bookmarks module
│ ├── index.ts # Module metadata
│ ├── schema.ts # Drizzle tables (category, bookmark)
│ ├── contract.ts # ORPC contracts
│ ├── router.ts # ORPC handlers
│ └── components/ # Module UI
│ ├── BookmarkCard.tsx # Display: clean card (icon + name)
│ ├── CategoryGrid.tsx # Display: category grid for homepage
│ ├── BookmarkFormDialog.tsx # Admin: create/edit bookmark dialog
│ ├── BookmarkManager.tsx # Admin: bookmark list with DnD
│ ├── CategoryFormDialog.tsx # Admin: create/edit category dialog
│ ├── CategoryManager.tsx # Admin: category list with DnD
│ ├── IconPickerDialog.tsx # Admin: icon picker in dialog
│ ├── GreetingHeader.tsx # Display: time-based greeting
│ └── SearchBar.tsx # Display: multi-engine search
├── routes/ # TanStack Router file routes
│ ├── __root.tsx # Root layout (HTML shell, Toaster, TooltipProvider)
│ ├── _protected.tsx # Auth guard layout (redirect to /login)
│ ├── _protected/
│ │ ├── index.tsx # Dashboard homepage (bookmark display)
│ │ ├── admin.tsx # Admin layout (SidebarProvider + Outlet)
│ │ └── admin/
│ │ ├── index.tsx # Admin overview (module cards)
│ │ └── bookmarks.tsx # Bookmark management page
│ ├── login.tsx # Login page
│ ├── signup.tsx # Signup page
│ └── api/
│ ├── $.ts # OpenAPI handler + Scalar docs
│ ├── 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)
│ │ ├── routers/ # Handler implementations
│ │ ├── interceptors.ts # Shared error interceptors
│ │ ├── context.ts # Request context
│ │ ├── server.ts # ORPC server instance
│ │ ── types.ts # Type exports
│ ├── $.ts # OpenAPI handler + Scalar docs
│ ├── auth.$.ts # Better Auth handler
── health.ts # Health check endpoint
│ └── rpc.$.ts # ORPC RPC handler
├── server/ # Server-side code
│ ├── api/ # ORPC layer
│ │ ├── contracts/ # Input/output schemas (Zod)
│ │ ├── middlewares/ # Middleware (dbMiddleware, authMiddleware)
│ │ ├── routers/ # Handler implementations
│ │ ├── interceptors.ts # Shared error interceptors
│ │ ├── context.ts # Request context types
│ │ ── server.ts # ORPC server instance
│ │ └── types.ts # Type exports
│ ├── auth/ # Better Auth
│ │ ├── schema.ts # Auth tables (user, session, account, verification)
│ │ ├── index.ts # Auth instance (betterAuth + drizzleAdapter)
│ │ ├── client.ts # Auth client (createAuthClient)
│ │ └── functions.ts # Server functions (getSession)
│ └── db/
│ ├── schema/ # Drizzle table definitions
│ ├── fields.ts # Shared field builders (id, createdAt, updatedAt)
│ ├── relations.ts # Drizzle relations (defineRelations, RQBv2)
│ └── index.ts # Database instance (postgres-js driver)
├── env.ts # Environment variable validation
├── router.tsx # Router configuration
├── routeTree.gen.ts # Auto-generated (DO NOT EDIT)
└── styles.css # Tailwind entry
│ ├── schema/ # Drizzle table re-exports
│ ├── fields.ts # Shared field builders (id, createdAt, updatedAt)
│ ├── relations.ts # Drizzle relations (defineRelations, RQBv2)
│ └── index.ts # Database singleton (module-level export)
├── env.ts # Environment variable validation
├── router.tsx # Router configuration
├── routeTree.gen.ts # Auto-generated (DO NOT EDIT)
└── styles.css # Tailwind + shadcn CSS variables
```
## ORPC Pattern
@@ -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({
orderBy: { createdAt: 'desc' },
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
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+13 -5
View File
@@ -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>
)
}
+93
View File
@@ -0,0 +1,93 @@
'use client'
import { Avatar as AvatarPrimitive } from '@base-ui/react/avatar'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Avatar({
className,
size = 'default',
...props
}: AvatarPrimitive.Root.Props & {
size?: 'default' | 'sm' | 'lg'
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
'group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten',
className,
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn('aspect-square size-full rounded-full object-cover', className)}
{...props}
/>
)
}
function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
'flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs',
className,
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="avatar-badge"
className={cn(
'absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none',
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
className,
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="avatar-group"
className={cn(
'group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background',
className,
)}
{...props}
/>
)
}
function AvatarGroupCount({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="avatar-group-count"
className={cn(
'relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3',
className,
)}
{...props}
/>
)
}
export { Avatar, AvatarBadge, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage }
+49
View File
@@ -0,0 +1,49 @@
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
destructive:
'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20',
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
link: 'text-primary underline-offset-4 hover:underline',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function Badge({
className,
variant = 'default',
render,
...props
}: useRender.ComponentProps<'span'> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: 'span',
props: mergeProps<'span'>(
{
className: cn(badgeVariants({ variant }), className),
},
props,
),
render,
state: {
slot: 'badge',
variant,
},
})
}
export { Badge, badgeVariants }
+50
View File
@@ -0,0 +1,50 @@
import { Button as ButtonPrimitive } from '@base-ui/react/button'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
outline:
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost:
'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
destructive:
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
icon: 'size-8',
'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Button({
className,
variant = 'default',
size = 'default',
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return <ButtonPrimitive data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />
}
export { Button, buttonVariants }
+70
View File
@@ -0,0 +1,70 @@
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Card({ className, size = 'default', ...props }: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
'group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
className,
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3',
className,
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm', className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-description" className={cn('text-sm text-muted-foreground', className)} {...props} />
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-4 group-data-[size=sm]/card:px-3', className)} {...props} />
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3', className)}
{...props}
/>
)
}
export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
+131
View File
@@ -0,0 +1,131 @@
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
import { XIcon } from 'lucide-react'
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
'fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
className,
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
'fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />}
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="dialog-header" className={cn('flex flex-col gap-2', className)} {...props} />
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<'div'> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
'-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end',
className,
)}
{...props}
>
{children}
{showCloseButton && <DialogPrimitive.Close render={<Button variant="outline" />}>Close</DialogPrimitive.Close>}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('font-heading text-base leading-none font-medium', className)}
{...props}
/>
)
}
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
'text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
className,
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -0,0 +1,251 @@
import { Menu as MenuPrimitive } from '@base-ui/react/menu'
import { CheckIcon, ChevronRightIcon } from 'lucide-react'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = 'start',
alignOffset = 0,
side = 'bottom',
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props & Pick<MenuPrimitive.Positioner.Props, 'align' | 'alignOffset' | 'side' | 'sideOffset'>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
'z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn('px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7', className)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className,
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = 'start',
alignOffset = -3,
side = 'right',
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
'w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
)
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground',
className,
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
}
+20
View File
@@ -0,0 +1,20 @@
import { Input as InputPrimitive } from '@base-ui/react/input'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className,
)}
{...props}
/>
)
}
export { Input }
@@ -0,0 +1,19 @@
import { Separator as SeparatorPrimitive } from '@base-ui/react/separator'
import { cn } from '@/lib/utils'
function Separator({ className, orientation = 'horizontal', ...props }: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
'shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
className,
)}
{...props}
/>
)
}
export { Separator }
+103
View File
@@ -0,0 +1,103 @@
'use client'
import { Dialog as SheetPrimitive } from '@base-ui/react/dialog'
import { XIcon } from 'lucide-react'
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
'fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs',
className,
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = 'right',
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: 'top' | 'right' | 'bottom' | 'left'
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
'fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={<Button variant="ghost" className="absolute top-3 right-3" size="icon-sm" />}
>
<XIcon />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="sheet-header" className={cn('flex flex-col gap-0.5 p-4', className)} {...props} />
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="sheet-footer" className={cn('mt-auto flex flex-col gap-2 p-4', className)} {...props} />
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn('font-heading text-base font-medium text-foreground', className)}
{...props}
/>
)
}
function SheetDescription({ className, ...props }: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
)
}
export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger }
+672
View File
@@ -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 }
+39
View File
@@ -0,0 +1,39 @@
'use client'
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Toaster as Sonner, type ToasterProps } from 'sonner'
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme()
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: 'cn-toast',
},
}}
{...props}
/>
)
}
export { Toaster }
+54
View File
@@ -0,0 +1,54 @@
'use client'
import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'
import { cn } from '@/lib/utils'
function TooltipProvider({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delay={delay} {...props} />
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = 'top',
sideOffset = 4,
align = 'center',
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<TooltipPrimitive.Positioner.Props, 'align' | 'alignOffset' | 'side' | 'sideOffset'>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
'z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
+19
View File
@@ -0,0 +1,19 @@
import * as React from 'react'
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener('change', onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener('change', onChange)
}, [])
return !!isMobile
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
@@ -0,0 +1,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>
)
}
+1 -1
View File
@@ -5,6 +5,6 @@ export const bookmarksModule: ModuleMetadata = {
name: '书签导航',
description: '常用链接和网站的快速导航',
icon: 'Compass',
route: '/bookmarks',
adminRoute: '/admin/bookmarks',
enabled: true,
}
+1 -1
View File
@@ -5,7 +5,7 @@ export interface ModuleMetadata {
name: string
description: string
icon: string
route: string
adminRoute: string
enabled: boolean
}
+67 -17
View File
@@ -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(
+4 -1
View File
@@ -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>
)
}
+29 -61
View File
@@ -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 })
}
const { data: categories } = useSuspenseQuery(orpc.bookmarks.category.list.queryOptions())
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"
<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
to={'/admin' as never}
className="rounded-full p-2.5 text-stone-400 transition-colors hover:bg-stone-100 hover:text-stone-600"
title="管理后台"
>
退
</button>
<Settings className="h-5 w-5" />
</Link>
</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
<div className="mx-auto max-w-2xl py-4">
<SearchBar />
</div>
return (
<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"
>
<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>
</Link>
)
})}
</div>
)}
<main>
<CategoryGrid categories={categories} />
</main>
</div>
</div>
)
+129
View File
@@ -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;
}
}