diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f702632..51e56f3 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -18,6 +18,16 @@ jobs: - name: Checkout uses: actions/checkout@v3 + # 1. 增加 Cache 策略,显著加快 npm ci 速度 + - name: Cache npm dependencies + uses: actions/cache@v3 + id: npm-cache + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + - name: Install dependencies run: npm ci @@ -27,6 +37,18 @@ jobs: - name: Typecheck run: npm run typecheck + # 2. 增加 Next.js 构建缓存 + - name: Cache Next.js build + uses: actions/cache@v3 + with: + path: | + ~/.npm + ${{ github.workspace }}/.next/cache + # Generate a new cache whenever packages or source files change. + key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }} + restore-keys: | + ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- + - name: Build run: npm run build @@ -35,7 +57,7 @@ jobs: # echo "=======================" # echo "1. Root directory files:" # ls -la - + # # echo "=======================" # echo "2. Checking .next directory:" # if [ -d ".next" ]; then @@ -58,10 +80,10 @@ jobs: cp -r .next/static/* .next/standalone/.next/static/ cp Dockerfile .next/standalone/Dockerfile - - name: 🔍 Debug - List Build Files - run: | - echo "=======================" - ls -la .next/standalone + # - name: 🔍 Debug - List Build Files + # run: | + # echo "=======================" + # ls -la .next/standalone - name: Upload production build artifact uses: actions/upload-artifact@v3 @@ -83,8 +105,21 @@ jobs: - name: Deploy to Docker run: | - docker build -t nextjs-app . + # 1. 使用 --no-cache 防止使用旧的构建层,确保部署的是最新代码 + # 2. 使用 --pull 确保基础镜像是最新的 + docker build --no-cache --pull -t nextjs-app . + + # 3. 优雅停止:先尝试 stop,如果失败则无需处理 (|| true) docker stop nextjs-app || true docker rm nextjs-app || true - docker run -d -p 8015:3000 --restart unless-stopped --name nextjs-app nextjs-app + + # 4. 运行容器: + # --init: 解决 Node.js PID 1 僵尸进程问题 + # --restart unless-stopped: 自动重启策略 + docker run -d \ + --init \ + -p 8015:3000 \ + --restart unless-stopped \ + --name nextjs-app \ + nextjs-app diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..b9d25f1 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,332 @@ +# Next_Edu Architecture RFC + +**Status**: PROPOSED +**Date**: 2025-12-22 +**Author**: Principal Software Architect +**Version**: 1.0.0 + +--- + +## 1. 核心原则 (Core Principles) + +本架构设计遵循以下核心原则,旨在构建一个高性能、可扩展、易维护的企业级在线教育平台。 + +1. **Vertical Slice Architecture (垂直切片)**: 拒绝传统的按技术分层(Layered Architecture),采用按业务功能分层。代码应根据“它属于哪个功能”而不是“它是什么文件”来组织。 +2. **Type Safety First (类型安全优先)**: 全链路 TypeScript (Strict Mode)。从数据库 Schema 到 API 再到 UI 组件,必须保持类型一致性。 +3. **Server-First (服务端优先)**: 充分利用 Next.js 15 App Router 的 RSC (React Server Components) 能力,减少客户端 Bundle 体积。 +4. **Performance by Default (默认高性能)**: 严禁引入重型动画库,动效优先使用 CSS Native 实现。Web Vitals 指标作为 CI 阻断标准。 +5. **Strict Engineering (严格工程化)**: CI/CD 流程标准化,代码风格统一,自动化测试覆盖。 + +--- + +## 2. 技术栈全景图 (Technology Panorama) + +### 核心框架 +* **Framework**: Next.js 15 (App Router) +* **Language**: TypeScript 5.x (Strict Mode enabled) + * *Correction*: 鉴于 Next.js App Router 的特性,`.tsx` 仅用于包含 JSX 的组件文件。所有的业务逻辑、Hooks、API 路由、Lib 工具函数必须使用 `.ts` 后缀,以明确区分“渲染层”与“逻辑层”。 +* **Runtime**: Node.js 20.x (LTS) + +### 数据层 +* **Database**: MySQL 8.0+ +* **ORM**: Drizzle ORM (轻量、无运行时开销、类型安全) +* **Driver**: `mysql2` (配合连接池) 或 `serverless-mysql` (针对特定 Serverless 环境) +* **Validation**: Zod (Schema 定义与运行时验证) + +### UI/UX 层 +* **Styling**: Tailwind CSS v3.4+ +* **Components**: Shadcn/UI (基于 Radix UI 的 Headless 组件拷贝) +* **Icons**: Lucide React +* **Animations**: CSS Transitions / Tailwind `animate-*` / `tailwindcss-animate` + * *Complex Interactions*: Framer Motion (仅限按需加载 `LazyMotion`) + +### 身份验证与授权 +* **Auth**: Auth.js v5 (NextAuth) + * *Decision Driver*: 相比 Clerk,Auth.js 提供了完全的**数据所有权**和**无 Vendor Lock-in**。 + * *Enterprise Needs*: 允许自定义 Session 结构(如注入 `Role` 字段)并直接对接现有 MySQL 数据库,满足复杂的企业级权限管理需求。 + +### 状态管理 +* **Server State**: TanStack Query v5 (仅用于复杂客户端轮询/无限加载) +* **URL State (Primary)**: Nuqs (Type-safe search params state manager) + * *Principle*: 绝大多数状态(筛选、分页、Tab)应存在 URL 中,以支持分享和书签。 +* **Global Client State (Secondary)**: Zustand + * *Usage*: 仅限极少数全局交互状态(如播放器悬浮窗、全局 Modal)。 + * *Anti-pattern*: **严禁使用 Redux**。避免不必要的样板代码和 Bundle 体积。 + +### 基础设施 & DevOps +* **CI/CD**: GitHub Actions (Strictly v3) +* **Linting**: ESLint (Next.js config), Prettier +* **Package Manager**: pnpm (推荐) 或 npm + +--- + +## 3. 项目目录结构规范 (Project Structure) + +采用 **Feature-based / Vertical Slice** 架构。所有业务逻辑应封装在 `src/modules` 中。 + +文档存放位置: +* 架构设计文档: `docs/architecture/` +* API 规范文档: `docs/api/` + +### 目录树 (Directory Tree) + +``` +Next_Edu/ +├── .github/ +│ └── workflows/ +│ └── ci.yml # GitHub Actions (v3 strict) +├── docs/ +│ └── architecture/ # 架构决策记录 (ADR) +├── drizzle/ # 数据库迁移文件 (Generated) +├── public/ # 静态资源 +├── src/ +│ ├── app/ # [路由层] 极薄,仅负责路由分发和布局 +│ │ ├── (auth)/ # 路由组 +│ │ ├── (dashboard)/ +│ │ ├── api/ # Webhooks / External APIs +│ │ ├── layout.tsx +│ │ └── page.tsx +│ │ +│ ├── modules/ # [核心业务层] 垂直切片 +│ │ ├── courses/ # 课程模块 +│ │ │ ├── components/ # 模块私有组件 (CourseCard, Player) +│ │ │ ├── actions.ts # Server Actions (业务逻辑入口) +│ │ │ ├── service.ts # 领域服务 (可选,复杂逻辑拆分) +│ │ │ ├── data-access.ts # 数据库查询 (DTOs) +│ │ │ └── types.ts # 模块私有类型 +│ │ │ +│ │ ├── users/ # 用户模块 +│ │ ├── payments/ # 支付模块 +│ │ └── community/ # 社区模块 +│ │ +│ ├── shared/ # [共享层] 仅存放真正通用的代码 +│ │ ├── components/ # 通用 UI (Button, Dialog - Shadcn) +│ │ ├── lib/ # 通用工具 (utils, date formatting) +│ │ ├── db/ # Drizzle Client & Schema +│ │ │ ├── index.ts # DB 连接实例 +│ │ │ └── schema.ts # 全局 Schema 定义 (或按模块拆分导出) +│ │ └── hooks/ # 通用 Hooks +│ │ +│ ├── env.mjs # 环境变量类型检查 +│ └── middleware.ts # 边缘中间件 (Auth check) +├── drizzle.config.ts # Drizzle 配置文件 +├── next.config.mjs +└── package.json +``` + +--- + +## 4. 数据库层设计 (Database Strategy) + +### 连接配置 (Connection Pooling) +在 Next.js 的 Serverless/Edge 环境中,直接连接 MySQL 可能导致连接数耗尽。我们采取以下策略: + +1. **开发环境**: 使用 Global Singleton 模式防止 Hot Reload 导致连接泄露。 +2. **生产环境**: + * 推荐使用支持 HTTP 连接或内置连接池的 Serverless MySQL 方案 (如 PlanetScale)。 + * 若使用标准 MySQL,必须配置连接池 (`connectionLimit`) 并合理设置空闲超时。 + +**代码示例 (`src/shared/db/index.ts`)**: + +```typescript +import { drizzle } from "drizzle-orm/mysql2"; +import mysql from "mysql2/promise"; +import * as schema from "./schema"; + +// Global cache to prevent connection exhaustion in development +const globalForDb = globalThis as unknown as { + conn: mysql.Pool | undefined; +}; + +const poolConnection = globalForDb.conn ?? mysql.createPool({ + uri: process.env.DATABASE_URL, + waitForConnections: true, + connectionLimit: 10, // 根据数据库规格调整 + queueLimit: 0 +}); + +if (process.env.NODE_ENV !== "production") globalForDb.conn = poolConnection; + +export const db = drizzle(poolConnection, { schema, mode: "default" }); +``` + +### Migration 策略 +* 使用 `drizzle-kit` 进行迁移管理。 +* 严禁在生产环境运行时自动执行 Migration。 +* **流程**: + 1. 修改 Schema (`schema.ts`). + 2. 运行 `pnpm drizzle-kit generate` 生成 SQL 文件。 + 3. Review SQL 文件。 + 4. 在 CI/CD 部署前置步骤或手动运行 `pnpm drizzle-kit migrate`。 + +### Server Components 中的数据查询 +* **Colocation**: 查询逻辑应尽量靠近使用它的组件,或者封装在 `data-access.ts` 中。 +* **Request Memoization**: 即使在一个请求中多次调用相同的查询函数,Next.js 的 `cache` (或 React `cache`) 也会自动去重。 + +```typescript +// src/modules/courses/data-access.ts +import { cache } from 'react'; +import { db } from '@/shared/db'; +import { eq } from 'drizzle-orm'; +import { courses } from '@/shared/db/schema'; + +// 使用 React cache 确保单次请求内的去重 +export const getCourseById = cache(async (id: string) => { + return await db.query.courses.findFirst({ + where: eq(courses.id, id), + }); +}); +``` + +--- + +## 5. UI/UX 动效规范 (Animation Guidelines) + +### 核心策略 +* **CSS Native First**: 90% 的交互通过 CSS `transition` 和 `animation` 实现。 +* **Hardware Acceleration**: 确保动画属性触发 GPU 加速 (`transform`, `opacity`)。 +* **Micro-interactions**: 关注 `:hover`, `:active`, `:focus-visible` 状态。 + +### 高性能通用组件示例 (Interactive Card) + +这是一个符合规范的卡片组件,使用了 Tailwind 的 `group` 和 `transform` 属性实现丝滑的微交互,且没有 JS 运行时开销。 + +```tsx +// src/shared/components/ui/interactive-card.tsx +import { cn } from "@/shared/lib/utils"; + +interface CardProps extends React.HTMLAttributes { + children: React.ReactNode; +} + +export function InteractiveCard({ className, children, ...props }: CardProps) { + return ( +
+ {/* 光泽效果 (Shimmer Effect) - 仅 CSS */} + + ); +} +``` + +--- + +## 6. CI/CD 配置文件模板 (GitHub Actions) + +**警告**: 必须严格遵守 `v3` 版本限制。严禁使用 `v4`。 + +文件路径: `.github/workflows/ci.yml` + +```yaml +name: CI/CD Pipeline + +on: + push: + branches: [ "main", "develop" ] + pull_request: + branches: [ "main", "develop" ] + +env: + NODE_VERSION: '20.x' + +jobs: + quality-check: + name: Quality & Type Check + runs-on: ubuntu-latest + steps: + # 强制使用 v3 + - name: Checkout Code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' # 或 'pnpm' + + - name: Install Dependencies + run: npm ci + + - name: Linting (ESLint) + run: npm run lint + + - name: Type Checking (TSC) + # 确保没有 TS 错误 + run: npx tsc --noEmit + + test: + name: Unit Tests + needs: quality-check + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install Dependencies + run: npm ci + + - name: Run Tests + run: npm run test + + build-check: + name: Production Build Check + needs: test + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install Dependencies + run: npm ci + + - name: Cache Next.js build + uses: actions/cache@v3 + with: + path: | + ~/.npm + ${{ github.workspace }}/.next/cache + # Generate a new cache whenever packages or source files change. + key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }} + restore-keys: | + ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- + + - name: Build Application + run: npm run build + env: + # 构建时跳过 ESLint/TS 检查 (因为已经在 quality-check job 做过了,加速构建) + NEXT_TELEMETRY_DISABLED: 1 +``` diff --git a/docs/architecture/001_database_schema_design.md b/docs/architecture/001_database_schema_design.md new file mode 100644 index 0000000..a3a0513 --- /dev/null +++ b/docs/architecture/001_database_schema_design.md @@ -0,0 +1,112 @@ +# 架构决策记录 (ADR): 数据库 Schema 设计方案 v1.0 + +**状态**: 已实施 (IMPLEMENTED) +**日期**: 2025-12-23 +**作者**: 首席系统架构师 +**背景**: Next_Edu 平台 - K12 智慧教育管理系统 + +--- + +## 1. 概述 (Overview) + +本文档详细记录了 Next_Edu 平台的数据库 Schema 架构设计。本设计优先考虑 **可扩展性 (Scalability)**、**灵活性 (Flexibility)**(针对复杂的嵌套内容)以及 **严格的类型安全 (Strict Type Safety)**,并完全符合 PRD 中规定的领域驱动设计 (DDD) 原则。 + +## 2. 技术栈决策 (Technology Stack Decisions) + +| 组件 | 选择 | 理由 | +| :--- | :--- | :--- | +| **数据库** | MySQL 8.0+ | 强大的关系型支持,完善的 JSON 能力,行业标准。 | +| **ORM** | Drizzle ORM | 轻量级,零运行时开销 (Zero-runtime overhead),业界一流的 TypeScript 类型推断。 | +| **ID 策略** | CUID2 | 分布式友好 (k-sortable),安全(防连续猜测攻击),比 UUID 更短。 | +| **认证方案** | Auth.js v5 | 标准化的 OAuth 流程,支持自定义数据库适配器。 | + +--- + +## 3. 核心 Schema 领域模型 (Core Schema Domains) + +物理文件位于 `src/shared/db/schema.ts`,逻辑上分为三大领域。 + +### 3.1 身份与访问管理 (IAM) + +我们采用了 **Auth.js 标准表** 与 **自定义 RBAC** 相结合的混合模式。 + +* **标准表**: `users`, `accounts` (OAuth), `sessions`, `verificationTokens`。 +* **RBAC 扩展**: + * `roles`: 定义系统角色(例如:`grade_head` 年级主任, `teacher` 老师)。 + * `users_to_roles`: 多对多关联表。 + * **设计目标**: 解决“一人多职”问题(例如:一个老师同时也是年级主任),避免在 `users` 表中堆砌字段。 + +### 3.2 智能题库中心 (Intelligent Question Bank) - 核心 + +这是最复杂的领域,需要支持无限层级嵌套和富文本内容。 + +#### 实体定义: +1. **`questions` (题目表)**: + * `id`: CUID2。 + * `content`: **JSON 类型**。存储结构化内容(如 SlateJS 节点),支持富文本、图片和公式混排。 + * `parentId`: **自引用 (Self-Reference)**。 + * *若为 NULL*: 独立题目 或 “大题干” (Parent)。 + * *若有值*: 子题目 (Child)(例如:一篇阅读理解下的第1小题)。 + * `type`: 枚举 (`single_choice`, `text`, `composite` 等)。 +2. **`knowledge_points` (知识点表)**: + * 通过 `parentId` 实现树状结构。 + * 支持无限层级 (学科 -> 章 -> 节 -> 知识点)。 +3. **`questions_to_knowledge_points`**: + * 多对多关联。一道题可考察多个知识点;一个知识点可关联数千道题。 + +### 3.3 教务教学流 (Academic Teaching Flow) + +将物理世界的教学过程映射为数字实体。 + +* **`textbooks` & `chapters`**: 标准的教材大纲映射。`chapters` 同样支持通过 `parentId` 进行嵌套。 +* **`exams`**: 考试/作业的聚合实体。 +* **`exam_submissions`**: 代表一名学生的单次答题记录。 +* **`submission_answers`**: 细粒度的答题详情,记录每道题的答案,支持自动评分 (`score`) 和人工反馈 (`feedback`)。 + +--- + +## 4. 关键设计模式 (Key Design Patterns) + +### 4.1 无限嵌套 ("Composite" Pattern) +我们没有为“题干”和“题目”创建单独的表,而是在 `questions` 表上使用 **自引用 (Self-Referencing)** 模式。 + +* **优点**: + * 统一的查询接口 (`db.query.questions.findFirst({ with: { children: true } })`)。 + * 递归逻辑可统一应用。 + * 当内容结构变化时,迁移更简单。 +* **缺点**: + * 需要处理递归查询逻辑(已通过 Drizzle Relations 解决)。 + +### 4.2 CUID2 优于 自增 ID +* **安全性**: 防止 ID 枚举攻击(猜测下一个用户 ID)。 +* **分布式**: 支持在客户端或多服务器节点生成,无碰撞风险。 +* **性能**: `k-sortable` 特性保证了比随机 UUID v4 更好的索引局部性。 + +### 4.3 JSON 存储内容 +* 教育内容不仅仅是“文本”。它包含格式、LaTeX 公式和图片引用。 +* 使用 `JSON` 存储允许前端 (Next.js) 直接渲染富组件,无需解析复杂的 HTML 字符串。 + +--- + +## 5. 安全与索引策略 (Security & Indexing Strategy) + +### 索引 (Indexes) +* **外键**: 所有外键列 (`author_id`, `parent_id` 等) 均显式建立索引。 +* **性能**: + * `parent_id_idx`: 对树形结构的遍历性能至关重要。 + * `email_idx`: 登录查询的核心索引。 + +### 类型安全 (Type Safety) +* 严格的 TypeScript 定义直接从 `src/shared/db/schema.ts` 导出。 +* Zod Schema (待生成) 将与这些 Drizzle 定义保持 1:1 对齐。 + +--- + +## 6. 目录结构 (Directory Structure) + +```bash +src/shared/db/ +├── index.ts # 单例数据库连接池 +├── schema.ts # 物理表结构定义 +└── relations.ts # 逻辑 Drizzle 关系定义 +``` diff --git a/docs/architecture/002_exam_structure_migration.md b/docs/architecture/002_exam_structure_migration.md new file mode 100644 index 0000000..1d82f3d --- /dev/null +++ b/docs/architecture/002_exam_structure_migration.md @@ -0,0 +1,52 @@ +# Database Schema Change Request: Exam Structure Support + +## 1. Table: `exams` + +### Change +**Add Column**: `structure` + +### Details +- **Type**: `JSON` +- **Nullable**: `TRUE` (Default: `NULL`) + +### Reason +To support hierarchical exam structures (e.g., Sections/Groups containing Questions). The existing flat `exam_questions` table only supports a simple list of questions and is insufficient for complex exam layouts (e.g., "Part A: Reading", "Part B: Writing"). + +### Before vs After + +**Before**: +`exams` table only stores metadata (`title`, `description`, etc.). Question ordering relies solely on `exam_questions.order`. + +**After**: +`exams` table includes `structure` column to store the full tree representation: +```json +[ + { "id": "uuid-1", "type": "group", "title": "Section A", "children": [...] }, + { "id": "uuid-2", "type": "question", "questionId": "q1", "score": 10 } +] +``` +*Note: `exam_questions` table is retained for relational integrity and efficient querying of question usage, but the presentation order/structure is now driven by this new JSON column.* + +--- + +## 2. Table: `questions_to_knowledge_points` + +### Change +**Rename Foreign Key Constraints** + +### Details +- Rename constraint for `question_id` to `q_kp_qid_fk` +- Rename constraint for `knowledge_point_id` to `q_kp_kpid_fk` + +### Reason +The default generated foreign key names (e.g., `questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk`) exceed the MySQL identifier length limit (64 characters), causing migration failures. + +### Before vs After + +**Before**: +- FK Name: `questions_to_knowledge_points_question_id_questions_id_fk` (Too long) +- FK Name: `questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk` (Too long) + +**After**: +- FK Name: `q_kp_qid_fk` +- FK Name: `q_kp_kpid_fk` diff --git a/docs/architecture/002_role_based_routing.md b/docs/architecture/002_role_based_routing.md new file mode 100644 index 0000000..c6b8e80 --- /dev/null +++ b/docs/architecture/002_role_based_routing.md @@ -0,0 +1,115 @@ +# Architecture RFC: Role-Based Routing & Directory Structure + +**Status**: PROPOSED +**Date**: 2025-12-23 +**Context**: Next_Edu supports multiple roles (Admin, Teacher, Student, Parent) with distinct UI/UX requirements. + +## 1. 核心问题 (The Problem) +目前项目结构中,页面与角色耦合不清。 +* `/dashboard` 目前硬编码为教师视图。 +* 不同角色的功能模块(如“课程”)可能有完全不同的视图和逻辑(教师管理课程 vs 学生学习课程)。 +* 缺乏统一的路由规范指导开发人员“把页面放哪里”。 + +## 2. 解决方案:基于角色的路由策略 (Role-Based Routing Strategy) + +我们采用 **"Hybrid Routing" (混合路由)** 策略: +1. **Explicit Dashboard Routing**: Dashboards are separated by role in the file structure (e.g., `/teacher/dashboard`). +2. **Explicit Role Scopes (显式角色域)**: 具体的业务功能页面放入 `/teacher`, `/student`, `/admin` 专属路径下。 + +### 2.1 目录结构 (Directory Structure) + +``` +src/app/(dashboard)/ +├── layout.tsx # Shared App Shell (Sidebar, Header) +├── dashboard/ +│ └── page.tsx # [Redirector] Redirects to role-specific dashboard +│ +├── teacher/ # 教师专属路由域 +│ ├── dashboard/ # Teacher Dashboard +│ │ └── page.tsx +│ ├── classes/ +│ │ └── page.tsx +│ ├── exams/ +│ │ └── page.tsx +│ └── textbooks/ +│ └── page.tsx +│ +├── student/ # 学生专属路由域 +│ ├── dashboard/ # Student Dashboard +│ │ └── page.tsx +│ ├── learning/ +│ │ └── page.tsx +│ └── schedule/ +│ └── page.tsx +│ +└── admin/ # 管理员专属路由域 + ├── dashboard/ # Admin Dashboard + │ └── page.tsx + ├── users/ + └── school/ +``` + +### 2.2 Dashboard Routing Logic + +`/dashboard` 页面现在作为一个 **Portal (入口)** 或 **Redirector (重定向器)**,而不是直接渲染内容。 + +**Example: `src/app/(dashboard)/dashboard/page.tsx`** + +```tsx +import { redirect } from "next/navigation"; +import { auth } from "@/auth"; + +export default async function DashboardPage() { + const session = await auth(); + const role = session?.user?.role; + + switch (role) { + case "teacher": + redirect("/teacher/dashboard"); + case "student": + redirect("/student/dashboard"); + case "admin": + redirect("/admin/dashboard"); + default: + redirect("/login"); + } +} +``` + +## 3. 模块化组织 (Module Organization) + +为了避免 `src/app` 变得臃肿,具体的**业务组件**必须存放在 `src/modules` 中。 + +``` +src/modules/ +├── teacher/ # 教师业务领域 +│ ├── components/ # 仅教师使用的组件 (e.g., GradebookTable) +│ ├── hooks/ +│ └── actions.ts +│ +├── student/ # 学生业务领域 +│ ├── components/ # (e.g., CourseProgressCard) +│ └── ... +│ +├── shared/ # 跨角色共享 +│ ├── components/ # (e.g., CourseCard - if generic) +``` + +## 4. 路由与导航配置 (Navigation Config) + +`src/modules/layout/config/navigation.ts` 已经配置好了基于角色的菜单。 +* 当用户访问 `/dashboard` 时,根据角色看到不同的 Dashboard 组件。 +* 点击侧边栏菜单(如 "Exams")时,跳转到显式路径 `/teacher/exams`。 + +## 5. 优势 (Benefits) +1. **Security**: 可以在 Middleware 或 Layout 层级轻松对 `/admin/*` 路径实施权限控制。 +2. **Clarity**: 开发者清楚知道“教师的试卷列表页”应该放在 `src/app/(dashboard)/teacher/exams/page.tsx`。 +3. **Decoupling**: 教师端和学生端的逻辑完全解耦,互不影响。 + +--- + +## 6. Action Items (执行计划) + +1. **Refactor Dashboard**: 将 `src/app/(dashboard)/dashboard/page.tsx` 重构为 Dispatcher。 +2. **Create Role Directories**: 在 `src/app/(dashboard)` 下创建 `teacher`, `student`, `admin` 目录。 +3. **Move Components**: 确保 `src/modules` 结构清晰。 diff --git a/docs/db/schema-changelog.md b/docs/db/schema-changelog.md new file mode 100644 index 0000000..a696e1c --- /dev/null +++ b/docs/db/schema-changelog.md @@ -0,0 +1,39 @@ +# Database Schema Changelog + +## v1.1.0 - Exam Structure & Performance Optimization +**Date:** 2025-12-29 +**Migration ID:** `0001_flawless_texas_twister` +**Author:** Principal Database Architect + +### 1. Summary +This release introduces support for hierarchical exam structures (Sectioning/Grouping) and optimizes database constraint naming for better compatibility with MySQL environments. + +### 2. Changes + +#### 2.1 Table: `exams` +* **Action**: `ADD COLUMN` +* **Field**: `structure` (JSON) +* **Reason**: To support nested exam layouts (e.g., "Part I: Listening", "Section A"). + * *Architecture Note*: This JSON field is strictly for **Presentation Layer** ordering and grouping. The `exam_questions` table remains the **Source of Truth** for relational integrity and scoring logic. + * **Schema Definition**: + ```typescript + type ExamStructure = Array< + | { type: 'group', title: string, children: ExamStructure } + | { type: 'question', questionId: string, score: number } + > + ``` + +#### 2.2 Table: `questions_to_knowledge_points` +* **Action**: `RENAME FOREIGN KEY` +* **Details**: + * Old: `questions_to_knowledge_points_question_id_questions_id_fk` -> New: `q_kp_qid_fk` + * Old: `questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk` -> New: `q_kp_kpid_fk` +* **Reason**: Previous names exceeded MySQL's 64-character identifier limit, causing potential migration failures in production environments. + +### 3. Migration Strategy +* **Up**: Run standard Drizzle migration. The script includes `ALTER TABLE ... DROP FOREIGN KEY` followed by `ADD CONSTRAINT`. +* **Down**: Revert `structure` column. Note that FK names can be kept short as they are implementation details. + +### 4. Impact Analysis +* **Performance**: Negligible. JSON parsing is done client-side or at application layer. +* **Data Integrity**: High. Existing data is unaffected. New `structure` field defaults to `NULL`. diff --git a/docs/db/seed-data.md b/docs/db/seed-data.md new file mode 100644 index 0000000..90be4f9 --- /dev/null +++ b/docs/db/seed-data.md @@ -0,0 +1,75 @@ +# Database Seeding Strategy + +**Status**: Implemented +**Script Location**: [`scripts/seed.ts`](../../scripts/seed.ts) +**Command**: `npm run db:seed` + +--- + +## 1. Overview +The seed script is designed to populate the database with a **representative set of data** that covers all core business scenarios. It serves two purposes: +1. **Development**: Provides a consistent baseline for developers. +2. **Validation**: Verifies complex schema relationships (e.g., recursive trees, JSON structures). + +## 2. Seed Data Topology + +### 2.1 Identity & Access Management (IAM) +We strictly follow the RBAC model defined in `docs/architecture/001_database_schema_design.md`. + +* **Roles**: + * `admin`: System Administrator. + * `teacher`: Academic Instructor. + * `student`: Learner. + * `grade_head`: Head of Grade Year (Demonstrates multi-role capability). +* **Users**: + * `admin@next-edu.com` (Role: Admin) + * `math@next-edu.com` (Role: Teacher + Grade Head) + * `alice@next-edu.com` (Role: Student) + +### 2.2 Knowledge Graph +Generates a hierarchical structure to test recursive queries (`parentId`). +* **Math** (Level 0) + * └── **Algebra** (Level 1) + * └── **Linear Equations** (Level 2) + +### 2.3 Question Bank +Includes rich content and nested structures. +1. **Simple Single Choice**: "What is 2 + 2?" +2. **Composite Question (Reading Comprehension)**: + * **Parent**: A reading passage. + * **Child 1**: Single Choice question about the passage. + * **Child 2**: Open-ended text question. + +### 2.4 Exams +Demonstrates the new **JSON Structure** field (`exams.structure`). +* **Title**: "Algebra Mid-Term 2025" +* **Structure**: + ```json + [ + { + "type": "group", + "title": "Part 1: Basics", + "children": [{ "type": "question", "questionId": "...", "score": 10 }] + }, + { + "type": "group", + "title": "Part 2: Reading", + "children": [{ "type": "question", "questionId": "...", "score": 20 }] + } + ] + ``` + +## 3. How to Run + +### Prerequisites +Ensure your `.env` file contains a valid `DATABASE_URL`. + +### Execution +Run the following command in the project root: + +```bash +npm run db:seed +``` + +### Reset Behavior +**WARNING**: The script currently performs a **TRUNCATE** on all core tables before seeding. This ensures a clean state but will **WIPE EXISTING DATA**. diff --git a/docs/design/001_auth_ui_implementation.md b/docs/design/001_auth_ui_implementation.md new file mode 100644 index 0000000..c4a8e21 --- /dev/null +++ b/docs/design/001_auth_ui_implementation.md @@ -0,0 +1,76 @@ +# Auth UI Implementation Details + +**Date**: 2025-12-23 +**Author**: Senior Frontend Engineer +**Module**: Auth (`src/modules/auth`) + +--- + +## 1. 概述 (Overview) + +本文档记录了登录 (`/login`) 和注册 (`/register`) 页面的前端实现细节。遵循 Vertical Slice Architecture 和 Pixel-Perfect UI 规范。 + +## 2. 架构设计 (Architecture) + +### 2.1 目录结构 +所有认证相关的业务逻辑和组件均封装在 `src/modules/auth` 下,保持了高内聚。 + +``` +src/ +├── app/ +│ └── (auth)/ # 路由层 (Server Components) +│ ├── layout.tsx # 统一的 AuthLayout 容器 +│ ├── login/page.tsx +│ └── register/page.tsx +│ +├── modules/ +│ └── auth/ # 业务模块 +│ └── components/ # 模块私有组件 +│ ├── auth-layout.tsx # 左右分屏布局 +│ ├── login-form.tsx # 登录表单 (Client Component) +│ └── register-form.tsx # 注册表单 (Client Component) +``` + +### 2.2 渲染策略 +* **Server Components**: 页面入口 (`page.tsx`) 和布局 (`layout.tsx`) 默认为服务端组件,负责元数据 (`Metadata`) 和静态结构渲染。 +* **Client Components**: 表单组件 (`*-form.tsx`) 标记为 `'use client'`,处理交互逻辑(状态管理、表单提交、Loading 状态)。 + +## 3. UI/UX 细节 + +### 3.1 布局 (Layout) +采用 **Split Screen (分屏)** 设计: +* **左侧 (Desktop Only)**: + * 深色背景 (`bg-zinc-900`),强调品牌沉浸感。 + * 包含 Logo (`Next_Edu`) 和用户证言 (`Blockquote`)。 + * 使用 `hidden lg:flex` 实现响应式显隐。 +* **右侧**: + * 居中对齐的表单容器。 + * 移动端优先 (`w-full`),桌面端限制最大宽度 (`sm:w-[350px]`)。 + +### 3.2 交互 (Interactions) +* **Loading State**: 表单提交时按钮进入 `disabled` 状态并显示 `Loader2` 旋转动画。 +* **Micro-animations**: + * 按钮 Hover 效果。 + * 链接 Hover 下划线 (`hover:underline`). +* **Feedback**: 模拟了 3 秒的异步请求延迟,用于演示加载状态。 + +## 4. 错误处理 (Error Handling) + +### 4.1 模块级错误边界 +* **Scoped Error Boundary**: `src/app/(auth)/error.tsx` 仅处理 Auth 模块内的运行时错误。 + * 显示友好的 "Authentication Error" 提示。 + * 提供 "Try again" 按钮重置状态。 + +### 4.2 模块级 404 +* **Scoped Not Found**: `src/app/(auth)/not-found.tsx` 处理 Auth 模块内的无效路径。 + * 引导用户返回 `/login` 页面,防止用户迷失。 + +## 5. 组件复用 +* 使用了 `src/shared/components/ui` 中的标准 Shadcn 组件: + * `Button`, `Input`, `Label` (新增). +* 图标库统一使用 `lucide-react`. + +## 5. 后续计划 (Next Steps) +* [ ] 集成 `next-auth` (Auth.js) 进行实际的身份验证。 +* [ ] 添加 Zod Schema 进行前端表单验证。 +* [ ] 对接后端 API (`src/modules/auth/actions.ts`). diff --git a/docs/design/002_teacher_dashboard_implementation.md b/docs/design/002_teacher_dashboard_implementation.md new file mode 100644 index 0000000..f5924fd --- /dev/null +++ b/docs/design/002_teacher_dashboard_implementation.md @@ -0,0 +1,100 @@ +# 教师仪表盘实现与 Hydration 修复记录 + +**日期**: 2025-12-23 +**作者**: 资深前端工程师 (Senior Frontend Engineer) +**状态**: 已实现 + +## 1. 概述 + +本文档详细说明了教师仪表盘 (Teacher Dashboard) 的实现细节,该实现严格遵循 Next_Edu 设计系统 v1.3.0。文档还记录了开发过程中遇到的 Hydration 错误及其解决方案。 + +## 2. 组件架构 + +仪表盘采用垂直切片架构 (Vertical Slice Architecture),代码位于 `src/modules/dashboard`。 + +### 2.1 文件结构 +``` +src/modules/dashboard/ +└── components/ + ├── teacher-stats.tsx # 核心指标 (学生数, 课程数, 待批改作业) + ├── teacher-schedule.tsx # 今日日程列表 + ├── recent-submissions.tsx # 最近的学生提交记录 + └── teacher-quick-actions.tsx # 常用操作 (创建作业等) +``` + +### 2.2 设计系统集成 +所有组件严格遵循 v1.3.0 规范: +- **排版 (Typography)**: 使用 `Geist Sans`,数据展示开启 `tabular-nums`。 +- **色彩 (Colors)**: 使用语义化 HSL 变量 (`muted`, `primary`, `destructive`)。 +- **图标 (Icons)**: 使用 `lucide-react` (如 `Users`, `BookOpen`, `Inbox`)。 +- **状态 (States)**: + - **Loading**: 使用自定义骨架屏 (Skeleton),拒绝全屏 Spinner。 + - **Empty**: 使用 `EmptyState` 组件处理无数据场景。 + +## 3. 组件详情 + +### 3.1 TeacherStats (教师统计) +- **用途**: 展示教师当前状态的高层概览。 +- **特性**: + - 在响应式网格中展示 4 个关键指标。 + - 支持 `isLoading` 属性以渲染骨架屏。 + - 使用 `Card` 组件作为容器。 + +### 3.2 TeacherSchedule (教师日程) +- **用途**: 展示今日课程安排。 +- **特性**: + - 列出课程时间及地点。 + - 使用 Badge 区分 "Lecture" (讲座) 和 "Workshop" (研讨会)。 + - **空状态**: 当无日程时显示 "No Classes Today"。 + +### 3.3 RecentSubmissions (最近提交) +- **用途**: 追踪最新的学生活动。 +- **特性**: + - 展示学生头像、姓名、作业名称及时间。 + - "Late" (迟交) 状态指示器。 + - **空状态**: 当列表为空时显示 "No New Submissions"。 + +### 3.4 EmptyState Component (空状态组件) +- **位置**: `src/shared/components/ui/empty-state.tsx` +- **规范**: + - 虚线边框容器。 + - 居中图标 (Muted 背景)。 + - 清晰的标题和描述。 + - 可选的操作按钮插槽。 + +## 4. Hydration 错误修复 + +### 4.1 问题描述 +开发过程中观察到 "Hydration failed" 错误,原因是 HTML 嵌套无效。具体来说,是 `p` 标签内包含了块级元素(或 React 在 hydration 检查期间视为块级的元素)。 + +### 4.2 根本原因分析 +React 的 hydration 过程对 HTML 有效性要求极高。将 `div` 放入 `p` 标签中违反了 HTML5 标准,但浏览器通常会自动修正 DOM 结构,导致实际 DOM 与 React 基于虚拟 DOM 预期的结构不一致。 + +### 4.3 实施的修复 +将所有仪表盘组件中存在风险的 `p` 标签替换为 `div` 标签,以确保嵌套结构的健壮性。 + +**示例 (RecentSubmissions):** + +*修改前 (有风险):* +```tsx +

