From ba8224e81eed97a08b30a2d0fe7bd29355243dec Mon Sep 17 00:00:00 2001 From: imbytecat Date: Mon, 30 Mar 2026 22:54:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E8=AE=BE=E8=AE=A1=20UI/UX=20?= =?UTF-8?q?=E2=80=94=20=E5=B1=95=E7=A4=BA/=E7=AE=A1=E7=90=86=E5=88=86?= =?UTF-8?q?=E7=A6=BB=20+=20shadcn/ui=20+=20Admin=20=E5=90=8E=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 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 规范) --- AGENTS.md | 18 +- apps/server/AGENTS.md | 311 +++++--- apps/server/components.json | 25 + apps/server/package.json | 18 +- apps/server/src/components/AdminSidebar.tsx | 90 +++ apps/server/src/components/ui/avatar.tsx | 93 +++ apps/server/src/components/ui/badge.tsx | 49 ++ apps/server/src/components/ui/button.tsx | 50 ++ apps/server/src/components/ui/card.tsx | 70 ++ apps/server/src/components/ui/dialog.tsx | 131 ++++ .../src/components/ui/dropdown-menu.tsx | 251 +++++++ apps/server/src/components/ui/input.tsx | 20 + apps/server/src/components/ui/separator.tsx | 19 + apps/server/src/components/ui/sheet.tsx | 103 +++ apps/server/src/components/ui/sidebar.tsx | 672 ++++++++++++++++++ apps/server/src/components/ui/skeleton.tsx | 7 + apps/server/src/components/ui/sonner.tsx | 39 + apps/server/src/components/ui/tooltip.tsx | 54 ++ apps/server/src/hooks/use-mobile.ts | 19 + apps/server/src/lib/utils.ts | 6 + .../bookmarks/components/BookmarkCard.tsx | 38 + .../components/BookmarkFormDialog.tsx | 149 ++++ .../bookmarks/components/BookmarkItem.tsx | 61 -- .../bookmarks/components/BookmarkManager.tsx | 196 +++++ .../components/CategoryFormDialog.tsx | 109 +++ .../bookmarks/components/CategoryGrid.tsx | 63 ++ .../bookmarks/components/CategoryManager.tsx | 196 +++++ .../bookmarks/components/CategorySection.tsx | 332 --------- .../bookmarks/components/IconPicker.tsx | 112 --- .../bookmarks/components/IconPickerDialog.tsx | 135 ++++ apps/server/src/modules/bookmarks/index.ts | 2 +- apps/server/src/modules/registry.ts | 2 +- apps/server/src/routeTree.gen.ts | 84 ++- apps/server/src/routes/__root.tsx | 5 +- apps/server/src/routes/_protected/admin.tsx | 25 + .../src/routes/_protected/admin/bookmarks.tsx | 61 ++ .../src/routes/_protected/admin/index.tsx | 48 ++ .../src/routes/_protected/bookmarks/index.tsx | 92 --- apps/server/src/routes/_protected/index.tsx | 90 +-- apps/server/src/styles.css | 129 ++++ bun.lock | 55 +- package.json | 13 +- 42 files changed, 3261 insertions(+), 781 deletions(-) create mode 100644 apps/server/components.json create mode 100644 apps/server/src/components/AdminSidebar.tsx create mode 100644 apps/server/src/components/ui/avatar.tsx create mode 100644 apps/server/src/components/ui/badge.tsx create mode 100644 apps/server/src/components/ui/button.tsx create mode 100644 apps/server/src/components/ui/card.tsx create mode 100644 apps/server/src/components/ui/dialog.tsx create mode 100644 apps/server/src/components/ui/dropdown-menu.tsx create mode 100644 apps/server/src/components/ui/input.tsx create mode 100644 apps/server/src/components/ui/separator.tsx create mode 100644 apps/server/src/components/ui/sheet.tsx create mode 100644 apps/server/src/components/ui/sidebar.tsx create mode 100644 apps/server/src/components/ui/skeleton.tsx create mode 100644 apps/server/src/components/ui/sonner.tsx create mode 100644 apps/server/src/components/ui/tooltip.tsx create mode 100644 apps/server/src/hooks/use-mobile.ts create mode 100644 apps/server/src/lib/utils.ts create mode 100644 apps/server/src/modules/bookmarks/components/BookmarkCard.tsx create mode 100644 apps/server/src/modules/bookmarks/components/BookmarkFormDialog.tsx delete mode 100644 apps/server/src/modules/bookmarks/components/BookmarkItem.tsx create mode 100644 apps/server/src/modules/bookmarks/components/BookmarkManager.tsx create mode 100644 apps/server/src/modules/bookmarks/components/CategoryFormDialog.tsx create mode 100644 apps/server/src/modules/bookmarks/components/CategoryGrid.tsx create mode 100644 apps/server/src/modules/bookmarks/components/CategoryManager.tsx delete mode 100644 apps/server/src/modules/bookmarks/components/CategorySection.tsx delete mode 100644 apps/server/src/modules/bookmarks/components/IconPicker.tsx create mode 100644 apps/server/src/modules/bookmarks/components/IconPickerDialog.tsx create mode 100644 apps/server/src/routes/_protected/admin.tsx create mode 100644 apps/server/src/routes/_protected/admin/bookmarks.tsx create mode 100644 apps/server/src/routes/_protected/admin/index.tsx delete mode 100644 apps/server/src/routes/_protected/bookmarks/index.tsx diff --git a/AGENTS.md b/AGENTS.md index 5adc141..2cd9e32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,13 +208,27 @@ export const myTable = pgTable('my_table', { . ├── apps/ │ ├── server/ # TanStack Start fullstack app +│ │ ├── components.json # shadcn/ui configuration │ │ ├── src/ │ │ │ ├── client/ # ORPC client + TanStack Query utils │ │ │ ├── components/ +│ │ │ │ ├── ui/ # shadcn/ui components (auto-managed) +│ │ │ │ └── AdminSidebar.tsx # Admin panel sidebar +│ │ │ ├── hooks/ # Custom React hooks +│ │ │ ├── lib/ # Utilities (cn, etc.) +│ │ │ ├── modules/ # Feature modules (bookmarks, etc.) │ │ │ ├── routes/ # File-based routing -│ │ │ └── server/ # API layer + database +│ │ │ │ ├── _protected/ # Auth guard +│ │ │ │ │ ├── index.tsx # Dashboard homepage +│ │ │ │ │ ├── admin.tsx # Admin layout (sidebar) +│ │ │ │ │ └── admin/ # Admin pages +│ │ │ │ │ ├── index.tsx # Admin overview +│ │ │ │ │ └── bookmarks.tsx # Bookmark management +│ │ │ │ └── api/ # API routes +│ │ │ └── server/ # API layer + database + auth │ │ │ ├── api/ # ORPC contracts, routers, middlewares -│ │ │ └── db/ # Drizzle schema +│ │ │ ├── auth/ # Better Auth (schema, instance, client) +│ │ │ └── db/ # Drizzle schema + relations │ │ └── AGENTS.md ├── packages/ │ └── tsconfig/ # Shared TS configs diff --git a/apps/server/AGENTS.md b/apps/server/AGENTS.md index 1af6b86..f372ac0 100644 --- a/apps/server/AGENTS.md +++ b/apps/server/AGENTS.md @@ -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 } /> + + {/* Form content */} + + +``` + +### DropdownMenu (for action menus) +```typescript +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' + +// Use render prop on trigger to avoid nested + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) { + const { toggleSidebar } = useSidebar() + + return ( + + + + + + + ) +} diff --git a/apps/server/src/modules/bookmarks/components/BookmarkItem.tsx b/apps/server/src/modules/bookmarks/components/BookmarkItem.tsx deleted file mode 100644 index 0a54e4d..0000000 --- a/apps/server/src/modules/bookmarks/components/BookmarkItem.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import * as icons from 'lucide-react' -import { GripVertical, Pencil, Trash2 } from 'lucide-react' - -const allIcons = icons as unknown as Record> - -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 ( -
-
- -
- -
- -
- - -

{bookmark.name}

-

{bookmark.url}

-
- -
- - -
-
- ) -} diff --git a/apps/server/src/modules/bookmarks/components/BookmarkManager.tsx b/apps/server/src/modules/bookmarks/components/BookmarkManager.tsx new file mode 100644 index 0000000..f787d19 --- /dev/null +++ b/apps/server/src/modules/bookmarks/components/BookmarkManager.tsx @@ -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> + +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 ( +
+ + +
+ +
+ + +

{bookmark.name}

+

{bookmark.url}

+
+ +
+ + + + + + } + /> + + +
+
+ ) +} + +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['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 ( + + + {category.name} +

共 {items.length} 个书签

+
+ + + + {items.length === 0 ? ( +
+ 当前分类还没有书签 +
+ ) : ( + + {items.map((bookmark, index) => ( + + ))} + + )} +
+ + + + + 添加书签 + + } + /> + +
+ ) +} diff --git a/apps/server/src/modules/bookmarks/components/CategoryFormDialog.tsx b/apps/server/src/modules/bookmarks/components/CategoryFormDialog.tsx new file mode 100644 index 0000000..8bbf6c7 --- /dev/null +++ b/apps/server/src/modules/bookmarks/components/CategoryFormDialog.tsx @@ -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) => { + 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 ( + + + +
+ + {isEdit ? '重命名分类' : '添加分类'} + {isEdit ? '更新当前分类名称' : '创建一个新的书签分类'} + + +
+ + setName(e.target.value)} + placeholder="例如:开发工具" + required + /> +
+ + + + + +
+
+
+ ) +} diff --git a/apps/server/src/modules/bookmarks/components/CategoryGrid.tsx b/apps/server/src/modules/bookmarks/components/CategoryGrid.tsx new file mode 100644 index 0000000..c9e6750 --- /dev/null +++ b/apps/server/src/modules/bookmarks/components/CategoryGrid.tsx @@ -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 ( +
+
+ +
+

还没有任何书签

+

前往管理后台添加你的第一个书签,打造你的数字主页。

+ + 前往管理后台 + +
+ ) + } + + return ( +
+ {categories.map((category) => ( +
+

{category.name}

+ + {category.bookmarks.length === 0 ? ( +
+ 暂无书签 +
+ ) : ( +
+ {category.bookmarks.map((bookmark) => ( + + ))} +
+ )} +
+ ))} +
+ ) +} diff --git a/apps/server/src/modules/bookmarks/components/CategoryManager.tsx b/apps/server/src/modules/bookmarks/components/CategoryManager.tsx new file mode 100644 index 0000000..55bf5bd --- /dev/null +++ b/apps/server/src/modules/bookmarks/components/CategoryManager.tsx @@ -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 ( +
+ + + + + + }> + + + + + + 重命名 + + } + /> + + + + 删除 + + + +
+ ) +} + +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['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 ( + + + 分类管理 + + + + + {items.map((category, index) => ( + onSelectCategory(category.id)} + /> + ))} + + + {items.length === 0 && ( +
+ 暂无分类 +
+ )} +
+ + + + + 添加分类 + + } + /> + +
+ ) +} diff --git a/apps/server/src/modules/bookmarks/components/CategorySection.tsx b/apps/server/src/modules/bookmarks/components/CategorySection.tsx deleted file mode 100644 index 7a220ab..0000000 --- a/apps/server/src/modules/bookmarks/components/CategorySection.tsx +++ /dev/null @@ -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 ( - - ) -} - -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(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['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 ( -
-
-
- - {editingName ? ( - 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 - /> - ) : ( -

{category.name}

- )} -
- -
- - {showMenu && ( -
- - -
- )} -
-
- -
- - {items.map((bm, idx) => ( - handleEditBookmark(bm)} - onDelete={() => deleteBookmark.mutate({ id: bm.id })} - /> - ))} - - - {items.length === 0 && !showAddForm &&

暂无书签

} - - {editingBookmark && ( -
-
- 编辑书签 - -
- 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" - /> - 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" - /> -
- - {showEditIconPicker && ( - setEditForm((p) => ({ ...p, icon }))} - onClose={() => setShowEditIconPicker(false)} - /> - )} -
- -
- )} - - {showAddForm ? ( -
-
- 添加书签 - -
- 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" - /> - 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" - /> -
- - {showIconPicker && ( - setNewBookmark((p) => ({ ...p, icon }))} - onClose={() => setShowIconPicker(false)} - /> - )} -
- -
- ) : ( - - )} -
-
- ) -} diff --git a/apps/server/src/modules/bookmarks/components/IconPicker.tsx b/apps/server/src/modules/bookmarks/components/IconPicker.tsx deleted file mode 100644 index 451788c..0000000 --- a/apps/server/src/modules/bookmarks/components/IconPicker.tsx +++ /dev/null @@ -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> - -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 ( -
- 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 - /> -
- {filtered.map((name) => { - const Icon = allIcons[name] - if (!Icon) return null - return ( - - ) - })} -
- {filtered.length === 0 &&

未找到匹配图标

} -
- ) -} diff --git a/apps/server/src/modules/bookmarks/components/IconPickerDialog.tsx b/apps/server/src/modules/bookmarks/components/IconPickerDialog.tsx new file mode 100644 index 0000000..3fade16 --- /dev/null +++ b/apps/server/src/modules/bookmarks/components/IconPickerDialog.tsx @@ -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> + +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 ( + + + + + + + + 选择图标 + 搜索并选择一个 lucide 图标作为书签图标 + + + setFilter(e.target.value)} placeholder="搜索图标..." /> + +
+ {filteredIcons.map((name) => { + const Icon = allIcons[name] + if (!Icon) { + return null + } + + return ( + + ) + })} +
+ + {filteredIcons.length === 0 &&

未找到匹配图标

} +
+
+ ) +} diff --git a/apps/server/src/modules/bookmarks/index.ts b/apps/server/src/modules/bookmarks/index.ts index 07aeaa5..7c7c226 100644 --- a/apps/server/src/modules/bookmarks/index.ts +++ b/apps/server/src/modules/bookmarks/index.ts @@ -5,6 +5,6 @@ export const bookmarksModule: ModuleMetadata = { name: '书签导航', description: '常用链接和网站的快速导航', icon: 'Compass', - route: '/bookmarks', + adminRoute: '/admin/bookmarks', enabled: true, } diff --git a/apps/server/src/modules/registry.ts b/apps/server/src/modules/registry.ts index 1409b74..a0a2c40 100644 --- a/apps/server/src/modules/registry.ts +++ b/apps/server/src/modules/registry.ts @@ -5,7 +5,7 @@ export interface ModuleMetadata { name: string description: string icon: string - route: string + adminRoute: string enabled: boolean } diff --git a/apps/server/src/routeTree.gen.ts b/apps/server/src/routeTree.gen.ts index 3a7fdf5..48a0441 100644 --- a/apps/server/src/routeTree.gen.ts +++ b/apps/server/src/routeTree.gen.ts @@ -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( diff --git a/apps/server/src/routes/__root.tsx b/apps/server/src/routes/__root.tsx index b7f758f..7dbb7cc 100644 --- a/apps/server/src/routes/__root.tsx +++ b/apps/server/src/routes/__root.tsx @@ -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 }>) { - {children} + {children} + {import.meta.env.DEV && ( + + +
+ + +
+
+ +
+
+ + ) +} diff --git a/apps/server/src/routes/_protected/admin/bookmarks.tsx b/apps/server/src/routes/_protected/admin/bookmarks.tsx new file mode 100644 index 0000000..f4720f8 --- /dev/null +++ b/apps/server/src/routes/_protected/admin/bookmarks.tsx @@ -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(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 ( +
+
+

书签管理

+

管理你的书签分类、图标和排序

+
+ +
+
+ +
+
+ {selectedCategory ? ( + + ) : ( +
+ 请选择一个分类开始管理书签 +
+ )} +
+
+
+ ) +} diff --git a/apps/server/src/routes/_protected/admin/index.tsx b/apps/server/src/routes/_protected/admin/index.tsx new file mode 100644 index 0000000..809eea2 --- /dev/null +++ b/apps/server/src/routes/_protected/admin/index.tsx @@ -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 + 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 ( +
+
+

管理总览

+

配置和管理你的 Kairos 模块

+
+ +
+ {modules + .filter((mod) => mod.enabled) + .map((mod) => { + const Icon = resolveIcon(mod.icon) + return ( + + + +
+ + {mod.name} +
+ {mod.description} +
+
+ + ) + })} +
+
+ ) +} diff --git a/apps/server/src/routes/_protected/bookmarks/index.tsx b/apps/server/src/routes/_protected/bookmarks/index.tsx deleted file mode 100644 index 1ca7991..0000000 --- a/apps/server/src/routes/_protected/bookmarks/index.tsx +++ /dev/null @@ -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 ( -
-
- - - - -
- {categoriesQuery.data.map((category) => ( - - ))} -
- - {categoriesQuery.data.length === 0 && !showAddCategory && ( -
-

还没有任何分类

-

创建一个分类来开始添加书签

-
- )} - - {showAddCategory ? ( -
- 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" - /> - - -
- ) : ( - - )} -
-
- ) -} diff --git a/apps/server/src/routes/_protected/index.tsx b/apps/server/src/routes/_protected/index.tsx index 979f237..7ad32ee 100644 --- a/apps/server/src/routes/_protected/index.tsx +++ b/apps/server/src/routes/_protected/index.tsx @@ -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 - -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 ( -
-
-
-
-

Kairos

-

- 欢迎回来,{user.name} · {user.email} -

-
- + +
- {enabledModules.length === 0 ? ( -
暂无可用模块
- ) : ( -
- {enabledModules.map((mod) => { - const IconComponent = iconComponents[mod.icon] ?? icons.Box +
+ +
- return ( - -
-
- -
-
-

{mod.name}

-

{mod.description}

-
-
- - ) - })} -
- )} +
+ +
) diff --git a/apps/server/src/styles.css b/apps/server/src/styles.css index f1d8c73..e49f238 100644 --- a/apps/server/src/styles.css +++ b/apps/server/src/styles.css @@ -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; + } +} diff --git a/bun.lock b/bun.lock index 6fbcff5..f8f933a 100644 --- a/bun.lock +++ b/bun.lock @@ -14,8 +14,10 @@ "name": "@furtherverse/server", "version": "1.0.0", "dependencies": { + "@base-ui/react": "catalog:", "@dnd-kit/dom": "catalog:", "@dnd-kit/react": "catalog:", + "@fontsource-variable/geist": "catalog:", "@orpc/client": "catalog:", "@orpc/contract": "catalog:", "@orpc/openapi": "catalog:", @@ -28,11 +30,17 @@ "@tanstack/react-router-ssr-query": "catalog:", "@tanstack/react-start": "catalog:", "better-auth": "catalog:", + "class-variance-authority": "catalog:", + "clsx": "catalog:", "drizzle-orm": "catalog:", "lucide-react": "catalog:", + "next-themes": "catalog:", "postgres": "catalog:", "react": "catalog:", "react-dom": "catalog:", + "sonner": "catalog:", + "tailwind-merge": "catalog:", + "tw-animate-css": "catalog:", "uuid": "catalog:", "zod": "catalog:", }, @@ -46,7 +54,7 @@ "@tanstack/react-router-devtools": "catalog:", "@types/bun": "catalog:", "@vitejs/plugin-react": "catalog:", - "babel-plugin-react-compiler": "^1.0.0", + "babel-plugin-react-compiler": "catalog:", "drizzle-kit": "catalog:", "nitro": "catalog:", "tailwindcss": "catalog:", @@ -59,8 +67,10 @@ }, }, "catalog": { + "@base-ui/react": "^1.3.0", "@dnd-kit/dom": "^0.3.2", "@dnd-kit/react": "^0.3.2", + "@fontsource-variable/geist": "^5.2.8", "@orpc/client": "^1.13.11", "@orpc/contract": "^1.13.11", "@orpc/openapi": "^1.13.11", @@ -80,15 +90,22 @@ "@tanstack/react-start": "^1.167.6", "@types/bun": "^1.3.11", "@vitejs/plugin-react": "^6.0.1", + "babel-plugin-react-compiler": "^1.0.0", "better-auth": "^1.2.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "drizzle-kit": "1.0.0-beta.15-859cf75", "drizzle-orm": "1.0.0-beta.15-859cf75", - "lucide-react": "^0.513.0", + "lucide-react": "^1.7.0", + "next-themes": "^0.4.6", "nitro": "npm:nitro-nightly@3.0.1-20260324-103046-9ce219ca", "postgres": "^3.4.8", "react": "^19.2.4", "react-dom": "^19.2.4", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.2", + "tw-animate-css": "^1.4.0", "uuid": "^13.0.0", "vite": "^8.0.2", "zod": "^4.3.6", @@ -160,12 +177,18 @@ "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@base-ui/react": ["@base-ui/react@1.3.0", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@base-ui/utils": "0.2.6", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "tabbable": "^6.4.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA=="], + + "@base-ui/utils": ["@base-ui/utils@0.2.6", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw=="], + "@better-auth/core": ["@better-auth/core@1.5.6", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-Ez9DZdIMFyxHremmoLz1emFPGNQomDC1jqqBPnZ6Ci+6TiGN3R9w/Y03cJn6I8r1ycKgOzeVMZtJ/erOZ27Gsw=="], "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.5.6", "", { "peerDependencies": { "@better-auth/core": "1.5.6", "@better-auth/utils": "^0.3.0", "drizzle-orm": ">=0.41.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-VfFFmaoFw3ug12SiSuIwzrMoHyIVmkMGWm9gZ4sXdYYVX4HboCL4m3fjzOhppcmK5OGatRuU+N1UX6wxCITcXw=="], @@ -274,6 +297,16 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + + "@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.8", "", {}, "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="], + "@furtherverse/server": ["@furtherverse/server@workspace:apps/server"], "@furtherverse/tsconfig": ["@furtherverse/tsconfig@workspace:packages/tsconfig"], @@ -576,6 +609,8 @@ "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], @@ -622,6 +657,8 @@ "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], + "drizzle-kit": ["drizzle-kit@1.0.0-beta.15-859cf75", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-Y36s1XQGVb1PgU3aRNgufp1K3D2VkIifu8kv4Ubsmxi+Dq+N7KMklnpp7Knu/XC4FZi2MHPPG3v3o097r0/TcQ=="], "drizzle-orm": ["drizzle-orm@1.0.0-beta.15-859cf75", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-dGVb2Q70H2AV6513hkOXR3Ud0FeGXLdugVq3YehoqkGIVTJrkuo0gRnCcW/dfI00O07t3T4HSh4clF/D/o/IsQ=="], @@ -772,7 +809,7 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "lucide-react": ["lucide-react@0.513.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-CJZKq2g8Y8yN4Aq002GahSXbG2JpFv9kXwyiOAMvUBv7pxeOFHUWKB0mO7MiY4ZVFCV4aNjv2BJFq/z3DgKPQg=="], + "lucide-react": ["lucide-react@1.7.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -786,6 +823,8 @@ "native-duplexpair": ["native-duplexpair@1.0.0", "", {}, "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA=="], + "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + "nf3": ["nf3@0.3.14", "", {}, "sha512-MjG9u/IlvSq5txxY0oug1sjrGZ2l37IuhExI1iPuwV4S3RcyRNGoy6xLwznH3ATK6PUAM4fbQVb4Rzy1L1nlzw=="], "nitro": ["nitro-nightly@3.0.1-20260324-103046-9ce219ca", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.4", "db0": "^0.3.4", "env-runner": "^0.1.6", "h3": "^2.0.1-rc.19", "hookable": "^6.1.0", "nf3": "^0.3.13", "ocache": "^0.1.4", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.0.0-rc.11", "srvx": "^0.11.13", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.7" }, "peerDependencies": { "dotenv": "*", "giget": "*", "jiti": "^2.6.1", "rollup": "^4.60.0", "vite": "^7 || ^8", "xml2js": "^0.6.2", "zephyr-agent": "^0.1.15" }, "optionalPeers": ["dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-N9ZennO7ZNNZT0/mBwfA288zGC5l+fp2MVu8QPjCwpyNSS+yvZacQrgVzWJsrl2UAAPVRgTroKbAo+m1Fds/sw=="], @@ -838,6 +877,8 @@ "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], @@ -866,6 +907,8 @@ "solid-js": ["solid-js@1.9.12", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw=="], + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -876,8 +919,12 @@ "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], + "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], @@ -898,6 +945,8 @@ "turbo": ["turbo@2.8.21", "", { "optionalDependencies": { "@turbo/darwin-64": "2.8.21", "@turbo/darwin-arm64": "2.8.21", "@turbo/linux-64": "2.8.21", "@turbo/linux-arm64": "2.8.21", "@turbo/windows-64": "2.8.21", "@turbo/windows-arm64": "2.8.21" }, "bin": { "turbo": "bin/turbo" } }, "sha512-FlJ8OD5Qcp0jTAM7E4a/RhUzRNds2GzKlyxHKA6N247VLy628rrxAGlMpIXSz6VB430+TiQDJ/SMl6PL1lu6wQ=="], + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + "type-fest": ["type-fest@5.5.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g=="], "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], diff --git a/package.json b/package.json index 62255fb..8d6626e 100644 --- a/package.json +++ b/package.json @@ -52,9 +52,18 @@ "@dnd-kit/dom": "^0.3.2", "@dnd-kit/react": "^0.3.2", "better-auth": "^1.2.8", - "lucide-react": "^0.513.0", + "lucide-react": "^1.7.0", "uuid": "^13.0.0", "vite": "^8.0.2", - "zod": "^4.3.6" + "zod": "^4.3.6", + "@base-ui/react": "^1.3.0", + "@fontsource-variable/geist": "^5.2.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "next-themes": "^0.4.6", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "tw-animate-css": "^1.4.0", + "babel-plugin-react-compiler": "^1.0.0" } }