+ {item.studentName} +

+``` + +*修改后 (安全):* +```tsx +
+ {item.studentName} +
+``` + +**受影响的组件:** +1. `recent-submissions.tsx` +2. `teacher-stats.tsx` +3. `teacher-schedule.tsx` + +## 5. 下一步计划 +- 将 Mock Data 对接到真实的 API 端点 (React Server Actions)。 +- 实现 "Quick Actions" (快捷操作) 的具体功能。 +- 为 Submissions 和 Schedule 添加 "View All" (查看全部) 跳转导航。 diff --git a/docs/design/003_textbooks_module_implementation.md b/docs/design/003_textbooks_module_implementation.md new file mode 100644 index 0000000..7cead9a --- /dev/null +++ b/docs/design/003_textbooks_module_implementation.md @@ -0,0 +1,98 @@ +# Textbooks Module Implementation Details + +**Date**: 2025-12-23 +**Author**: DevOps Architect +**Module**: Textbooks (`src/modules/textbooks`) + +--- + +## 1. 概述 (Overview) + +本文档记录了教材模块 (`Textbooks Module`) 的全栈实现细节。该模块负责教材、章节结构及知识点映射的数字化管理,采用了 **Vertical Slice Architecture** 和 **Immersive Workbench** 交互设计。 + +## 2. 架构设计 (Architecture) + +### 2.1 目录结构 +所有教材相关的业务逻辑、数据访问和组件均封装在 `src/modules/textbooks` 下,实现了高度的模块化和隔离。 + +``` +src/ +├── app/ +│ └── (dashboard)/ +│ └── teacher/ +│ └── textbooks/ # 路由层 (Server Components) +│ ├── page.tsx # 列表页 +│ ├── loading.tsx # 列表骨架屏 +│ └── [id]/ # 详情页 +│ ├── page.tsx +│ └── loading.tsx +│ +├── modules/ +│ └── textbooks/ # 业务模块 +│ ├── actions.ts # Server Actions (增删改) +│ ├── data-access.ts # 数据访问层 (Mock/DB) +│ ├── types.ts # 类型定义 (Schema-aligned) +│ └── components/ # 模块私有组件 +│ ├── textbook-content-layout.tsx # [核心] 三栏布局工作台 +│ ├── chapter-sidebar-list.tsx # 递归章节树 +│ ├── knowledge-point-panel.tsx # 知识点管理面板 +│ ├── create-chapter-dialog.tsx # 章节创建弹窗 +│ └── ... (其他交互组件) +``` + +### 2.2 渲染策略 +* **Server Components**: 页面入口 (`page.tsx`) 负责初始数据获取 (Data Fetching),利用 `Promise.all` 并行拉取教材、章节和知识点数据,实现 "Render-as-you-fetch"。 +* **Client Components**: 工作台布局 (`textbook-content-layout.tsx`) 标记为 `'use client'`,接管后续的所有交互逻辑(状态管理、局部更新、弹窗控制),提供类似 SPA 的流畅体验。 + +## 3. UI/UX 细节 + +### 3.1 布局 (Layout) +采用 **Immersive Workbench (沉浸式工作台)** 三栏设计: +* **左侧 (Navigation)**: + * 展示递归的章节树 (`Recursive Tree`)。 + * 支持折叠/展开,清晰展示教材结构。 + * 顶部提供 "+" 按钮快速创建新章节。 +* **中间 (Content)**: + * **阅读模式**: 渲染 Markdown 格式的章节正文。 + * **编辑模式**: 提供 `Textarea` 进行内容创作,支持实时保存。 +* **右侧 (Context)**: + * **上下文感知**: 仅显示当前选中章节的关联知识点。 + * 提供知识点的快速添加 (`Dialog`) 和删除操作。 + +### 3.2 交互 (Interactions) +* **Selection State**: 点击左侧章节,中间和右侧区域即时更新,无需页面跳转。 +* **Optimistic UI**: 虽然使用 Server Actions,但通过本地状态 (`useState`) 实现了操作的即时反馈(如保存正文后立即退出编辑模式)。 +* **Feedback**: 使用 `sonner` (`toast`) 提供操作成功或失败的提示。 + +## 4. 数据流与逻辑 (Data Flow) + +### 4.1 Server Actions +所有数据变更操作均通过 `src/modules/textbooks/actions.ts` 定义的 Server Actions 处理: +* `createChapterAction`: 创建章节(支持嵌套)。 +* `updateChapterContentAction`: 更新正文内容。 +* `createKnowledgePointAction`: 创建知识点并自动关联当前章节。 +* `updateTextbookAction`: 更新教材元数据(Title, Subject, Grade, Publisher)。 +* `deleteTextbookAction`: 删除教材及其关联数据。 +* `delete...Action`: 处理删除逻辑。 + +### 4.2 数据访问层 (Data Access) +* **Mock Implementation**: 目前在 `data-access.ts` 中使用内存数组模拟数据库操作,并人为增加了延迟 (`setTimeout`) 以测试 Loading 状态。 +* **Type Safety**: 定义了严格的 TypeScript 类型 (`Chapter`, `KnowledgePoint`, `UpdateTextbookInput`),确保前后端数据契约一致。 + +## 5. 组件复用 +* 使用了 `src/shared/components/ui` 中的 Shadcn 组件: + * `Dialog`, `ScrollArea`, `Card`, `Button`, `Input`, `Textarea`, `Select`. + * `Collapsible` 用于实现递归章节树。 +* 图标库统一使用 `lucide-react`. + +## 6. Settings 功能实现 (New) +* **入口**: 详情页右上角的 "Settings" 按钮。 +* **组件**: `TextbookSettingsDialog`。 +* **功能**: + * **Edit**: 修改教材的基本信息。 + * **Delete**: 提供红色删除按钮,二次确认后执行删除并跳转回列表页。 + +## 7. 后续计划 (Next Steps) +* [ ] **富文本编辑器**: 集成 Tiptap 替换现有的 Markdown Textarea,支持更丰富的格式。 +* [ ] **拖拽排序**: 实现章节树的拖拽排序 (`dnd-kit`)。 +* [ ] **数据库对接**: 将 `data-access.ts` 中的 Mock 逻辑替换为真实的 `drizzle-orm` 数据库调用。 diff --git a/docs/design/004_question_bank_implementation.md b/docs/design/004_question_bank_implementation.md new file mode 100644 index 0000000..8a8b827 --- /dev/null +++ b/docs/design/004_question_bank_implementation.md @@ -0,0 +1,87 @@ +# Question Bank Module Implementation + +## 1. Overview +The Question Bank module (`src/modules/questions`) is a core component for teachers to manage their examination resources. It implements a comprehensive CRUD interface with advanced filtering and batch operations. + +**Status**: IMPLEMENTED +**Date**: 2025-12-23 +**Author**: Senior Frontend Engineer + +--- + +## 2. Architecture & Tech Stack + +### 2.1 Vertical Slice Architecture +Following the project's architectural guidelines, all question-related logic is encapsulated within `src/modules/questions`: +- `components/`: UI components (Data Table, Dialogs, Filters) +- `actions.ts`: Server Actions for data mutation +- `data-access.ts`: Database query logic +- `schema.ts`: Zod schemas for validation +- `types.ts`: TypeScript interfaces + +### 2.2 Key Technologies +- **Data Grid**: `@tanstack/react-table` for high-performance rendering. +- **State Management**: `nuqs` for URL-based state (filters, search). +- **Forms**: `react-hook-form` + `zod` + `shadcn/ui` form components. +- **Validation**: Strict server-side and client-side validation using Zod schemas. + +--- + +## 3. Component Design + +### 3.1 QuestionDataTable (`question-data-table.tsx`) +- **Features**: Pagination, Sorting, Row Selection. +- **Performance**: Uses `React.memo` compatible patterns where possible (though `useReactTable` itself is not memoized). +- **Responsiveness**: Mobile-first design with horizontal scroll for complex columns. + +### 3.2 QuestionColumns (`question-columns.tsx`) +Custom cell renderers for rich data display: +- **Type Badge**: Color-coded badges for different question types (Single Choice, Multiple Choice, etc.). +- **Difficulty**: Visual indicator with color (Green -> Red) and numerical value. +- **Actions**: Dropdown menu for Edit, Delete, View Details, and Copy ID. + +### 3.3 Create/Edit Dialog (`create-question-dialog.tsx`) +A unified dialog component for both creating and editing questions. +- **Dynamic Fields**: Shows/hides "Options" field based on question type. +- **Interactive Options**: Allows adding/removing/reordering options for choice questions. +- **Optimistic UI**: Shows loading states during submission. + +### 3.4 Filters (`question-filters.tsx`) +- **URL Sync**: All filter states (Search, Type, Difficulty) are synced to URL parameters. +- **Debounce**: Search input uses debounce to prevent excessive requests. +- **Server Filtering**: Filtering logic is executed on the server side (currently simulated in `page.tsx`, ready for DB integration). + +--- + +## 4. Implementation Details + +### 4.1 Data Flow +1. **Read**: `page.tsx` (Server Component) fetches data based on `searchParams`. +2. **Write**: Client components invoke Server Actions (simulated) -> Revalidate Path -> UI Updates. +3. **Filter**: User interaction -> Update URL -> Server Component Re-render -> New Data. + +### 4.2 Type Safety +A shared `Question` interface ensures consistency across the stack: +```typescript +export interface Question { + id: string; + content: any; // Rich text structure + type: QuestionType; + difficulty: number; + // ... relations +} +``` + +### 4.3 UI/UX Standards +- **Empty States**: Custom `EmptyState` component when no data matches. +- **Loading States**: Skeleton screens for table loading. +- **Feedback**: `Sonner` toasts for success/error notifications. +- **Confirmation**: `AlertDialog` for destructive actions (Delete). + +--- + +## 5. Next Steps +- [ ] Integrate with real Database (replace Mock Data). +- [ ] Implement Rich Text Editor (Slate.js / Tiptap) for question content. +- [ ] Add "Batch Import" functionality. +- [ ] Implement "Tags" management for Knowledge Points. diff --git a/docs/design/005_exam_module_implementation.md b/docs/design/005_exam_module_implementation.md new file mode 100644 index 0000000..a6c0f68 --- /dev/null +++ b/docs/design/005_exam_module_implementation.md @@ -0,0 +1,90 @@ +# 考试模块实现设计文档 + +## 1. 概述 +考试模块提供了一个完整的评估管理生命周期,使教师能够创建考试、组卷(支持嵌套分组)、发布评估以及对学生的提交进行评分。 + +## 2. 数据架构 + +### 2.1 核心实体 +- **Exams**: 根实体,包含元数据(标题、时间安排)和结构信息。 +- **ExamQuestions**: 关系链接,用于查询题目的使用情况(扁平化表示)。 +- **ExamSubmissions**: 学生的考试尝试记录。 +- **SubmissionAnswers**: 链接到特定题目的单个答案。 + +### 2.2 `structure` 字段 +为了支持层级布局(如章节/分组),我们在 `exams` 表中引入了一个 JSON 列 `structure`。这作为考试布局的“单一事实来源”(Source of Truth)。 + +**JSON Schema:** +```typescript +type ExamNode = { + id: string; // 节点的唯一 UUID + type: 'group' | 'question'; + title?: string; // 'group' 类型必填 + questionId?: string; // 'question' 类型必填 + score?: number; // 在此考试上下文中的分值 + children?: ExamNode[]; // 'group' 类型的递归子节点 +} +``` + +## 3. 组件架构 + +### 3.1 组卷(构建器) +位于 `/teacher/exams/[id]/build`。 + +- **`ExamAssembly` (客户端组件)** + - 管理 `structure` 状态树。 + - 处理“添加题目”、“添加章节”、“移除”和“重新排序”操作。 + - 实时计算总分和进度。 +- **`StructureEditor` (客户端组件)** + - 基于 `@dnd-kit` 构建。 + - 提供嵌套的可排序(Sortable)界面。 + - 支持在组内/组间拖拽题目(当前优化为 2 层深度)。 +- **`QuestionBankList`** + - 可搜索/筛选的可用题目列表。 + - “添加”操作将节点追加到结构树中。 + +### 3.2 阅卷界面 +位于 `/teacher/exams/grading/[submissionId]`。 + +- **`GradingView` (客户端组件)** + - **左侧面板**: 只读视图,显示学生的答案与题目内容。 + - **右侧面板**: 评分和反馈的输入字段。 + - **状态**: 在提交前管理本地更改。 +- **Actions**: `gradeSubmissionAction` 更新 `submissionAnswers` 并将总分聚合到 `examSubmissions`。 + +## 4. 关键工作流 + +### 4.1 创建与构建考试 +1. **创建**: 教师输入基本信息(标题、科目)。数据库创建记录(草稿状态)。 +2. **构建**: + - 教师打开“构建”页面。 + - 服务器从数据库 Hydrate(注水)`initialStructure`。 + - 教师从题库拖拽题目到结构树。 + - 教师创建章节(分组)。 + - **保存**: 同时提交 `questionsJson`(扁平化,用于索引)和 `structureJson`(树状,用于布局)到 `updateExamAction`。 +3. **发布**: 状态变更为 `published`。 + +### 4.2 阅卷流程 +1. **列表**: 教师查看 `submission-data-table`。 +2. **评分**: 打开特定提交。 +3. **审查**: 遍历题目。 + - 系统显示学生答案。 + - 教师输入分数(上限为满分)和反馈。 +4. **提交**: 服务器更新单个答案记录并重新计算提交总分。 + +## 5. 技术决策 + +### 5.1 混合存储策略 +我们在存储考试题目时采用了 **混合方法**: +- **关系型 (`exam_questions`)**: 用于“查找所有使用题目 X 的考试”查询和外键约束。 +- **文档型 (`exams.structure`)**: 用于渲染嵌套 UI 和保留任意排序/分组。 +*理由*: 这结合了 SQL 的完整性和 NoSQL 在 UI 布局上的灵活性。 + +### 5.2 拖拽功能 +使用 `@dnd-kit` 代替旧库,因为: +- 更好的无障碍支持(键盘支持)。 +- 模块化架构(Sensors, Modifiers)。 +- 面向未来(现代 React Hooks 模式)。 + +### 5.3 Server Actions +所有变更操作(保存草稿、发布、评分)均使用 Next.js Server Actions,以确保类型安全并自动重新验证缓存。 diff --git a/docs/design/design_system.md b/docs/design/design_system.md new file mode 100644 index 0000000..1dd4dab --- /dev/null +++ b/docs/design/design_system.md @@ -0,0 +1,258 @@ +# Next_Edu Design System Specs + +**Version**: 1.4.0 (Updated) +**Status**: ACTIVE +**Role**: Chief Creative Director +**Philosophy**: "Data as Art" - Clean, Minimalist, Information-Dense. + +--- + +## 1. 核心理念 (Core Philosophy) + +Next_Edu 旨在对抗教育系统常见的信息过载。我们的设计风格深受 **International Typographic Style (国际主义设计风格)** 影响。 + +* **Precision (精准)**: 每一个像素的留白都有其目的。 +* **Clarity (清晰)**: 通过排版和对比度区分层级,而非装饰性的色块。 +* **Efficiency (效率)**: 专为高密度数据操作优化,减少视觉噪音。 + +--- + +## 2. 视觉基础 (Visual Foundation) + +### 2.1 色彩系统 (Color System) + +我们放弃 Hex 直选,全面采用 **HSL 语义化变量** 以支持完美的多主题适配。 + +#### **Base (基调)** +* **Neutral**: Zinc (锌色). 冷峻、纯净,无偏色。 +* **Brand**: Deep Indigo (深靛蓝). + * `Primary`: 专业、权威,避免幼稚的高饱和蓝。 + * 语义: `hsl(var(--primary))` + +#### **Functional (功能色)** +| 语义 | 色系 | 用途 | +| :--- | :--- | :--- | +| **Destructive** | Red | 删除、危险操作、系统错误 | +| **Warning** | Amber | 需注意的状态、非阻断性警告 | +| **Success** | Emerald | 操作成功、状态正常 | +| **Info** | Blue | 一般性提示、帮助信息 | + +#### **Surface Hierarchy (层级)** +1. **Background**: 应用底层背景。 +2. **Card**: 内容承载容器,轻微提升层级。 +3. **Popover**: 悬浮层、下拉菜单,最高层级。 +4. **Muted**: 用于次级信息或禁用状态背景。 + +### 2.2 排版 (Typography) + +* **Font Family**: `Geist Sans` > `Inter` > `System UI`. + * *Requirement*: 必须开启 `tabular-nums` (等宽数字) 特性,确保表格数据对齐。 +* **Scale**: + * **Base Body**: 14px (0.875rem) - 提升信息密度。 + * **H1 (Page Title)**: 24px, Tracking -0.02em, Weight 600. + * **H2 (Section Title)**: 20px, Tracking -0.01em, Weight 600. + * **H3 (Card Title)**: 16px, Weight 600. + * **Tiny/Caption**: 12px, Text-Muted-Foreground. + +### 2.3 质感 (Look & Feel) + +* **Borders**: `1px solid var(--border)`. 界面骨架,以此分割区域而非背景色块。 +* **Radius**: + * `sm` (4px): Badges, Checkboxes. + * `md` (8px): **Default**. Buttons, Inputs, Cards. + * `lg` (12px): Modals, Dialogs. +* **Shadows**: + * Default: None (Flat). + * Hover: `shadow-sm` (仅用于可交互元素). + * Dropdown/Popover: `shadow-md`. + * *Ban*: 禁止使用大面积弥散阴影。 + +--- + +## 3. 核心布局 (App Shell) + +### 3.1 架构 (Architecture) +我们采用了 `SidebarProvider` + `AppSidebar` 的组合模式,确保了布局的灵活性和移动端的完美适配。 + +* **Provider**: `SidebarProvider` (src/modules/layout/components/sidebar-provider.tsx) + * 管理侧边栏状态 (`expanded`, `isMobile`). + * 负责在移动端渲染 Sheet (Drawer)。 + * 负责在桌面端渲染 Sticky Sidebar。 + * **Key Prop**: `sidebar` (显式传递侧边栏组件)。 + +### 3.2 布局结构 +``` ++-------------------------------------------------------+ +| Sidebar | Header (Sticky) | +| |-------------------------------------------| +| (Collap- | Main Content | +| sible) | | +| | +-------------------------------------+ | +| | | Card | | +| | | +---------------------------------+ | | +| | | | Data Table | | | +| | | +---------------------------------+ | | +| | +-------------------------------------+ | +| | | ++-------------------------------------------------------+ +``` + +### 3.3 详细规范 + +#### **Sidebar (侧边栏)** +* **Width**: Expanded `260px` | Collapsed `64px` | Mobile `Sheet (Drawer)`. +* **Behavior**: + * Desktop: 固定左侧,支持折叠。 + * Mobile: 默认隐藏,点击汉堡菜单从左侧滑出。 +* **Navigation Item**: + * Height: `36px` (Compact). + * State: + * `Inactive`: `text-muted-foreground hover:text-foreground`. + * `Active`: `bg-sidebar-accent text-sidebar-accent-foreground font-medium`. + +#### **Header (顶栏)** +* **Height**: `64px` (h-16). +* **Layout**: `flex items-center justify-between px-6 border-b`. +* **Components**: + 1. **Breadcrumb**: 显示当前路径,层级清晰。 + 2. **Global Search**: `Cmd+K` 触发,居中或靠右。 + 3. **User Nav**: 头像 + 下拉菜单。 + +#### **Main Content (内容区)** +* **Padding**: `p-6` (Desktop) / `p-4` (Mobile). +* **Max Width**: `max-w-[1600px]` (默认) 或 `w-full` (针对超宽报表)。 + +--- + +## 4. 导航与角色系统 (Navigation & Roles) + +Next_Edu 支持多角色(Multi-Tenant / Role-Based)导航系统。 + +### 4.1 配置文件 +导航结构已从 UI 组件中解耦,统一配置在: +`src/modules/layout/config/navigation.ts` + +### 4.2 支持的角色 (Roles) +系统内置支持以下角色,每个角色拥有独立的侧边栏菜单结构: +* **Admin**: 系统管理员,拥有所有管理权限 (School Management, User Management, Finance)。 +* **Teacher**: 教师,关注班级管理 (My Classes)、成绩录入 (Gradebook) 和日程。 +* **Student**: 学生,关注课程学习 (My Learning) 和作业提交。 +* **Parent**: 家长,关注子女动态和学费缴纳。 + +### 4.3 开发与调试 +* **View As (Dev Mode)**: 在开发环境下,侧边栏顶部提供 "View As" 下拉菜单,允许开发者实时切换角色视角,预览不同角色的导航结构。 +* **Implementation**: `AppSidebar` 组件通过读取 `NAV_CONFIG[currentRole]` 动态渲染菜单项。 + +--- + +## 5. 错误处理与边界情况 (Error Handling & Boundaries) + +系统必须优雅地处理错误和边缘情况,避免白屏或无反馈。 + +### 5.1 全局错误边界 (Global Error Boundary) +* **Scope**: 捕获渲染期间的未处理异常。 +* **UI**: 显示友好的错误页面(非技术堆栈信息),提供 "Try Again" 按钮重置状态。 +* **Implementation**: 使用 React `ErrorBoundary` 或 Next.js `error.tsx`。 + +### 5.2 404 Not Found +* **Design**: 必须保留 App Shell (Sidebar + Header),仅在 Main Content 区域显示 404 提示。 +* **Content**: "Page not found" 文案 + 返回 Dashboard 的主操作按钮。 + +### 5.3 空状态 (Empty States) +当列表或表格无数据时,**严禁**只显示空白。 +* **Component**: `EmptyState`。 +* **Composition**: + 1. **Icon**: 线性风格图标 (muted foreground). + 2. **Title**: 简短说明 (e.g., "No students found"). + 3. **Description**: 解释原因或下一步操作 (e.g., "Add a student to get started"). + 4. **Action**: (可选) "Create New" 按钮。 + +### 5.4 加载状态 (Loading States) +* **Initial Load**: 使用 `Skeleton` 骨架屏,模拟内容布局,避免 CLS (Content Layout Shift)。禁止使用全屏 Spinner。 +* **Action Loading**: 按钮点击后进入 `disabled` + `spinner` 状态。 +* **Table Loading**: 表格内容区域显示 3-5 行 Skeleton Rows。 + +### 5.5 表单验证 (Form Validation) +* **Style**: 错误信息显示在输入框下方,字号 `text-xs`,颜色 `text-destructive`。 +* **Input**: 边框变红 (`border-destructive`)。 + +--- + +## 6. 职责边界与协作 (Responsibility Boundaries) + +**[IMPORTANT] 严禁越界修改 (Strict No-Modification Policy)** + +为了维护大型项目的可维护性,UI 工程师和开发人员必须遵守以下边界规则: + +### 6.1 模块化原则 (Modularity) +* **Scope**: 开发者仅应对分配给自己的模块负责。例如,负责 "Dashboard" 的开发者**不应**修改 "Sidebar" 或 "Auth" 模块的代码。 +* **Dependencies**: 如果你的模块依赖于其他模块的变更,**必须**先与该模块的负责人沟通,或在 PR 中明确标注。 + +### 6.2 共享组件 (Shared Components) +* **Immutable Core**: `src/shared/components/ui` 下的基础组件(如 Button, Card)视为**核心库**。 +* **Extension**: 如果基础组件不能满足需求,优先考虑组合(Composition)或创建新的业务组件,而不是修改核心组件的源码。 +* **Modification Request**: 只有在发现严重 Bug 或需要全局样式调整时,才允许修改核心组件,且必须经过 Design Lead 审批。 + +### 6.3 样式一致性 (Consistency) +* **Global CSS**: `globals.css` 定义了系统的物理法则。严禁在局部组件中随意覆盖全局 CSS 变量。 +* **Tailwind Config**: 禁止随意在组件中添加任意值(Arbitrary Values, e.g., `w-[123px]`),必须使用 Design Token。 + +--- + +## 7. 组件设计规范 (Component Specs) + +### 7.1 Card (卡片) +卡片是信息组织的基本单元。 +* **Class**: `bg-card text-card-foreground border rounded-lg shadow-none`. +* **Header**: `p-6 pb-2`. Title (`font-semibold leading-none tracking-tight`). +* **Content**: `p-6 pt-0`. + +### 7.2 Data Table (数据表格) +教务系统的核心组件。 +* **Density**: + * `Default`: Row Height `48px` (h-12). + * `Compact`: Row Height `36px` (h-9). +* **Header**: `bg-muted/50 text-muted-foreground text-xs uppercase font-medium`. +* **Stripes**: 默认关闭。仅在列数 > 8 时开启 `even:bg-muted/50`。 +* **Actions**: 行操作按钮应默认隐形 (`opacity-0`),Hover 时显示 (`group-hover:opacity-100`),减少视觉干扰。 + +### 7.3 Feedback (反馈与通知) +* **Toast**: 使用 `Sonner` 组件。 + * 位置: 默认右下角 (Bottom Right). + * 样式: 极简黑白风格 (跟随主题),支持撤销操作。 + * 调用: `toast("Event has been created", { description: "Sunday, December 03, 2023 at 9:00 AM" })`. +* **Skeleton**: 加载状态必须使用 Skeleton 骨架屏,禁止使用全屏 Spinner。 +* **Badge**: 状态指示器。 + * `default`: 主要状态 (Primary). + * `secondary`: 次要状态 (Neutral). + * `destructive`: 错误/警告状态 (Error). + * `outline`: 描边风格 (Subtle). + +--- + +## 8. 开发指南 (Developer Guide) + +### 8.1 CSS Variables +所有颜色和圆角均通过 CSS 变量控制,定义在 `globals.css` 中。禁止在代码中 Hardcode 颜色值 (如 `#FFFFFF`, `rgb(0,0,0)` )。 + +### 8.2 Tailwind Utility 优先 +优先使用 Tailwind Utility Classes。 +* ✅ `text-sm text-muted-foreground` +* ❌ `.custom-text-class { font-size: 14px; color: #666; }` + +### 8.3 Dark Mode +设计系统原生支持深色模式。只要正确使用语义化颜色变量(如 `bg-background`, `text-foreground`),Dark Mode 将自动完美适配,无需额外编写 `dark:` 修饰符(除非为了特殊调整)。 + +### 8.4 组件库引用 +所有 UI 组件位于 `src/shared/components/ui`。 +* `Button`: 基础按钮 +* `Input`: 输入框 +* `Select`: 下拉选择器 (New) +* `Sheet`: 侧边栏/抽屉 +* `Sonner`: Toast 通知 +* `Badge`: 徽章/标签 +* `Skeleton`: 加载占位符 +* `DropdownMenu`: 下拉菜单 +* `Avatar`: 头像 +* `Label`: 表单标签 +* `EmptyState`: 空状态占位 (New) diff --git a/docs/product_requirements.md b/docs/product_requirements.md new file mode 100644 index 0000000..063c6f0 --- /dev/null +++ b/docs/product_requirements.md @@ -0,0 +1,180 @@ +# Next_Edu 产品需求文档 (PRD) - K12 智慧教学管理系统 + +**版本**: 2.0.0 (K12 Enterprise Edition) +**状态**: 规划中 +**最后更新**: 2025-12-22 +**作者**: Senior EdTech Product Manager +**适用范围**: 全校级教学管理 (教-考-练-评) + +--- + +## 1. 角色与权限矩阵 (Complex Role Matrix) + +本系统采用基于 RBAC (Role-Based Access Control) 的多维权限设计,并结合 **行级安全 (Row-Level Security, RLS)** 策略,确保数据隔离与行政管理的精确匹配。 + +### 1.1 角色定义与核心职责 + +| 角色 | 核心职责 | 权限特征 (Scope) | +| :--- | :--- | :--- | +| **系统管理员 (Admin)** | 基础数据维护、账号管理、学期设置 | 全局系统配置,不可触碰教学业务数据内容(隐私保护)。 | +| **校长 (Principal)** | 全校教学概况监控、宏观统计报表 | **全校可见**。查看所有年级、学科的统计数据(平均分、作业完成率),无修改具体的题目/作业权限。 | +| **年级主任 (Grade Head)** | 本年级行政管理、班级均衡度分析 | **年级可见**。管理本年级所有行政班级;查看本年级跨学科对比;无权干涉其他年级。 | +| **教研组长 (Subject Head)** | 学科资源建设、命题质量把控 | **学科可见**。管理本学科公共题库、教案模板;查看全校该学科教学质量;无权查看其他学科详情。 | +| **班主任 (Class Teacher)** | 班级学生管理、家校通知、综合评价 | **行政班可见**。查看本班所有学生的跨学科成绩、考勤;发布班级公告。 | +| **任课老师 (Teacher)** | 备课、出卷、批改、个别辅导 | **教学班可见**。仅能操作自己所教班级的该学科作业/考试;私有题库管理。 | +| **学生 (Student)** | 完成作业、参加考试、查看错题本 | **个人可见**。仅能访问分配给自己的任务;查看个人成长档案。 | + +### 1.2 关键权限辨析:年级主任 vs 教研组长 + +* **维度差异**: + * **年级主任 (横向管理)**: 关注的是 **"人" (People & Administration)**。例如:高一(3)班的整体纪律如何?高一年级整体是否在期中考试中达标?他们需要跨学科的数据视图(如:某学生是否偏科)。 + * **教研组长 (纵向管理)**: 关注的是 **"内容" (Content & Pedagogy)**。例如:英语科目的“阅读理解”题型得分率全校是否偏低?公共题库的题目质量如何?他们需要跨年级但单学科的深度视图。 + +* **数据可见性 (RLS 策略)**: + * `GradeHead_View`: `WHERE class.grade_id = :current_user_grade_id` + * `SubjectHead_View`: `WHERE course.subject_id = :current_user_subject_id` (可能跨所有年级) + +--- + +## 2. 核心功能模块深度拆解 + +### 2.1 智能题库中心 (Smart Question Bank) + +这是系统的核心资产库,必须支持高复杂度的题目结构。 + +* **多层嵌套题目结构 (Nested Questions)**: + * **场景**: 英语完形填空、语文现代文阅读、理综大题。 + * **逻辑**: 引入 **"题干 (Stem)"** 与 **"子题 (Sub-question)"** 的概念。 + * **父题 (Parent)**: 承载公共题干(如一篇 500 字的文章、一张物理实验图表)。通常不直接设分,或者设总分。 + * **子题 (Child)**: 依附于父题,是具体的答题点(选择、填空、简答)。每个子题有独立的分值、答案和解析。 + * **交互**: 组卷时,拖动“父题”,所有“子题”必须作为一个原子整体跟随移动,不可拆分。 + +* **知识点图谱 (Knowledge Graph)**: + * **结构**: 树状结构 (Tree)。例如:`数学 -> 代数 -> 函数 -> 二次函数 -> 二次函数的图像`。 + * **关联**: + * **多对多 (Many-to-Many)**: 一道题可能考察多个知识点(综合题)。 + * **权重**: (可选高级需求) 标记主要考点与次要考点。 + +### 2.2 课本与大纲映射 (Textbook & Curriculum) + +* **课本数字化**: + * 系统预置主流教材版本 (如人教版、北师大版)。 + * **核心映射**: `Textbook Chapter` (课本章节) <--> `Knowledge Point` (知识点)。 + * **价值**: 老师备课时,只需选择“必修一 第一章”,系统自动推荐关联的“集合”相关题目,无需手动去海量题库搜索。 + +### 2.3 试卷/作业组装引擎 (Assembly Engine) + +* **智能筛选**: 支持交集筛选(同时包含“力学”和“三角函数”的题目)。 +* **AB 卷生成**: 针对防作弊场景,支持题目乱序或选项乱序(Shuffle)。 +* **作业分层**: 支持“必做题”与“选做题”设置,满足分层教学需求。 + +### 2.4 消息通知中心 (Notification System) + +分级分策略的消息分发: + +* **强提醒 (High Priority)**: 系统公告、考试开始提醒。通过站内信 + 弹窗 + (集成)短信/微信模板消息。 +* **业务流 (Medium Priority)**: 作业发布、成绩出炉。站内红点 + 列表推送。 +* **弱提醒 (Low Priority)**: 错题本更新、周报生成。仅在进入相关模块时提示。 + +--- + +## 3. 数据实体关系推演 (Data Entity Relationships) + +基于 MySQL 关系型数据库的设计方案。 + +### 3.1 核心实体模型 (ER Draft) + +1. **SysUser**: `id`, `username`, `role`, `school_id` +2. **TeacherProfile**: `user_id`, `is_grade_head`, `is_subject_head` +3. **Class**: `id`, `grade_level` (e.g., 10), `class_name` (e.g., "3班"), `homeroom_teacher_id` +4. **Subject**: `id`, `name` (e.g., "Math") +5. **Course**: `id`, `class_id`, `subject_id`, `teacher_id` (核心教学关系表: 谁教哪个班的哪门课) + +### 3.2 题库与知识点设计 (关键难点) + +#### Table: `knowledge_points` (知识点树) +* `id`: UUID +* `subject_id`: FK +* `name`: VARCHAR +* `parent_id`: UUID (Self-reference, Root is NULL) +* `level`: INT (1, 2, 3...) +* `code`: VARCHAR (e.g., "M-ALG-01-02" 用于快速检索) + +#### Table: `questions` (支持嵌套) +* `id`: UUID +* `content`: TEXT (HTML/Markdown, store images as URLs) +* `type`: ENUM ('SINGLE', 'MULTI', 'FILL', 'ESSAY', 'COMPOSITE') +* `parent_id`: UUID (Self-reference, **核心设计**) + * If `NULL`: 这是一道独立题目 OR 复合题的大题干。 + * If `NOT NULL`: 这是一个子题目,属于 `parent_id` 对应的题干。 +* `difficulty`: INT (1-5) +* `answer`: TEXT (JSON structure for structured answers) +* `analysis`: TEXT (解析) +* `created_by`: FK (Teacher) +* `scope`: ENUM ('PUBLIC', 'PRIVATE') + +#### Table: `question_knowledge` (题目-知识点关联) +* `question_id`: FK +* `knowledge_point_id`: FK +* **Primary Key**: (`question_id`, `knowledge_point_id`) + +### 3.3 课本映射设计 + +#### Table: `textbooks` +* `id`: UUID +* `name`: VARCHAR +* `grade_level`: INT +* `subject_id`: FK + +#### Table: `textbook_chapters` +* `id`: UUID +* `textbook_id`: FK +* `name`: VARCHAR +* `parent_id`: UUID (Sections within Chapters) +* `content`: TEXT (Rich text content of the chapter/section) -- [ADDED for Content Viewing] +* `order`: INT + +#### Table: `chapter_knowledge_mapping` +* `chapter_id`: FK +* `knowledge_point_id`: FK +* *解释*: 这张表是连接“教学进度”与“底层知识”的桥梁。 + +--- + +## 4. 关键业务流程 (User Flows) + +### 4.1 智能组卷与发布流程 (Exam Creation Flow) + +这是一个高频且复杂的路径,需要极高的流畅度。 + +1. **启动组卷**: + * 老师进入 [教学工作台] -> 点击 [新建试卷/作业]。 + * 输入基本信息(名称、考试时长、总分)。 + +2. **设定范围 (锚定课本)**: + * 老师选择教材版本:`人教版高中数学必修一`。 + * 选择章节:勾选 `第一章 集合` 和 `第二章 函数概念`。 + * *系统动作*: 后台查询 `chapter_knowledge_mapping`,提取出这几章对应的所有 `knowledge_points`。 + +3. **筛选题目**: + * 系统展示题目列表,默认过滤条件为上述提取的知识点。 + * 老师增加筛选:`难度: 中等`, `题型: 选择题`。 + * **处理嵌套题**: 如果筛选结果包含一个“完形填空”的子题,系统在 UI 上必须**强制展示**其对应的父题干,并提示老师“需整体添加”。 + +4. **加入试题篮 (Cart)**: + * 老师点击“+”号。 + * 试题篮动态更新:`当前题目数: 15, 预计总分: 85`。 + +5. **试卷精修 (Refine)**: + * 进入“试卷预览”模式。 + * 调整题目顺序 (Drag & drop)。 + * 修改某道题的分值(覆盖默认分值)。 + +6. **发布设置**: + * 选择发布对象:`高一(3)班`, `高一(5)班` (基于 `Course` 表权限)。 + * 设置时间:`开始时间`, `截止时间`。 + * 发布模式:`在线作答` 或 `线下答题卡` (若线下,系统生成 PDF 和答题卡样式)。 + +7. **完成**: + * 学生端收到 `Notifications` 推送。 + * `Exams` 表生成记录,`ExamAllocations` 表为每个班级/学生生成状态记录。 diff --git a/docs/scripts/reset-db.ts b/docs/scripts/reset-db.ts new file mode 100644 index 0000000..b574a56 --- /dev/null +++ b/docs/scripts/reset-db.ts @@ -0,0 +1,35 @@ +import "dotenv/config" +import { db } from "@/shared/db" +import { sql } from "drizzle-orm" + +async function reset() { + console.log("🔥 Resetting database...") + + // Disable foreign key checks + await db.execute(sql`SET FOREIGN_KEY_CHECKS = 0;`) + + // Get all table names + const tables = await db.execute(sql` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = DATABASE(); + `) + + // Drop each table + for (const row of (tables[0] as unknown as any[])) { + const tableName = row.TABLE_NAME || row.table_name + console.log(`Dropping table: ${tableName}`) + await db.execute(sql.raw(`DROP TABLE IF EXISTS \`${tableName}\`;`)) + } + + // Re-enable foreign key checks + await db.execute(sql`SET FOREIGN_KEY_CHECKS = 1;`) + + console.log("✅ Database reset complete.") + process.exit(0) +} + +reset().catch((err) => { + console.error("❌ Reset failed:", err) + process.exit(1) +}) diff --git a/docs/scripts/seed-exams.ts b/docs/scripts/seed-exams.ts new file mode 100644 index 0000000..dd2e018 --- /dev/null +++ b/docs/scripts/seed-exams.ts @@ -0,0 +1,346 @@ +import "dotenv/config" +import { db } from "@/shared/db" +import { users, exams, questions, knowledgePoints, examSubmissions, examQuestions, submissionAnswers } from "@/shared/db/schema" +import { createId } from "@paralleldrive/cuid2" +import { faker } from "@faker-js/faker" +import { eq } from "drizzle-orm" + +/** + * Seed Script for Next_Edu + * + * Usage: + * 1. Ensure DATABASE_URL is set in .env + * 2. Run with tsx: npx tsx docs/scripts/seed-exams.ts + */ + +const SUBJECTS = ["Mathematics", "Physics", "English", "Chemistry", "Biology"] +const GRADES = ["Grade 10", "Grade 11", "Grade 12"] +const DIFFICULTY = [1, 2, 3, 4, 5] + +async function seed() { + console.log("🌱 Starting seed process...") + + // 1. Create a Teacher User if not exists + const teacherEmail = "teacher@example.com" + let teacherId = "user_teacher_123" + + const existingTeacher = await db.query.users.findFirst({ + where: eq(users.email, teacherEmail) + }) + + if (!existingTeacher) { + console.log("Creating teacher user...") + await db.insert(users).values({ + id: teacherId, + name: "Senior Teacher", + email: teacherEmail, + role: "teacher", + image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Teacher", + }) + } else { + teacherId = existingTeacher.id + console.log("Teacher user exists:", teacherId) + } + + // 1b. Create Students + console.log("Creating students...") + const studentIds: string[] = [] + for (let i = 0; i < 5; i++) { + const sId = createId() + studentIds.push(sId) + await db.insert(users).values({ + id: sId, + name: faker.person.fullName(), + email: faker.internet.email(), + role: "student", + image: `https://api.dicebear.com/7.x/avataaars/svg?seed=${sId}`, + }) + } + + // 2. Create Knowledge Points + console.log("Creating knowledge points...") + const kpIds: string[] = [] + for (const subject of SUBJECTS) { + for (let i = 0; i < 3; i++) { + const kpId = createId() + kpIds.push(kpId) + await db.insert(knowledgePoints).values({ + id: kpId, + name: `${subject} - ${faker.science.unit()}`, + description: faker.lorem.sentence(), + level: 1, + }) + } + } + + // 3. Create Questions + console.log("Creating questions...") + const questionIds: string[] = [] + for (let i = 0; i < 50; i++) { + const qId = createId() + questionIds.push(qId) + + const type = faker.helpers.arrayElement(["single_choice", "multiple_choice", "text", "judgment"]) + + await db.insert(questions).values({ + id: qId, + content: { + text: faker.lorem.paragraph(), + options: type.includes("choice") ? [ + { id: "A", text: faker.lorem.sentence(), isCorrect: true }, + { id: "B", text: faker.lorem.sentence(), isCorrect: false }, + { id: "C", text: faker.lorem.sentence(), isCorrect: false }, + { id: "D", text: faker.lorem.sentence(), isCorrect: false }, + ] : undefined + }, + type: type as any, + difficulty: faker.helpers.arrayElement(DIFFICULTY), + authorId: teacherId, + }) + } + + // 4. Create Exams & Submissions + console.log("Creating exams and submissions...") + for (let i = 0; i < 15; i++) { + const examId = createId() + const subject = faker.helpers.arrayElement(SUBJECTS) + const grade = faker.helpers.arrayElement(GRADES) + const status = faker.helpers.arrayElement(["draft", "published", "archived"]) + + const scheduledAt = faker.date.soon({ days: 30 }) + + const meta = { + subject, + grade, + difficulty: faker.helpers.arrayElement(DIFFICULTY), + totalScore: 100, + durationMin: faker.helpers.arrayElement([45, 60, 90, 120]), + questionCount: faker.number.int({ min: 10, max: 30 }), + tags: [faker.word.sample(), faker.word.sample()], + scheduledAt: scheduledAt.toISOString() + } + + await db.insert(exams).values({ + id: examId, + title: `${subject} ${faker.helpers.arrayElement(["Midterm", "Final", "Quiz", "Unit Test"])}`, + description: JSON.stringify(meta), + creatorId: teacherId, + startTime: scheduledAt, + status: status as any, + }) + + // Link some questions to this exam (random 5 questions) + const selectedQuestions = faker.helpers.arrayElements(questionIds, 5) + await db.insert(examQuestions).values( + selectedQuestions.map((qId, idx) => ({ + examId, + questionId: qId, + score: 20, // 5 * 20 = 100 + order: idx + })) + ) + + // Create submissions for published exams + if (status === "published") { + const submittingStudents = faker.helpers.arrayElements(studentIds, faker.number.int({ min: 1, max: 3 })) + for (const studentId of submittingStudents) { + const submissionId = createId() + const submissionStatus = faker.helpers.arrayElement(["submitted", "graded"]) + + await db.insert(examSubmissions).values({ + id: submissionId, + examId, + studentId, + score: submissionStatus === "graded" ? faker.number.int({ min: 60, max: 100 }) : null, + status: submissionStatus, + submittedAt: faker.date.recent(), + }) + + // Generate answers for this submission + for (const qId of selectedQuestions) { + await db.insert(submissionAnswers).values({ + id: createId(), + submissionId: submissionId, + questionId: qId, + answerContent: { answer: faker.lorem.sentence() }, // Mock answer + score: submissionStatus === "graded" ? faker.number.int({ min: 0, max: 20 }) : null, + feedback: submissionStatus === "graded" ? faker.lorem.sentence() : null, + }) + } + } + } + } + + // 5. Create a specific Primary School Chinese Exam (小学语文) + console.log("Creating Primary School Chinese Exam...") + const chineseExamId = createId() + const chineseQuestions = [] + + // 5a. Pinyin Questions + const pinyinQ1 = createId() + const pinyinQ2 = createId() + chineseQuestions.push({ id: pinyinQ1, score: 5 }, { id: pinyinQ2, score: 5 }) + + await db.insert(questions).values([ + { + id: pinyinQ1, + content: { text: "看拼音写词语:chūn tiān ( )" }, + type: "text", + difficulty: 1, + authorId: teacherId, + }, + { + id: pinyinQ2, + content: { text: "看拼音写词语:huā duǒ ( )" }, + type: "text", + difficulty: 1, + authorId: teacherId, + } + ]) + + // 5b. Vocabulary Questions + const vocabQ1 = createId() + const vocabQ2 = createId() + chineseQuestions.push({ id: vocabQ1, score: 5 }, { id: vocabQ2, score: 5 }) + + await db.insert(questions).values([ + { + id: vocabQ1, + content: { + text: "选词填空:今天天气真( )。", + options: [ + { id: "A", text: "美好", isCorrect: false }, + { id: "B", text: "晴朗", isCorrect: true }, + { id: "C", text: "快乐", isCorrect: false } + ] + }, + type: "single_choice", + difficulty: 2, + authorId: teacherId, + }, + { + id: vocabQ2, + content: { + text: "下列词语中,书写正确的是( )。", + options: [ + { id: "A", text: "漂扬", isCorrect: false }, + { id: "B", text: "飘扬", isCorrect: true }, + { id: "C", text: "票扬", isCorrect: false } + ] + }, + type: "single_choice", + difficulty: 2, + authorId: teacherId, + } + ]) + + // 5c. Reading Comprehension Questions + const readingQ1 = createId() + const readingQ2 = createId() + chineseQuestions.push({ id: readingQ1, score: 10 }, { id: readingQ2, score: 10 }) + + await db.insert(questions).values([ + { + id: readingQ1, + content: { + text: "阅读短文《小兔子乖乖》,回答问题:\n\n小兔子乖乖,把门儿开开...\n\n文中提到的动物是?", + options: [ + { id: "A", text: "大灰狼", isCorrect: false }, + { id: "B", text: "小兔子", isCorrect: true }, + { id: "C", text: "小花猫", isCorrect: false } + ] + }, + type: "single_choice", + difficulty: 3, + authorId: teacherId, + }, + { + id: readingQ2, + content: { text: "请用一句话形容小兔子。" }, + type: "text", + difficulty: 3, + authorId: teacherId, + } + ]) + + // 5d. Construct Exam Structure + const chineseExamStructure = [ + { + id: createId(), + type: "group", + title: "第一部分:基础知识", + children: [ + { + id: createId(), + type: "group", + title: "一、看拼音写词语", + children: [ + { id: createId(), type: "question", questionId: pinyinQ1, score: 5 }, + { id: createId(), type: "question", questionId: pinyinQ2, score: 5 } + ] + }, + { + id: createId(), + type: "group", + title: "二、词语积累", + children: [ + { id: createId(), type: "question", questionId: vocabQ1, score: 5 }, + { id: createId(), type: "question", questionId: vocabQ2, score: 5 } + ] + } + ] + }, + { + id: createId(), + type: "group", + title: "第二部分:阅读理解", + children: [ + { + id: createId(), + type: "group", + title: "三、短文阅读", + children: [ + { id: createId(), type: "question", questionId: readingQ1, score: 10 }, + { id: createId(), type: "question", questionId: readingQ2, score: 10 } + ] + } + ] + } + ] + + await db.insert(exams).values({ + id: chineseExamId, + title: "小学语文三年级上册期末考试", + description: JSON.stringify({ + subject: "Chinese", + grade: "Grade 3", + difficulty: 3, + totalScore: 40, + durationMin: 90, + questionCount: 6, + tags: ["期末", "语文", "三年级"] + }), + structure: chineseExamStructure, + creatorId: teacherId, + status: "published", + startTime: new Date(), + }) + + // Link questions to exam + await db.insert(examQuestions).values( + chineseQuestions.map((q, idx) => ({ + examId: chineseExamId, + questionId: q.id, + score: q.score, + order: idx + })) + ) + + console.log("✅ Seed completed successfully!") + process.exit(0) +} + +seed().catch((err) => { + console.error("❌ Seed failed:", err) + process.exit(1) +}) diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..948ee0b --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./src/shared/db/schema.ts", + out: "./drizzle", + dialect: "mysql", + dbCredentials: { + url: process.env.DATABASE_URL!, + }, +}); diff --git a/drizzle/0000_aberrant_cobalt_man.sql b/drizzle/0000_aberrant_cobalt_man.sql new file mode 100644 index 0000000..2eb7b8a --- /dev/null +++ b/drizzle/0000_aberrant_cobalt_man.sql @@ -0,0 +1,183 @@ +CREATE TABLE `accounts` ( + `userId` varchar(128) NOT NULL, + `type` varchar(255) NOT NULL, + `provider` varchar(255) NOT NULL, + `providerAccountId` varchar(255) NOT NULL, + `refresh_token` text, + `access_token` text, + `expires_at` int, + `token_type` varchar(255), + `scope` varchar(255), + `id_token` text, + `session_state` varchar(255), + CONSTRAINT `accounts_provider_providerAccountId_pk` PRIMARY KEY(`provider`,`providerAccountId`) +); +--> statement-breakpoint +CREATE TABLE `chapters` ( + `id` varchar(128) NOT NULL, + `textbook_id` varchar(128) NOT NULL, + `title` varchar(255) NOT NULL, + `order` int DEFAULT 0, + `parent_id` varchar(128), + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `chapters_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `exam_questions` ( + `exam_id` varchar(128) NOT NULL, + `question_id` varchar(128) NOT NULL, + `score` int DEFAULT 0, + `order` int DEFAULT 0, + CONSTRAINT `exam_questions_exam_id_question_id_pk` PRIMARY KEY(`exam_id`,`question_id`) +); +--> statement-breakpoint +CREATE TABLE `exam_submissions` ( + `id` varchar(128) NOT NULL, + `exam_id` varchar(128) NOT NULL, + `student_id` varchar(128) NOT NULL, + `score` int, + `status` varchar(50) DEFAULT 'started', + `submitted_at` timestamp, + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `exam_submissions_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `exams` ( + `id` varchar(128) NOT NULL, + `title` varchar(255) NOT NULL, + `description` text, + `creator_id` varchar(128) NOT NULL, + `start_time` timestamp, + `end_time` timestamp, + `status` varchar(50) DEFAULT 'draft', + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `exams_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `knowledge_points` ( + `id` varchar(128) NOT NULL, + `name` varchar(255) NOT NULL, + `description` text, + `parent_id` varchar(128), + `level` int DEFAULT 0, + `order` int DEFAULT 0, + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `knowledge_points_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `questions` ( + `id` varchar(128) NOT NULL, + `content` json NOT NULL, + `type` enum('single_choice','multiple_choice','text','judgment','composite') NOT NULL, + `difficulty` int DEFAULT 1, + `parent_id` varchar(128), + `author_id` varchar(128) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `questions_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `questions_to_knowledge_points` ( + `question_id` varchar(128) NOT NULL, + `knowledge_point_id` varchar(128) NOT NULL, + CONSTRAINT `questions_to_knowledge_points_question_id_knowledge_point_id_pk` PRIMARY KEY(`question_id`,`knowledge_point_id`) +); +--> statement-breakpoint +CREATE TABLE `roles` ( + `id` varchar(128) NOT NULL, + `name` varchar(50) NOT NULL, + `description` varchar(255), + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `roles_id` PRIMARY KEY(`id`), + CONSTRAINT `roles_name_unique` UNIQUE(`name`) +); +--> statement-breakpoint +CREATE TABLE `sessions` ( + `sessionToken` varchar(255) NOT NULL, + `userId` varchar(128) NOT NULL, + `expires` timestamp NOT NULL, + CONSTRAINT `sessions_sessionToken` PRIMARY KEY(`sessionToken`) +); +--> statement-breakpoint +CREATE TABLE `submission_answers` ( + `id` varchar(128) NOT NULL, + `submission_id` varchar(128) NOT NULL, + `question_id` varchar(128) NOT NULL, + `answer_content` json, + `score` int, + `feedback` text, + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `submission_answers_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `textbooks` ( + `id` varchar(128) NOT NULL, + `title` varchar(255) NOT NULL, + `subject` varchar(100) NOT NULL, + `grade` varchar(50), + `publisher` varchar(100), + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `textbooks_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `users` ( + `id` varchar(128) NOT NULL, + `name` varchar(255), + `email` varchar(255) NOT NULL, + `emailVerified` timestamp, + `image` varchar(255), + `role` varchar(50) DEFAULT 'student', + `password` varchar(255), + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `users_id` PRIMARY KEY(`id`), + CONSTRAINT `users_email_unique` UNIQUE(`email`) +); +--> statement-breakpoint +CREATE TABLE `users_to_roles` ( + `user_id` varchar(128) NOT NULL, + `role_id` varchar(128) NOT NULL, + CONSTRAINT `users_to_roles_user_id_role_id_pk` PRIMARY KEY(`user_id`,`role_id`) +); +--> statement-breakpoint +CREATE TABLE `verificationTokens` ( + `identifier` varchar(255) NOT NULL, + `token` varchar(255) NOT NULL, + `expires` timestamp NOT NULL, + CONSTRAINT `verificationTokens_identifier_token_pk` PRIMARY KEY(`identifier`,`token`) +); +--> statement-breakpoint +ALTER TABLE `accounts` ADD CONSTRAINT `accounts_userId_users_id_fk` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `chapters` ADD CONSTRAINT `chapters_textbook_id_textbooks_id_fk` FOREIGN KEY (`textbook_id`) REFERENCES `textbooks`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `exam_questions` ADD CONSTRAINT `exam_questions_exam_id_exams_id_fk` FOREIGN KEY (`exam_id`) REFERENCES `exams`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `exam_questions` ADD CONSTRAINT `exam_questions_question_id_questions_id_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `exam_submissions` ADD CONSTRAINT `exam_submissions_exam_id_exams_id_fk` FOREIGN KEY (`exam_id`) REFERENCES `exams`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `exam_submissions` ADD CONSTRAINT `exam_submissions_student_id_users_id_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `exams` ADD CONSTRAINT `exams_creator_id_users_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `questions` ADD CONSTRAINT `questions_author_id_users_id_fk` FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `questions_to_knowledge_points_question_id_questions_id_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk` FOREIGN KEY (`knowledge_point_id`) REFERENCES `knowledge_points`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `sessions` ADD CONSTRAINT `sessions_userId_users_id_fk` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `submission_answers` ADD CONSTRAINT `submission_answers_submission_id_exam_submissions_id_fk` FOREIGN KEY (`submission_id`) REFERENCES `exam_submissions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `submission_answers` ADD CONSTRAINT `submission_answers_question_id_questions_id_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `users_to_roles` ADD CONSTRAINT `users_to_roles_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `users_to_roles` ADD CONSTRAINT `users_to_roles_role_id_roles_id_fk` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `account_userId_idx` ON `accounts` (`userId`);--> statement-breakpoint +CREATE INDEX `textbook_idx` ON `chapters` (`textbook_id`);--> statement-breakpoint +CREATE INDEX `parent_id_idx` ON `chapters` (`parent_id`);--> statement-breakpoint +CREATE INDEX `exam_student_idx` ON `exam_submissions` (`exam_id`,`student_id`);--> statement-breakpoint +CREATE INDEX `parent_id_idx` ON `knowledge_points` (`parent_id`);--> statement-breakpoint +CREATE INDEX `parent_id_idx` ON `questions` (`parent_id`);--> statement-breakpoint +CREATE INDEX `author_id_idx` ON `questions` (`author_id`);--> statement-breakpoint +CREATE INDEX `kp_idx` ON `questions_to_knowledge_points` (`knowledge_point_id`);--> statement-breakpoint +CREATE INDEX `session_userId_idx` ON `sessions` (`userId`);--> statement-breakpoint +CREATE INDEX `submission_idx` ON `submission_answers` (`submission_id`);--> statement-breakpoint +CREATE INDEX `email_idx` ON `users` (`email`);--> statement-breakpoint +CREATE INDEX `user_id_idx` ON `users_to_roles` (`user_id`); \ No newline at end of file diff --git a/drizzle/0001_flawless_texas_twister.sql b/drizzle/0001_flawless_texas_twister.sql new file mode 100644 index 0000000..1ed65ef --- /dev/null +++ b/drizzle/0001_flawless_texas_twister.sql @@ -0,0 +1,5 @@ +ALTER TABLE `exams` ADD `structure` json;--> statement-breakpoint +ALTER TABLE `questions_to_knowledge_points` DROP FOREIGN KEY `questions_to_knowledge_points_question_id_questions_id_fk`;--> statement-breakpoint +ALTER TABLE `questions_to_knowledge_points` DROP FOREIGN KEY `questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk`;--> statement-breakpoint +ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `q_kp_qid_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `q_kp_kpid_fk` FOREIGN KEY (`knowledge_point_id`) REFERENCES `knowledge_points`(`id`) ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..7df477c --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,1286 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "43c3b7a0-a45f-4305-aa2c-c548dd09afcf", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "userId": { + "name": "userId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "accounts_userId_users_id_fk": { + "name": "accounts_userId_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "accounts_provider_providerAccountId_pk": { + "name": "accounts_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "chapters": { + "name": "chapters", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "textbook_id": { + "name": "textbook_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "parent_id": { + "name": "parent_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "textbook_idx": { + "name": "textbook_idx", + "columns": [ + "textbook_id" + ], + "isUnique": false + }, + "parent_id_idx": { + "name": "parent_id_idx", + "columns": [ + "parent_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chapters_textbook_id_textbooks_id_fk": { + "name": "chapters_textbook_id_textbooks_id_fk", + "tableFrom": "chapters", + "tableTo": "textbooks", + "columnsFrom": [ + "textbook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "chapters_id": { + "name": "chapters_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "exam_questions": { + "name": "exam_questions", + "columns": { + "exam_id": { + "name": "exam_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "exam_questions_exam_id_exams_id_fk": { + "name": "exam_questions_exam_id_exams_id_fk", + "tableFrom": "exam_questions", + "tableTo": "exams", + "columnsFrom": [ + "exam_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "exam_questions_question_id_questions_id_fk": { + "name": "exam_questions_question_id_questions_id_fk", + "tableFrom": "exam_questions", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "exam_questions_exam_id_question_id_pk": { + "name": "exam_questions_exam_id_question_id_pk", + "columns": [ + "exam_id", + "question_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "exam_submissions": { + "name": "exam_submissions", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exam_id": { + "name": "exam_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "student_id": { + "name": "student_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'started'" + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "exam_student_idx": { + "name": "exam_student_idx", + "columns": [ + "exam_id", + "student_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "exam_submissions_exam_id_exams_id_fk": { + "name": "exam_submissions_exam_id_exams_id_fk", + "tableFrom": "exam_submissions", + "tableTo": "exams", + "columnsFrom": [ + "exam_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "exam_submissions_student_id_users_id_fk": { + "name": "exam_submissions_student_id_users_id_fk", + "tableFrom": "exam_submissions", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "exam_submissions_id": { + "name": "exam_submissions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "exams": { + "name": "exams", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "exams_creator_id_users_id_fk": { + "name": "exams_creator_id_users_id_fk", + "tableFrom": "exams", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "exams_id": { + "name": "exams_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "knowledge_points": { + "name": "knowledge_points", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "parent_id_idx": { + "name": "parent_id_idx", + "columns": [ + "parent_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "knowledge_points_id": { + "name": "knowledge_points_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "questions": { + "name": "questions", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "enum('single_choice','multiple_choice','text','judgment','composite')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "difficulty": { + "name": "difficulty", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "parent_id": { + "name": "parent_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "parent_id_idx": { + "name": "parent_id_idx", + "columns": [ + "parent_id" + ], + "isUnique": false + }, + "author_id_idx": { + "name": "author_id_idx", + "columns": [ + "author_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "questions_author_id_users_id_fk": { + "name": "questions_author_id_users_id_fk", + "tableFrom": "questions", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "questions_id": { + "name": "questions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "questions_to_knowledge_points": { + "name": "questions_to_knowledge_points", + "columns": { + "question_id": { + "name": "question_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "knowledge_point_id": { + "name": "knowledge_point_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "kp_idx": { + "name": "kp_idx", + "columns": [ + "knowledge_point_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "questions_to_knowledge_points_question_id_questions_id_fk": { + "name": "questions_to_knowledge_points_question_id_questions_id_fk", + "tableFrom": "questions_to_knowledge_points", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk": { + "name": "questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk", + "tableFrom": "questions_to_knowledge_points", + "tableTo": "knowledge_points", + "columnsFrom": [ + "knowledge_point_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "questions_to_knowledge_points_question_id_knowledge_point_id_pk": { + "name": "questions_to_knowledge_points_question_id_knowledge_point_id_pk", + "columns": [ + "question_id", + "knowledge_point_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "roles_id": { + "name": "roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_userId_users_id_fk": { + "name": "sessions_userId_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_sessionToken": { + "name": "sessions_sessionToken", + "columns": [ + "sessionToken" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "submission_answers": { + "name": "submission_answers", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "submission_id": { + "name": "submission_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answer_content": { + "name": "answer_content", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "submission_idx": { + "name": "submission_idx", + "columns": [ + "submission_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "submission_answers_submission_id_exam_submissions_id_fk": { + "name": "submission_answers_submission_id_exam_submissions_id_fk", + "tableFrom": "submission_answers", + "tableTo": "exam_submissions", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "submission_answers_question_id_questions_id_fk": { + "name": "submission_answers_question_id_questions_id_fk", + "tableFrom": "submission_answers", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "submission_answers_id": { + "name": "submission_answers_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "textbooks": { + "name": "textbooks", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grade": { + "name": "grade", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "publisher": { + "name": "publisher", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "textbooks_id": { + "name": "textbooks_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'student'" + }, + "password": { + "name": "password", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "email_idx": { + "name": "email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ] + } + }, + "checkConstraint": {} + }, + "users_to_roles": { + "name": "users_to_roles", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "users_to_roles_user_id_users_id_fk": { + "name": "users_to_roles_user_id_users_id_fk", + "tableFrom": "users_to_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "users_to_roles_role_id_roles_id_fk": { + "name": "users_to_roles_role_id_roles_id_fk", + "tableFrom": "users_to_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "users_to_roles_user_id_role_id_pk": { + "name": "users_to_roles_user_id_role_id_pk", + "columns": [ + "user_id", + "role_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "verificationTokens": { + "name": "verificationTokens", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationTokens_identifier_token_pk": { + "name": "verificationTokens_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..50aa8ab --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1293 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "c1afed29-ad52-484d-a6a1-272b6dec6a24", + "prevId": "43c3b7a0-a45f-4305-aa2c-c548dd09afcf", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "userId": { + "name": "userId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "accounts_userId_users_id_fk": { + "name": "accounts_userId_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "accounts_provider_providerAccountId_pk": { + "name": "accounts_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "chapters": { + "name": "chapters", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "textbook_id": { + "name": "textbook_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "parent_id": { + "name": "parent_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "textbook_idx": { + "name": "textbook_idx", + "columns": [ + "textbook_id" + ], + "isUnique": false + }, + "parent_id_idx": { + "name": "parent_id_idx", + "columns": [ + "parent_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chapters_textbook_id_textbooks_id_fk": { + "name": "chapters_textbook_id_textbooks_id_fk", + "tableFrom": "chapters", + "tableTo": "textbooks", + "columnsFrom": [ + "textbook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "chapters_id": { + "name": "chapters_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "exam_questions": { + "name": "exam_questions", + "columns": { + "exam_id": { + "name": "exam_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "exam_questions_exam_id_exams_id_fk": { + "name": "exam_questions_exam_id_exams_id_fk", + "tableFrom": "exam_questions", + "tableTo": "exams", + "columnsFrom": [ + "exam_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "exam_questions_question_id_questions_id_fk": { + "name": "exam_questions_question_id_questions_id_fk", + "tableFrom": "exam_questions", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "exam_questions_exam_id_question_id_pk": { + "name": "exam_questions_exam_id_question_id_pk", + "columns": [ + "exam_id", + "question_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "exam_submissions": { + "name": "exam_submissions", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exam_id": { + "name": "exam_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "student_id": { + "name": "student_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'started'" + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "exam_student_idx": { + "name": "exam_student_idx", + "columns": [ + "exam_id", + "student_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "exam_submissions_exam_id_exams_id_fk": { + "name": "exam_submissions_exam_id_exams_id_fk", + "tableFrom": "exam_submissions", + "tableTo": "exams", + "columnsFrom": [ + "exam_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "exam_submissions_student_id_users_id_fk": { + "name": "exam_submissions_student_id_users_id_fk", + "tableFrom": "exam_submissions", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "exam_submissions_id": { + "name": "exam_submissions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "exams": { + "name": "exams", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "structure": { + "name": "structure", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "exams_creator_id_users_id_fk": { + "name": "exams_creator_id_users_id_fk", + "tableFrom": "exams", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "exams_id": { + "name": "exams_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "knowledge_points": { + "name": "knowledge_points", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "parent_id_idx": { + "name": "parent_id_idx", + "columns": [ + "parent_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "knowledge_points_id": { + "name": "knowledge_points_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "questions": { + "name": "questions", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "enum('single_choice','multiple_choice','text','judgment','composite')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "difficulty": { + "name": "difficulty", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "parent_id": { + "name": "parent_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "parent_id_idx": { + "name": "parent_id_idx", + "columns": [ + "parent_id" + ], + "isUnique": false + }, + "author_id_idx": { + "name": "author_id_idx", + "columns": [ + "author_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "questions_author_id_users_id_fk": { + "name": "questions_author_id_users_id_fk", + "tableFrom": "questions", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "questions_id": { + "name": "questions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "questions_to_knowledge_points": { + "name": "questions_to_knowledge_points", + "columns": { + "question_id": { + "name": "question_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "knowledge_point_id": { + "name": "knowledge_point_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "kp_idx": { + "name": "kp_idx", + "columns": [ + "knowledge_point_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "questions_to_knowledge_points_question_id_questions_id_fk": { + "name": "questions_to_knowledge_points_question_id_questions_id_fk", + "tableFrom": "questions_to_knowledge_points", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk": { + "name": "questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk", + "tableFrom": "questions_to_knowledge_points", + "tableTo": "knowledge_points", + "columnsFrom": [ + "knowledge_point_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "questions_to_knowledge_points_question_id_knowledge_point_id_pk": { + "name": "questions_to_knowledge_points_question_id_knowledge_point_id_pk", + "columns": [ + "question_id", + "knowledge_point_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "roles_id": { + "name": "roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_userId_users_id_fk": { + "name": "sessions_userId_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_sessionToken": { + "name": "sessions_sessionToken", + "columns": [ + "sessionToken" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "submission_answers": { + "name": "submission_answers", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "submission_id": { + "name": "submission_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answer_content": { + "name": "answer_content", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "submission_idx": { + "name": "submission_idx", + "columns": [ + "submission_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "submission_answers_submission_id_exam_submissions_id_fk": { + "name": "submission_answers_submission_id_exam_submissions_id_fk", + "tableFrom": "submission_answers", + "tableTo": "exam_submissions", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "submission_answers_question_id_questions_id_fk": { + "name": "submission_answers_question_id_questions_id_fk", + "tableFrom": "submission_answers", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "submission_answers_id": { + "name": "submission_answers_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "textbooks": { + "name": "textbooks", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grade": { + "name": "grade", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "publisher": { + "name": "publisher", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "textbooks_id": { + "name": "textbooks_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'student'" + }, + "password": { + "name": "password", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "email_idx": { + "name": "email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ] + } + }, + "checkConstraint": {} + }, + "users_to_roles": { + "name": "users_to_roles", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "users_to_roles_user_id_users_id_fk": { + "name": "users_to_roles_user_id_users_id_fk", + "tableFrom": "users_to_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "users_to_roles_role_id_roles_id_fk": { + "name": "users_to_roles_role_id_roles_id_fk", + "tableFrom": "users_to_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "users_to_roles_user_id_role_id_pk": { + "name": "users_to_roles_user_id_role_id_pk", + "columns": [ + "user_id", + "role_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "verificationTokens": { + "name": "verificationTokens", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationTokens_identifier_token_pk": { + "name": "verificationTokens_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..d520528 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "mysql", + "entries": [ + { + "idx": 0, + "version": "5", + "when": 1766460456274, + "tag": "0000_aberrant_cobalt_man", + "breakpoints": true + }, + { + "idx": 1, + "version": "5", + "when": 1767004087964, + "tag": "0001_flawless_texas_twister", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b510e17..08abd88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,17 +8,56 @@ "name": "cicd", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@hookform/resolvers": "^5.2.2", + "@paralleldrive/cuid2": "^3.0.4", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@t3-oss/env-nextjs": "^0.13.10", + "@tanstack/react-query": "^5.90.12", + "@tanstack/react-table": "^8.21.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "drizzle-orm": "^0.45.1", + "lucide-react": "^0.562.0", + "mysql2": "^3.16.0", "next": "16.0.10", + "next-auth": "^5.0.0-beta.30", + "next-themes": "^0.4.6", + "nuqs": "^2.8.5", "react": "19.2.1", - "react-dom": "19.2.1" + "react-dom": "19.2.1", + "react-hook-form": "^7.69.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0", + "tailwindcss-animate": "^1.0.7", + "zod": "^4.2.1", + "zustand": "^5.0.9" }, "devDependencies": { + "@faker-js/faker": "^10.1.0", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "dotenv": "^17.2.3", + "drizzle-kit": "^0.31.8", "eslint": "^9", "eslint-config-next": "16.0.10", + "prettier": "^3.7.4", + "prettier-plugin-tailwindcss": "^0.7.2", "tailwindcss": "^4", "typescript": "^5" } @@ -36,6 +75,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@auth/core": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz", + "integrity": "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -276,6 +344,66 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@emnapi/core": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", @@ -309,6 +437,884 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", @@ -453,6 +1459,73 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@faker-js/faker": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.1.0.tgz", + "integrity": "sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", + "npm": ">=10" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1178,6 +2251,18 @@ "node": ">= 10" } }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1226,6 +2311,1869 @@ "node": ">=12.4.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-3.0.4.tgz", + "integrity": "sha512-sM6M2PWrByOEpN2QYAdulhEbSZmChwj0e52u4hpwB7u4PznFiNAavtE6m7O8tWUlzX+jT2eKKtc5/ZgX+IHrtg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1", + "bignumber.js": "^9.3.1", + "error-causes": "^3.0.2" + }, + "bin": { + "cuid2": "bin/cuid2.js" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz", + "integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1233,6 +4181,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1242,6 +4202,61 @@ "tslib": "^2.8.0" } }, + "node_modules/@t3-oss/env-core": { + "version": "0.13.10", + "resolved": "https://registry.npmjs.org/@t3-oss/env-core/-/env-core-0.13.10.tgz", + "integrity": "sha512-NNFfdlJ+HmPHkLi2HKy7nwuat9SIYOxei9K10lO2YlcSObDILY7mHZNSHsieIM3A0/5OOzw/P/b+yLvPdaG52g==", + "license": "MIT", + "peerDependencies": { + "arktype": "^2.1.0", + "typescript": ">=5.0.0", + "valibot": "^1.0.0-beta.7 || ^1.0.0", + "zod": "^3.24.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "arktype": { + "optional": true + }, + "typescript": { + "optional": true + }, + "valibot": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@t3-oss/env-nextjs": { + "version": "0.13.10", + "resolved": "https://registry.npmjs.org/@t3-oss/env-nextjs/-/env-nextjs-0.13.10.tgz", + "integrity": "sha512-JfSA2WXOnvcc/uMdp31paMsfbYhhdvLLRxlwvrnlPE9bwM/n0Z+Qb9xRv48nPpvfMhOrkrTYw1I5Yc06WIKBJQ==", + "license": "MIT", + "dependencies": { + "@t3-oss/env-core": "0.13.10" + }, + "peerDependencies": { + "arktype": "^2.1.0", + "typescript": ">=5.0.0", + "valibot": "^1.0.0-beta.7 || ^1.0.0", + "zod": "^3.24.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "arktype": { + "optional": true + }, + "typescript": { + "optional": true + }, + "valibot": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/@tailwindcss/node": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", @@ -1513,6 +4528,65 @@ "tailwindcss": "4.1.18" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz", + "integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz", + "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1559,7 +4633,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1569,7 +4643,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -2176,6 +5250,18 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -2379,6 +5465,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/axe-core": { "version": "4.11.0", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", @@ -2416,6 +5511,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2474,6 +5578,13 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2571,12 +5682,33 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2630,7 +5762,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -2755,6 +5887,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2765,6 +5906,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -2778,6 +5925,160 @@ "node": ">=0.10.0" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/drizzle-kit": { + "version": "0.31.8", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.8.tgz", + "integrity": "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "esbuild-register": "^3.5.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz", + "integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@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": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2821,6 +6122,12 @@ "node": ">=10.13.0" } }, + "node_modules/error-causes": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/error-causes/-/error-causes-3.0.2.tgz", + "integrity": "sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw==", + "license": "MIT" + }, "node_modules/es-abstract": { "version": "1.24.0", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", @@ -2998,6 +6305,61 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esbuild-register": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", + "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "esbuild": ">=0.12 <1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3627,6 +6989,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -3672,6 +7043,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -3891,6 +7271,22 @@ "hermes-estree": "0.25.1" } }, + "node_modules/iconv-lite": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", + "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4213,6 +7609,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -4400,6 +7802,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4811,6 +8222,12 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -4834,6 +8251,30 @@ "yallist": "^3.0.2" } }, + "node_modules/lru.min": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz", + "integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/lucide-react": { + "version": "0.562.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", + "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4908,6 +8349,38 @@ "dev": true, "license": "MIT" }, + "node_modules/mysql2": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.16.0.tgz", + "integrity": "sha512-AEGW7QLLSuSnjCS4pk3EIqOmogegmze9h8EyrndavUQnIUcfkVal/sK7QznE+a3bc6rzPbAiui9Jcb+96tPwYA==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -5001,6 +8474,43 @@ } } }, + "node_modules/next-auth": { + "version": "5.0.0-beta.30", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.30.tgz", + "integrity": "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.0" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", + "nodemailer": "^7.0.7", + "react": "^18.2.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -5036,6 +8546,52 @@ "dev": true, "license": "MIT" }, + "node_modules/nuqs": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/nuqs/-/nuqs-2.8.5.tgz", + "integrity": "sha512-ndhnNB9eLX/bsiGFkBNsrfOWf3BCbzBMD+b5GkD5o2Q96Q+llHnoUlZsrO3tgJKZZV7LLlVCvFKdj+sjBITRzg==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/franky47" + }, + "peerDependencies": { + "@remix-run/react": ">=2", + "@tanstack/react-router": "^1", + "next": ">=14.2.0", + "react": ">=18.2.0 || ^19.0.0-0", + "react-router": "^5 || ^6 || ^7", + "react-router-dom": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "@remix-run/react": { + "optional": true + }, + "@tanstack/react-router": { + "optional": true + }, + "next": { + "optional": true + }, + "react-router": { + "optional": true + }, + "react-router-dom": { + "optional": true + } + } + }, + "node_modules/oauth4webapi": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.3.tgz", + "integrity": "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5325,6 +8881,25 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5335,6 +8910,101 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz", + "integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5399,6 +9069,22 @@ "react": "^19.2.1" } }, + "node_modules/react-hook-form": { + "version": "7.69.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.69.0.tgz", + "integrity": "sha512-yt6ZGME9f4F6WHwevrvpAjh42HMvocuSnSIHUGycBqXIJdhqGSPQzTpGF+1NLREk/58IdPxEMfPcFCjlMhclGw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -5406,6 +9092,75 @@ "dev": true, "license": "MIT" }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5581,6 +9336,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -5597,6 +9358,11 @@ "semver": "bin/semver.js" } }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -5803,6 +9569,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5812,6 +9598,26 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -6018,13 +9824,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "dev": true, "license": "MIT" }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", @@ -6240,7 +10064,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -6376,6 +10200,58 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6512,10 +10388,9 @@ } }, "node_modules/zod": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", - "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", - "dev": true, + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", + "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -6533,6 +10408,35 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", + "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 2b99248..f5eba5f 100644 --- a/package.json +++ b/package.json @@ -7,20 +7,60 @@ "build": "next build", "start": "next start", "lint": "eslint", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "db:seed": "npx tsx scripts/seed.ts" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@hookform/resolvers": "^5.2.2", + "@paralleldrive/cuid2": "^3.0.4", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@t3-oss/env-nextjs": "^0.13.10", + "@tanstack/react-query": "^5.90.12", + "@tanstack/react-table": "^8.21.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "drizzle-orm": "^0.45.1", + "lucide-react": "^0.562.0", + "mysql2": "^3.16.0", "next": "16.0.10", + "next-auth": "^5.0.0-beta.30", + "next-themes": "^0.4.6", + "nuqs": "^2.8.5", "react": "19.2.1", - "react-dom": "19.2.1" + "react-dom": "19.2.1", + "react-hook-form": "^7.69.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0", + "tailwindcss-animate": "^1.0.7", + "zod": "^4.2.1", + "zustand": "^5.0.9" }, "devDependencies": { + "@faker-js/faker": "^10.1.0", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "dotenv": "^17.2.3", + "drizzle-kit": "^0.31.8", "eslint": "^9", "eslint-config-next": "16.0.10", + "prettier": "^3.7.4", + "prettier-plugin-tailwindcss": "^0.7.2", "tailwindcss": "^4", "typescript": "^5" } diff --git a/scripts/seed.ts b/scripts/seed.ts new file mode 100644 index 0000000..2ec67a9 --- /dev/null +++ b/scripts/seed.ts @@ -0,0 +1,236 @@ +import "dotenv/config"; +import { db } from "../src/shared/db"; +import { + users, roles, usersToRoles, + questions, knowledgePoints, questionsToKnowledgePoints, + exams, examQuestions, examSubmissions, submissionAnswers, + textbooks, chapters +} from "../src/shared/db/schema"; +import { createId } from "@paralleldrive/cuid2"; +import { faker } from "@faker-js/faker"; +import { sql } from "drizzle-orm"; + +/** + * Enterprise-Grade Seed Script for Next_Edu + * + * Scenarios Covered: + * 1. IAM: RBAC with multiple roles (Teacher & Grade Head). + * 2. Knowledge Graph: Nested Knowledge Points (Math -> Algebra -> Linear Equations). + * 3. Question Bank: Rich Text Content & Nested Questions (Reading Comprehension). + * 4. Exams: JSON Structure for Sectioning. + */ + +async function seed() { + console.log("🌱 Starting Database Seed..."); + const start = performance.now(); + + // --- 0. Cleanup (Optional: Truncate tables for fresh start) --- + // Note: Order matters due to foreign keys if checks are enabled. + // Ideally, use: SET FOREIGN_KEY_CHECKS = 0; + try { + await db.execute(sql`SET FOREIGN_KEY_CHECKS = 0;`); + const tables = [ + "submission_answers", "exam_submissions", "exam_questions", "exams", + "questions_to_knowledge_points", "questions", "knowledge_points", + "chapters", "textbooks", + "users_to_roles", "roles", "users", "accounts", "sessions" + ]; + for (const table of tables) { + await db.execute(sql.raw(`TRUNCATE TABLE \`${table}\`;`)); + } + await db.execute(sql`SET FOREIGN_KEY_CHECKS = 1;`); + console.log("🧹 Cleaned up existing data."); + } catch (e) { + console.warn("⚠️ Cleanup warning (might be fresh DB):", e); + } + + // --- 1. IAM & Roles --- + console.log("👤 Seeding IAM..."); + + // Roles + const roleMap = { + admin: "role_admin", + teacher: "role_teacher", + student: "role_student", + grade_head: "role_grade_head" + }; + + await db.insert(roles).values([ + { id: roleMap.admin, name: "admin", description: "System Administrator" }, + { id: roleMap.teacher, name: "teacher", description: "Academic Instructor" }, + { id: roleMap.student, name: "student", description: "Learner" }, + { id: roleMap.grade_head, name: "grade_head", description: "Head of Grade Year" } + ]); + + // Users + const usersData = [ + { + id: "user_admin", + name: "Admin User", + email: "admin@next-edu.com", + role: "admin", // Legacy field + image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Admin" + }, + { + id: "user_teacher_math", + name: "Mr. Math", + email: "math@next-edu.com", + role: "teacher", + image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Math" + }, + { + id: "user_student_1", + name: "Alice Student", + email: "alice@next-edu.com", + role: "student", + image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Alice" + } + ]; + + await db.insert(users).values(usersData); + + // Assign Roles (RBAC) + await db.insert(usersToRoles).values([ + { userId: "user_admin", roleId: roleMap.admin }, + { userId: "user_teacher_math", roleId: roleMap.teacher }, + // Math teacher is also a Grade Head + { userId: "user_teacher_math", roleId: roleMap.grade_head }, + { userId: "user_student_1", roleId: roleMap.student }, + ]); + + // --- 2. Knowledge Graph (Tree) --- + console.log("🧠 Seeding Knowledge Graph..."); + + const kpMathId = createId(); + const kpAlgebraId = createId(); + const kpLinearId = createId(); + + await db.insert(knowledgePoints).values([ + { id: kpMathId, name: "Mathematics", level: 0 }, + { id: kpAlgebraId, name: "Algebra", parentId: kpMathId, level: 1 }, + { id: kpLinearId, name: "Linear Equations", parentId: kpAlgebraId, level: 2 }, + ]); + + // --- 3. Question Bank (Rich Content) --- + console.log("📚 Seeding Question Bank..."); + + // 3.1 Simple Single Choice + const qSimpleId = createId(); + await db.insert(questions).values({ + id: qSimpleId, + authorId: "user_teacher_math", + type: "single_choice", + difficulty: 1, + content: { + text: "What is 2 + 2?", + options: [ + { id: "A", text: "3", isCorrect: false }, + { id: "B", text: "4", isCorrect: true }, + { id: "C", text: "5", isCorrect: false } + ] + } + }); + + // Link to KP + await db.insert(questionsToKnowledgePoints).values({ + questionId: qSimpleId, + knowledgePointId: kpLinearId // Just for demo + }); + + // 3.2 Composite Question (Reading Comprehension) + const qParentId = createId(); + const qChild1Id = createId(); + const qChild2Id = createId(); + + // Parent (Passage) + await db.insert(questions).values({ + id: qParentId, + authorId: "user_teacher_math", + type: "composite", + difficulty: 3, + content: { + text: "Read the following passage about Algebra...\n(Long text here)...", + assets: [] + } + }); + + // Children + await db.insert(questions).values([ + { + id: qChild1Id, + authorId: "user_teacher_math", + parentId: qParentId, // <--- Key: Nested + type: "single_choice", + difficulty: 2, + content: { + text: "What is the main topic?", + options: [ + { id: "A", text: "Geometry", isCorrect: false }, + { id: "B", text: "Algebra", isCorrect: true } + ] + } + }, + { + id: qChild2Id, + authorId: "user_teacher_math", + parentId: qParentId, + type: "text", + difficulty: 4, + content: { + text: "Explain the concept of variables.", + } + } + ]); + + // --- 4. Exams (New Structure) --- + console.log("📝 Seeding Exams..."); + + const examId = createId(); + const examStructure = [ + { + type: "group", + title: "Part 1: Basics", + children: [ + { type: "question", questionId: qSimpleId, score: 10 } + ] + }, + { + type: "group", + title: "Part 2: Reading", + children: [ + // For composite questions, we usually add the parent, and the system fetches children + { type: "question", questionId: qParentId, score: 20 } + ] + } + ]; + + await db.insert(exams).values({ + id: examId, + title: "Algebra Mid-Term 2025", + description: "Comprehensive assessment", + creatorId: "user_teacher_math", + status: "published", + startTime: new Date(), + structure: examStructure as any // Bypass strict typing for seed + }); + + // Link questions physically (Source of Truth) + await db.insert(examQuestions).values([ + { examId, questionId: qSimpleId, score: 10, order: 0 }, + { examId, questionId: qParentId, score: 20, order: 1 }, + // Note: Child questions are often implicitly included or explicitly added depending on logic. + // For this seed, we assume linking Parent is enough for the relation, + // but let's link children too for completeness if the query strategy requires it. + { examId, questionId: qChild1Id, score: 0, order: 2 }, + { examId, questionId: qChild2Id, score: 0, order: 3 }, + ]); + + const end = performance.now(); + console.log(`✅ Seed completed in ${(end - start).toFixed(2)}ms`); + process.exit(0); +} + +seed().catch((err) => { + console.error("❌ Seed failed:", err); + process.exit(1); +}); diff --git a/src/app/(auth)/error.tsx b/src/app/(auth)/error.tsx new file mode 100644 index 0000000..a558f03 --- /dev/null +++ b/src/app/(auth)/error.tsx @@ -0,0 +1,34 @@ +"use client" + +import { useEffect } from "react" +import { Button } from "@/shared/components/ui/button" +import { AlertCircle } from "lucide-react" + +export default function AuthError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + console.error(error) + }, [error]) + + return ( +
+
+ +
+
+

Authentication Error

+

+ There was a problem signing you in. Please try again. +

+
+ +
+ ) +} diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..fdcd0ae --- /dev/null +++ b/src/app/(auth)/layout.tsx @@ -0,0 +1,5 @@ +import { AuthLayout } from "@/modules/auth/components/auth-layout" + +export default function Layout({ children }: { children: React.ReactNode }) { + return {children} +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..100a214 --- /dev/null +++ b/src/app/(auth)/login/page.tsx @@ -0,0 +1,11 @@ +import { Metadata } from "next" +import { LoginForm } from "@/modules/auth/components/login-form" + +export const metadata: Metadata = { + title: "Login - Next_Edu", + description: "Login to your account", +} + +export default function LoginPage() { + return +} diff --git a/src/app/(auth)/not-found.tsx b/src/app/(auth)/not-found.tsx new file mode 100644 index 0000000..0db4a77 --- /dev/null +++ b/src/app/(auth)/not-found.tsx @@ -0,0 +1,22 @@ +import Link from "next/link" +import { Button } from "@/shared/components/ui/button" +import { FileQuestion } from "lucide-react" + +export default function AuthNotFound() { + return ( +
+
+ +
+
+

Page Not Found

+

+ The authentication page you are looking for does not exist. +

+
+ +
+ ) +} diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx new file mode 100644 index 0000000..bff841c --- /dev/null +++ b/src/app/(auth)/register/page.tsx @@ -0,0 +1,11 @@ +import { Metadata } from "next" +import { RegisterForm } from "@/modules/auth/components/register-form" + +export const metadata: Metadata = { + title: "Register - Next_Edu", + description: "Create an account", +} + +export default function RegisterPage() { + return +} diff --git a/src/app/(dashboard)/admin/dashboard/page.tsx b/src/app/(dashboard)/admin/dashboard/page.tsx new file mode 100644 index 0000000..4769f2c --- /dev/null +++ b/src/app/(dashboard)/admin/dashboard/page.tsx @@ -0,0 +1,5 @@ +import { AdminDashboard } from "@/modules/dashboard/components/admin-view" + +export default function AdminDashboardPage() { + return +} diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 0000000..31faf44 --- /dev/null +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -0,0 +1,72 @@ +"use client" + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/shared/components/ui/button" +import Link from "next/link" +import { Shield, GraduationCap, Users, User } from "lucide-react" + +// In a real app, this would be a server component that redirects based on session +// But for this demo/dev environment, we keep the manual selection or add auto-redirect logic if we had auth state. + +export default function DashboardPage() { + // Mock Auth Logic (Optional: Uncomment to test auto-redirect) + /* + const router = useRouter(); + useEffect(() => { + // const role = "teacher"; // Fetch from auth hook + // if (role) router.push(`/${role}/dashboard`); + }, []); + */ + + return ( +
+
+

Welcome to Next_Edu

+

Select your role to view the corresponding dashboard.

+

+ [DEV MODE] In production, you would be redirected automatically based on your login session. +

+
+ +
+ + + + + + + + + + + + +
+
+ ) +} diff --git a/src/app/(dashboard)/error.tsx b/src/app/(dashboard)/error.tsx new file mode 100644 index 0000000..30a18de --- /dev/null +++ b/src/app/(dashboard)/error.tsx @@ -0,0 +1,35 @@ +"use client" + +import { useEffect } from "react" +import { AlertCircle } from "lucide-react" + +import { Button } from "@/shared/components/ui/button" +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + // Log the error to an error reporting service + console.error(error) + }, [error]) + + return ( +
+ reset() + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..26a5b06 --- /dev/null +++ b/src/app/(dashboard)/layout.tsx @@ -0,0 +1,18 @@ +import { AppSidebar } from "@/modules/layout/components/app-sidebar" +import { SidebarProvider } from "@/modules/layout/components/sidebar-provider" +import { SiteHeader } from "@/modules/layout/components/site-header" + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + }> + +
+ {children} +
+
+ ) +} diff --git a/src/app/(dashboard)/not-found.tsx b/src/app/(dashboard)/not-found.tsx new file mode 100644 index 0000000..e4ee575 --- /dev/null +++ b/src/app/(dashboard)/not-found.tsx @@ -0,0 +1,23 @@ +import Link from "next/link" +import { FileQuestion } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function NotFound() { + return ( +
+ + + Return to Dashboard + +
+ ) +} diff --git a/src/app/(dashboard)/parent/dashboard/page.tsx b/src/app/(dashboard)/parent/dashboard/page.tsx new file mode 100644 index 0000000..8480dce --- /dev/null +++ b/src/app/(dashboard)/parent/dashboard/page.tsx @@ -0,0 +1,8 @@ +export default function ParentDashboardPage() { + return ( +
+

Parent Dashboard

+

Welcome, Parent!

+
+ ) +} diff --git a/src/app/(dashboard)/student/dashboard/page.tsx b/src/app/(dashboard)/student/dashboard/page.tsx new file mode 100644 index 0000000..a093ee3 --- /dev/null +++ b/src/app/(dashboard)/student/dashboard/page.tsx @@ -0,0 +1,5 @@ +import { StudentDashboard } from "@/modules/dashboard/components/student-view" + +export default function StudentDashboardPage() { + return +} diff --git a/src/app/(dashboard)/teacher/classes/my/page.tsx b/src/app/(dashboard)/teacher/classes/my/page.tsx new file mode 100644 index 0000000..39f1d24 --- /dev/null +++ b/src/app/(dashboard)/teacher/classes/my/page.tsx @@ -0,0 +1,22 @@ +import { EmptyState } from "@/shared/components/ui/empty-state" +import { Users } from "lucide-react" + +export default function MyClassesPage() { + return ( +
+
+
+

My Classes

+

+ Overview of your classes. +

+
+
+ +
+ ) +} diff --git a/src/app/(dashboard)/teacher/classes/page.tsx b/src/app/(dashboard)/teacher/classes/page.tsx new file mode 100644 index 0000000..131697b --- /dev/null +++ b/src/app/(dashboard)/teacher/classes/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation" + +export default function ClassesPage() { + redirect("/teacher/classes/my") +} diff --git a/src/app/(dashboard)/teacher/classes/schedule/page.tsx b/src/app/(dashboard)/teacher/classes/schedule/page.tsx new file mode 100644 index 0000000..4655efb --- /dev/null +++ b/src/app/(dashboard)/teacher/classes/schedule/page.tsx @@ -0,0 +1,22 @@ +import { EmptyState } from "@/shared/components/ui/empty-state" +import { Calendar } from "lucide-react" + +export default function SchedulePage() { + return ( +
+
+
+

Schedule

+

+ View class schedule. +

+
+
+ +
+ ) +} diff --git a/src/app/(dashboard)/teacher/classes/students/page.tsx b/src/app/(dashboard)/teacher/classes/students/page.tsx new file mode 100644 index 0000000..55f5463 --- /dev/null +++ b/src/app/(dashboard)/teacher/classes/students/page.tsx @@ -0,0 +1,22 @@ +import { EmptyState } from "@/shared/components/ui/empty-state" +import { User } from "lucide-react" + +export default function StudentsPage() { + return ( +
+
+
+

Students

+

+ Manage student list. +

+
+
+ +
+ ) +} diff --git a/src/app/(dashboard)/teacher/dashboard/page.tsx b/src/app/(dashboard)/teacher/dashboard/page.tsx new file mode 100644 index 0000000..7dd66fd --- /dev/null +++ b/src/app/(dashboard)/teacher/dashboard/page.tsx @@ -0,0 +1,28 @@ +import { TeacherStats } from "@/modules/dashboard/components/teacher-stats"; +import { TeacherSchedule } from "@/modules/dashboard/components/teacher-schedule"; +import { RecentSubmissions } from "@/modules/dashboard/components/recent-submissions"; +import { TeacherQuickActions } from "@/modules/dashboard/components/teacher-quick-actions"; + +export default function TeacherDashboardPage() { + return ( +
+
+

Teacher Dashboard

+
+ +
+
+ + {/* Overview Stats */} + + +
+ {/* Left Column: Schedule (3/7 width) */} + + + {/* Right Column: Recent Activity (4/7 width) */} + +
+
+ ); +} diff --git a/src/app/(dashboard)/teacher/exams/[id]/build/page.tsx b/src/app/(dashboard)/teacher/exams/[id]/build/page.tsx new file mode 100644 index 0000000..bae3f5c --- /dev/null +++ b/src/app/(dashboard)/teacher/exams/[id]/build/page.tsx @@ -0,0 +1,76 @@ +import { notFound } from "next/navigation" +import { ExamAssembly } from "@/modules/exams/components/exam-assembly" +import { getExamById } from "@/modules/exams/data-access" +import { getQuestions } from "@/modules/questions/data-access" +import type { Question } from "@/modules/questions/types" +import type { ExamNode } from "@/modules/exams/components/assembly/selected-question-list" +import { createId } from "@paralleldrive/cuid2" + +export default async function BuildExamPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + + const exam = await getExamById(id) + if (!exam) return notFound() + + // Fetch all available questions (for selection pool) + // In a real app, this might be paginated or filtered by exam subject/grade + const { data: questionsData } = await getQuestions({ pageSize: 100 }) + + const questionOptions: Question[] = questionsData.map((q) => ({ + id: q.id, + content: q.content as any, + type: q.type as any, + difficulty: q.difficulty ?? 1, + createdAt: new Date(q.createdAt), + updatedAt: new Date(q.updatedAt), + author: q.author ? { + id: q.author.id, + name: q.author.name || "Unknown", + image: q.author.image || null + } : null, + knowledgePoints: (q.questionsToKnowledgePoints || []).map((kp) => ({ + id: kp.knowledgePoint.id, + name: kp.knowledgePoint.name + })) + })) + + const initialSelected = (exam.questions || []).map(q => ({ + id: q.id, + score: q.score || 0 + })) + + // Prepare initialStructure on server side to avoid hydration mismatch with random IDs + let initialStructure: ExamNode[] = exam.structure as ExamNode[] || [] + + if (initialStructure.length === 0 && initialSelected.length > 0) { + initialStructure = initialSelected.map(s => ({ + id: createId(), // Generate stable ID on server + type: 'question', + questionId: s.id, + score: s.score + })) + } + + return ( +
+
+
+

Build Exam

+

Add questions and adjust scores.

+
+
+ +
+ ) +} diff --git a/src/app/(dashboard)/teacher/exams/all/loading.tsx b/src/app/(dashboard)/teacher/exams/all/loading.tsx new file mode 100644 index 0000000..9773464 --- /dev/null +++ b/src/app/(dashboard)/teacher/exams/all/loading.tsx @@ -0,0 +1,25 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+
+ +
+
+ + + + + +
+
+
+
+ ) +} + diff --git a/src/app/(dashboard)/teacher/exams/all/page.tsx b/src/app/(dashboard)/teacher/exams/all/page.tsx new file mode 100644 index 0000000..7410a8d --- /dev/null +++ b/src/app/(dashboard)/teacher/exams/all/page.tsx @@ -0,0 +1,47 @@ +import { Suspense } from "react" +import Link from "next/link" +import { Button } from "@/shared/components/ui/button" +import { ExamDataTable } from "@/modules/exams/components/exam-data-table" +import { examColumns } from "@/modules/exams/components/exam-columns" +import { ExamFilters } from "@/modules/exams/components/exam-filters" +import { getExams } from "@/modules/exams/data-access" + +export default async function AllExamsPage({ + searchParams, +}: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }> +}) { + const params = await searchParams + + const exams = await getExams({ + q: params.q as string, + status: params.status as string, + difficulty: params.difficulty as string, + }) + + return ( +
+
+
+

All Exams

+

View and manage all your exams.

+
+
+ +
+
+ +
+ }> + + + +
+ +
+
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/exams/create/loading.tsx b/src/app/(dashboard)/teacher/exams/create/loading.tsx new file mode 100644 index 0000000..bfdff09 --- /dev/null +++ b/src/app/(dashboard)/teacher/exams/create/loading.tsx @@ -0,0 +1,17 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+
+ + +
+
+ ) +} + diff --git a/src/app/(dashboard)/teacher/exams/create/page.tsx b/src/app/(dashboard)/teacher/exams/create/page.tsx new file mode 100644 index 0000000..c1ee42e --- /dev/null +++ b/src/app/(dashboard)/teacher/exams/create/page.tsx @@ -0,0 +1,15 @@ +import { ExamForm } from "@/modules/exams/components/exam-form" + +export default function CreateExamPage() { + return ( +
+
+
+

Create Exam

+

Design a new exam for your students.

+
+
+ +
+ ) +} diff --git a/src/app/(dashboard)/teacher/exams/grading/[submissionId]/page.tsx b/src/app/(dashboard)/teacher/exams/grading/[submissionId]/page.tsx new file mode 100644 index 0000000..549e950 --- /dev/null +++ b/src/app/(dashboard)/teacher/exams/grading/[submissionId]/page.tsx @@ -0,0 +1,40 @@ +import { notFound } from "next/navigation" +import { GradingView } from "@/modules/exams/components/grading-view" +import { getSubmissionDetails } from "@/modules/exams/data-access" +import { formatDate } from "@/shared/lib/utils" + +export default async function SubmissionGradingPage({ params }: { params: Promise<{ submissionId: string }> }) { + const { submissionId } = await params + const submission = await getSubmissionDetails(submissionId) + + if (!submission) { + return notFound() + } + + return ( +
+
+
+

{submission.examTitle}

+
+ Student: {submission.studentName} + + Submitted: {submission.submittedAt ? formatDate(submission.submittedAt) : "-"} + + Status: {submission.status} +
+
+
+ + +
+ ) +} diff --git a/src/app/(dashboard)/teacher/exams/grading/loading.tsx b/src/app/(dashboard)/teacher/exams/grading/loading.tsx new file mode 100644 index 0000000..984cd17 --- /dev/null +++ b/src/app/(dashboard)/teacher/exams/grading/loading.tsx @@ -0,0 +1,21 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+
+
+ + + + +
+
+
+ ) +} + diff --git a/src/app/(dashboard)/teacher/exams/grading/page.tsx b/src/app/(dashboard)/teacher/exams/grading/page.tsx new file mode 100644 index 0000000..3c724cc --- /dev/null +++ b/src/app/(dashboard)/teacher/exams/grading/page.tsx @@ -0,0 +1,22 @@ +import { SubmissionDataTable } from "@/modules/exams/components/submission-data-table" +import { submissionColumns } from "@/modules/exams/components/submission-columns" +import { getExamSubmissions } from "@/modules/exams/data-access" + +export default async function ExamGradingPage() { + const submissions = await getExamSubmissions() + + return ( +
+
+
+

Grading

+

Grade student exam submissions.

+
+
+ +
+ +
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/exams/page.tsx b/src/app/(dashboard)/teacher/exams/page.tsx new file mode 100644 index 0000000..eb4f95d --- /dev/null +++ b/src/app/(dashboard)/teacher/exams/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation" + +export default function ExamsPage() { + redirect("/teacher/exams/all") +} diff --git a/src/app/(dashboard)/teacher/homework/assignments/page.tsx b/src/app/(dashboard)/teacher/homework/assignments/page.tsx new file mode 100644 index 0000000..685c0ba --- /dev/null +++ b/src/app/(dashboard)/teacher/homework/assignments/page.tsx @@ -0,0 +1,26 @@ +import { EmptyState } from "@/shared/components/ui/empty-state" +import { PenTool } from "lucide-react" + +export default function AssignmentsPage() { + return ( +
+
+
+

Assignments

+

+ Manage homework assignments. +

+
+
+ +
+ ) +} diff --git a/src/app/(dashboard)/teacher/homework/page.tsx b/src/app/(dashboard)/teacher/homework/page.tsx new file mode 100644 index 0000000..2e53055 --- /dev/null +++ b/src/app/(dashboard)/teacher/homework/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation" + +export default function HomeworkPage() { + redirect("/teacher/homework/assignments") +} diff --git a/src/app/(dashboard)/teacher/homework/submissions/page.tsx b/src/app/(dashboard)/teacher/homework/submissions/page.tsx new file mode 100644 index 0000000..9b77b42 --- /dev/null +++ b/src/app/(dashboard)/teacher/homework/submissions/page.tsx @@ -0,0 +1,22 @@ +import { EmptyState } from "@/shared/components/ui/empty-state" +import { Inbox } from "lucide-react" + +export default function SubmissionsPage() { + return ( +
+
+
+

Submissions

+

+ Review student homework submissions. +

+
+
+ +
+ ) +} diff --git a/src/app/(dashboard)/teacher/questions/page.tsx b/src/app/(dashboard)/teacher/questions/page.tsx new file mode 100644 index 0000000..b156047 --- /dev/null +++ b/src/app/(dashboard)/teacher/questions/page.tsx @@ -0,0 +1,74 @@ +import { Suspense } from "react" +import { Plus } from "lucide-react" + +import { Button } from "@/shared/components/ui/button" +import { QuestionDataTable } from "@/modules/questions/components/question-data-table" +import { columns } from "@/modules/questions/components/question-columns" +import { QuestionFilters } from "@/modules/questions/components/question-filters" +import { CreateQuestionButton } from "@/modules/questions/components/create-question-button" +import { MOCK_QUESTIONS } from "@/modules/questions/mock-data" +import { Question } from "@/modules/questions/types" + +// Simulate backend delay and filtering +async function getQuestions(searchParams: { [key: string]: string | string[] | undefined }) { + // In a real app, you would call your DB or API here + // await new Promise((resolve) => setTimeout(resolve, 500)) // Simulate network latency + + let filtered = [...MOCK_QUESTIONS] + + const q = searchParams.q as string + const type = searchParams.type as string + const difficulty = searchParams.difficulty as string + + if (q) { + filtered = filtered.filter((item) => + (typeof item.content === 'string' && item.content.toLowerCase().includes(q.toLowerCase())) || + (typeof item.content === 'object' && JSON.stringify(item.content).toLowerCase().includes(q.toLowerCase())) + ) + } + + if (type && type !== "all") { + filtered = filtered.filter((item) => item.type === type) + } + + if (difficulty && difficulty !== "all") { + filtered = filtered.filter((item) => item.difficulty === parseInt(difficulty)) + } + + return filtered +} + +export default async function QuestionBankPage({ + searchParams, +}: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }> +}) { + const params = await searchParams + const questions = await getQuestions(params) + + return ( +
+
+
+

Question Bank

+

+ Manage your question repository for exams and assignments. +

+
+
+ +
+
+ +
+ }> + + + +
+ +
+
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/textbooks/[id]/loading.tsx b/src/app/(dashboard)/teacher/textbooks/[id]/loading.tsx new file mode 100644 index 0000000..738a353 --- /dev/null +++ b/src/app/(dashboard)/teacher/textbooks/[id]/loading.tsx @@ -0,0 +1,66 @@ +import { Skeleton } from "@/shared/components/ui/skeleton"; +import { Separator } from "@/shared/components/ui/separator"; + +export default function Loading() { + return ( +
+ {/* Header Skeleton */} +
+ {/* Back Button */} +
+
+ + +
+ +
+ {/* Edit Button */} +
+ +
+ {/* Main Content Skeleton */} +
+
+ + +
+ +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + +
+ ))} +
+
+ + {/* Sidebar Skeleton */} +
+
+ +
+ + +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+
+
+
+
+ ); +} diff --git a/src/app/(dashboard)/teacher/textbooks/[id]/page.tsx b/src/app/(dashboard)/teacher/textbooks/[id]/page.tsx new file mode 100644 index 0000000..90a987d --- /dev/null +++ b/src/app/(dashboard)/teacher/textbooks/[id]/page.tsx @@ -0,0 +1,80 @@ +import { notFound } from "next/navigation"; +import { ArrowLeft, Edit } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/shared/components/ui/button"; +import { Badge } from "@/shared/components/ui/badge"; +import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByChapterId } from "@/modules/textbooks/data-access"; +import { TextbookContentLayout } from "@/modules/textbooks/components/textbook-content-layout"; +import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog"; + +export default async function TextbookDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + const [textbook, chapters] = await Promise.all([ + getTextbookById(id), + getChaptersByTextbookId(id), + ]); + + if (!textbook) { + notFound(); + } + + // Fetch all KPs for these chapters. In a real app, this might be optimized to fetch only needed or use a different query strategy. + // For now, we simulate fetching KPs for all chapters to pass down, or we could fetch on demand. + // Given the layout loads everything client-side for interactivity, let's fetch all KPs associated with any chapter in this textbook. + // We'll need to extend the data access for this specific query pattern or loop. + // For simplicity in this mock, let's assume getKnowledgePointsByChapterId can handle fetching all KPs for a textbook if we had such a function, + // or we iterate. Let's create a helper to get all KPs for the textbook's chapters. + + // Actually, let's update data-access to support getting KPs by Textbook ID directly or just fetch all for mock. + // Since we don't have getKnowledgePointsByTextbookId, we will map over chapters. + + const allKnowledgePoints = (await Promise.all( + chapters.map(c => getKnowledgePointsByChapterId(c.id)) + )).flat(); + + // Also need to get KPs for children chapters if any + const childrenKPs = (await Promise.all( + chapters.flatMap(c => c.children || []).map(child => getKnowledgePointsByChapterId(child.id)) + )).flat(); + + const knowledgePoints = [...allKnowledgePoints, ...childrenKPs]; + + return ( +
+ {/* Header / Nav (Fixed height) */} +
+ +
+
+ {textbook.subject} + + {textbook.grade} + +
+

{textbook.title}

+
+
+ +
+
+ + {/* Main Content Layout (Flex grow) */} +
+ +
+
+ ); +} diff --git a/src/app/(dashboard)/teacher/textbooks/loading.tsx b/src/app/(dashboard)/teacher/textbooks/loading.tsx new file mode 100644 index 0000000..29d0983 --- /dev/null +++ b/src/app/(dashboard)/teacher/textbooks/loading.tsx @@ -0,0 +1,48 @@ +import { Skeleton } from "@/shared/components/ui/skeleton"; +import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card"; + +export default function Loading() { + return ( +
+ {/* Header Skeleton */} +
+
+ + +
+ +
+ + {/* Toolbar Skeleton */} +
+ +
+ + +
+
+ + {/* Grid Content Skeleton */} +
+ {Array.from({ length: 8 }).map((_, i) => ( + +
+ +
+ + + + + + + + + + + +
+ ))} +
+
+ ); +} diff --git a/src/app/(dashboard)/teacher/textbooks/page.tsx b/src/app/(dashboard)/teacher/textbooks/page.tsx new file mode 100644 index 0000000..bd36688 --- /dev/null +++ b/src/app/(dashboard)/teacher/textbooks/page.tsx @@ -0,0 +1,78 @@ +import { Search, Filter } from "lucide-react"; +import { Button } from "@/shared/components/ui/button"; +import { Input } from "@/shared/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select"; +import { TextbookCard } from "@/modules/textbooks/components/textbook-card"; +import { TextbookFormDialog } from "@/modules/textbooks/components/textbook-form-dialog"; +import { getTextbooks } from "@/modules/textbooks/data-access"; + +export default async function TextbooksPage() { + // In a real app, we would parse searchParams here + const textbooks = await getTextbooks(); + + return ( +
+ {/* Page Header */} +
+
+

Textbooks

+

+ Manage your digital curriculum resources and chapters. +

+
+ +
+ + {/* Toolbar */} +
+
+ + +
+
+ + + +
+
+ + {/* Grid Content */} +
+ {textbooks.map((textbook) => ( + + ))} +
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..69a102a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,26 +1,177 @@ @import "tailwindcss"; +@plugin "tailwindcss-animate"; + +@custom-variant dark (&:where(.dark, .dark *)); + :root { - --background: #ffffff; - --foreground: #171717; + /* Neutral: Zinc - Clean, Professional, International Style */ + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + + /* Brand: Deep Indigo */ + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + + /* Destructive: Subtle Red */ + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + + /* Borders & UI */ + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 5.9% 10%; + + --radius: 0.5rem; + + /* Chart / Data Visualization Colors */ + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + + /* Sidebar Specific */ + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; +} + +.dark { + /* Dark Mode: Deep Zinc Base */ + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + + /* Brand Dark: Adjusted for contrast */ + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; } @theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); -} + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); + + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + + --color-chart-1: hsl(var(--chart-1)); + --color-chart-2: hsl(var(--chart-2)); + --color-chart-3: hsl(var(--chart-3)); + --color-chart-4: hsl(var(--chart-4)); + --color-chart-5: hsl(var(--chart-5)); + + --color-sidebar: hsl(var(--sidebar-background)); + --color-sidebar-foreground: hsl(var(--sidebar-foreground)); + --color-sidebar-primary: hsl(var(--sidebar-primary)); + --color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground)); + --color-sidebar-accent: hsl(var(--sidebar-accent)); + --color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground)); + --color-sidebar-border: hsl(var(--sidebar-border)); + --color-sidebar-ring: hsl(var(--sidebar-ring)); -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); + + --animate-accordion-down: accordion-down 0.2s ease-out; + --animate-accordion-up: accordion-up 0.2s ease-out; + + @keyframes accordion-down { + from { height: 0; } + to { height: var(--radix-accordion-content-height); } + } + @keyframes accordion-up { + from { height: var(--radix-accordion-content-height); } + to { height: 0; } } } -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; +/* Base Styles */ +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + font-feature-settings: "rlig" 1, "calt" 1; + } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..c8cba9f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,20 +1,12 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { ThemeProvider } from "@/shared/components/theme-provider"; +import { Toaster } from "@/shared/components/ui/sonner"; +import { NuqsAdapter } from 'nuqs/adapters/next/app' import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Next_Edu - K12 智慧教务系统", + description: "Enterprise Grade K12 Education Management System", }; export default function RootLayout({ @@ -23,11 +15,21 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - {children} + + + {children} + + + ); diff --git a/src/app/page.tsx b/src/app/page.tsx index a81a847..a74cb27 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,66 +1,5 @@ -import Image from "next/image"; +import { redirect } from "next/navigation"; export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. - This is Update. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- -
-
- ); + redirect("/dashboard"); } diff --git a/src/env.mjs b/src/env.mjs new file mode 100644 index 0000000..868af9c --- /dev/null +++ b/src/env.mjs @@ -0,0 +1,23 @@ +import { createEnv } from "@t3-oss/env-nextjs"; +import { z } from "zod"; + +export const env = createEnv({ + server: { + DATABASE_URL: z.string().url(), + NODE_ENV: z.enum(["development", "test", "production"]).default("development"), + NEXTAUTH_SECRET: z.string().min(1).optional(), + NEXTAUTH_URL: z.string().url().optional(), + }, + client: { + NEXT_PUBLIC_APP_URL: z.string().url().optional(), + }, + runtimeEnv: { + DATABASE_URL: process.env.DATABASE_URL, + NODE_ENV: process.env.NODE_ENV, + NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, + NEXTAUTH_URL: process.env.NEXTAUTH_URL, + NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, + }, + skipValidation: !!process.env.SKIP_ENV_VALIDATION, + emptyStringAsUndefined: true, +}); diff --git a/src/modules/auth/components/auth-layout.tsx b/src/modules/auth/components/auth-layout.tsx new file mode 100644 index 0000000..8f75823 --- /dev/null +++ b/src/modules/auth/components/auth-layout.tsx @@ -0,0 +1,33 @@ +import Link from "next/link" +import { GraduationCap } from "lucide-react" + +interface AuthLayoutProps { + children: React.ReactNode +} + +export function AuthLayout({ children }: AuthLayoutProps) { + return ( +
+
+
+
+ + Next_Edu +
+
+
+

+ “This platform has completely transformed how we deliver education to our students. The attention to detail and performance is unmatched.” +

+
Sofia Davis
+
+
+
+
+
+ {children} +
+
+
+ ) +} diff --git a/src/modules/auth/components/login-form.tsx b/src/modules/auth/components/login-form.tsx new file mode 100644 index 0000000..456e5a6 --- /dev/null +++ b/src/modules/auth/components/login-form.tsx @@ -0,0 +1,103 @@ +"use client" + +import * as React from "react" +import Link from "next/link" +import { Button } from "@/shared/components/ui/button" +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { cn } from "@/shared/lib/utils" +import { Loader2, Github } from "lucide-react" + +interface LoginFormProps extends React.HTMLAttributes {} + +export function LoginForm({ className, ...props }: LoginFormProps) { + const [isLoading, setIsLoading] = React.useState(false) + + async function onSubmit(event: React.SyntheticEvent) { + event.preventDefault() + setIsLoading(true) + + setTimeout(() => { + setIsLoading(false) + }, 3000) + } + + return ( +
+
+

+ Welcome back +

+

+ Enter your email to sign in to your account +

+
+
+
+
+ + +
+
+
+ + + Forgot password? + +
+ +
+ +
+
+
+
+ +
+
+ + Or continue with + +
+
+ +

+ Don't have an account?{" "} + + Sign up + +

+
+ ) +} diff --git a/src/modules/auth/components/register-form.tsx b/src/modules/auth/components/register-form.tsx new file mode 100644 index 0000000..d8e2abd --- /dev/null +++ b/src/modules/auth/components/register-form.tsx @@ -0,0 +1,107 @@ +"use client" + +import * as React from "react" +import Link from "next/link" +import { Button } from "@/shared/components/ui/button" +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { cn } from "@/shared/lib/utils" +import { Loader2, Github } from "lucide-react" + +interface RegisterFormProps extends React.HTMLAttributes {} + +export function RegisterForm({ className, ...props }: RegisterFormProps) { + const [isLoading, setIsLoading] = React.useState(false) + + async function onSubmit(event: React.SyntheticEvent) { + event.preventDefault() + setIsLoading(true) + + setTimeout(() => { + setIsLoading(false) + }, 3000) + } + + return ( +
+
+

+ Create an account +

+

+ Enter your email below to create your account +

+
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+ +
+
+ + Or continue with + +
+
+ +

+ Already have an account?{" "} + + Sign in + +

+
+ ) +} diff --git a/src/modules/dashboard/components/admin-view.tsx b/src/modules/dashboard/components/admin-view.tsx new file mode 100644 index 0000000..e69e048 --- /dev/null +++ b/src/modules/dashboard/components/admin-view.tsx @@ -0,0 +1,25 @@ +"use client" + +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" + +export function AdminDashboard() { + return ( +
+

Admin Dashboard

+
+ + System Status + Operational + + + Total Users + 2,450 + + + Active Sessions + 142 + +
+
+ ) +} diff --git a/src/modules/dashboard/components/recent-submissions.tsx b/src/modules/dashboard/components/recent-submissions.tsx new file mode 100644 index 0000000..c72fb00 --- /dev/null +++ b/src/modules/dashboard/components/recent-submissions.tsx @@ -0,0 +1,105 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"; +import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"; +import { EmptyState } from "@/shared/components/ui/empty-state"; +import { Inbox } from "lucide-react"; + +interface SubmissionItem { + id: string; + studentName: string; + studentAvatar?: string; + assignment: string; + submittedAt: string; + status: "submitted" | "late"; +} + +const MOCK_SUBMISSIONS: SubmissionItem[] = [ + { + id: "1", + studentName: "Alice Johnson", + assignment: "React Component Composition", + submittedAt: "10 minutes ago", + status: "submitted", + }, + { + id: "2", + studentName: "Bob Smith", + assignment: "Design System Analysis", + submittedAt: "1 hour ago", + status: "submitted", + }, + { + id: "3", + studentName: "Charlie Brown", + assignment: "React Component Composition", + submittedAt: "2 hours ago", + status: "late", + }, + { + id: "4", + studentName: "Diana Prince", + assignment: "CSS Grid Layout", + submittedAt: "Yesterday", + status: "submitted", + }, + { + id: "5", + studentName: "Evan Wright", + assignment: "Design System Analysis", + submittedAt: "Yesterday", + status: "submitted", + }, +]; + +export function RecentSubmissions() { + const hasSubmissions = MOCK_SUBMISSIONS.length > 0; + + return ( + + + Recent Submissions + + + {!hasSubmissions ? ( + + ) : ( +
+ {MOCK_SUBMISSIONS.map((item) => ( +
+
+ + + {item.studentName.charAt(0)} + +
+

+ {item.studentName} +

+

+ Submitted {item.assignment} +

+
+
+
+
+ {/* Using static date for demo to prevent hydration mismatch */} + {item.submittedAt} +
+ {item.status === "late" && ( + + Late + + )} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/modules/dashboard/components/student-view.tsx b/src/modules/dashboard/components/student-view.tsx new file mode 100644 index 0000000..1947bc3 --- /dev/null +++ b/src/modules/dashboard/components/student-view.tsx @@ -0,0 +1,21 @@ +"use client" + +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" + +export function StudentDashboard() { + return ( +
+

Student Dashboard

+
+ + My Courses + Enrolled in 5 courses + + + Assignments + 2 due this week + +
+
+ ) +} diff --git a/src/modules/dashboard/components/teacher-quick-actions.tsx b/src/modules/dashboard/components/teacher-quick-actions.tsx new file mode 100644 index 0000000..c33df9f --- /dev/null +++ b/src/modules/dashboard/components/teacher-quick-actions.tsx @@ -0,0 +1,21 @@ +import { Button } from "@/shared/components/ui/button"; +import { PlusCircle, CheckSquare, MessageSquare } from "lucide-react"; + +export function TeacherQuickActions() { + return ( +
+ + + +
+ ); +} diff --git a/src/modules/dashboard/components/teacher-schedule.tsx b/src/modules/dashboard/components/teacher-schedule.tsx new file mode 100644 index 0000000..81fd94f --- /dev/null +++ b/src/modules/dashboard/components/teacher-schedule.tsx @@ -0,0 +1,81 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"; +import { Badge } from "@/shared/components/ui/badge"; +import { Clock, MapPin, CalendarX } from "lucide-react"; +import { EmptyState } from "@/shared/components/ui/empty-state"; + +interface ScheduleItem { + id: string; + course: string; + time: string; + location: string; + type: "Lecture" | "Workshop" | "Lab"; +} + +// MOCK_SCHEDULE can be empty to test empty state +const MOCK_SCHEDULE: ScheduleItem[] = [ + { + id: "1", + course: "Advanced Web Development", + time: "09:00 AM - 10:30 AM", + location: "Room 304", + type: "Lecture", + }, + { + id: "2", + course: "UI/UX Design Principles", + time: "11:00 AM - 12:30 PM", + location: "Design Studio A", + type: "Workshop", + }, + { + id: "3", + course: "Frontend Frameworks", + time: "02:00 PM - 03:30 PM", + location: "Online (Zoom)", + type: "Lecture", + }, +]; + +export function TeacherSchedule() { + const hasSchedule = MOCK_SCHEDULE.length > 0; + + return ( + + + Today's Schedule + + + {!hasSchedule ? ( + + ) : ( +
+ {MOCK_SCHEDULE.map((item) => ( +
+
+

{item.course}

+
+ + {item.time} + + {item.location} +
+
+ + {item.type} + +
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/modules/dashboard/components/teacher-stats.tsx b/src/modules/dashboard/components/teacher-stats.tsx new file mode 100644 index 0000000..da81e6e --- /dev/null +++ b/src/modules/dashboard/components/teacher-stats.tsx @@ -0,0 +1,83 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"; +import { Users, BookOpen, FileCheck, Calendar } from "lucide-react"; +import { Skeleton } from "@/shared/components/ui/skeleton"; + +interface StatItem { + title: string; + value: string; + description: string; + icon: React.ElementType; +} + +const MOCK_STATS: StatItem[] = [ + { + title: "Total Students", + value: "1,248", + description: "+12% from last semester", + icon: Users, + }, + { + title: "Active Courses", + value: "4", + description: "2 lectures, 2 workshops", + icon: BookOpen, + }, + { + title: "To Grade", + value: "28", + description: "5 submissions pending review", + icon: FileCheck, + }, + { + title: "Upcoming Classes", + value: "3", + description: "Today's schedule", + icon: Calendar, + }, +]; + +interface TeacherStatsProps { + isLoading?: boolean; +} + +export function TeacherStats({ isLoading = false }: TeacherStatsProps) { + if (isLoading) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + + + + + ))} +
+ ); + } + + return ( +
+ {MOCK_STATS.map((stat, i) => ( + + + + {stat.title} + + + + +
{stat.value}
+

+ {stat.description} +

+
+
+ ))} +
+ ); +} diff --git a/src/modules/dashboard/components/teacher-view.tsx b/src/modules/dashboard/components/teacher-view.tsx new file mode 100644 index 0000000..39cafd1 --- /dev/null +++ b/src/modules/dashboard/components/teacher-view.tsx @@ -0,0 +1,25 @@ +"use client" + +import { TeacherQuickActions } from "@/modules/dashboard/components/teacher-quick-actions"; +import { TeacherStats } from "@/modules/dashboard/components/teacher-stats"; +import { TeacherSchedule } from "@/modules/dashboard/components/teacher-schedule"; +import { RecentSubmissions } from "@/modules/dashboard/components/recent-submissions"; + +// This component is now exclusively for the Teacher Role View +export function TeacherDashboard() { + return ( +
+
+

Teacher Dashboard

+ +
+ + + +
+ + +
+
+ ) +} diff --git a/src/modules/exams/actions.ts b/src/modules/exams/actions.ts new file mode 100644 index 0000000..99473fb --- /dev/null +++ b/src/modules/exams/actions.ts @@ -0,0 +1,244 @@ +"use server" + +import { revalidatePath } from "next/cache" +import { ActionState } from "@/shared/types/action-state" +import { z } from "zod" +import { createId } from "@paralleldrive/cuid2" +import { db } from "@/shared/db" +import { exams, examQuestions, submissionAnswers, examSubmissions } from "@/shared/db/schema" +import { eq } from "drizzle-orm" + +const ExamCreateSchema = z.object({ + title: z.string().min(1), + subject: z.string().min(1), + grade: z.string().min(1), + difficulty: z.coerce.number().int().min(1).max(5), + totalScore: z.coerce.number().int().min(1), + durationMin: z.coerce.number().int().min(1), + scheduledAt: z.string().optional().nullable(), + questions: z + .array( + z.object({ + id: z.string(), + score: z.coerce.number().int().min(0), + }) + ) + .optional(), +}) + +export async function createExamAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + const rawQuestions = formData.get("questionsJson") as string | null + + const parsed = ExamCreateSchema.safeParse({ + title: formData.get("title"), + subject: formData.get("subject"), + grade: formData.get("grade"), + difficulty: formData.get("difficulty"), + totalScore: formData.get("totalScore"), + durationMin: formData.get("durationMin"), + scheduledAt: formData.get("scheduledAt"), + questions: rawQuestions ? JSON.parse(rawQuestions) : [], + }) + + if (!parsed.success) { + return { + success: false, + message: "Invalid form data", + errors: parsed.error.flatten().fieldErrors, + } + } + + const input = parsed.data + + const examId = createId() + const scheduled = input.scheduledAt || undefined + + const meta = { + subject: input.subject, + grade: input.grade, + difficulty: input.difficulty, + totalScore: input.totalScore, + durationMin: input.durationMin, + scheduledAt: scheduled ?? undefined, + } + + try { + const user = await getCurrentUser() + await db.insert(exams).values({ + id: examId, + title: input.title, + description: JSON.stringify(meta), + creatorId: user?.id ?? "user_teacher_123", + startTime: scheduled ? new Date(scheduled) : null, + status: "draft", + }) + } catch (error) { + console.error("Failed to create exam:", error) + return { + success: false, + message: "Database error: Failed to create exam", + } + } + + revalidatePath("/teacher/exams/all") + + return { + success: true, + message: "Exam created successfully.", + data: examId, + } +} + +const ExamUpdateSchema = z.object({ + examId: z.string().min(1), + questions: z + .array( + z.object({ + id: z.string(), + score: z.coerce.number().int().min(0), + }) + ) + .default([]), + structure: z.any().optional(), // Accept structure JSON + status: z.enum(["draft", "published", "archived"]).optional(), +}) + +export async function updateExamAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + const rawQuestions = formData.get("questionsJson") as string | null + const rawStructure = formData.get("structureJson") as string | null + + const parsed = ExamUpdateSchema.safeParse({ + examId: formData.get("examId"), + questions: rawQuestions ? JSON.parse(rawQuestions) : [], + structure: rawStructure ? JSON.parse(rawStructure) : undefined, + status: formData.get("status") ?? undefined, + }) + + if (!parsed.success) { + return { + success: false, + message: "Invalid update data", + errors: parsed.error.flatten().fieldErrors, + } + } + + const { examId, questions, structure, status } = parsed.data + + try { + await db.delete(examQuestions).where(eq(examQuestions.examId, examId)) + if (questions.length > 0) { + await db.insert(examQuestions).values( + questions.map((q, idx) => ({ + examId, + questionId: q.id, + score: q.score ?? 0, + order: idx, + })) + ) + } + + // Prepare update object + const updateData: any = {} + if (status) updateData.status = status + if (structure) updateData.structure = structure + + if (Object.keys(updateData).length > 0) { + await db.update(exams).set(updateData).where(eq(exams.id, examId)) + } + + } catch (error) { + console.error("Failed to update exam:", error) + return { + success: false, + message: "Database error: Failed to update exam", + } + } + + revalidatePath("/teacher/exams/all") + + return { + success: true, + message: "Exam updated", + data: examId, + } +} + +const GradingSchema = z.object({ + submissionId: z.string().min(1), + answers: z.array(z.object({ + id: z.string(), // answer id + score: z.coerce.number().min(0), + feedback: z.string().optional() + })) +}) + +export async function gradeSubmissionAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + const rawAnswers = formData.get("answersJson") as string | null + const parsed = GradingSchema.safeParse({ + submissionId: formData.get("submissionId"), + answers: rawAnswers ? JSON.parse(rawAnswers) : [] + }) + + if (!parsed.success) { + return { + success: false, + message: "Invalid grading data", + errors: parsed.error.flatten().fieldErrors + } + } + + const { submissionId, answers } = parsed.data + + try { + let totalScore = 0 + + // Update each answer + for (const ans of answers) { + await db.update(submissionAnswers) + .set({ + score: ans.score, + feedback: ans.feedback, + updatedAt: new Date() + }) + .where(eq(submissionAnswers.id, ans.id)) + + totalScore += ans.score + } + + // Update submission total score and status + await db.update(examSubmissions) + .set({ + score: totalScore, + status: "graded", + updatedAt: new Date() + }) + .where(eq(examSubmissions.id, submissionId)) + + } catch (error) { + console.error("Grading failed:", error) + return { + success: false, + message: "Database error during grading" + } + } + + revalidatePath(`/teacher/exams/grading`) + + return { + success: true, + message: "Grading saved successfully" + } +} + +async function getCurrentUser() { + return { id: "user_teacher_123", role: "teacher" } +} diff --git a/src/modules/exams/components/assembly/question-bank-list.tsx b/src/modules/exams/components/assembly/question-bank-list.tsx new file mode 100644 index 0000000..c13059a --- /dev/null +++ b/src/modules/exams/components/assembly/question-bank-list.tsx @@ -0,0 +1,65 @@ +"use client" + +import { Button } from "@/shared/components/ui/button" +import { Badge } from "@/shared/components/ui/badge" +import { Card } from "@/shared/components/ui/card" +import { Plus } from "lucide-react" +import type { Question } from "@/modules/questions/types" + +type QuestionBankListProps = { + questions: Question[] + onAdd: (question: Question) => void + isAdded: (id: string) => boolean +} + +export function QuestionBankList({ questions, onAdd, isAdded }: QuestionBankListProps) { + if (questions.length === 0) { + return ( +
+ No questions found matching your filters. +
+ ) + } + + return ( +
+ {questions.map((q) => { + const added = isAdded(q.id) + const content = q.content as { text?: string } + return ( + +
+
+ + {q.type.replace("_", " ")} + + + Lvl {q.difficulty} + + {q.knowledgePoints?.slice(0, 1).map((kp) => ( + + {kp.name} + + ))} +
+

+ {content.text || "No content preview"} +

+
+
+ +
+
+ ) + })} +
+ ) +} diff --git a/src/modules/exams/components/assembly/selected-question-list.tsx b/src/modules/exams/components/assembly/selected-question-list.tsx new file mode 100644 index 0000000..d7d8293 --- /dev/null +++ b/src/modules/exams/components/assembly/selected-question-list.tsx @@ -0,0 +1,181 @@ +"use client" + +import { Button } from "@/shared/components/ui/button" +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { ArrowUp, ArrowDown, Trash2 } from "lucide-react" +import type { Question } from "@/modules/questions/types" + +export type ExamNode = { + id: string + type: 'group' | 'question' + title?: string // For group + questionId?: string // For question + score?: number + children?: ExamNode[] // For group + question?: Question // Populated for rendering +} + +type SelectedQuestionListProps = { + items: ExamNode[] + onRemove: (id: string, parentId?: string) => void + onMove: (id: string, direction: 'up' | 'down', parentId?: string) => void + onScoreChange: (id: string, score: number) => void + onGroupTitleChange: (id: string, title: string) => void + onAddGroup: () => void +} + +export function SelectedQuestionList({ + items, + onRemove, + onMove, + onScoreChange, + onGroupTitleChange, + onAddGroup +}: SelectedQuestionListProps) { + if (items.length === 0) { + return ( +
+

No questions selected. Add questions from the bank or create a group.

+ +
+ ) + } + + return ( +
+ {items.map((node, idx) => { + if (node.type === 'group') { + return ( +
+
+ onGroupTitleChange(node.id, e.target.value)} + className="font-semibold h-9 bg-transparent border-transparent hover:border-input focus:bg-background" + /> +
+ + + +
+
+ +
+ {node.children?.length === 0 ? ( +
Drag questions here or add from bank
+ ) : ( + node.children?.map((child, cIdx) => ( + onRemove(child.id, node.id)} + onMove={(dir) => onMove(child.id, dir, node.id)} + onScoreChange={(score) => onScoreChange(child.id, score)} + /> + )) + )} +
+
+ ) + } + + return ( + onRemove(node.id)} + onMove={(dir) => onMove(node.id, dir)} + onScoreChange={(score) => onScoreChange(node.id, score)} + /> + ) + })} + +
+ +
+
+ ) +} + +function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: { + item: ExamNode + index: number + total: number + onRemove: () => void + onMove: (dir: 'up' | 'down') => void + onScoreChange: (score: number) => void +}) { + const content = item.question?.content as { text?: string } + return ( +
+
+
+ + {index + 1} + +

+ {content?.text || "Question content"} +

+
+ +
+ +
+
+ + +
+ +
+ + onScoreChange(parseInt(e.target.value) || 0)} + /> +
+
+
+ ) +} diff --git a/src/modules/exams/components/assembly/structure-editor.tsx b/src/modules/exams/components/assembly/structure-editor.tsx new file mode 100644 index 0000000..9646626 --- /dev/null +++ b/src/modules/exams/components/assembly/structure-editor.tsx @@ -0,0 +1,570 @@ +"use client" + +import React, { useMemo, useState } from "react" +import { + DndContext, + pointerWithin, + rectIntersection, + getFirstCollision, + CollisionDetection, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragOverlay, + defaultDropAnimationSideEffects, + DragStartEvent, + DragOverEvent, + DragEndEvent, + DropAnimation, + MeasuringStrategy, +} from "@dnd-kit/core" +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { Button } from "@/shared/components/ui/button" +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/components/ui/collapsible" +import { Trash2, GripVertical, ChevronDown, ChevronRight, Calculator } from "lucide-react" +import { cn } from "@/shared/lib/utils" +import type { ExamNode } from "./selected-question-list" +import type { Question } from "@/modules/questions/types" + +// --- Types --- + +type StructureEditorProps = { + items: ExamNode[] + onChange: (items: ExamNode[]) => void + onScoreChange: (id: string, score: number) => void + onGroupTitleChange: (id: string, title: string) => void + onRemove: (id: string) => void + onAddGroup: () => void +} + +// --- Components --- + +function SortableItem({ + id, + item, + onRemove, + onScoreChange +}: { + id: string + item: ExamNode + onRemove: () => void + onScoreChange: (val: number) => void +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + } + + const content = item.question?.content as { text?: string } + + return ( +
+
+
+ +

+ {content?.text || "Question content"} +

+
+ +
+ +
+
+ + onScoreChange(parseInt(e.target.value) || 0)} + /> +
+
+
+ ) +} + +function SortableGroup({ + id, + item, + children, + onRemove, + onTitleChange +}: { + id: string + item: ExamNode + children: React.ReactNode + onRemove: () => void + onTitleChange: (val: string) => void +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }) + + const [isOpen, setIsOpen] = useState(true) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + } + + const totalScore = useMemo(() => { + const calc = (nodes: ExamNode[]): number => { + return nodes.reduce((acc, node) => { + if (node.type === 'question') return acc + (node.score || 0) + if (node.type === 'group') return acc + calc(node.children || []) + return acc + }, 0) + } + return calc(item.children || []) + }, [item]) + + return ( + +
+ + + + + + + onTitleChange(e.target.value)} + placeholder="Section Title" + className="font-semibold h-9 bg-transparent border-transparent hover:border-input focus:bg-background flex-1" + /> + +
+ + {totalScore} pts +
+ + +
+ + + {children} + +
+ ) +} + +function StructureRenderer({ nodes, ...props }: { + nodes: ExamNode[] + onRemove: (id: string) => void + onScoreChange: (id: string, score: number) => void + onGroupTitleChange: (id: string, title: string) => void +}) { + return ( + n.id)} strategy={verticalListSortingStrategy}> + {nodes.map(node => ( + + {node.type === 'group' ? ( + props.onRemove(node.id)} + onTitleChange={(val) => props.onGroupTitleChange(node.id, val)} + > + + {(!node.children || node.children.length === 0) && ( +
+ Drag items here +
+ )} +
+ ) : ( + props.onRemove(node.id)} + onScoreChange={(val) => props.onScoreChange(node.id, val)} + /> + )} +
+ ))} +
+ ) +} + +// --- Main Component --- + +const dropAnimation: DropAnimation = { + sideEffects: defaultDropAnimationSideEffects({ + styles: { + active: { + opacity: '0.5', + }, + }, + }), +} + +export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleChange, onRemove, onAddGroup }: StructureEditorProps) { + const [activeId, setActiveId] = useState(null) + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + // Recursively find item + const findItem = (id: string, nodes: ExamNode[] = items): ExamNode | null => { + for (const node of nodes) { + if (node.id === id) return node + if (node.children) { + const found = findItem(id, node.children) + if (found) return found + } + } + return null + } + + const activeItem = activeId ? findItem(activeId) : null + + // DND Handlers + + function handleDragStart(event: DragStartEvent) { + setActiveId(event.active.id as string) + } + + // Custom collision detection for nested sortables + const customCollisionDetection: CollisionDetection = (args) => { + // 1. First check pointer within for precise container detection + const pointerCollisions = pointerWithin(args) + + // If we have pointer collisions, prioritize the most specific one (usually the smallest/innermost container) + if (pointerCollisions.length > 0) { + return pointerCollisions + } + + // 2. Fallback to rect intersection for smoother sortable reordering when not directly over a container + return rectIntersection(args) + } + + function handleDragOver(event: DragOverEvent) { + const { active, over } = event + if (!over) return + + const activeId = active.id as string + const overId = over.id as string + + if (activeId === overId) return + + // Find if we are moving over a Group container + // "overId" could be a SortableItem (Question) OR a SortableGroup (Group) + + const activeNode = findItem(activeId) + const overNode = findItem(overId) + + if (!activeNode || !overNode) return + + // CRITICAL FIX: Prevent dragging a node onto its own descendant + // This happens when dragging a group and hovering over its own children. + // If we proceed, we would remove the group (and its children) and then fail to find the child to insert next to. + const isDescendantOfActive = (childId: string): boolean => { + const check = (node: ExamNode): boolean => { + if (!node.children) return false + return node.children.some(c => c.id === childId || check(c)) + } + return check(activeNode) + } + + if (isDescendantOfActive(overId)) return + + // Find which list the `over` item belongs to + const findContainerId = (id: string, list: ExamNode[], parentId: string = 'root'): string | undefined => { + if (list.some(i => i.id === id)) return parentId + for (const node of list) { + if (node.children) { + const res = findContainerId(id, node.children, node.id) + if (res) return res + } + } + return undefined + } + + const activeContainerId = findContainerId(activeId, items) + const overContainerId = findContainerId(overId, items) + + // Scenario 1: Moving item into a Group by hovering over the Group itself + // If overNode is a Group, we might want to move INTO it + if (overNode.type === 'group') { + // Logic: If active item is NOT in this group already + // AND we are not trying to move a group into its own descendant (circular check) + + const isDescendant = (parent: ExamNode, childId: string): boolean => { + if (!parent.children) return false + for (const c of parent.children) { + if (c.id === childId) return true + if (isDescendant(c, childId)) return true + } + return false + } + + // If moving a group, check if overNode is a descendant of activeNode + if (activeNode.type === 'group' && isDescendant(activeNode, overNode.id)) { + return + } + + if (activeContainerId !== overNode.id) { + // ... implementation continues ... + + const newItems = JSON.parse(JSON.stringify(items)) as ExamNode[] + + // Remove active from old location + const removeRecursive = (list: ExamNode[]): ExamNode | null => { + const idx = list.findIndex(i => i.id === activeId) + if (idx !== -1) return list.splice(idx, 1)[0] + for (const node of list) { + if (node.children) { + const res = removeRecursive(node.children) + if (res) return res + } + } + return null + } + + const movedItem = removeRecursive(newItems) + if (!movedItem) return + + // Insert into new Group (overNode) + // We need to find the overNode in the NEW structure (since we cloned it) + const findGroupAndInsert = (list: ExamNode[]) => { + for (const node of list) { + if (node.id === overId) { + if (!node.children) node.children = [] + node.children.push(movedItem) + return true + } + if (node.children) { + if (findGroupAndInsert(node.children)) return true + } + } + return false + } + + findGroupAndInsert(newItems) + onChange(newItems) + return + } + } + + // Scenario 2: Moving between different lists (e.g. from Root to Group A, or Group A to Group B) + if (activeContainerId !== overContainerId) { + // Standard Sortable Move + const newItems = JSON.parse(JSON.stringify(items)) as ExamNode[] + + const removeRecursive = (list: ExamNode[]): ExamNode | null => { + const idx = list.findIndex(i => i.id === activeId) + if (idx !== -1) return list.splice(idx, 1)[0] + for (const node of list) { + if (node.children) { + const res = removeRecursive(node.children) + if (res) return res + } + } + return null + } + + const movedItem = removeRecursive(newItems) + if (!movedItem) return + + // Insert into destination list at specific index + // We need to find the destination list array and the index of `overId` + const insertRecursive = (list: ExamNode[]): boolean => { + const idx = list.findIndex(i => i.id === overId) + if (idx !== -1) { + // Insert before or after based on direction? + // Usually dnd-kit handles order if we are in same container, but cross-container we need to pick a spot. + // We'll insert at the index of `overId`. + + // However, if we insert AT the index, dnd-kit might get confused if we are dragging DOWN vs UP. + // But since we are changing containers, just inserting at the target index is usually fine. + // The issue "swapping positions is not smooth" might be because we insert *at* index, displacing the target. + // Let's try to determine if we are "below" or "above" the target? + // For cross-container, simpler is better. Inserting at index is standard. + + list.splice(idx, 0, movedItem) + return true + } + for (const node of list) { + if (node.children) { + if (insertRecursive(node.children)) return true + } + } + return false + } + + insertRecursive(newItems) + onChange(newItems) + } + } + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event + setActiveId(null) + + if (!over) return + + const activeId = active.id as string + const overId = over.id as string + + if (activeId === overId) return + + // Re-find positions in the potentially updated state + // Note: Since we mutate in DragOver, the item might already be in the new container. + // So activeContainerId might equal overContainerId now! + + const findContainerId = (id: string, list: ExamNode[], parentId: string = 'root'): string | undefined => { + if (list.some(i => i.id === id)) return parentId + for (const node of list) { + if (node.children) { + const res = findContainerId(id, node.children, node.id) + if (res) return res + } + } + return undefined + } + + const activeContainerId = findContainerId(activeId, items) + const overContainerId = findContainerId(overId, items) + + if (activeContainerId === overContainerId) { + // Same container reorder + const newItems = JSON.parse(JSON.stringify(items)) as ExamNode[] + + const getMutableList = (groupId?: string): ExamNode[] => { + if (groupId === 'root') return newItems + // Need recursive find + const findGroup = (list: ExamNode[]): ExamNode | null => { + for (const node of list) { + if (node.id === groupId) return node + if (node.children) { + const res = findGroup(node.children) + if (res) return res + } + } + return null + } + return findGroup(newItems)?.children || [] + } + + const list = getMutableList(activeContainerId) + const oldIndex = list.findIndex(i => i.id === activeId) + const newIndex = list.findIndex(i => i.id === overId) + + if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) { + const moved = arrayMove(list, oldIndex, newIndex) + + // Update the list reference in parent + if (activeContainerId === 'root') { + onChange(moved) + } else { + // list is already a reference to children array if we did it right? + // getMutableList returned `group.children`. Modifying `list` directly via arrayMove returns NEW array. + // So we need to re-assign. + const group = findItem(activeContainerId!, newItems) + if (group) group.children = moved + onChange(newItems) + } + } + } + } + + return ( + +
+ + +
+ +
+
+ + + {activeItem ? ( + activeItem.type === 'group' ? ( +
+
+ + {activeItem.title || "Section"} +
+
+ ) : ( +
+ +

{(activeItem.question?.content as any)?.text || "Question"}

+
+ ) + ) : null} +
+
+ ) +} diff --git a/src/modules/exams/components/exam-actions.tsx b/src/modules/exams/components/exam-actions.tsx new file mode 100644 index 0000000..8e9cdf3 --- /dev/null +++ b/src/modules/exams/components/exam-actions.tsx @@ -0,0 +1,170 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { MoreHorizontal, Eye, Pencil, Trash, Archive, UploadCloud, Undo2, Copy } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/shared/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/components/ui/dropdown-menu" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/components/ui/alert-dialog" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/components/ui/dialog" + +import { Exam } from "../types" + +interface ExamActionsProps { + exam: Exam +} + +export function ExamActions({ exam }: ExamActionsProps) { + const router = useRouter() + const [showViewDialog, setShowViewDialog] = useState(false) + const [showDeleteDialog, setShowDeleteDialog] = useState(false) + + const copyId = () => { + navigator.clipboard.writeText(exam.id) + toast.success("Exam ID copied to clipboard") + } + + const publishExam = async () => { + toast.success("Exam published") + } + + const unpublishExam = async () => { + toast.success("Exam moved to draft") + } + + const archiveExam = async () => { + toast.success("Exam archived") + } + + const handleDelete = async () => { + try { + await new Promise((r) => setTimeout(r, 800)) + toast.success("Exam deleted successfully") + setShowDeleteDialog(false) + } catch (e) { + toast.error("Failed to delete exam") + } + } + + return ( + <> + + + + + + Actions + + Copy ID + + + setShowViewDialog(true)}> + View + + + Edit + + router.push(`/teacher/exams/${exam.id}/build`)}> + Build + + + + Publish + + + Move to Draft + + + Archive + + setShowDeleteDialog(true)} + > + Delete + + + + + + + + Exam Details + ID: {exam.id} + +
+
+ Title: + {exam.title} +
+
+ Subject: + {exam.subject} +
+
+ Grade: + {exam.grade} +
+
+ Total Score: + {exam.totalScore} +
+
+ Duration: + {exam.durationMin} min +
+
+
+
+ + + + + Delete exam? + + This action cannot be undone. This will permanently delete the exam. + + + + Cancel + { + e.preventDefault() + handleDelete() + }} + > + Delete + + + + + + ) +} diff --git a/src/modules/exams/components/exam-assembly.tsx b/src/modules/exams/components/exam-assembly.tsx new file mode 100644 index 0000000..ed13bd7 --- /dev/null +++ b/src/modules/exams/components/exam-assembly.tsx @@ -0,0 +1,343 @@ +"use client" + +import { useMemo, useState } from "react" +import { useFormStatus } from "react-dom" +import { useRouter } from "next/navigation" +import { toast } from "sonner" +import { Search } from "lucide-react" + +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Button } from "@/shared/components/ui/button" +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select" +import { ScrollArea } from "@/shared/components/ui/scroll-area" +import { Separator } from "@/shared/components/ui/separator" +import { Badge } from "@/shared/components/ui/badge" +import type { Question } from "@/modules/questions/types" +import { updateExamAction } from "@/modules/exams/actions" +import { StructureEditor } from "./assembly/structure-editor" +import { QuestionBankList } from "./assembly/question-bank-list" +import type { ExamNode } from "./assembly/selected-question-list" +import { createId } from "@paralleldrive/cuid2" + +type ExamAssemblyProps = { + examId: string + title: string + subject: string + grade: string + difficulty: number + totalScore: number + durationMin: number + initialSelected?: Array<{ id: string; score: number }> + initialStructure?: ExamNode[] // New prop + questionOptions: Question[] +} + +function SubmitButton({ label }: { label: string }) { + const { pending } = useFormStatus() + return ( + + ) +} + +export function ExamAssembly(props: ExamAssemblyProps) { + const router = useRouter() + const [search, setSearch] = useState("") + const [typeFilter, setTypeFilter] = useState("all") + const [difficultyFilter, setDifficultyFilter] = useState("all") + + // Initialize structure state + const [structure, setStructure] = useState(() => { + // Hydrate structure with full question objects + const hydrate = (nodes: ExamNode[]): ExamNode[] => { + return nodes.map(node => { + if (node.type === 'question') { + const q = props.questionOptions.find(opt => opt.id === node.questionId) + return { ...node, question: q } + } + if (node.type === 'group') { + return { ...node, children: hydrate(node.children || []) } + } + return node + }) + } + + // Use initialStructure if provided (Server generated or DB stored) + if (props.initialStructure && props.initialStructure.length > 0) { + return hydrate(props.initialStructure) + } + + // Fallback logic removed as Server Component handles initial migration + return [] + }) + + const filteredQuestions = useMemo(() => { + let list: Question[] = [...props.questionOptions] + + if (search) { + const lower = search.toLowerCase() + list = list.filter(q => { + const content = q.content as { text?: string } + return content.text?.toLowerCase().includes(lower) + }) + } + + if (typeFilter !== "all") { + list = list.filter((q) => q.type === (typeFilter as Question["type"])) + } + if (difficultyFilter !== "all") { + const d = parseInt(difficultyFilter) + list = list.filter((q) => q.difficulty === d) + } + return list + }, [search, typeFilter, difficultyFilter, props.questionOptions]) + + // Recursively calculate total score + const assignedTotal = useMemo(() => { + const calc = (nodes: ExamNode[]): number => { + return nodes.reduce((sum, node) => { + if (node.type === 'question') return sum + (node.score || 0) + if (node.type === 'group') return sum + calc(node.children || []) + return sum + }, 0) + } + return calc(structure) + }, [structure]) + + const progress = Math.min(100, Math.max(0, (assignedTotal / props.totalScore) * 100)) + + const handleAdd = (question: Question) => { + setStructure(prev => [ + ...prev, + { + id: createId(), + type: 'question', + questionId: question.id, + score: 10, + question + } + ]) + } + + const handleAddGroup = () => { + setStructure(prev => [ + ...prev, + { + id: createId(), + type: 'group', + title: 'New Section', + children: [] + } + ]) + } + + const handleRemove = (id: string) => { + const removeRecursive = (nodes: ExamNode[]): ExamNode[] => { + return nodes.filter(n => n.id !== id).map(n => { + if (n.type === 'group') { + return { ...n, children: removeRecursive(n.children || []) } + } + return n + }) + } + setStructure(prev => removeRecursive(prev)) + } + + const handleScoreChange = (id: string, score: number) => { + const updateRecursive = (nodes: ExamNode[]): ExamNode[] => { + return nodes.map(n => { + if (n.id === id) return { ...n, score } + if (n.type === 'group') return { ...n, children: updateRecursive(n.children || []) } + return n + }) + } + setStructure(prev => updateRecursive(prev)) + } + + const handleGroupTitleChange = (id: string, title: string) => { + const updateRecursive = (nodes: ExamNode[]): ExamNode[] => { + return nodes.map(n => { + if (n.id === id) return { ...n, title } + if (n.type === 'group') return { ...n, children: updateRecursive(n.children || []) } + return n + }) + } + setStructure(prev => updateRecursive(prev)) + } + + // Helper to extract flat list for DB examQuestions table + const getFlatQuestions = () => { + const list: Array<{ id: string; score: number }> = [] + const traverse = (nodes: ExamNode[]) => { + nodes.forEach(n => { + if (n.type === 'question' && n.questionId) { + list.push({ id: n.questionId, score: n.score || 0 }) + } + if (n.type === 'group') { + traverse(n.children || []) + } + }) + } + traverse(structure) + return list + } + + // Helper to strip runtime question objects for DB structure storage + const getCleanStructure = () => { + const clean = (nodes: ExamNode[]): any[] => { + return nodes.map(n => { + const { question, ...rest } = n + if (n.type === 'group') { + return { ...rest, children: clean(n.children || []) } + } + return rest + }) + } + return clean(structure) + } + + const handleSave = async (formData: FormData) => { + formData.set("examId", props.examId) + formData.set("questionsJson", JSON.stringify(getFlatQuestions())) + formData.set("structureJson", JSON.stringify(getCleanStructure())) + + const result = await updateExamAction(null, formData) + if (result.success) { + toast.success("Saved draft") + } else { + toast.error(result.message || "Save failed") + } + } + + const handlePublish = async (formData: FormData) => { + formData.set("examId", props.examId) + formData.set("questionsJson", JSON.stringify(getFlatQuestions())) + formData.set("structureJson", JSON.stringify(getCleanStructure())) + formData.set("status", "published") + + const result = await updateExamAction(null, formData) + if (result.success) { + toast.success("Published exam") + router.push("/teacher/exams/all") + } else { + toast.error(result.message || "Publish failed") + } + } + + return ( +
+ {/* Left: Preview (3 cols) */} + + +
+ Exam Structure +
+
+ {assignedTotal} / {props.totalScore} + Total Score +
+
+
props.totalScore ? "bg-destructive" : "bg-primary" + }`} + style={{ width: `${progress}%` }} + /> +
+
+
+ + + +
+
+
{props.subject}
+
{props.grade}
+
Duration: {props.durationMin} min
+
+ + +
+
+ +
+
+ + +
+ + +
+ + + {/* Right: Question Bank (2 cols) */} + + + Question Bank +
+ + setSearch(e.target.value)} + /> +
+
+ + +
+
+ + + + + { + // Check if question is added anywhere in the structure + const isAddedRecursive = (nodes: ExamNode[]): boolean => { + return nodes.some(n => { + if (n.type === 'question' && n.questionId === id) return true + if (n.type === 'group' && n.children) return isAddedRecursive(n.children) + return false + }) + } + return isAddedRecursive(structure) + }} + /> + +
+
+ ) +} diff --git a/src/modules/exams/components/exam-columns.tsx b/src/modules/exams/components/exam-columns.tsx new file mode 100644 index 0000000..3ab7fb6 --- /dev/null +++ b/src/modules/exams/components/exam-columns.tsx @@ -0,0 +1,137 @@ +"use client" + +import { ColumnDef } from "@tanstack/react-table" +import { Checkbox } from "@/shared/components/ui/checkbox" +import { Badge } from "@/shared/components/ui/badge" +import { cn, formatDate } from "@/shared/lib/utils" +import { Exam } from "../types" +import { ExamActions } from "./exam-actions" + +export const examColumns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + size: 36, + }, + { + accessorKey: "title", + header: "Title", + cell: ({ row }) => ( +
+ {row.original.title} + {row.original.tags && row.original.tags.length > 0 && ( +
+ {row.original.tags.slice(0, 2).map((t) => ( + + {t} + + ))} + {row.original.tags.length > 2 && ( + +{row.original.tags.length - 2} + )} +
+ )} +
+ ), + }, + { + accessorKey: "subject", + header: "Subject", + }, + { + accessorKey: "grade", + header: "Grade", + cell: ({ row }) => ( + {row.original.grade} + ), + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => { + const status = row.original.status + const variant = status === "published" ? "secondary" : status === "archived" ? "destructive" : "outline" + return ( + + {status} + + ) + }, + }, + { + accessorKey: "difficulty", + header: "Difficulty", + cell: ({ row }) => { + const diff = row.original.difficulty + return ( +
+ + {diff === 1 + ? "Easy" + : diff === 2 + ? "Easy-Med" + : diff === 3 + ? "Medium" + : diff === 4 + ? "Med-Hard" + : "Hard"} + + ({diff}) +
+ ) + }, + }, + { + accessorKey: "durationMin", + header: "Duration", + cell: ({ row }) => {row.original.durationMin} min, + }, + { + accessorKey: "totalScore", + header: "Total", + cell: ({ row }) => {row.original.totalScore}, + }, + { + accessorKey: "scheduledAt", + header: "Scheduled", + cell: ({ row }) => ( + + {row.original.scheduledAt ? formatDate(row.original.scheduledAt) : "-"} + + ), + }, + { + accessorKey: "createdAt", + header: "Created", + cell: ({ row }) => ( + + {formatDate(row.original.createdAt)} + + ), + }, + { + id: "actions", + cell: ({ row }) => , + }, +] + diff --git a/src/modules/exams/components/exam-data-table.tsx b/src/modules/exams/components/exam-data-table.tsx new file mode 100644 index 0000000..367454c --- /dev/null +++ b/src/modules/exams/components/exam-data-table.tsx @@ -0,0 +1,110 @@ +"use client" + +import * as React from "react" +import { + ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, + getPaginationRowModel, + SortingState, + getSortedRowModel, + getFilteredRowModel, + RowSelectionState, +} from "@tanstack/react-table" + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/components/ui/table" +import { Button } from "@/shared/components/ui/button" +import { ChevronLeft, ChevronRight } from "lucide-react" + +interface DataTableProps { + columns: ColumnDef[] + data: TData[] +} + +export function ExamDataTable({ columns, data }: DataTableProps) { + const [sorting, setSorting] = React.useState([]) + const [rowSelection, setRowSelection] = React.useState({}) + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + onRowSelectionChange: setRowSelection, + getFilteredRowModel: getFilteredRowModel(), + state: { + sorting, + rowSelection, + }, + }) + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+
+ {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) + selected. +
+
+ + +
+
+
+ ) +} + diff --git a/src/modules/exams/components/exam-filters.tsx b/src/modules/exams/components/exam-filters.tsx new file mode 100644 index 0000000..97eb5d6 --- /dev/null +++ b/src/modules/exams/components/exam-filters.tsx @@ -0,0 +1,76 @@ +"use client" + +import { useQueryState, parseAsString } from "nuqs" +import { Search, X } from "lucide-react" + +import { Input } from "@/shared/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select" +import { Button } from "@/shared/components/ui/button" + +export function ExamFilters() { + const [search, setSearch] = useQueryState("q", parseAsString.withOptions({ shallow: false })) + const [status, setStatus] = useQueryState("status", parseAsString.withOptions({ shallow: false })) + const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withOptions({ shallow: false })) + + return ( +
+
+ + setSearch(e.target.value || null)} + /> +
+ + + + + + {(search || (status && status !== "all") || (difficulty && difficulty !== "all")) && ( + + )} +
+ ) +} + diff --git a/src/modules/exams/components/exam-form.tsx b/src/modules/exams/components/exam-form.tsx new file mode 100644 index 0000000..87c1472 --- /dev/null +++ b/src/modules/exams/components/exam-form.tsx @@ -0,0 +1,99 @@ +"use client" + +import { useState } from "react" +import { useFormStatus } from "react-dom" +import { toast } from "sonner" +import { useRouter } from "next/navigation" + +import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/shared/components/ui/card" +import { Button } from "@/shared/components/ui/button" +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select" +import { createExamAction } from "../actions" + +function SubmitButton() { + const { pending } = useFormStatus() + return ( + + ) +} + +export function ExamForm() { + const router = useRouter() + const [difficulty, setDifficulty] = useState("3") + + const handleSubmit = async (formData: FormData) => { + const result = await createExamAction(null, formData) + if (result.success) { + toast.success(result.message) + if (result.data) { + router.push(`/teacher/exams/${result.data}/build`) + } + } else { + toast.error(result.message) + } + } + + return ( + + + Exam Creator + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + + +
+
+
+ ) +} diff --git a/src/modules/exams/components/grading-view.tsx b/src/modules/exams/components/grading-view.tsx new file mode 100644 index 0000000..2e5e57b --- /dev/null +++ b/src/modules/exams/components/grading-view.tsx @@ -0,0 +1,177 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { toast } from "sonner" +import { Badge } from "@/shared/components/ui/badge" +import { Button } from "@/shared/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { Textarea } from "@/shared/components/ui/textarea" +import { ScrollArea } from "@/shared/components/ui/scroll-area" +import { Separator } from "@/shared/components/ui/separator" +import { gradeSubmissionAction } from "../actions" + +type Answer = { + id: string + questionId: string + questionContent: any + questionType: string + maxScore: number + studentAnswer: any + score: number | null + feedback: string | null + order: number +} + +type GradingViewProps = { + submissionId: string + studentName: string + examTitle: string + submittedAt: string | null + status: string + totalScore: number | null + answers: Answer[] +} + +export function GradingView({ + submissionId, + studentName, + examTitle, + submittedAt, + status, + totalScore, + answers: initialAnswers +}: GradingViewProps) { + const router = useRouter() + const [answers, setAnswers] = useState(initialAnswers) + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleScoreChange = (id: string, val: string) => { + const score = val === "" ? 0 : parseInt(val) + setAnswers(prev => prev.map(a => a.id === id ? { ...a, score } : a)) + } + + const handleFeedbackChange = (id: string, val: string) => { + setAnswers(prev => prev.map(a => a.id === id ? { ...a, feedback: val } : a)) + } + + const currentTotal = answers.reduce((sum, a) => sum + (a.score || 0), 0) + + const handleSubmit = async () => { + setIsSubmitting(true) + const payload = answers.map(a => ({ + id: a.id, + score: a.score || 0, + feedback: a.feedback + })) + + const formData = new FormData() + formData.set("submissionId", submissionId) + formData.set("answersJson", JSON.stringify(payload)) + + const result = await gradeSubmissionAction(null, formData) + + if (result.success) { + toast.success("Grading saved") + router.push("/teacher/exams/grading") + } else { + toast.error(result.message || "Failed to save") + } + setIsSubmitting(false) + } + + return ( +
+ {/* Left: Questions & Answers */} +
+
+

Student Response

+
+ +
+ {answers.map((ans, index) => ( +
+
+
+ Question {index + 1} +
{ans.questionContent?.text}
+ {/* Render options if multiple choice, etc. - Simplified for now */} +
+ Max: {ans.maxScore} +
+ +
+ +

+ {typeof ans.studentAnswer?.answer === 'string' + ? ans.studentAnswer.answer + : JSON.stringify(ans.studentAnswer)} +

+
+ + +
+ ))} +
+
+
+ + {/* Right: Grading Panel */} +
+
+

Grading

+
+ Total Score + {currentTotal} +
+
+ + +
+ {answers.map((ans, index) => ( + + + + Q{index + 1} + Max: {ans.maxScore} + + + +
+ + handleScoreChange(ans.id, e.target.value)} + /> +
+
+ +