From 1e9a82e912736d87092715fae13329b76254d052 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Mon, 9 Mar 2026 11:18:22 +0100 Subject: [PATCH 1/2] feat: complete rewrite of course (#622) * theming improvements * border style improvements * refactor: migrate org routes to server-side data fetching Move client-side API calls to server-side load functions for better performance, security, and SEO. Implement permission-based course filtering * refactor: clean up course and community components This commit removes unused components and files related to courses and community features, streamlining the codebase. It also updates various routes to enhance performance and maintainability. Additionally, it introduces new types and constants for better type safety and organization. * refactor: fix video formatting on website and other styles * refactor: update course and dashboard components for improved styling and functionality * more icon improvements * refactor: streamline course and dashboard components This commit updates various course and dashboard components for improved styling and functionality. It includes the removal of unused files, updates to imports and exports, and enhancements to type safety. Additionally, it introduces new routes and optimizes existing ones for better performance and maintainability. * refactor: enhance lesson completion logic * chore: delete all emails on dashboard and move other api routes to backend. move all emails to the backend and * feat: Refactor API and database queries for courses, quizzes, and lessons, migrating Supabase functions and schema management. * get app loading * fix ui and build error of cio/api * refactor: move direct database queries to query layer - Create query functions: getCourseWithOrgData, getOrganizationByCourseId, updateCourseVersion, updateLessonsSectionId, getProfileByGroupMemberId, getQuestionTypesByIds - Refactor services to use query functions instead of direct db queries - Add transaction support to updateCourseVersion and updateLessonsSectionId - Fix getProfileByGroupMemberId to return profile directly (not nested) - Fix updateLessonsSectionId to use .returning() instead of rowCount This aligns services with architecture pattern where services orchestrate queries rather than executing them directly. * exercise saving improvements * fix and optimise exercise creation * refactor: update dialog components for improved styling and functionality - Adjusted dialog content width and maximum constraints for better responsiveness. - Changed heading font size for consistency in the new exercise modal. - Replaced CloseIcon with CrossIcon for a more modern appearance in dialog close button. * refactor: enhance course and exercise management components - Introduced new components for exercise creation, editing, and viewing, improving user experience. - Refactored existing lesson and exercise components for better organization and maintainability. - Updated routing for exercises and lessons to streamline navigation within the course structure. - Added new utility functions and constants to support content management across the application. * feat: implement course content management API and UI components - Added new API routes for updating and deleting course content, enhancing content management capabilities. - Introduced new Svelte components for displaying and managing course content, including content lists and action handlers. - Updated existing course routing to integrate new content management features. - Refactored content-related utility functions for improved organization and maintainability. * refactor: remove Supabase references and implement course section management - Removed Supabase-related environment variables and Dockerfile arguments to streamline configuration. - Introduced a new section management API with routes for creating, updating, deleting, and reordering course sections. - Updated course services to handle section operations, ensuring consistency in course content management. - Refactored existing lesson section logic to align with the new course section structure, enhancing maintainability. * chore: update .prettierignore to exclude additional file types - Added exclusions for SQL and environment files to the Prettier ignore list. - Included Dockerfile and .prettierignore itself to prevent formatting issues. * feat: enhance course content management with translation support and UI improvements - Added translation keys for user-facing text in course-related components, ensuring all UI copy is sourced from translation files. - Updated lesson and exercise creation components to utilize translation keys for success and error messages, improving localization. - Refactored course content management components to streamline the creation and editing process, enhancing user experience. - Introduced new constants for content options and stepper states to standardize component behavior and improve maintainability. - Improved styling and functionality of course content modals and steppers for better user interaction. * feat: enhance course content management and UI components - Updated .prettierignore to exclude storybook-static directory. - Improved exercise creation logic by refining option handling and adding UUID support for option values. - Enhanced navigation component with debugging output for custom links. - Introduced a new method in CourseApi to update content items locally, improving state management during navigation. - Added caching for exercise templates by tag in ExerciseTemplateApi to optimize performance. - Refactored lesson and exercise components for better organization and maintainability, including updates to modal styling and functionality. - Implemented local patching for lesson and exercise list items to ensure UI consistency after updates. * student + teacher invite (not fully tested) * refactor: streamline course team member middleware - Removed unnecessary console logging and timing for checking user permissions. - Simplified the logic for determining if a user is a course team member or organization admin. * reorder course sidebar to move content to its own page. * feat: implement unified course enrollment route and API - Introduced a new enrollment route `/course/:slug/enroll` to replace the previous invite system. - Unified enrollment logic for free and paid courses, allowing direct enrollment for free courses and invite token validation for paid courses. - Updated course landing page to redirect to the new enrollment route for free courses. - Removed obsolete invite routes and related logic, streamlining the enrollment process. - Enhanced API with a new `POST /course/:courseId/enroll` endpoint for handling enrollments. - Added rate limiting for enrollment requests to prevent abuse. - Updated UI components to reflect changes in enrollment flow and messaging. * chore: update docker stack, docs, and auth token automation * chore: make prettier check ignore unknown file types * fix: avoid using authClient on server file and keep authClient client focused * chore: always get the server on demand depending on the environment * chore: update dashboard configuration and Docker setup for environment variables - Modified the Svelte configuration to directly use the PUBLIC_SERVER_URL environment variable. - Enhanced the Docker Compose file to include health check formatting and added environment variable arguments for the dashboard build. - Updated the Dockerfile to echo environment variable values during the build process, ensuring clarity on the configuration being used. * chore: enhance Docker setup and documentation for production deployment - Added a new production Docker Compose file and example environment file for published images. - Updated README and usage documentation to include instructions for deploying with production images. - Modified Svelte configuration to standardize the use of the posthog domain. - Removed unnecessary console logs from the API client and layout server files for cleaner output. - Improved overall clarity in Docker documentation regarding self-hosting and image publishing. * chore(db): log function name in query catch blocks (console.error 'funcName error:', error) Co-authored-by: Cursor * fix: multiple refresh issue * fix: server key not passed to api * fix: clarify usage of PRIVATE_SERVER_KEY and AUTH_BEARER_TOKEN in documentation - Updated documentation to specify that PRIVATE_SERVER_KEY is used for server-to-server API key authentication and is validated by API key middleware. - Clarified that AUTH_BEARER_TOKEN is a separate token not utilized by apiKeyMiddleware. - Adjusted examples in Docker setup and self-hosting documentation to reflect these changes. * fix migration script * fix: improve language handling and add database reset script - Updated language extraction to handle potential undefined values, defaulting to 'en' if necessary. - Added a new script for resetting the database, including functionality to truncate public tables and run setup with optional seeding. * feat: add promote ungrouped section functionality and enhance document handling in dashboard - Implemented a new API endpoint to promote synthetic "Ungrouped" content into a real persisted section, allowing for better organization of course materials. - Updated the dashboard to include a new component for displaying content count badges for lessons and exercises. - Enhanced document management features, including a new document card component with dropdown actions for viewing, downloading, and removing documents. - Improved file upload handling with a dedicated file drop zone and error handling for file type and size restrictions. - Refactored existing components for better readability and maintainability, including updates to lesson and document lists. * chore: use root env file for docker compose * docs: document docker no-build startup flow * chore: add no-build option to docker full stack script * fix(email): handle SMTP TLS mode by port * feat(auth): add configurable cookie domain env and docs * apps/dashboard/src/lib/utils/services/auth/session.ts * fix(email): make sender configurable via SMTP_SENDER * chore(i18n): regenerate locale translations and support language targeting * chore(config): switch api to PUBLIC_SERVER_URL * fix(i18n): persist selected locale across navigation * feat(assets): implement organization asset management routes and services - Added new routes for managing organization assets, including listing, creating, updating, deleting, and exporting assets. - Introduced asset-related services for handling asset operations such as creation, retrieval, and deletion. - Integrated YouTube metadata resolution and asset attachment/detachment functionalities. - Updated organization routes to include the new asset management routes. - Enhanced error handling for asset-related operations. * fix(document-list): update conditional rendering for edit mode in document list * debug self hosted layout setup * refactor(sidebar): simplify lms sidebar footer and improve navigation item rendering * feat: tags * chore: tags roadmap + translations * fix(api): add authMiddleware to exercise, invites, and content routers - exerciseRouter: apply authMiddleware before courseMemberMiddleware on all 10 routes - invitesRouter: apply authMiddleware before courseTeamMemberMiddleware on all 4 routes - contentRouter: add authMiddleware to PUT route (DELETE already had it) Both courseMemberMiddleware and courseTeamMemberMiddleware document requiring authMiddleware to be applied first. This aligns with lessonRouter pattern and ensures explicit middleware contract regardless of global session middleware. Co-authored-by: Rotimi Best * fix(course): use courseTeamMemberMiddleware for PUT, DELETE, and analytics routes These routes require admin/tutor role per JSDoc but were using courseMemberMiddleware which allows any course member including students. Switch to courseTeamMemberMiddleware to restrict access to ADMIN/TUTOR group members and org admins. Co-authored-by: Rotimi Best * chore: unify pricing component between dashboard and website (#630) * feat: modularize pricing card * feat: initia implementation of pricing togge * fix: minor amendment * refactor: update pricing components for improved layout and styling - Adjusted spacing in the pricing card layout for better visual consistency. - Modified button styles and sizes for enhanced usability. - Cleaned up the pricing toggle component by removing unnecessary console logs and restructuring the button rendering logic. * add handle click * chore(css): update Tailwind CSS output with new utility classes and theme variables * fix(api): enable orgMemberMiddleware for stats route This change ensures that the stats route is protected by orgMemberMiddleware, aligning access control with other routes. Additionally, removed unnecessary console log from the layout server file to clean up the code. --------- Co-authored-by: Rotimi Best * pass query parameter to signup from pricing page * ui updates to pricing cards * commit all PRDs * add open api spec to api prd * make blue default theme * more prds * sync option id sequence on question update * docs: more classroomio features * chore: more ui improvements * docs: new component docs * chore: CSP Configuration (#638) * feat(validation): add GitHub repository name validation - Add ZRepositoryName schema for validating GitHub repo names - Add ZRepositoryOwner for owner/username validation - Add ZRepositoryIdentifier for owner/repo format - Follow GitHub rules: A-Za-z0-9_.-, 1-100 chars, reserved names - Add translation keys for validation messages Fixes #637 Co-authored-by: Rotimi Best * Revert "feat(validation): add GitHub repository name validation" This reverts commit 3436536bb17a0f05b3a9fe8dd7682cde64dd1dce. * feat(dashboard): make CSP external domains configurable via env Move hardcoded external domains from svelte.config.js to .env configuration for on-premise deployments with strict security policies, data residency constraints, or CISO-driven allowlists. - ALLOWED_EXTERNAL_DOMAINS: single list for all CSP directives - CSP_SCRIPT_SRC_DOMAINS, CSP_STYLE_SRC_DOMAINS, CSP_CONNECT_SRC_DOMAINS, CSP_FRAME_SRC_DOMAINS, CSP_FONT_SRC_DOMAINS: per-directive override - When unset, defaults retain current SaaS behavior (backward compatible) Fixes #637 Co-authored-by: Rotimi Best * fix(dashboard): self-hosted CSP uses only env vars, no defaults Co-authored-by: Rotimi Best * fix(dashboard): add all classroomio.com domains to CSP defaults Co-authored-by: Rotimi Best * docs: add CSP ALLOWED_EXTERNAL_DOMAINS to self-hosting guides Co-authored-by: Rotimi Best * feat(docker): pass CSP domain env vars as build args to dashboard Co-authored-by: Rotimi Best --------- Co-authored-by: Cursor Agent Co-authored-by: Rotimi Best * feat: add SSO and token authentication routes and services - Introduced new routes for SSO and token authentication under the organization namespace. - Implemented SSO connection management including creation, update, activation, and deletion. - Added token authentication configuration with support for secret rotation and status retrieval. - Enhanced error handling for SSO and token authentication processes. - Updated existing routes and services to integrate new functionalities. - Improved gradebook service to fetch student marks and exercises. - Refactored API structure for better organization and maintainability. * update translations * fix: add PUBLIC_SERVER_URL to .env.example to prevent localhost fallback in production (#641) Co-authored-by: Cursor Agent * feat: new exercise question types * MinIO S3 storage backend (#639) * feat(storage): add MinIO/S3-compatible object storage backend - Add OBJECT_STORAGE_* env vars for generic S3-compatible backends (MinIO, AWS S3) - Create storage config module with Cloudflare R2 fallback for backward compatibility - Refactor s3.ts to use configurable client and bucket names from config - Update media.ts to use provider-neutral public URL (OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL or CLOUDFLARE_IMAGE_BUCKET_DOMAIN) - Add MinIO service and init to docker-compose (profile: minio) with videos/documents/media buckets - Add localhost:9000 to dashboard CSP for local MinIO presigned URL uploads - Update self-hosting and Cloudflare docs with MinIO option - Update .env examples and docker USAGE.md Fixes #635 Co-authored-by: Rotimi Best * docs: add MINIO_ROOT_USER and MINIO_ROOT_PASSWORD to self-hosting guides Co-authored-by: Rotimi Best * feat(media): refactor media management and integrate MinIO support - Updated media API to use a new `mediaApi` module, replacing `mediaManagerApi` references across components. - Added MinIO configuration options to the Docker setup, allowing for optional object storage exclusion. - Enhanced environment variable management for MinIO in the `.env.example` file. - Updated Content Security Policy (CSP) to allow local MinIO access in the dashboard. - Introduced new media components for asset management, including asset cards and usage dialogs. This refactor improves the modularity of the media handling system and provides better support for S3-compatible storage solutions. * feat(dashboard): enhance logging and refactor auth session handling - Added console logging for event and environment variables in hooks.server.ts to aid debugging. - Refactored auth session handling to use authServerClient instead of authClient in session.ts for improved consistency. - Updated Dockerfile to ensure the dashboard build output is present before starting the application. - Expanded self-hosting documentation to include troubleshooting steps for disk space issues in PostgreSQL and Docker. These changes improve the observability of the application and streamline the authentication process. * refactor(dashboard): remove debug logging and enhance Postgres healthcheck - Removed console logging for event and environment variables in hooks.server.ts to clean up the code. - Updated Postgres healthcheck in docker-compose.yaml to require a successful `SELECT 1` query, ensuring dependent services only start once the database is ready. - Expanded self-hosting documentation to include troubleshooting steps for database recovery issues. These changes improve code clarity and reliability in service startup sequences. --------- Co-authored-by: Cursor Agent Co-authored-by: Rotimi Best * feat: add Plyr media player icons and PDF.js integration - Introduced new Plyr SVG icons for media player controls in both course and dashboard applications. - Updated Plyr player configuration to use local icon assets. - Integrated PDF.js for document viewing, replacing external CDN links with local paths for improved performance and reliability. - Enhanced PDF viewer modal z-index for better visibility. This update improves the media player experience and ensures consistent access to necessary resources. * feat: enhance account and organization services with type definitions and promise return types - Updated `getAccountData` to return a structured result type including profile and organizations. - Modified `getOrganizationsWithFilters` to specify a promise return type for better type safety. - Added new type imports for organization-related data structures to improve clarity and maintainability. These changes improve type safety and enhance the overall structure of the account and organization services. * feat: implement Redis caching for presigned URLs and refactor API interactions - Added Redis caching for presigned download URLs in the API to reduce redundant S3 calls, improving performance. - Introduced a new `PresignApi` class in the frontend to centralize presigned URL requests, enhancing code maintainability. - Updated various components to utilize the new `presignApi` methods for document and video upload/download operations. - Enhanced error handling for Redis operations to ensure graceful degradation in case of cache failures. These changes optimize the presigned URL generation process and streamline API interactions across the application. * fix: add better-auth dependency to dashboard (#645) * chore: unify exercise answer data format * feat: make selfhosting run under one org for simplicity (#647) * feat: implement self-hosting PRD + license key system Self-hosting single-org: - Add PUBLIC_IS_SELFHOSTED to API env, block org creation when org exists - Auto-add non-members as students in GET /account bootstrap - getOrgSiteInfo: root domain returns single org for self-hosted - App init: route to /lms when self-hosted + no orgs - Hide org switcher and Add Org modal when self-hosted Invite-only signup: - Add settings.signup.inviteOnly via ZUpdateOrganization - Allow public signups toggle in org auth settings - Signup page: hide form when invite-only and no invite context - Landing page nav: respect inviteOnly for signup link First signup auto-verify: - Self-hosted first signup (no orgs) auto-verifies email in createProfileHook License key system: - LICENSE_KEY, LICENSE_VERIFICATION_URL for external verification API - Feature gates: SSO, token-auth, no-tracking (PostHog/Senja) - requireLicense middleware for API routes - Dashboard: license store, gate PostHog/Senja/SSO/token-auth UI - docs/LICENSE_API_CONTRACT.md for external API spec Co-authored-by: Rotimi Best * feat: get rid of carbon completely * chore: license verification happens only for selfhosted instance * fix: in self hosted instance, orgSite is the default * docs: how to write math in cio editor * Update packages/db/src/queries/organization/organization.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update apps/api/src/services/license.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix: translations, cursor pr reviews, simplify license fetching and check * refactor: update analytics setup for cloud and self-hosted deployments; enhance settings merge logic * refactor: update license fetching URL for development environment; optimize organization query to prevent plan row loss * more selfhosting improvements * feat: enhance signup guard for self-hosted instances by requiring organization context when no org ID is provided * pr reviews * fix: correct query parameter handling in course navigation * pr reviews --------- Co-authored-by: Cursor Agent Co-authored-by: Rotimi Best Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: Cursor Co-authored-by: Rotimi Best Co-authored-by: Abdulmuizz Hamzat <103611443+buildwithhamzat@users.noreply.github.com> Co-authored-by: Adeyemi Adetunji Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../plans/ai_assistant_prd_5a6f948c.plan.md | 626 + ...se_enroll_route_migration_883d0aae.plan.md | 199 + .../plans/course_page_ui_storybook.plan.md | 511 + .../course_page_ui_storybook_6c7b16fd.plan.md | 180 + ...al_presigned_url_strategy_ac12cdab.plan.md | 156 + .../plans/jwt_token_exchange_82c2a3ee.plan.md | 292 + .../unify_renderer_answer_data_format.plan.md | 300 + .dockerignore | 3 +- .prettierignore | 14 +- .slim.yaml | 6 + AGENTS.md | 275 + ARCHITECHTURE.md | 2804 +- PRD-TRACKER.md | 37 + README.md | 137 +- ai/prompts/lms-context.md | 12 - apps/api/.env.example | 37 +- apps/api/Dockerfile | 4 - apps/api/package.json | 12 +- apps/api/scripts/README.md | 2 - apps/api/src/app.ts | 40 +- apps/api/src/config/env.ts | 29 +- apps/api/src/config/storage.ts | 136 + apps/api/src/constants/index.ts | 2 +- apps/api/src/index.ts | 12 +- apps/api/src/middlewares/api-key.ts | 6 +- apps/api/src/middlewares/course-member.ts | 27 +- .../api/src/middlewares/course-team-member.ts | 63 + apps/api/src/middlewares/license.ts | 34 + .../newsfeed-comment-author-or-team.ts | 85 + apps/api/src/middlewares/org-member.ts | 12 +- apps/api/src/middlewares/rate-limiter.ts | 6 +- apps/api/src/middlewares/signup-guard.ts | 88 + apps/api/src/routes/account/account.ts | 35 +- apps/api/src/routes/community/community.ts | 15 +- .../middlewares/comment-author-or-team.ts | 4 +- .../middlewares/question-author-or-team.ts | 4 +- apps/api/src/routes/course/attendance.ts | 24 + apps/api/src/routes/course/content.ts | 34 + apps/api/src/routes/course/course.ts | 370 +- apps/api/src/routes/course/exercise.ts | 211 + apps/api/src/routes/course/invite.ts | 123 + apps/api/src/routes/course/katex.ts | 2 +- apps/api/src/routes/course/lesson-language.ts | 139 + apps/api/src/routes/course/lesson.ts | 268 +- apps/api/src/routes/course/mark.ts | 27 + apps/api/src/routes/course/newsfeed.ts | 202 + apps/api/src/routes/course/payment-request.ts | 39 + apps/api/src/routes/course/people.ts | 138 + apps/api/src/routes/course/presign.ts | 74 +- apps/api/src/routes/course/section.ts | 97 + apps/api/src/routes/course/submission.ts | 94 + apps/api/src/routes/domain/domain.ts | 51 + apps/api/src/routes/exercise/index.ts | 1 - apps/api/src/routes/exercise/template.ts | 38 - apps/api/src/routes/invite/index.ts | 1 + apps/api/src/routes/invite/invite.ts | 134 + apps/api/src/routes/license.ts | 30 + apps/api/src/routes/onboarding/onboarding.ts | 6 +- apps/api/src/routes/organization/assets.ts | 303 + apps/api/src/routes/organization/index.ts | 3 + .../src/routes/organization/organization.ts | 352 +- apps/api/src/routes/organization/quiz.ts | 143 + apps/api/src/routes/organization/sso.ts | 175 + apps/api/src/routes/organization/tags.ts | 141 + .../api/src/routes/organization/token-auth.ts | 116 + apps/api/src/routes/sso/discovery.ts | 44 + apps/api/src/routes/unsplash/unsplash.ts | 42 + apps/api/src/services/account.ts | 62 +- apps/api/src/services/assets/assets.ts | 426 + apps/api/src/services/assets/index.ts | 1 + .../api/src/services/attendance/attendance.ts | 46 + apps/api/src/services/attendance/index.ts | 1 + apps/api/src/services/auth/sso.ts | 133 + apps/api/src/services/community.ts | 87 +- apps/api/src/services/course/clone.ts | 79 +- apps/api/src/services/course/content.ts | 271 + apps/api/src/services/course/course.ts | 452 + apps/api/src/services/course/invite.ts | 1039 + .../src/services/course/payment-request.ts | 92 + apps/api/src/services/course/people.ts | 282 + apps/api/src/services/course/section.ts | 235 + apps/api/src/services/course/utils.ts | 159 + apps/api/src/services/dash.ts | 2 +- apps/api/src/services/exercise/exercise.ts | 412 + apps/api/src/services/exercise/index.ts | 1 + apps/api/src/services/exercise/template.ts | 15 +- apps/api/src/services/exercise/utils.ts | 445 + apps/api/src/services/index.ts | 8 + apps/api/src/services/lesson-language.ts | 120 + apps/api/src/services/lesson/index.ts | 1 + apps/api/src/services/lesson/lesson.ts | 362 + apps/api/src/services/license.ts | 111 + apps/api/src/services/mark/gradebook.ts | 106 + apps/api/src/services/mark/index.ts | 1 + apps/api/src/services/mark/mark.ts | 16 + apps/api/src/services/media.ts | 24 +- apps/api/src/services/newsfeed/index.ts | 1 + apps/api/src/services/newsfeed/newsfeed.ts | 413 + apps/api/src/services/onboarding.ts | 29 +- apps/api/src/services/org/domain.ts | 48 + apps/api/src/services/organization.ts | 336 +- apps/api/src/services/organization/invite.ts | 421 + apps/api/src/services/organization/sso.ts | 233 + .../src/services/organization/token-auth.ts | 95 + apps/api/src/services/quiz.ts | 118 + apps/api/src/services/submission/index.ts | 1 + .../api/src/services/submission/submission.ts | 882 + apps/api/src/services/tag.ts | 303 + apps/api/src/types/auth.ts | 4 +- apps/api/src/utils/errors.ts | 91 +- apps/api/src/utils/lesson-media.ts | 147 + apps/api/src/utils/presigned-urls.ts | 77 + apps/api/src/utils/redis/limiter.ts | 27 +- apps/api/src/utils/redis/redis.ts | 36 +- apps/api/src/utils/s3.ts | 176 +- apps/api/src/utils/template.ts | 4 + apps/api/tsconfig.json | 1 + apps/course-app/static/plyr.svg | 1 + apps/dashboard/.env.example | 18 +- apps/dashboard/.prettierrc | 18 + apps/dashboard/README.md | 42 +- apps/dashboard/ai/AUTH_FLOW.md | 19 +- apps/dashboard/ai/BREADCRUMBS_REFACTOR.md | 687 - apps/dashboard/ai/PAGE_ROUTE_GUIDE.md | 10 +- apps/dashboard/eslint.config.js | 4 +- apps/dashboard/package.json | 9 +- apps/dashboard/scripts/play.js | 20 - apps/dashboard/scripts/translate.cjs | 15 +- apps/dashboard/src/app.css | 21 +- apps/dashboard/src/app.html | 8 - apps/dashboard/src/hooks.server.ts | 5 +- .../AI/AIButton/CustomPromptBtn.svelte | 4 +- .../Analytics/AnalyticsGraph.svelte | 164 - .../StudentCertificate/Index.svelte | 13 - .../Lesson/ActivateSectionsModal.svelte | 66 - .../Lesson/Exercise/EditMode.svelte | 254 - .../components/Lesson/Exercise/Preview.svelte | 92 - .../Exercise/Submissions/Individual.svelte | 119 - .../Exercise/Submissions/Summary.svelte | 191 - .../Lesson/Exercise/Submissions/functions.ts | 66 - .../Lesson/Exercise/Submissions/index.svelte | 87 - .../Lesson/Exercise/ViewMode.svelte | 377 - .../components/Lesson/Exercise/functions.ts | 104 - .../components/Lesson/Exercise/index.svelte | 193 - .../Lesson/Exercises/NewExerciseModal.svelte | 375 - .../components/Lesson/Exercises/index.svelte | 224 - .../components/Lesson/LessonList.svelte | 198 - .../Lesson/LessonSectionList.svelte | 358 - .../Document/AddDocumentToLesson.svelte | 310 - .../Materials/Document/DocumentList.svelte | 135 - .../Lesson/Materials/Document/store.ts | 35 - .../Materials/Video/AddVideoToLesson.svelte | 54 - .../Lesson/Materials/Video/Index.svelte | 54 - .../Lesson/Materials/Video/UploadVideo.svelte | 234 - .../Materials/Video/YoutubeVideo.svelte | 82 - .../Materials/components/Comments.svelte | 371 - .../Materials/components/ComponentNote.svelte | 35 - .../components/ComponentSlide.svelte | 34 - .../components/ComponentVideo.svelte | 84 - .../components/Lesson/Materials/index.svelte | 576 - .../components/Lesson/NewLessonModal.svelte | 175 - .../Course/components/Lesson/functions.ts | 32 - .../Course/components/Lesson/store.ts | 7 - .../Course/components/Lesson/store/answers.ts | 26 - .../Course/components/Lesson/store/lessons.ts | 296 - .../components/NewsFeed/NewFeedModal.svelte | 153 - .../components/NewsFeed/NewsFeedCard.svelte | 265 - .../Course/components/NewsFeed/store.ts | 8 - .../components/People/InvitationModal.svelte | 290 - .../components/People/ShareQRImage.svelte | 41 - .../Course/components/People/store.ts | 7 - .../Course/components/People/types.ts | 21 - .../src/lib/components/Course/constants.ts | 4 - .../src/lib/components/Course/function.ts | 53 - .../src/lib/components/Course/store.ts | 237 - .../src/lib/components/Course/types.ts | 28 - .../components/CourseContainer/index.svelte | 100 - .../components/Navigation/CustomLinks.svelte | 5 + .../src/lib/components/Navigation/Logo.svelte | 3 +- .../components/Navigation/MobileMenu.svelte | 4 +- .../src/lib/components/Navigation/app.svelte | 5 +- .../lib/components/Navigation/index.svelte | 42 +- .../Question/CheckboxQuestion/index.svelte | 160 - .../Question/RadioQuestion/index.svelte | 162 - .../Question/TextareaQuestion/index.svelte | 121 - .../src/lib/components/Question/constants.ts | 33 - apps/dashboard/src/lib/config.ts | 10 - .../src/lib/features/app/init.svelte.ts | 53 +- .../src/lib/features/app/layout-setup.ts | 34 +- .../features/audience/pages/audience.svelte | 24 +- .../audience/pages/user-analytics.svelte | 46 +- .../community/api/community.svelte.ts | 118 +- .../components/community-comment-item.svelte | 3 +- .../components/community-list-loader.svelte | 2 +- .../community/components/delete-modal.svelte | 4 +- .../community/pages/community-question.svelte | 31 +- .../features/community/pages/community.svelte | 24 +- .../src/lib/features/community/utils/types.ts | 28 +- .../features/course/api/attendance.svelte.ts | 66 + .../lib/features/course/api/content.svelte.ts | 64 + .../course/api/course-clone.svelte.ts | 4 +- .../lib/features/course/api/course.svelte.ts | 701 + .../lib/features/course/api/courses.svelte.ts | 89 + .../course/api/exercise-template.svelte.ts | 73 + .../features/course/api/exercise.svelte.ts | 251 + .../src/lib/features/course/api/index.ts | 11 + .../lib/features/course/api/lesson.svelte.ts | 863 + .../lib/features/course/api/mark.svelte.ts | 38 + .../features/course/api/newsfeed.svelte.ts | 498 + .../lib/features/course/api/people.svelte.ts | 295 + .../lib/features/course/api/presign.svelte.ts | 79 + .../features/course/api/submission.svelte.ts | 162 + .../analytics/analytics-card.svelte} | 6 +- .../analytics/analytics-graph.svelte | 157 + .../analytics/analytics-skeleton.svelte} | 4 +- .../components/analytics/empty-state.svelte} | 3 +- .../analytics/icons/exercises-icon.svelte} | 0 .../analytics/icons/lessons-icon.svelte} | 0 .../analytics/icons/progress-icon.svelte} | 0 .../analytics/icons/students-icon.svelte} | 0 .../analytics/student-table.svelte} | 44 +- .../course/components/card-dropdown.svelte | 2 +- .../course/components/card-loader.svelte | 6 +- .../features/course/components/card.svelte | 72 +- .../components/ceritficate/design.svelte} | 79 +- .../issue-certificate-modal.svelte} | 0 .../components/ceritficate}/mockData.ts | 0 .../components/ceritficate/reports.svelte} | 10 +- .../course/components/ceritficate}/store.ts | 0 .../student-certificate/index.svelte | 13 + .../locked-certificate.svelte} | 0 .../unlocked-certificate.svelte} | 26 +- .../templates/blue-badge-pattern.svelte} | 12 +- .../templates/blue-professional-badge.svelte} | 10 +- .../ceritficate/templates/plain.svelte} | 12 +- .../templates/professional.svelte} | 10 +- .../templates/purple-badge-pattern.svelte} | 10 +- .../purple-professional-badge.svelte} | 10 +- .../components/content-count-badges.svelte | 39 + .../course/components/content/constants.ts | 68 + .../content/content-create-modal.svelte | 263 + .../content/exercise-create-stepper.svelte | 307 + .../content/lesson-create-stepper.svelte | 106 + .../content/section-create-stepper.svelte | 127 + .../course/components/content/store.ts | 46 + .../course/components/content/types.ts | 53 + .../course/components/course-container.ts} | 0 .../components/course-content-icon.svelte | 25 + .../components/course-filter-popover.svelte | 151 + .../course/components/course-header.svelte | 6 +- .../components/course-tag-picker.svelte | 64 + .../course/components/exercise}/constants.ts | 1 + .../exercise/delete-confirmation.svelte} | 2 +- .../components/exercise/edit-mode.svelte | 313 + .../course/components/exercise/functions.ts | 166 + .../exercise/grading-loader.svelte} | 0 .../exercise/mark-exercise-modal.svelte} | 339 +- .../components/exercise/order-modal.svelte} | 8 +- .../course/components/exercise/order-utils.ts | 26 + .../course/components/exercise/preview.svelte | 114 + .../components/exercise/question-labels.ts | 285 + .../exercise/question-type-utils.ts | 71 + .../course/components/exercise/store.ts} | 154 +- .../exercise/submissions/individual.svelte | 95 + .../components/exercise/submissions}/store.ts | 2 +- .../exercise/submissions/submissions.svelte | 79 + .../exercise/submissions/summary.svelte | 84 + .../components/exercise/submissions/types.ts | 21 + .../exercise/update-description.svelte} | 79 +- .../components/exercise/view-mode.svelte | 523 + .../lib/features/course/components/index.ts | 3 + .../course/components/lesson/ai-button.svelte | 54 + .../course/components/lesson/comments.svelte | 208 + .../course/components/lesson}/constants.ts | 6 +- .../lesson/content-action-helpers.ts | 109 + .../components/lesson/content-actions.svelte | 73 + .../components/lesson/content-list.svelte | 474 + .../lesson/content-navigation-actions.svelte | 157 + .../components/lesson/content-row.svelte | 90 + .../lesson/content-section-list.svelte | 698 + .../lesson/delete-lesson-confirmation.svelte} | 2 +- .../lesson/document/add-document-modal.svelte | 331 + .../document/document-card-dropdown.svelte | 28 + .../lesson/document/document-card.svelte | 72 + .../lesson/document/document-list.svelte | 65 + .../lesson/document/document.svelte} | 57 +- .../course/components/lesson/index.ts | 11 + .../lesson/language-selector.svelte | 18 + .../lesson/lesson-page-edit-header.svelte | 86 + .../lesson/lesson-version-history.svelte} | 55 +- .../course/components/lesson/loader.svelte} | 0 .../course/components/lesson/note/note.svelte | 80 + .../components/lesson/section-header.svelte | 100 + .../components/lesson/slide/slide.svelte | 53 + .../course/components/lesson/store.ts | 127 + .../course/components/lesson/utils.ts | 25 + .../lesson/video/add-video-modal.svelte | 62 + .../components/lesson/video}/constants.ts | 6 + .../lesson/video/embed-link.svelte} | 62 +- .../lesson/video/library-video.svelte | 91 + .../components/lesson/video/media-uploader.ts | 33 + .../lesson/video/upload-video.svelte | 308 + .../lesson/video/video-card-dropdown.svelte | 28 + .../lesson/video/video-card-utils.ts | 100 + .../components/lesson/video/video-card.svelte | 96 + .../lesson/video/video-thumbnail-utils.ts | 79 + .../components/lesson/video/video.svelte | 93 + .../lesson/video/youtube-video.svelte | 103 + .../features/course/components/list.svelte | 27 +- .../course/components/new-course-modal.svelte | 131 +- .../components/newsfeed/card/card.svelte | 103 + .../components/newsfeed/card/comments.svelte | 229 + .../newsfeed/card/delete-confirmation.svelte} | 0 .../components/newsfeed/card/header.svelte | 66 + .../components/newsfeed/card/reactions.svelte | 43 + .../course/components/newsfeed/index.ts | 3 + .../components/newsfeed/new-feed-modal.svelte | 105 + .../newsfeed/news-feed-loader.svelte} | 0 .../people/bulk-email-section.svelte | 90 + .../people/delete-confirmation.svelte} | 0 .../components/people/invitation-modal.svelte | 185 + .../people/invite-list-section.svelte | 163 + .../people/invite-settings-section.svelte | 53 + .../course/components/people/invite-utils.ts | 62 + .../components/people/share-qr-image.svelte | 40 + .../course/components/people/store.ts | 38 + .../people/tutor-select-section.svelte | 75 + .../course/components/people/types.ts | 39 + .../components/question-container.svelte} | 17 +- .../course-content-sidebar-navigation.svelte | 97 + .../sidebar/course-content-tree.svelte | 169 + .../sidebar/course-sidebar-navigation.svelte | 385 +- .../components/sidebar/course-sidebar.svelte | 32 +- .../components/sidebar/sidebar-history.ts | 134 + .../components/truncated-with-tooltip.svelte | 22 + .../features/course/pages/analytics.svelte | 69 +- .../features/course/pages/attendance.svelte | 199 +- .../features/course/pages/certificates.svelte | 6 +- .../lib/features/course/pages/courses.svelte | 113 +- .../lib/features/course/pages/exercise.svelte | 316 + .../src/lib/features/course/pages/index.ts | 14 +- .../lib/features/course/pages/lesson.svelte | 455 + .../lib/features/course/pages/lessons.svelte | 111 +- .../lib/features/course/pages/marks.svelte | 253 +- .../lib/features/course/pages/newsfeed.svelte | 245 +- .../lib/features/course/pages/people.svelte | 90 +- .../lib/features/course/pages/settings.svelte | 422 +- .../features/course/pages/submissions.svelte | 483 +- .../src/lib/features/course/store.ts | 104 + .../src/lib/features/course/types.ts | 45 + .../lib/features/course/utils/constants.ts | 33 +- .../src/lib/features/course/utils/content.ts | 55 + .../lib/features/course/utils/functions.ts | 91 +- .../lib/features/course/utils/marks-utils.ts | 230 + .../features/course/utils/settings-store.ts | 30 +- .../features/course/utils/sidebar-routes.ts | 55 + .../src/lib/features/course/utils/store.ts | 22 +- .../course/utils/submissions-utils.ts | 46 + .../src/lib/features/course/utils/types.ts | 409 + .../features/exercise-template/api/index.ts | 1 - .../exercise-template/api/template.svelte.ts | 39 - .../exercise-template/utils/constants.ts | 12 - .../features/exercise-template/utils/types.ts | 16 - .../features/license/api/license.svelte.ts | 37 + .../lib/features/lms/api/exercises.server.ts | 32 + .../lib/features/lms/api/exercises.svelte.ts | 33 + .../features/lms/components/learning.svelte | 26 +- .../lib/features/lms/pages/dashboard.svelte | 52 +- .../lib/features/lms/pages/exercises.svelte | 35 +- .../src/lib/features/lms/pages/explore.svelte | 54 +- .../lib/features/lms/pages/mylearning.svelte | 59 +- .../src/lib/features/media/api/index.ts | 1 + .../media/api/media-manager.svelte.ts | 385 + .../media/components/asset-card.svelte | 111 + .../components/asset-usage-dialog.svelte | 137 + .../media/components/edit-asset-dialog.svelte | 176 + .../lib/features/media/components/index.ts | 5 + .../media/components/media-filters.svelte | 75 + .../media/components/storage-cards.svelte | 48 + .../dashboard/src/lib/features/media/index.ts | 4 + .../src/lib/features/media/pages/index.ts | 1 + .../src/lib/features/media/pages/media.svelte | 190 + .../src/lib/features/media/utils/constants.ts | 24 + .../src/lib/features/media/utils/index.ts | 3 + .../media/utils/media-manager-utils.ts | 89 + .../src/lib/features/media/utils/types.ts | 56 + .../components/verify-email-modal.svelte | 2 +- .../components/welcome-modal.svelte | 4 +- .../src/lib/features/org/api/org.server.ts | 117 +- .../src/lib/features/org/api/org.svelte.ts | 133 +- .../src/lib/features/org/api/quiz.svelte.ts | 157 + .../src/lib/features/org/api/sso.svelte.ts | 194 + .../lib/features/org/api/token-auth.svelte.ts | 101 + .../src/lib/features/org/api/types.ts | 5 - .../landing-page/landing-page.svelte | 112 +- .../landing-page/page-header.svelte | 2 +- .../org/components/quiz/delete-modal.svelte | 18 +- .../org/components/quiz/loader.svelte | 2 +- .../org/components/quiz/new-quiz-modal.svelte | 91 +- .../org/components/quiz/quiz-question.svelte | 2 +- .../org/components/quiz/quizzes.svelte | 34 +- .../components/settings/landing-page-store.ts | 2 +- .../src/lib/features/org/store/sso.svelte.ts | 118 + .../src/lib/features/org/utils/sso.ts | 34 + .../src/lib/features/org/utils/types.ts | 87 +- .../settings/components/auth-general.svelte | 165 + .../settings/components/auth-sso.svelte | 347 + .../components/auth-token-auth.svelte | 227 + .../lib/features/settings/components/index.ts | 4 +- .../lib/features/settings/pages/auth.svelte | 94 + .../features/settings/pages/billing.svelte | 2 +- .../settings/pages/customize-lms.svelte | 2 +- .../features/settings/pages/domains.svelte | 32 +- .../src/lib/features/settings/pages/index.ts | 4 + .../settings/pages/integrations.svelte | 2 +- .../settings/pages/landingpage.svelte | 2 +- .../lib/features/settings/pages/org.svelte | 37 +- .../features/settings/pages/profile.svelte | 2 +- .../lib/features/settings/pages/sso.svelte | 349 + .../lib/features/settings/pages/teams.svelte | 12 +- .../features/settings/pages/token-auth.svelte | 227 + .../src/lib/features/settings/utils/store.ts | 2 +- .../setup/api/setup-progress.svelte.ts | 123 +- .../src/lib/features/tag/api/index.ts | 1 + .../src/lib/features/tag/api/tag.svelte.ts | 269 + apps/dashboard/src/lib/features/tag/index.ts | 3 + .../src/lib/features/tag/pages/index.ts | 1 + .../lib/features/tag/pages/tags-admin.svelte | 418 + .../src/lib/features/tag/utils/index.ts | 1 + .../src/lib/features/tag/utils/types.ts | 19 + .../ui/analytics/activity-card.svelte | 18 +- .../ui/analytics/hero-profile-card.svelte | 2 +- .../src/lib/features/ui/auth-ui.svelte | 14 +- .../src/lib/features/ui/close-btn.svelte | 6 +- .../components/IntersectionObserver.ts | 19 - .../components/editor/certificate-form.svelte | 2 +- .../components/editor/description-form.svelte | 2 +- .../components/editor/editor.svelte | 284 +- .../components/editor/goals-form.svelte | 2 +- .../components/editor/instructor-form.svelte | 2 +- .../components/editor/pricing-form.svelte | 2 +- .../components/editor/requirement-form.svelte | 2 +- .../components/editor/reviews-form.svelte | 4 +- .../components/nav-section.svelte | 2 +- .../components/payment-modal.svelte | 30 +- .../components/pricing-section.svelte | 46 +- .../components/sections-display.svelte | 6 +- .../course-landing-page.svelte | 166 +- .../features/ui/course-landing-page/utils.ts | 83 +- .../ui/html-render.svelte} | 0 .../ui/icons/circle-check-icon.svelte | 2 +- .../lib/features/ui/icons/error-icon.svelte | 185 - .../src/lib/features/ui/icons/index.ts | 1 - apps/dashboard/src/lib/features/ui/index.ts | 3 + .../src/lib/features/ui/media-player/index.ts | 3 + .../ui/media-player/media-player.svelte | 28 + .../ui/media-player/players/constants.ts | 13 + .../media-player/players/muse-player.svelte | 28 + .../media-player/players/plyr-player.svelte | 113 + .../src/lib/features/ui/media-player/types.ts | 21 + .../src/lib/features/ui/media-player/utils.ts | 65 + .../ui/navigation/app-breadcrumbs.svelte | 1 + .../features/ui/navigation/app-header.svelte | 2 +- .../features/ui/navigation/app-setup.svelte | 2 +- .../ui/navigation/breadcrumb-configs.ts | 2 - .../lib/features/ui/navigation/breadcrumb.ts | 9 +- .../features/ui/navigation/lms-breadcrumb.ts | 7 +- .../ui/navigation/lms-breadcrumbs.svelte | 1 + .../features/ui/navigation/lms-header.svelte | 2 +- .../features/ui/navigation/lms-navigation.ts | 27 +- .../features/ui/navigation/org-navigation.ts | 105 +- .../src/lib/features/ui/question/constants.ts | 60 + .../ui/question/grade.svelte} | 7 +- .../src/lib/features/ui/question/index.ts | 4 + .../ui/question/question-title.svelte} | 4 +- .../ui/question/reason-box.svelte} | 0 .../features/ui/role-based-security.svelte | 13 +- .../src/lib/features/ui/search.svelte | 31 +- .../src/lib/features/ui/senja-embed.svelte | 19 +- .../features/ui/sidebar/footer/menu.svelte | 2 +- .../ui/sidebar/lms-sidebar/lms-sidebar.svelte | 8 +- .../ui/sidebar/lms-sidebar/nav-main.svelte | 82 +- .../ui/sidebar/org-sidebar/app-logo.svelte | 2 +- .../ui/sidebar/org-sidebar/nav-main.svelte | 46 +- .../ui/sidebar/org-sidebar/org-logo.svelte | 2 +- .../sidebar/org-sidebar/org-switcher.svelte | 40 +- .../org-sidebar/upgrade-trigger.svelte | 2 +- .../src/lib/features/ui/text-editor.svelte | 4 +- .../src/lib/features/ui/upgrade-banner.svelte | 52 +- .../src/lib/features/ui/upgrade-modal.svelte | 100 +- .../lib/features/ui/upgrade-powered-by.svelte | 4 +- .../src/lib/features/ui/upload-image.svelte | 5 +- .../ui/upload-widget/upload-widget.svelte | 80 +- .../lib/features/ui/upload-widget/utils.ts | 25 +- .../src/lib/utils/constants/routes.ts | 17 +- .../src/lib/utils/functions/appSetup.ts | 25 +- .../src/lib/utils/functions/course.ts | 17 - .../src/lib/utils/functions/domain.ts | 31 +- .../lib/utils/functions/formatYoutubeVideo.ts | 11 +- .../src/lib/utils/functions/metaTags.ts | 85 + .../src/lib/utils/functions/permissions.ts | 52 - .../lib/utils/functions/removeDuplicate.ts | 4 +- .../lib/utils/functions/routes/dashboard.ts | 32 +- .../lib/utils/functions/supabase.server.ts | 24 - .../src/lib/utils/functions/supabase.ts | 38 - .../src/lib/utils/functions/theme.ts | 4 +- .../src/lib/utils/functions/translations.ts | 39 +- .../dashboard/src/lib/utils/functions/user.ts | 25 +- .../src/lib/utils/functions/validator.ts | 21 - .../src/lib/utils/services/api/base.svelte.ts | 3 +- .../src/lib/utils/services/api/index.ts | 66 +- .../lib/utils/services/attendance/index.ts | 5 - .../src/lib/utils/services/auth/client.ts | 10 +- .../src/lib/utils/services/auth/server.ts | 12 + .../src/lib/utils/services/auth/session.ts | 9 +- .../src/lib/utils/services/courses/index.ts | 617 - .../lib/utils/services/courses/mockdata.ts | 82 - .../src/lib/utils/services/courses/presign.ts | 48 +- .../src/lib/utils/services/dashboard/index.ts | 47 - .../src/lib/utils/services/lms/exercises.ts | 101 - .../src/lib/utils/services/marks/index.ts | 7 - .../middlewares/authentication.server.ts | 20 - .../lib/utils/services/middlewares/index.ts | 1 - .../src/lib/utils/services/newsfeed/index.ts | 173 - .../services/notification/notification.ts | 51 - .../src/lib/utils/services/posthog/index.ts | 11 +- .../src/lib/utils/services/sentry/index.ts | 2 +- .../lib/utils/services/submissions/index.ts | 108 - .../src/lib/utils/store/attendance.ts | 10 - apps/dashboard/src/lib/utils/store/org.ts | 17 +- .../src/lib/utils/translations/da.json | 2019 +- .../src/lib/utils/translations/de.json | 3760 +- .../src/lib/utils/translations/en.json | 814 +- .../src/lib/utils/translations/es.json | 3754 +- .../src/lib/utils/translations/fr.json | 3752 +- .../src/lib/utils/translations/hi.json | 3770 +- .../src/lib/utils/translations/pl.json | 2260 +- .../src/lib/utils/translations/pt.json | 3754 +- .../src/lib/utils/translations/ru.json | 3756 +- .../src/lib/utils/translations/vi.json | 2014 +- apps/dashboard/src/lib/utils/types/config.ts | 3 - .../src/lib/utils/types/dashboard.ts | 17 - apps/dashboard/src/lib/utils/types/feed.ts | 46 - apps/dashboard/src/lib/utils/types/index.ts | 429 - .../src/lib/utils/types/submission.ts | 49 - .../src/lib/utils/validation/validation.ts | 23 +- .../src/routes/(auth)/login/+page.svelte | 217 +- .../src/routes/(auth)/onboarding/+page.svelte | 2 +- .../src/routes/(auth)/reset/+page.svelte | 2 +- .../src/routes/(auth)/signup/+page.svelte | 190 +- .../(auth)/verify-email-error/+page.svelte | 2 +- apps/dashboard/src/routes/+error.svelte | 50 +- apps/dashboard/src/routes/+layout.server.ts | 68 +- apps/dashboard/src/routes/+layout.svelte | 72 +- apps/dashboard/src/routes/+layout.ts | 7 +- apps/dashboard/src/routes/404/+page.svelte | 8 +- .../api/admin/cleanup-tokens/+server.ts | 64 - .../api/admin/security-monitor/+server.ts | 81 - .../src/routes/api/analytics/dash/+server.ts | 89 - .../src/routes/api/analytics/user/+server.ts | 260 - .../src/routes/api/completion/+server.ts | 47 - .../api/completion/customprompt/+server.ts | 38 - .../api/completion/exerciseprompt/+server.ts | 81 - .../api/completion/gradingprompt/+server.ts | 50 - .../routes/api/courses/analytics/+server.ts | 288 - .../src/routes/api/courses/data/+server.ts | 114 - .../routes/api/courses/exercises/+server.ts | 60 - .../src/routes/api/courses/marks/+server.ts | 62 - .../routes/api/courses/newsfeed/+server.ts | 80 - .../routes/api/courses/submission/+server.ts | 92 - .../routes/api/courses/submissions/+server.ts | 80 - .../src/routes/api/domain/+server.ts | 39 - .../exercise_submission_update/+server.ts | 26 - .../api/email/course/newsfeed/+server.ts | 84 - .../course/student_prove_payment/+server.ts | 50 - .../email/course/student_welcome/+server.ts | 34 - .../email/course/submission_update/+server.ts | 26 - .../teacher_student_buycourse/+server.ts | 34 - .../course/teacher_student_joined/+server.ts | 32 - .../email/course/teacher_welcome/+server.ts | 37 - .../src/routes/api/email/invite/+server.ts | 46 - .../src/routes/api/org/audience/+server.ts | 86 - .../src/routes/api/org/team/+server.ts | 88 - .../src/routes/api/polar/portal/+server.ts | 4 +- .../src/routes/api/unsplash/+server.ts | 33 - .../src/routes/course/[slug]/+page.svelte | 2 +- .../src/routes/course/[slug]/+page.ts | 52 +- .../course/[slug]/enroll/+page.server.ts | 104 + .../routes/course/[slug]/enroll/+page.svelte | 134 + .../src/routes/courses/+page.server.ts | 50 + .../dashboard/src/routes/courses/+page.svelte | 176 + .../src/routes/courses/[id]/+layout.server.ts | 12 + .../src/routes/courses/[id]/+layout.svelte | 107 +- .../src/routes/courses/[id]/+page.svelte | 32 +- .../src/routes/courses/[id]/+page.ts | 4 +- .../courses/[id]/analytics/+page.server.ts | 29 + .../courses/[id]/analytics/+page.svelte | 11 +- .../routes/courses/[id]/analytics/+page.ts | 7 - .../courses/[id]/attendance/+page.svelte | 9 +- .../routes/courses/[id]/attendance/+page.ts | 4 +- .../courses/[id]/certificates/+page.svelte | 13 +- .../routes/courses/[id]/certificates/+page.ts | 4 +- .../exercises/[exerciseId]/+page.server.ts | 40 + .../[id]/exercises/[exerciseId]/+page.svelte | 90 + .../courses/[id]/landingpage/+page.svelte | 56 +- .../routes/courses/[id]/landingpage/+page.ts | 4 +- .../routes/courses/[id]/lessons/+page.svelte | 46 +- .../src/routes/courses/[id]/lessons/+page.ts | 4 +- .../lessons/[...lessonParams]/+page.server.ts | 14 - .../lessons/[...lessonParams]/+page.svelte | 344 - .../[id]/lessons/[lessonId]/+page.server.ts | 25 + .../[id]/lessons/[lessonId]/+page.svelte | 43 + .../routes/courses/[id]/marks/+page.server.ts | 25 + .../routes/courses/[id]/marks/+page.svelte | 139 +- .../src/routes/courses/[id]/marks/+page.ts | 5 - .../routes/courses/[id]/people/+layout.svelte | 57 +- .../src/routes/courses/[id]/people/+layout.ts | 6 +- .../[id]/people/[personId]/+page.server.ts | 41 + .../[id]/people/[personId]/+page.svelte | 59 +- .../courses/[id]/people/[personId]/+page.ts | 8 - .../routes/courses/[id]/settings/+page.svelte | 47 +- .../src/routes/courses/[id]/settings/+page.ts | 4 +- .../courses/[id]/submissions/+page.server.ts | 38 + .../courses/[id]/submissions/+page.svelte | 19 +- .../routes/courses/[id]/submissions/+page.ts | 5 - .../routes/invite/s/[hash]/+layout.server.ts | 27 - .../src/routes/invite/s/[hash]/+page.svelte | 135 - .../routes/invite/t/[hash]/+layout.server.ts | 40 +- .../src/routes/invite/t/[hash]/+page.svelte | 286 +- apps/dashboard/src/routes/lms/+layout.svelte | 2 +- .../src/routes/lms/settings/+page.svelte | 2 +- .../src/routes/org/[slug]/+layout.server.ts | 30 +- .../src/routes/org/[slug]/+layout.svelte | 7 +- .../src/routes/org/[slug]/+page.server.ts | 34 + .../src/routes/org/[slug]/+page.svelte | 28 +- apps/dashboard/src/routes/org/[slug]/+page.ts | 5 - .../org/[slug]/audience/+page.server.ts | 21 + .../routes/org/[slug]/audience/+page.svelte | 8 +- .../audience/[...params]/+page.server.ts | 30 + .../org/[slug]/audience/[...params]/+page.ts | 11 - .../org/[slug]/community/+page.server.ts | 22 + .../routes/org/[slug]/community/+page.svelte | 6 +- .../[slug]/community/[cslug]/+page.server.ts | 19 + .../{[slug] => [cslug]}/+page.svelte | 2 +- .../org/[slug]/community/[slug]/+page.ts | 5 - .../routes/org/[slug]/courses/+page.server.ts | 42 + .../routes/org/[slug]/courses/+page.svelte | 150 +- .../src/routes/org/[slug]/courses/+page.ts | 5 - .../routes/org/[slug]/media/+page.server.ts | 52 + .../src/routes/org/[slug]/media/+page.svelte | 38 + .../org/[slug]/quiz/[slug]/+page.svelte | 51 +- .../routes/org/[slug]/settings/+page.svelte | 2 +- .../org/[slug]/settings/auth/+page.svelte | 20 + .../org/[slug]/settings/auth/sso/+page.svelte | 20 + .../settings/auth/token-auth/+page.svelte | 20 + .../[slug]/settings/landingpage/+page.svelte | 2 +- .../org/[slug]/settings/org/+page.svelte | 2 +- .../routes/org/[slug]/setup/+page.server.ts | 21 + .../src/routes/org/[slug]/setup/+page.svelte | 21 +- .../src/routes/org/[slug]/setup/+page.ts | 5 - .../routes/org/[slug]/tags/+page.server.ts | 26 + .../src/routes/org/[slug]/tags/+page.svelte | 7 + apps/dashboard/static/carbon-all.css | 1 - apps/dashboard/static/js/pdf.js/pdf.min.js | 22 + .../static/js/pdf.js/pdf.worker.min.js | 22 + apps/dashboard/static/plyr.svg | 1 + apps/dashboard/svelte.config.js | 187 +- .../contributor-guides/cloudflare-setup.mdx | 94 +- apps/docs/content/docs/enterprise-sso.mdx | 202 + .../docs/how-to-guides/invite-students.mdx | 76 +- .../docs/content/docs/how-to-guides/meta.json | 3 +- .../docs/how-to-guides/use-math-in-editor.mdx | 43 + apps/docs/content/docs/meta.json | 2 + .../content/docs/quickstart/self-hosting.mdx | 312 +- apps/docs/content/docs/tags.mdx | 69 + apps/website/REACT_GEMINI.md | 167 + apps/website/package.json | 18 +- apps/website/src/blog/students-analytics.md | 9 +- .../src/lib/components/blog-list-item.svelte | 14 +- .../components/core-feature-section.svelte | 2 +- .../src/lib/components/core-features.svelte | 2 +- apps/website/src/lib/components/faq.svelte | 10 +- apps/website/src/lib/components/footer.svelte | 20 +- apps/website/src/lib/components/hero.svelte | 18 +- apps/website/src/lib/components/modal.svelte | 2 +- .../src/lib/components/navigation.svelte | 4 +- .../src/lib/components/page-header.svelte | 2 +- .../lib/components/student-dashboard.svelte | 4 +- .../website/src/lib/components/success.svelte | 2 +- .../src/lib/components/testimonial.svelte | 2 +- .../src/lib/components/timeline.svelte | 6 +- .../src/lib/components/upload-widget.svelte | 8 +- .../features/tools/progress/background.svelte | 2 +- .../lib/features/tools/progress/report.svelte | 4 +- .../lib/features/tools/stopwatch/timer.svelte | 6 +- .../features/tools/tic-tac-toe/board.svelte | 2 +- .../routes/(marketing)/bootcamps/+page.svelte | 8 +- .../customer-education/+page.svelte | 6 +- .../employee-training/+page.svelte | 6 +- .../routes/(redirects)/signup/+page.server.ts | 6 +- .../src/routes/blog/[slug]/+page.svelte | 2 +- .../src/routes/oss-friends/+page.svelte | 4 +- apps/website/src/routes/pricing/+page.svelte | 172 +- apps/website/src/routes/roadmap/+page.svelte | 12 +- apps/website/src/routes/teach/+page.svelte | 4 +- .../src/routes/tools/name-picker/+page.svelte | 8 +- .../src/routes/tools/pomodoro/+page.svelte | 4 +- .../src/routes/tools/progress/+page.svelte | 2 +- .../src/routes/tools/tic-tac-toe/+page.svelte | 10 +- docker/.env.prod.example | 56 + docker/Dockerfile.api | 33 +- docker/Dockerfile.dashboard | 85 +- docker/docker-compose.prod.yaml | 136 + docker/docker-compose.yaml | 206 +- docker/docker-push.sh | 10 + .../docs/{README.md => PUBLISHING_IMAGES.md} | 49 +- docker/docs/SELF_HOST.md | 249 + docker/docs/USAGE.md | 308 +- docker/docs/commands.md | 179 +- lefthook.yml | 1 - package.json | 15 +- packages/db/package.json | 18 +- packages/db/src/auth.ts | 15 +- packages/db/src/auth/hooks/auth-settings.ts | 155 + packages/db/src/auth/hooks/create-profile.ts | 54 +- .../db/src/auth/hooks/sso-provisioning.ts | 125 + packages/db/src/auth/hooks/sync-user.ts | 5 +- .../db/src/auth/plugins/token-exchange.ts | 127 + packages/db/src/auth/token-exchange.ts | 105 + packages/db/src/constants.ts | 34 +- packages/db/src/drizzle.ts | 4 + .../db/src/migrations/0002_many_revanche.sql | 2 +- .../migrations/0003_fancy_harry_osborn.sql | 243 + .../0004_medical_true_believers.sql | 2 + .../src/migrations/0005_outgoing_doomsday.sql | 101 + .../migrations/0006_modern_the_initiative.sql | 52 + .../db/src/migrations/meta/0003_snapshot.json | 3834 ++ .../db/src/migrations/meta/0004_snapshot.json | 3866 ++ .../db/src/migrations/meta/0005_snapshot.json | 4692 ++ .../db/src/migrations/meta/0006_snapshot.json | 5133 +++ packages/db/src/migrations/meta/_journal.json | 28 + .../db/src/queries/analytics/analytics.ts | 322 + packages/db/src/queries/analytics/index.ts | 1 + packages/db/src/queries/assets/assets.ts | 486 + packages/db/src/queries/assets/index.ts | 1 + .../db/src/queries/attendance/attendance.ts | 96 + packages/db/src/queries/attendance/index.ts | 1 + packages/db/src/queries/auth/profile.ts | 6 + .../db/src/queries/community/community.ts | 37 +- packages/db/src/queries/course/.gitkeep | 0 packages/db/src/queries/course/content.ts | 166 + packages/db/src/queries/course/course.ts | 777 +- packages/db/src/queries/course/index.ts | 4 + packages/db/src/queries/course/invite.ts | 335 + packages/db/src/queries/course/people.ts | 309 + packages/db/src/queries/exercise/exercise.ts | 490 +- packages/db/src/queries/exercise/index.ts | 2 + packages/db/src/queries/exercise/lms.ts | 193 + packages/db/src/queries/group/group.ts | 107 +- packages/db/src/queries/index.ts | 7 + packages/db/src/queries/lesson/index.ts | 1 + packages/db/src/queries/lesson/language.ts | 56 + packages/db/src/queries/lesson/lesson.ts | 329 +- packages/db/src/queries/mark/index.ts | 1 + packages/db/src/queries/mark/mark.ts | 71 + packages/db/src/queries/newsfeed/index.ts | 1 + packages/db/src/queries/newsfeed/newsfeed.ts | 489 + packages/db/src/queries/organization/index.ts | 2 + .../db/src/queries/organization/invite.ts | 182 + .../src/queries/organization/organization.ts | 168 +- packages/db/src/queries/organization/quiz.ts | 42 + packages/db/src/queries/organization/sso.ts | 116 + .../db/src/queries/organization/token-auth.ts | 81 + packages/db/src/queries/submission/index.ts | 1 + .../db/src/queries/submission/submission.ts | 350 + packages/db/src/queries/tag/index.ts | 1 + packages/db/src/queries/tag/tag.ts | 399 + packages/db/src/relations.ts | 124 +- packages/db/src/schema.ts | 679 +- .../src/scripts/backfill-course-grouping.ts | 29 + .../src/scripts/backfill-exercise-course.ts | 219 + .../db/src/scripts/backfill-lesson-assets.ts | 1249 + packages/db/src/scripts/db-reset.ts | 97 + packages/db/src/scripts/db-setup.ts | 58 +- .../db/src/scripts/migrate-answer-data.ts | 141 + packages/db/src/scripts/seed.ts | 34 +- .../db/src/scripts/supabase-to-betterauth.ts | 362 +- .../db/src/scripts/sync-orphaned-profiles.ts | 83 + packages/db/src/types.ts | 48 +- packages/db/src/utils/seed/course.ts | 25 +- packages/db/src/utils/seed/courseSection.ts | 46 + packages/db/src/utils/seed/exercise.ts | 35 +- packages/db/src/utils/seed/lesson.ts | 44 +- packages/db/src/utils/seed/profile.ts | 24 +- packages/db/src/utils/seed/questionType.ts | 42 + packages/db/src/utils/seed/users.ts | 32 +- packages/email/package.json | 2 +- packages/email/src/emails/index.ts | 23 + packages/email/src/emails/invite-teacher.ts | 5 +- packages/email/src/emails/newsfeed.ts | 49 + .../email/src/emails/student-course-invite.ts | 27 + .../src/emails/student-course-welcome.ts | 25 + .../email/src/emails/student-prove-payment.ts | 26 + .../src/emails/teacher-course-welcome.ts | 27 + .../src/emails/teacher-student-buy-request.ts | 27 + .../src/emails/teacher-student-joined.ts | 24 + packages/email/src/index.ts | 3 +- packages/email/src/send.ts | 7 +- packages/email/src/utils/constants.ts | 22 +- .../src/utils/functions/email-helpers.ts | 14 + .../email/src/utils/services/nodemailer.ts | 21 +- packages/question-types/.gitignore | 3 + .../question-types/ADDING_A_QUESTION_TYPE.md | 331 + packages/question-types/README.md | 270 + packages/question-types/package.json | 32 + packages/question-types/src/answer-codecs.ts | 282 + packages/question-types/src/answer-data.ts | 32 + packages/question-types/src/exercise-types.ts | 180 + packages/question-types/src/form-state.ts | 24 + packages/question-types/src/index.ts | 14 + .../src/question-answer-payload.ts | 82 + .../src/question-scoring-rules.ts | 22 + .../src/question-type-capabilities.ts | 14 + .../question-types/src/question-type-keys.ts | 22 + .../src/question-type-lookup.ts | 26 + .../src/question-type-registry.ts | 153 + .../question-types/src/question-type-types.ts | 11 + .../question-types/src/render-contract.ts | 48 + .../question-types/src/renderer-registry.ts | 23 + .../tests/form-and-render-contract.test.ts | 66 + .../tests/question-type-registry.test.ts | 157 + .../tests/renderer-registry.test.ts | 67 + packages/question-types/tsconfig.json | 20 + packages/question-types/vitest.config.ts | 8 + packages/storybook/.prettierignore | 1 + packages/storybook/HOW_TO_CREATE_STORIES.md | 464 - packages/storybook/README.md | 469 +- packages/storybook/package.json | 4 +- .../src/atoms/alert/alert.stories.svelte | 68 + .../src/atoms/badge/badge.stories.svelte | 14 + .../src/atoms/popover/popover.stories.svelte | 4 +- .../toggle-group/toggle-group.stories.svelte | 32 + .../button-group/button-group.stories.svelte | 2 +- .../src/molecules/code/code.stories.svelte | 166 + .../storybook/src/molecules/code/fields.ts | 1 + .../document-card.stories.svelte | 46 + .../exercise-question.stories.svelte | 130 + .../exercise-question/question-fixtures.ts | 210 + .../exercise-question/question-labels.ts | 112 + .../question-type-modes.svelte | 194 + .../src/molecules/field/field.stories.svelte | 20 - .../src/molecules/file-drop-zone/fields.ts | 1 + .../file-drop-zone.stories.svelte | 113 + .../input-group/input-group.stories.svelte | 5 +- .../storybook/src/molecules/page/fields.ts | 2 +- .../src/molecules/page/page.stories.svelte | 2 +- .../src/molecules/pricing-card/fields.ts | 12 + .../pricing-card/pricing-card.stories.svelte | 322 + .../pricing-toggle.stories.svelte | 37 + .../src/molecules/radio-option-card/fields.ts | 2 + .../radio-option-card.stories.svelte | 167 + packages/ui/.prettierignore | 1 + packages/ui/README.md | 228 + packages/ui/jsrepo.json | 1 + packages/ui/package.json | 18 +- .../ui/src/base/alert/alert-callout.svelte | 34 + .../src/base/alert/alert-description.svelte | 23 + packages/ui/src/base/alert/alert-title.svelte | 20 + packages/ui/src/base/alert/alert.svelte | 42 + packages/ui/src/base/alert/index.ts | 16 + packages/ui/src/base/badge/badge.svelte | 4 + .../button-group/button-group-text.svelte | 2 +- .../src/base/button-group/button-group.svelte | 2 +- packages/ui/src/base/button/button.svelte | 22 +- packages/ui/src/base/card/card-header.svelte | 2 +- .../ui/src/base/chart/chart-container.svelte | 4 +- .../ui/src/base/chart/chart-legend.svelte | 25 + packages/ui/src/base/chart/chart-style.svelte | 2 +- .../ui/src/base/chart/chart-tooltip.svelte | 2 +- packages/ui/src/base/chart/chart-utils.ts | 6 + packages/ui/src/base/chart/index.ts | 6 +- packages/ui/src/base/checkbox/checkbox.svelte | 4 +- .../ui/src/base/command/command-dialog.svelte | 2 +- .../src/base/copy-button/copy-button.svelte | 11 +- .../ui/src/base/dialog/dialog-content.svelte | 6 +- .../ui/src/base/dialog/dialog-overlay.svelte | 2 +- packages/ui/src/base/empty/empty-media.svelte | 2 +- packages/ui/src/base/field/field.svelte | 2 +- .../base/input-group/input-group-addon.svelte | 2 +- .../input-group/input-group-button.svelte | 2 +- packages/ui/src/base/input/index.ts | 4 +- packages/ui/src/base/input/input.svelte | 4 +- packages/ui/src/base/item/index.ts | 18 +- packages/ui/src/base/item/item-media.svelte | 2 +- .../src/base/item/item-sub-description.svelte | 23 + .../navigation-menu-content.svelte | 2 +- .../navigation-menu-indicator.svelte | 2 +- .../navigation-menu-viewport.svelte | 2 +- packages/ui/src/base/page/page-header.svelte | 12 +- .../src/base/password/password-input.svelte | 2 +- .../src/base/radio-group/radio-group.svelte | 2 +- .../ui/src/base/sheet/sheet-content.svelte | 20 +- .../base/sidebar/sidebar-menu-action.svelte | 2 +- .../base/sidebar/sidebar-menu-button.svelte | 2 +- .../sidebar/sidebar-menu-sub-button.svelte | 2 +- .../src/base/sidebar/sidebar-menu-sub.svelte | 2 +- packages/ui/src/base/spinner/spinner.svelte | 7 +- packages/ui/src/base/toggle-group/index.ts | 17 + .../base/toggle-group/toggle-group-context.ts | 8 + .../toggle-group/toggle-group-item.svelte | 59 + .../src/base/toggle-group/toggle-group.svelte | 54 + packages/ui/src/base/toggle/toggle.svelte | 2 +- .../checkbox-field/checkbox-field.svelte | 14 +- packages/ui/src/custom/chip/chip.svelte | 2 +- packages/ui/src/custom/code/code-block.svelte | 33 + .../src/custom/code/code-copy-button.svelte | 24 + .../src/custom/code/code-file-header.svelte | 43 + .../ui/src/custom/code/code-overflow.svelte | 40 + .../ui/src/custom/code/code-utils.svelte.ts | 125 + packages/ui/src/custom/code/code.svelte | 101 + packages/ui/src/custom/code/index.ts | 32 + .../ui/src/custom/code/lang-from-filename.ts | 21 + packages/ui/src/custom/code/shiki.ts | 22 + packages/ui/src/custom/code/types.ts | 27 + .../custom/document-card/document-card.svelte | 85 + packages/ui/src/custom/document-card/index.ts | 1 + .../custom/domain-input/domain-input.svelte | 2 +- packages/ui/src/custom/editor/editor.css | 36 +- packages/ui/src/custom/editor/editor.svelte | 15 +- packages/ui/src/custom/editor/editor.ts | 14 +- .../editor/extensions/InlineMathReplacer.ts | 32 +- .../editor/extensions/drag-handle/index.ts | 2 +- .../editor/ui/components/CodeBlock.svelte | 7 +- .../editor/ui/components/MediaExtended.svelte | 4 +- .../editor/ui/drag-handle-extended.svelte | 10 +- .../ui/src/custom/editor/ui/editor.svelte | 16 +- .../ui/src/custom/editor/ui/menus/Menu.svelte | 19 +- packages/ui/src/custom/empty/empty.svelte | 6 +- .../ui/src/custom/exercise-question/index.ts | 56 + .../question-form-root.svelte | 26 + .../exercise-question/question-list.svelte | 43 + .../question-navigation.svelte | 42 + .../question-renderer.svelte | 477 + .../exercise-question/question-surface.svelte | 14 + .../exercise-question/question-title.svelte | 12 + .../exercise-question/renderer-contract.ts | 153 + .../renderers/checkbox/edit.svelte | 230 + .../renderers/checkbox/preview.svelte | 35 + .../renderers/checkbox/submission.svelte | 93 + .../renderers/checkbox/take.svelte | 94 + .../renderers/file-upload-types.ts | 115 + .../renderers/file-upload/edit.svelte | 119 + .../renderers/file-upload/preview.svelte | 23 + .../renderers/file-upload/submission.svelte | 14 + .../renderers/file-upload/take.svelte | 143 + .../renderers/fill-blank/edit.svelte | 31 + .../renderers/fill-blank/preview.svelte | 14 + .../renderers/fill-blank/submission.svelte | 109 + .../renderers/fill-blank/take.svelte | 40 + .../renderers/hotspot/edit.svelte | 11 + .../renderers/hotspot/preview.svelte | 11 + .../renderers/hotspot/submission.svelte | 14 + .../renderers/hotspot/take.svelte | 50 + .../renderers/link/edit.svelte | 33 + .../renderers/link/preview.svelte | 55 + .../renderers/link/submission.svelte | 14 + .../renderers/link/take.svelte | 111 + .../renderers/matching/edit.svelte | 11 + .../renderers/matching/preview.svelte | 11 + .../renderers/matching/submission.svelte | 14 + .../renderers/matching/take.svelte | 50 + .../renderers/numeric/edit.svelte | 60 + .../renderers/numeric/preview.svelte | 17 + .../renderers/numeric/submission.svelte | 14 + .../renderers/numeric/take.svelte | 37 + .../renderers/option-image-utils.ts | 69 + .../renderers/option-image.svelte | 56 + .../renderers/ordering-utils.ts | 116 + .../renderers/ordering/edit.svelte | 181 + .../renderers/ordering/preview.svelte | 53 + .../renderers/ordering/submission.svelte | 14 + .../renderers/ordering/take.svelte | 95 + .../renderers/radio/edit.svelte | 225 + .../renderers/radio/preview.svelte | 35 + .../renderers/radio/submission.svelte | 74 + .../renderers/radio/take.svelte | 80 + .../renderers/short-answer/edit.svelte | 28 + .../renderers/short-answer/preview.svelte | 11 + .../renderers/short-answer/submission.svelte | 14 + .../renderers/short-answer/take.svelte | 25 + .../renderers/submission-generic.svelte | 92 + .../renderers/submission-utils.ts | 72 + .../renderers/textarea/edit.svelte | 12 + .../renderers/textarea/preview.svelte | 12 + .../renderers/textarea/submission.svelte | 66 + .../renderers/textarea/take.svelte | 42 + .../renderers/true-false/edit.svelte | 68 + .../renderers/true-false/preview.svelte | 17 + .../renderers/true-false/submission.svelte | 93 + .../renderers/true-false/take.svelte | 33 + .../file-drop-zone-textarea.svelte | 24 + .../file-drop-zone-trigger.svelte | 64 + .../file-drop-zone-util.svelte.ts | 220 + .../file-drop-zone/file-drop-zone.svelte | 34 + .../ui/src/custom/file-drop-zone/index.ts | 35 + .../ui/src/custom/file-drop-zone/types.ts | 63 + .../src/custom/icon-button/icon-button.svelte | 10 +- .../image-cropper/image-cropper-dialog.svelte | 2 +- .../src/custom/input-field/input-field.svelte | 17 +- packages/ui/src/custom/media-player/index.ts | 6 + .../custom/media-player/media-player.svelte | 24 + .../custom/media-player/players/constants.ts | 13 + .../media-player/players/muse-player.svelte | 28 + .../media-player/players/plyr-player.svelte | 90 + packages/ui/src/custom/media-player/types.ts | 21 + packages/ui/src/custom/media-player/utils.ts | 43 + .../src/custom/moving-icons/analytics.svelte | 76 + .../moving-icons/arrow-top-right.svelte | 50 + .../src/custom/moving-icons/attachment.svelte | 80 + .../src/custom/moving-icons/attendance.svelte | 75 + .../ui/src/custom/moving-icons/bell.svelte | 111 + .../custom/moving-icons/certificate.svelte | 68 + .../ui/src/custom/moving-icons/close.svelte | 80 + .../src/custom/moving-icons/community.svelte | 101 + .../ui/src/custom/moving-icons/content.svelte | 77 + .../ui/src/custom/moving-icons/course.svelte | 106 + .../src/custom/moving-icons/dashboard.svelte | 135 + .../src/custom/moving-icons/exercise.svelte | 79 + .../ui/src/custom/moving-icons/explore.svelte | 68 + .../ui/src/custom/moving-icons/goal.svelte | 79 + .../ui/src/custom/moving-icons/header.svelte | 71 + .../ui/src/custom/moving-icons/home.svelte | 77 + .../custom/moving-icons/hoverable-item.svelte | 21 + packages/ui/src/custom/moving-icons/index.ts | 29 + .../custom/moving-icons/landingpage.svelte | 109 + .../ui/src/custom/moving-icons/lesson.svelte | 152 + .../ui/src/custom/moving-icons/marks.svelte | 95 + .../ui/src/custom/moving-icons/money.svelte | 82 + .../src/custom/moving-icons/news-feed.svelte | 101 + .../ui/src/custom/moving-icons/people.svelte | 70 + .../ui/src/custom/moving-icons/person.svelte | 89 + .../ui/src/custom/moving-icons/premium.svelte | 147 + .../ui/src/custom/moving-icons/preview.svelte | 81 + .../ui/src/custom/moving-icons/review.svelte | 71 + .../src/custom/moving-icons/settings.svelte | 65 + .../ui/src/custom/moving-icons/setup.svelte | 136 + .../src/custom/moving-icons/submission.svelte | 89 + .../ui/src/custom/moving-icons/upload.svelte | 65 + .../ui/src/custom/password/password.svelte | 2 +- packages/ui/src/custom/pricing-card/index.ts | 6 + .../custom/pricing-card/pricing-card.svelte | 108 + packages/ui/src/custom/pricing-card/types.ts | 20 + .../ui/src/custom/pricing-toggle/index.ts | 4 + .../pricing-toggle/pricing-toggle.svelte | 59 + .../src/custom/radio-item/radio-item.svelte | 14 +- .../ui/src/custom/radio-option-card/index.ts | 6 + .../radio-option-card-group.svelte | 41 + .../radio-option-card.svelte | 43 + packages/ui/src/custom/search/search.svelte | 2 +- .../simple-logo-nav/simple-logo-nav.svelte | 4 +- .../textarea-field/textarea-field.svelte | 2 +- .../underline-tabs-trigger.svelte | 4 +- .../ui/src/custom/youtube-link-form/index.ts | 5 + .../youtube-link-form.svelte | 89 + .../youtube-link-form/youtube-link-utils.ts | 72 + packages/ui/src/index.css | 47 + packages/ui/src/index.ts | 9 + packages/ui/src/output.css | 4 +- packages/ui/src/use-clipboard.svelte.ts | 99 + packages/ui/src/utils.ts | 13 + packages/utils/package.json | 47 +- packages/utils/src/constants/content.ts | 5 + packages/utils/src/constants/index.ts | 1 + packages/utils/src/functions/array.ts | 12 + .../utils/src/functions/fileValidation.ts | 2 +- packages/utils/src/functions/index.ts | 1 + packages/utils/src/index.ts | 3 +- packages/utils/src/license/constants.ts | 18 + packages/utils/src/license/index.ts | 1 + .../utils/src/validation/account/index.ts | 1 + .../utils/src/validation/account/profile.ts | 7 + .../utils/src/validation/assets/assets.ts | 121 + packages/utils/src/validation/assets/index.ts | 1 + .../src/validation/attendance/attendance.ts | 15 + .../utils/src/validation/attendance/index.ts | 1 + packages/utils/src/validation/constants.ts | 8 + .../utils/src/validation/course/course.ts | 163 + packages/utils/src/validation/course/index.ts | 3 + .../utils/src/validation/course/invite.ts | 53 + .../utils/src/validation/course/people.ts | 28 + .../utils/src/validation/course/section.ts | 37 + .../utils/src/validation/exercise/exercise.ts | 177 + .../utils/src/validation/exercise/index.ts | 1 + packages/utils/src/validation/index.ts | 7 + packages/utils/src/validation/lesson/index.ts | 2 + .../utils/src/validation/lesson/language.ts | 29 + .../utils/src/validation/lesson/lesson.ts | 126 + .../utils/src/validation/newsfeed/index.ts | 1 + .../utils/src/validation/newsfeed/newsfeed.ts | 53 + .../src/validation/organization/exercise.ts | 7 + .../src/validation/organization/index.ts | 5 + .../src/validation/organization/invite.ts | 7 + .../validation/organization/organization.ts | 52 +- .../utils/src/validation/organization/quiz.ts | 32 + .../utils/src/validation/organization/sso.ts | 58 + .../src/validation/organization/token-auth.ts | 19 + .../utils/src/validation/submission/index.ts | 1 + .../src/validation/submission/submission.ts | 44 + packages/utils/src/validation/tag/index.ts | 1 + packages/utils/src/validation/tag/tag.ts | 69 + packages/utils/tsconfig.json | 6 +- pnpm-lock.yaml | 2455 +- prd/ai-course-assistant/README.md | 280 + .../implementation-plan.md | 66 + prd/classes/README.md | 993 + prd/compliance-training-platform/README.md | 979 + prd/course-cohorts/README.md | 814 + prd/course-import/implementation-plan.md | 239 + prd/course-templates/README.md | 554 + prd/course-widget-embed/README.md | 684 + .../implementation-plan.md | 172 + prd/email-template-customization/README.md | 343 + prd/enterprise-sso [DONE]/README.md | 424 + prd/exercise-question-types [DONE]/README.md | 434 + .../implementation-plan.md | 245 + prd/exercise-sections/README.md | 1135 + prd/exercise-sections/implementation-plan.md | 1340 + prd/lesson-edit [DONE]/README.md | 37 + prd/lessons-list [DONE]/README.md | 154 + prd/media-manager [DONE]/README.md | 308 + .../backfill-lesson-videos-to-assets.md | 143 + prd/media-manager [DONE]/decoupling.md | 247 + prd/notification-system/README.md | 1659 + prd/onboarding-ai-bootstrap/README.md | 294 + prd/pricing-improvements/README.md | 114 + prd/public-api/README.md | 365 + prd/public-courses/README.md | 488 + prd/public-courses/implementation-plan.md | 308 + prd/secure-student-invites [DONE]/README.md | 141 + prd/selective-exercise-assignment/README.md | 481 + .../implementation-plan.md | 115 + prd/self-host-single-org.md | 42 + prd/tags-feature [DONE]/README.md | 464 + .../implementation-plan.md | 149 + prd/tags-feature [DONE]/ui-format-spec.md | 293 + prd/webhooks/README.md | 447 + run-docker-full-stack.sh | 221 + supabase/.gitignore | 3 - supabase/config.toml | 138 - supabase/data.sql | 35884 ---------------- supabase/functions/.vscode/extensions.json | 3 - supabase/functions/.vscode/settings.json | 5 - supabase/functions/_shared/cors.ts | 4 - supabase/functions/_shared/supabaseAdmin.ts | 0 supabase/functions/_shared/supabaseClient.ts | 0 supabase/functions/grades-tmp/index.ts | 57 - supabase/functions/import_map.json | 5 - supabase/functions/notify/index.ts | 20 - .../20231115082347_remote_schema.sql | 1285 - .../migrations/20231118210545_profile.sql | 1 - .../20231207061507_update_get_courses.sql | 22 - .../20231213104402_course_metadata.sql | 1 - ...1_organizationmember_author_profile_id.sql | 11 - .../20240212090837_course_newsfeed.sql | 48 - ...855_add_courseid_to_community_question.sql | 20 - .../20240320081809_email_verification.sql | 6 - .../migrations/20240323154037_translation.sql | 13 - .../migrations/20240406215136_org_billing.sql | 26 - .../migrations/20240509080635_ai_grading.sql | 1 - .../20240516092053_customization.sql | 1 - .../migrations/20240516153823_course_type.sql | 37 - .../20240518054308_new_certificates.sql | 31 - .../20240524192915_get_courses_for_admin.sql | 31 - .../20240528050512_cascade_delete_lesson.sql | 47 - ...40610132742_lesson_language_versioning.sql | 25 - .../20240618210526_lesson_versions.sql | 7 - .../20240701085652_show_explore_courses.sql | 17 - supabase/migrations/20240717053936_rls.sql | 1321 - .../20240718200323_rls_auth_fix.sql | 8 - .../20240720100738_fix_autosave_lesson.sql | 59 - .../20240811123634_lesson_section.sql | 145 - .../20240824193502_restrict_accounts.sql | 27 - .../20240904050707_custom_domain.sql | 11 - ...907090948_open_orgplan_for_landingpage.sql | 8 - .../20241016235827_student_analytics.sql | 125 - ...1203000001_email_verification_security.sql | 281 - ...241223012712_validate_mark_as_complete.sql | 18 - .../20241231154543_dashboard_stats.sql | 80 - .../migrations/20250106020355_polar_setup.sql | 7 - .../20250808030821_rls_secure_courses.sql | 390 - ...20250808035209_support_polish_language.sql | 27 - .../migrations/20250909000000_lesson_pdf.sql | 4 - .../20250917235052_course_analytics.sql | 34 - .../20251013120000_danish_support.sql | 27 - .../20251205045311_modify_profile_policy.sql | 10 - supabase/seed.sql | 472 - 1196 files changed, 118809 insertions(+), 74705 deletions(-) create mode 100644 .cursor/plans/ai_assistant_prd_5a6f948c.plan.md create mode 100644 .cursor/plans/course_enroll_route_migration_883d0aae.plan.md create mode 100644 .cursor/plans/course_page_ui_storybook.plan.md create mode 100644 .cursor/plans/course_page_ui_storybook_6c7b16fd.plan.md create mode 100644 .cursor/plans/global_presigned_url_strategy_ac12cdab.plan.md create mode 100644 .cursor/plans/jwt_token_exchange_82c2a3ee.plan.md create mode 100644 .cursor/plans/unify_renderer_answer_data_format.plan.md create mode 100644 .slim.yaml create mode 100644 AGENTS.md create mode 100644 PRD-TRACKER.md delete mode 100644 ai/prompts/lms-context.md create mode 100644 apps/api/src/config/storage.ts create mode 100644 apps/api/src/middlewares/course-team-member.ts create mode 100644 apps/api/src/middlewares/license.ts create mode 100644 apps/api/src/middlewares/newsfeed-comment-author-or-team.ts create mode 100644 apps/api/src/middlewares/signup-guard.ts create mode 100644 apps/api/src/routes/course/attendance.ts create mode 100644 apps/api/src/routes/course/content.ts create mode 100644 apps/api/src/routes/course/exercise.ts create mode 100644 apps/api/src/routes/course/invite.ts create mode 100644 apps/api/src/routes/course/lesson-language.ts create mode 100644 apps/api/src/routes/course/mark.ts create mode 100644 apps/api/src/routes/course/newsfeed.ts create mode 100644 apps/api/src/routes/course/payment-request.ts create mode 100644 apps/api/src/routes/course/people.ts create mode 100644 apps/api/src/routes/course/section.ts create mode 100644 apps/api/src/routes/course/submission.ts create mode 100644 apps/api/src/routes/domain/domain.ts delete mode 100644 apps/api/src/routes/exercise/index.ts delete mode 100644 apps/api/src/routes/exercise/template.ts create mode 100644 apps/api/src/routes/invite/index.ts create mode 100644 apps/api/src/routes/invite/invite.ts create mode 100644 apps/api/src/routes/license.ts create mode 100644 apps/api/src/routes/organization/assets.ts create mode 100644 apps/api/src/routes/organization/quiz.ts create mode 100644 apps/api/src/routes/organization/sso.ts create mode 100644 apps/api/src/routes/organization/tags.ts create mode 100644 apps/api/src/routes/organization/token-auth.ts create mode 100644 apps/api/src/routes/sso/discovery.ts create mode 100644 apps/api/src/routes/unsplash/unsplash.ts create mode 100644 apps/api/src/services/assets/assets.ts create mode 100644 apps/api/src/services/assets/index.ts create mode 100644 apps/api/src/services/attendance/attendance.ts create mode 100644 apps/api/src/services/attendance/index.ts create mode 100644 apps/api/src/services/auth/sso.ts create mode 100644 apps/api/src/services/course/content.ts create mode 100644 apps/api/src/services/course/course.ts create mode 100644 apps/api/src/services/course/invite.ts create mode 100644 apps/api/src/services/course/payment-request.ts create mode 100644 apps/api/src/services/course/people.ts create mode 100644 apps/api/src/services/course/section.ts create mode 100644 apps/api/src/services/course/utils.ts create mode 100644 apps/api/src/services/exercise/exercise.ts create mode 100644 apps/api/src/services/exercise/index.ts create mode 100644 apps/api/src/services/exercise/utils.ts create mode 100644 apps/api/src/services/lesson-language.ts create mode 100644 apps/api/src/services/lesson/index.ts create mode 100644 apps/api/src/services/lesson/lesson.ts create mode 100644 apps/api/src/services/license.ts create mode 100644 apps/api/src/services/mark/gradebook.ts create mode 100644 apps/api/src/services/mark/index.ts create mode 100644 apps/api/src/services/mark/mark.ts create mode 100644 apps/api/src/services/newsfeed/index.ts create mode 100644 apps/api/src/services/newsfeed/newsfeed.ts create mode 100644 apps/api/src/services/org/domain.ts create mode 100644 apps/api/src/services/organization/invite.ts create mode 100644 apps/api/src/services/organization/sso.ts create mode 100644 apps/api/src/services/organization/token-auth.ts create mode 100644 apps/api/src/services/quiz.ts create mode 100644 apps/api/src/services/submission/index.ts create mode 100644 apps/api/src/services/submission/submission.ts create mode 100644 apps/api/src/services/tag.ts create mode 100644 apps/api/src/utils/lesson-media.ts create mode 100644 apps/api/src/utils/presigned-urls.ts create mode 100644 apps/course-app/static/plyr.svg create mode 100644 apps/dashboard/.prettierrc delete mode 100644 apps/dashboard/ai/BREADCRUMBS_REFACTOR.md delete mode 100644 apps/dashboard/scripts/play.js delete mode 100644 apps/dashboard/src/lib/components/Course/components/Analytics/AnalyticsGraph.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Ceritficate/StudentCertificate/Index.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/ActivateSectionsModal.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/EditMode.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/Preview.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/Submissions/Individual.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/Submissions/Summary.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/Submissions/functions.ts delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/Submissions/index.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/ViewMode.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/functions.ts delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/index.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Exercises/NewExerciseModal.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Exercises/index.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/LessonList.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/LessonSectionList.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Document/AddDocumentToLesson.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Document/DocumentList.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Document/store.ts delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Video/AddVideoToLesson.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Video/Index.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Video/UploadVideo.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Video/YoutubeVideo.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/Comments.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/ComponentNote.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/ComponentSlide.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/ComponentVideo.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Materials/index.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/NewLessonModal.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/functions.ts delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/store.ts delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/store/answers.ts delete mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/store/lessons.ts delete mode 100644 apps/dashboard/src/lib/components/Course/components/NewsFeed/NewFeedModal.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/NewsFeed/NewsFeedCard.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/NewsFeed/store.ts delete mode 100644 apps/dashboard/src/lib/components/Course/components/People/InvitationModal.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/People/ShareQRImage.svelte delete mode 100644 apps/dashboard/src/lib/components/Course/components/People/store.ts delete mode 100644 apps/dashboard/src/lib/components/Course/components/People/types.ts delete mode 100644 apps/dashboard/src/lib/components/Course/constants.ts delete mode 100644 apps/dashboard/src/lib/components/Course/function.ts delete mode 100644 apps/dashboard/src/lib/components/Course/store.ts delete mode 100644 apps/dashboard/src/lib/components/Course/types.ts delete mode 100644 apps/dashboard/src/lib/components/CourseContainer/index.svelte delete mode 100644 apps/dashboard/src/lib/components/Question/CheckboxQuestion/index.svelte delete mode 100644 apps/dashboard/src/lib/components/Question/RadioQuestion/index.svelte delete mode 100644 apps/dashboard/src/lib/components/Question/TextareaQuestion/index.svelte delete mode 100644 apps/dashboard/src/lib/components/Question/constants.ts delete mode 100644 apps/dashboard/src/lib/config.ts create mode 100644 apps/dashboard/src/lib/features/course/api/attendance.svelte.ts create mode 100644 apps/dashboard/src/lib/features/course/api/content.svelte.ts create mode 100644 apps/dashboard/src/lib/features/course/api/course.svelte.ts create mode 100644 apps/dashboard/src/lib/features/course/api/courses.svelte.ts create mode 100644 apps/dashboard/src/lib/features/course/api/exercise-template.svelte.ts create mode 100644 apps/dashboard/src/lib/features/course/api/exercise.svelte.ts create mode 100644 apps/dashboard/src/lib/features/course/api/lesson.svelte.ts create mode 100644 apps/dashboard/src/lib/features/course/api/mark.svelte.ts create mode 100644 apps/dashboard/src/lib/features/course/api/newsfeed.svelte.ts create mode 100644 apps/dashboard/src/lib/features/course/api/people.svelte.ts create mode 100644 apps/dashboard/src/lib/features/course/api/presign.svelte.ts create mode 100644 apps/dashboard/src/lib/features/course/api/submission.svelte.ts rename apps/dashboard/src/lib/{components/Course/components/Analytics/AnalyticsCard.svelte => features/course/components/analytics/analytics-card.svelte} (91%) create mode 100644 apps/dashboard/src/lib/features/course/components/analytics/analytics-graph.svelte rename apps/dashboard/src/lib/{components/Course/components/Analytics/AnalyticsSkeleton.svelte => features/course/components/analytics/analytics-skeleton.svelte} (96%) rename apps/dashboard/src/lib/{components/Course/components/Analytics/EmptyState.svelte => features/course/components/analytics/empty-state.svelte} (93%) rename apps/dashboard/src/lib/{components/Course/components/Analytics/icons/ExercisesIcon.svelte => features/course/components/analytics/icons/exercises-icon.svelte} (100%) rename apps/dashboard/src/lib/{components/Course/components/Analytics/icons/LessonsIcon.svelte => features/course/components/analytics/icons/lessons-icon.svelte} (100%) rename apps/dashboard/src/lib/{components/Course/components/Analytics/icons/ProgressIcon.svelte => features/course/components/analytics/icons/progress-icon.svelte} (100%) rename apps/dashboard/src/lib/{components/Course/components/Analytics/icons/StudentsIcon.svelte => features/course/components/analytics/icons/students-icon.svelte} (100%) rename apps/dashboard/src/lib/{components/Course/components/Analytics/StudentTable.svelte => features/course/components/analytics/student-table.svelte} (77%) rename apps/dashboard/src/lib/{components/Course/components/Ceritficate/Design.svelte => features/course/components/ceritficate/design.svelte} (70%) rename apps/dashboard/src/lib/{components/Course/components/Ceritficate/IssueCertificateModal.svelte => features/course/components/ceritficate/issue-certificate-modal.svelte} (100%) rename apps/dashboard/src/lib/{components/Course/components/Ceritficate => features/course/components/ceritficate}/mockData.ts (100%) rename apps/dashboard/src/lib/{components/Course/components/Ceritficate/Reports.svelte => features/course/components/ceritficate/reports.svelte} (92%) rename apps/dashboard/src/lib/{components/Course/components/Ceritficate => features/course/components/ceritficate}/store.ts (100%) create mode 100644 apps/dashboard/src/lib/features/course/components/ceritficate/student-certificate/index.svelte rename apps/dashboard/src/lib/{components/Course/components/Ceritficate/StudentCertificate/LockedCertificate.svelte => features/course/components/ceritficate/student-certificate/locked-certificate.svelte} (100%) rename apps/dashboard/src/lib/{components/Course/components/Ceritficate/StudentCertificate/UnlockedCertificate.svelte => features/course/components/ceritficate/student-certificate/unlocked-certificate.svelte} (76%) rename apps/dashboard/src/lib/{components/Course/components/Ceritficate/templates/BlueBadgePattern.svelte => features/course/components/ceritficate/templates/blue-badge-pattern.svelte} (85%) rename apps/dashboard/src/lib/{components/Course/components/Ceritficate/templates/BlueProfessionalBadge.svelte => features/course/components/ceritficate/templates/blue-professional-badge.svelte} (83%) rename apps/dashboard/src/lib/{components/Course/components/Ceritficate/templates/Plain.svelte => features/course/components/ceritficate/templates/plain.svelte} (75%) rename apps/dashboard/src/lib/{components/Course/components/Ceritficate/templates/Professional.svelte => features/course/components/ceritficate/templates/professional.svelte} (80%) rename apps/dashboard/src/lib/{components/Course/components/Ceritficate/templates/PurpleBadgePattern.svelte => features/course/components/ceritficate/templates/purple-badge-pattern.svelte} (87%) rename apps/dashboard/src/lib/{components/Course/components/Ceritficate/templates/PurpleProfessionalBadge.svelte => features/course/components/ceritficate/templates/purple-professional-badge.svelte} (83%) create mode 100644 apps/dashboard/src/lib/features/course/components/content-count-badges.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/content/constants.ts create mode 100644 apps/dashboard/src/lib/features/course/components/content/content-create-modal.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/content/exercise-create-stepper.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/content/lesson-create-stepper.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/content/section-create-stepper.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/content/store.ts create mode 100644 apps/dashboard/src/lib/features/course/components/content/types.ts rename apps/dashboard/src/lib/{components/CourseContainer/index.ts => features/course/components/course-container.ts} (100%) create mode 100644 apps/dashboard/src/lib/features/course/components/course-content-icon.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/course-filter-popover.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/course-tag-picker.svelte rename apps/dashboard/src/lib/{components/Course/components/Lesson/Exercise => features/course/components/exercise}/constants.ts (83%) rename apps/dashboard/src/lib/{components/Course/components/Lesson/Exercise/DeleteConfirmation.svelte => features/course/components/exercise/delete-confirmation.svelte} (95%) create mode 100644 apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/exercise/functions.ts rename apps/dashboard/src/lib/{components/Course/components/Lesson/Exercise/GradingLoader.svelte => features/course/components/exercise/grading-loader.svelte} (100%) rename apps/dashboard/src/lib/{components/Course/components/Lesson/Exercise/MarkExerciseModal.svelte => features/course/components/exercise/mark-exercise-modal.svelte} (53%) rename apps/dashboard/src/lib/{components/Course/components/Lesson/Exercise/OrderModal.svelte => features/course/components/exercise/order-modal.svelte} (92%) create mode 100644 apps/dashboard/src/lib/features/course/components/exercise/order-utils.ts create mode 100644 apps/dashboard/src/lib/features/course/components/exercise/preview.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/exercise/question-labels.ts create mode 100644 apps/dashboard/src/lib/features/course/components/exercise/question-type-utils.ts rename apps/dashboard/src/lib/{components/Course/components/Lesson/store/exercise.ts => features/course/components/exercise/store.ts} (55%) create mode 100644 apps/dashboard/src/lib/features/course/components/exercise/submissions/individual.svelte rename apps/dashboard/src/lib/{components/Course/components/Lesson/Exercise/Submissions => features/course/components/exercise/submissions}/store.ts (63%) create mode 100644 apps/dashboard/src/lib/features/course/components/exercise/submissions/submissions.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/exercise/submissions/summary.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/exercise/submissions/types.ts rename apps/dashboard/src/lib/{components/Course/components/Lesson/Exercise/UpdateDescription.svelte => features/course/components/exercise/update-description.svelte} (70%) create mode 100644 apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/ai-button.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/comments.svelte rename apps/dashboard/src/lib/{components/Course/components/Lesson/Materials => features/course/components/lesson}/constants.ts (89%) create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/content-action-helpers.ts create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/content-actions.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/content-list.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/content-navigation-actions.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/content-row.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/content-section-list.svelte rename apps/dashboard/src/lib/{components/Course/components/Lesson/DeleteLessonConfirmation.svelte => features/course/components/lesson/delete-lesson-confirmation.svelte} (96%) create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/document/add-document-modal.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/document/document-card-dropdown.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/document/document-card.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/document/document-list.svelte rename apps/dashboard/src/lib/{components/Course/components/Lesson/Materials/components/ComponentDocument.svelte => features/course/components/lesson/document/document.svelte} (88%) create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/index.ts create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/language-selector.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/lesson-page-edit-header.svelte rename apps/dashboard/src/lib/{components/Course/components/Lesson/LessonVersionHistory.svelte => features/course/components/lesson/lesson-version-history.svelte} (83%) rename apps/dashboard/src/lib/{components/Course/components/Lesson/Materials/Loader.svelte => features/course/components/lesson/loader.svelte} (100%) create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/note/note.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/section-header.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/slide/slide.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/store.ts create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/utils.ts create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/video/add-video-modal.svelte rename apps/dashboard/src/lib/{components/Course/components/Lesson/Materials/Video => features/course/components/lesson/video}/constants.ts (76%) rename apps/dashboard/src/lib/{components/Course/components/Lesson/Materials/Video/EmbedLink.svelte => features/course/components/lesson/video/embed-link.svelte} (58%) create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/video/library-video.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/video/media-uploader.ts create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/video/upload-video.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/video/video-card-dropdown.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/video/video-card-utils.ts create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/video/video-card.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/video/video-thumbnail-utils.ts create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/video/video.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/video/youtube-video.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/newsfeed/card/card.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/newsfeed/card/comments.svelte rename apps/dashboard/src/lib/{components/Course/components/NewsFeed/DeleteFeedConfirmation.svelte => features/course/components/newsfeed/card/delete-confirmation.svelte} (100%) create mode 100644 apps/dashboard/src/lib/features/course/components/newsfeed/card/header.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/newsfeed/card/reactions.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/newsfeed/index.ts create mode 100644 apps/dashboard/src/lib/features/course/components/newsfeed/new-feed-modal.svelte rename apps/dashboard/src/lib/{components/Course/components/NewsFeed/NewsFeedLoader.svelte => features/course/components/newsfeed/news-feed-loader.svelte} (100%) create mode 100644 apps/dashboard/src/lib/features/course/components/people/bulk-email-section.svelte rename apps/dashboard/src/lib/{components/Course/components/People/DeleteConfirmation.svelte => features/course/components/people/delete-confirmation.svelte} (100%) create mode 100644 apps/dashboard/src/lib/features/course/components/people/invitation-modal.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/people/invite-list-section.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/people/invite-settings-section.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/people/invite-utils.ts create mode 100644 apps/dashboard/src/lib/features/course/components/people/share-qr-image.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/people/store.ts create mode 100644 apps/dashboard/src/lib/features/course/components/people/tutor-select-section.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/people/types.ts rename apps/dashboard/src/lib/{components/QuestionContainer/index.svelte => features/course/components/question-container.svelte} (86%) create mode 100644 apps/dashboard/src/lib/features/course/components/sidebar/course-content-sidebar-navigation.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/sidebar/course-content-tree.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/sidebar/sidebar-history.ts create mode 100644 apps/dashboard/src/lib/features/course/components/truncated-with-tooltip.svelte create mode 100644 apps/dashboard/src/lib/features/course/pages/exercise.svelte create mode 100644 apps/dashboard/src/lib/features/course/pages/lesson.svelte create mode 100644 apps/dashboard/src/lib/features/course/store.ts create mode 100644 apps/dashboard/src/lib/features/course/types.ts create mode 100644 apps/dashboard/src/lib/features/course/utils/content.ts create mode 100644 apps/dashboard/src/lib/features/course/utils/marks-utils.ts create mode 100644 apps/dashboard/src/lib/features/course/utils/sidebar-routes.ts create mode 100644 apps/dashboard/src/lib/features/course/utils/submissions-utils.ts create mode 100644 apps/dashboard/src/lib/features/course/utils/types.ts delete mode 100644 apps/dashboard/src/lib/features/exercise-template/api/index.ts delete mode 100644 apps/dashboard/src/lib/features/exercise-template/api/template.svelte.ts delete mode 100644 apps/dashboard/src/lib/features/exercise-template/utils/constants.ts delete mode 100644 apps/dashboard/src/lib/features/exercise-template/utils/types.ts create mode 100644 apps/dashboard/src/lib/features/license/api/license.svelte.ts create mode 100644 apps/dashboard/src/lib/features/lms/api/exercises.server.ts create mode 100644 apps/dashboard/src/lib/features/lms/api/exercises.svelte.ts create mode 100644 apps/dashboard/src/lib/features/media/api/index.ts create mode 100644 apps/dashboard/src/lib/features/media/api/media-manager.svelte.ts create mode 100644 apps/dashboard/src/lib/features/media/components/asset-card.svelte create mode 100644 apps/dashboard/src/lib/features/media/components/asset-usage-dialog.svelte create mode 100644 apps/dashboard/src/lib/features/media/components/edit-asset-dialog.svelte create mode 100644 apps/dashboard/src/lib/features/media/components/index.ts create mode 100644 apps/dashboard/src/lib/features/media/components/media-filters.svelte create mode 100644 apps/dashboard/src/lib/features/media/components/storage-cards.svelte create mode 100644 apps/dashboard/src/lib/features/media/index.ts create mode 100644 apps/dashboard/src/lib/features/media/pages/index.ts create mode 100644 apps/dashboard/src/lib/features/media/pages/media.svelte create mode 100644 apps/dashboard/src/lib/features/media/utils/constants.ts create mode 100644 apps/dashboard/src/lib/features/media/utils/index.ts create mode 100644 apps/dashboard/src/lib/features/media/utils/media-manager-utils.ts create mode 100644 apps/dashboard/src/lib/features/media/utils/types.ts create mode 100644 apps/dashboard/src/lib/features/org/api/quiz.svelte.ts create mode 100644 apps/dashboard/src/lib/features/org/api/sso.svelte.ts create mode 100644 apps/dashboard/src/lib/features/org/api/token-auth.svelte.ts delete mode 100644 apps/dashboard/src/lib/features/org/api/types.ts create mode 100644 apps/dashboard/src/lib/features/org/store/sso.svelte.ts create mode 100644 apps/dashboard/src/lib/features/org/utils/sso.ts create mode 100644 apps/dashboard/src/lib/features/settings/components/auth-general.svelte create mode 100644 apps/dashboard/src/lib/features/settings/components/auth-sso.svelte create mode 100644 apps/dashboard/src/lib/features/settings/components/auth-token-auth.svelte create mode 100644 apps/dashboard/src/lib/features/settings/pages/auth.svelte create mode 100644 apps/dashboard/src/lib/features/settings/pages/sso.svelte create mode 100644 apps/dashboard/src/lib/features/settings/pages/token-auth.svelte create mode 100644 apps/dashboard/src/lib/features/tag/api/index.ts create mode 100644 apps/dashboard/src/lib/features/tag/api/tag.svelte.ts create mode 100644 apps/dashboard/src/lib/features/tag/index.ts create mode 100644 apps/dashboard/src/lib/features/tag/pages/index.ts create mode 100644 apps/dashboard/src/lib/features/tag/pages/tags-admin.svelte create mode 100644 apps/dashboard/src/lib/features/tag/utils/index.ts create mode 100644 apps/dashboard/src/lib/features/tag/utils/types.ts delete mode 100644 apps/dashboard/src/lib/features/ui/course-landing-page/components/IntersectionObserver.ts rename apps/dashboard/src/lib/{components/HTMLRender/HTMLRender.svelte => features/ui/html-render.svelte} (100%) delete mode 100644 apps/dashboard/src/lib/features/ui/icons/error-icon.svelte create mode 100644 apps/dashboard/src/lib/features/ui/media-player/index.ts create mode 100644 apps/dashboard/src/lib/features/ui/media-player/media-player.svelte create mode 100644 apps/dashboard/src/lib/features/ui/media-player/players/constants.ts create mode 100644 apps/dashboard/src/lib/features/ui/media-player/players/muse-player.svelte create mode 100644 apps/dashboard/src/lib/features/ui/media-player/players/plyr-player.svelte create mode 100644 apps/dashboard/src/lib/features/ui/media-player/types.ts create mode 100644 apps/dashboard/src/lib/features/ui/media-player/utils.ts create mode 100644 apps/dashboard/src/lib/features/ui/question/constants.ts rename apps/dashboard/src/lib/{components/Question/Grade.svelte => features/ui/question/grade.svelte} (82%) create mode 100644 apps/dashboard/src/lib/features/ui/question/index.ts rename apps/dashboard/src/lib/{components/Question/QuestionTitle.svelte => features/ui/question/question-title.svelte} (66%) rename apps/dashboard/src/lib/{components/Question/ReasonBox.svelte => features/ui/question/reason-box.svelte} (100%) create mode 100644 apps/dashboard/src/lib/utils/functions/metaTags.ts delete mode 100644 apps/dashboard/src/lib/utils/functions/permissions.ts delete mode 100644 apps/dashboard/src/lib/utils/functions/supabase.server.ts delete mode 100644 apps/dashboard/src/lib/utils/functions/supabase.ts delete mode 100644 apps/dashboard/src/lib/utils/services/attendance/index.ts create mode 100644 apps/dashboard/src/lib/utils/services/auth/server.ts delete mode 100644 apps/dashboard/src/lib/utils/services/courses/index.ts delete mode 100644 apps/dashboard/src/lib/utils/services/courses/mockdata.ts delete mode 100644 apps/dashboard/src/lib/utils/services/dashboard/index.ts delete mode 100644 apps/dashboard/src/lib/utils/services/lms/exercises.ts delete mode 100644 apps/dashboard/src/lib/utils/services/marks/index.ts delete mode 100644 apps/dashboard/src/lib/utils/services/middlewares/authentication.server.ts delete mode 100644 apps/dashboard/src/lib/utils/services/middlewares/index.ts delete mode 100644 apps/dashboard/src/lib/utils/services/newsfeed/index.ts delete mode 100644 apps/dashboard/src/lib/utils/services/notification/notification.ts delete mode 100644 apps/dashboard/src/lib/utils/services/submissions/index.ts delete mode 100644 apps/dashboard/src/lib/utils/store/attendance.ts delete mode 100644 apps/dashboard/src/lib/utils/types/config.ts delete mode 100644 apps/dashboard/src/lib/utils/types/dashboard.ts delete mode 100644 apps/dashboard/src/lib/utils/types/feed.ts delete mode 100644 apps/dashboard/src/lib/utils/types/index.ts delete mode 100644 apps/dashboard/src/lib/utils/types/submission.ts delete mode 100644 apps/dashboard/src/routes/api/admin/cleanup-tokens/+server.ts delete mode 100644 apps/dashboard/src/routes/api/admin/security-monitor/+server.ts delete mode 100644 apps/dashboard/src/routes/api/analytics/dash/+server.ts delete mode 100644 apps/dashboard/src/routes/api/analytics/user/+server.ts delete mode 100644 apps/dashboard/src/routes/api/completion/+server.ts delete mode 100644 apps/dashboard/src/routes/api/completion/customprompt/+server.ts delete mode 100644 apps/dashboard/src/routes/api/completion/exerciseprompt/+server.ts delete mode 100644 apps/dashboard/src/routes/api/completion/gradingprompt/+server.ts delete mode 100644 apps/dashboard/src/routes/api/courses/analytics/+server.ts delete mode 100644 apps/dashboard/src/routes/api/courses/data/+server.ts delete mode 100644 apps/dashboard/src/routes/api/courses/exercises/+server.ts delete mode 100644 apps/dashboard/src/routes/api/courses/marks/+server.ts delete mode 100644 apps/dashboard/src/routes/api/courses/newsfeed/+server.ts delete mode 100644 apps/dashboard/src/routes/api/courses/submission/+server.ts delete mode 100644 apps/dashboard/src/routes/api/courses/submissions/+server.ts delete mode 100644 apps/dashboard/src/routes/api/domain/+server.ts delete mode 100644 apps/dashboard/src/routes/api/email/course/exercise_submission_update/+server.ts delete mode 100644 apps/dashboard/src/routes/api/email/course/newsfeed/+server.ts delete mode 100644 apps/dashboard/src/routes/api/email/course/student_prove_payment/+server.ts delete mode 100644 apps/dashboard/src/routes/api/email/course/student_welcome/+server.ts delete mode 100644 apps/dashboard/src/routes/api/email/course/submission_update/+server.ts delete mode 100644 apps/dashboard/src/routes/api/email/course/teacher_student_buycourse/+server.ts delete mode 100644 apps/dashboard/src/routes/api/email/course/teacher_student_joined/+server.ts delete mode 100644 apps/dashboard/src/routes/api/email/course/teacher_welcome/+server.ts delete mode 100644 apps/dashboard/src/routes/api/email/invite/+server.ts delete mode 100644 apps/dashboard/src/routes/api/org/audience/+server.ts delete mode 100644 apps/dashboard/src/routes/api/org/team/+server.ts delete mode 100644 apps/dashboard/src/routes/api/unsplash/+server.ts create mode 100644 apps/dashboard/src/routes/course/[slug]/enroll/+page.server.ts create mode 100644 apps/dashboard/src/routes/course/[slug]/enroll/+page.svelte create mode 100644 apps/dashboard/src/routes/courses/+page.server.ts create mode 100644 apps/dashboard/src/routes/courses/+page.svelte create mode 100644 apps/dashboard/src/routes/courses/[id]/+layout.server.ts create mode 100644 apps/dashboard/src/routes/courses/[id]/analytics/+page.server.ts delete mode 100644 apps/dashboard/src/routes/courses/[id]/analytics/+page.ts create mode 100644 apps/dashboard/src/routes/courses/[id]/exercises/[exerciseId]/+page.server.ts create mode 100644 apps/dashboard/src/routes/courses/[id]/exercises/[exerciseId]/+page.svelte delete mode 100644 apps/dashboard/src/routes/courses/[id]/lessons/[...lessonParams]/+page.server.ts delete mode 100644 apps/dashboard/src/routes/courses/[id]/lessons/[...lessonParams]/+page.svelte create mode 100644 apps/dashboard/src/routes/courses/[id]/lessons/[lessonId]/+page.server.ts create mode 100644 apps/dashboard/src/routes/courses/[id]/lessons/[lessonId]/+page.svelte create mode 100644 apps/dashboard/src/routes/courses/[id]/marks/+page.server.ts delete mode 100644 apps/dashboard/src/routes/courses/[id]/marks/+page.ts create mode 100644 apps/dashboard/src/routes/courses/[id]/people/[personId]/+page.server.ts delete mode 100644 apps/dashboard/src/routes/courses/[id]/people/[personId]/+page.ts create mode 100644 apps/dashboard/src/routes/courses/[id]/submissions/+page.server.ts delete mode 100644 apps/dashboard/src/routes/courses/[id]/submissions/+page.ts delete mode 100644 apps/dashboard/src/routes/invite/s/[hash]/+layout.server.ts delete mode 100644 apps/dashboard/src/routes/invite/s/[hash]/+page.svelte create mode 100644 apps/dashboard/src/routes/org/[slug]/+page.server.ts delete mode 100644 apps/dashboard/src/routes/org/[slug]/+page.ts create mode 100644 apps/dashboard/src/routes/org/[slug]/audience/+page.server.ts create mode 100644 apps/dashboard/src/routes/org/[slug]/audience/[...params]/+page.server.ts delete mode 100644 apps/dashboard/src/routes/org/[slug]/audience/[...params]/+page.ts create mode 100644 apps/dashboard/src/routes/org/[slug]/community/+page.server.ts create mode 100644 apps/dashboard/src/routes/org/[slug]/community/[cslug]/+page.server.ts rename apps/dashboard/src/routes/org/[slug]/community/{[slug] => [cslug]}/+page.svelte (69%) delete mode 100644 apps/dashboard/src/routes/org/[slug]/community/[slug]/+page.ts create mode 100644 apps/dashboard/src/routes/org/[slug]/courses/+page.server.ts delete mode 100644 apps/dashboard/src/routes/org/[slug]/courses/+page.ts create mode 100644 apps/dashboard/src/routes/org/[slug]/media/+page.server.ts create mode 100644 apps/dashboard/src/routes/org/[slug]/media/+page.svelte create mode 100644 apps/dashboard/src/routes/org/[slug]/settings/auth/+page.svelte create mode 100644 apps/dashboard/src/routes/org/[slug]/settings/auth/sso/+page.svelte create mode 100644 apps/dashboard/src/routes/org/[slug]/settings/auth/token-auth/+page.svelte create mode 100644 apps/dashboard/src/routes/org/[slug]/setup/+page.server.ts delete mode 100644 apps/dashboard/src/routes/org/[slug]/setup/+page.ts create mode 100644 apps/dashboard/src/routes/org/[slug]/tags/+page.server.ts create mode 100644 apps/dashboard/src/routes/org/[slug]/tags/+page.svelte delete mode 100644 apps/dashboard/static/carbon-all.css create mode 100644 apps/dashboard/static/js/pdf.js/pdf.min.js create mode 100644 apps/dashboard/static/js/pdf.js/pdf.worker.min.js create mode 100644 apps/dashboard/static/plyr.svg create mode 100644 apps/docs/content/docs/enterprise-sso.mdx create mode 100644 apps/docs/content/docs/how-to-guides/use-math-in-editor.mdx create mode 100644 apps/docs/content/docs/tags.mdx create mode 100644 apps/website/REACT_GEMINI.md create mode 100644 docker/.env.prod.example create mode 100644 docker/docker-compose.prod.yaml rename docker/docs/{README.md => PUBLISHING_IMAGES.md} (80%) create mode 100644 docker/docs/SELF_HOST.md create mode 100644 packages/db/src/auth/hooks/auth-settings.ts create mode 100644 packages/db/src/auth/hooks/sso-provisioning.ts create mode 100644 packages/db/src/auth/plugins/token-exchange.ts create mode 100644 packages/db/src/auth/token-exchange.ts create mode 100644 packages/db/src/migrations/0003_fancy_harry_osborn.sql create mode 100644 packages/db/src/migrations/0004_medical_true_believers.sql create mode 100644 packages/db/src/migrations/0005_outgoing_doomsday.sql create mode 100644 packages/db/src/migrations/0006_modern_the_initiative.sql create mode 100644 packages/db/src/migrations/meta/0003_snapshot.json create mode 100644 packages/db/src/migrations/meta/0004_snapshot.json create mode 100644 packages/db/src/migrations/meta/0005_snapshot.json create mode 100644 packages/db/src/migrations/meta/0006_snapshot.json create mode 100644 packages/db/src/queries/analytics/analytics.ts create mode 100644 packages/db/src/queries/analytics/index.ts create mode 100644 packages/db/src/queries/assets/assets.ts create mode 100644 packages/db/src/queries/assets/index.ts create mode 100644 packages/db/src/queries/attendance/attendance.ts create mode 100644 packages/db/src/queries/attendance/index.ts delete mode 100644 packages/db/src/queries/course/.gitkeep create mode 100644 packages/db/src/queries/course/content.ts create mode 100644 packages/db/src/queries/course/invite.ts create mode 100644 packages/db/src/queries/course/people.ts create mode 100644 packages/db/src/queries/exercise/lms.ts create mode 100644 packages/db/src/queries/lesson/language.ts create mode 100644 packages/db/src/queries/mark/index.ts create mode 100644 packages/db/src/queries/mark/mark.ts create mode 100644 packages/db/src/queries/newsfeed/index.ts create mode 100644 packages/db/src/queries/newsfeed/newsfeed.ts create mode 100644 packages/db/src/queries/organization/invite.ts create mode 100644 packages/db/src/queries/organization/quiz.ts create mode 100644 packages/db/src/queries/organization/sso.ts create mode 100644 packages/db/src/queries/organization/token-auth.ts create mode 100644 packages/db/src/queries/submission/index.ts create mode 100644 packages/db/src/queries/submission/submission.ts create mode 100644 packages/db/src/queries/tag/index.ts create mode 100644 packages/db/src/queries/tag/tag.ts create mode 100644 packages/db/src/scripts/backfill-course-grouping.ts create mode 100644 packages/db/src/scripts/backfill-exercise-course.ts create mode 100644 packages/db/src/scripts/backfill-lesson-assets.ts create mode 100644 packages/db/src/scripts/db-reset.ts create mode 100644 packages/db/src/scripts/migrate-answer-data.ts create mode 100644 packages/db/src/scripts/sync-orphaned-profiles.ts create mode 100644 packages/db/src/utils/seed/courseSection.ts create mode 100644 packages/email/src/emails/newsfeed.ts create mode 100644 packages/email/src/emails/student-course-invite.ts create mode 100644 packages/email/src/emails/student-course-welcome.ts create mode 100644 packages/email/src/emails/student-prove-payment.ts create mode 100644 packages/email/src/emails/teacher-course-welcome.ts create mode 100644 packages/email/src/emails/teacher-student-buy-request.ts create mode 100644 packages/email/src/emails/teacher-student-joined.ts create mode 100644 packages/question-types/.gitignore create mode 100644 packages/question-types/ADDING_A_QUESTION_TYPE.md create mode 100644 packages/question-types/README.md create mode 100644 packages/question-types/package.json create mode 100644 packages/question-types/src/answer-codecs.ts create mode 100644 packages/question-types/src/answer-data.ts create mode 100644 packages/question-types/src/exercise-types.ts create mode 100644 packages/question-types/src/form-state.ts create mode 100644 packages/question-types/src/index.ts create mode 100644 packages/question-types/src/question-answer-payload.ts create mode 100644 packages/question-types/src/question-scoring-rules.ts create mode 100644 packages/question-types/src/question-type-capabilities.ts create mode 100644 packages/question-types/src/question-type-keys.ts create mode 100644 packages/question-types/src/question-type-lookup.ts create mode 100644 packages/question-types/src/question-type-registry.ts create mode 100644 packages/question-types/src/question-type-types.ts create mode 100644 packages/question-types/src/render-contract.ts create mode 100644 packages/question-types/src/renderer-registry.ts create mode 100644 packages/question-types/tests/form-and-render-contract.test.ts create mode 100644 packages/question-types/tests/question-type-registry.test.ts create mode 100644 packages/question-types/tests/renderer-registry.test.ts create mode 100644 packages/question-types/tsconfig.json create mode 100644 packages/question-types/vitest.config.ts create mode 100644 packages/storybook/.prettierignore delete mode 100644 packages/storybook/HOW_TO_CREATE_STORIES.md create mode 100644 packages/storybook/src/atoms/alert/alert.stories.svelte create mode 100644 packages/storybook/src/atoms/toggle-group/toggle-group.stories.svelte create mode 100644 packages/storybook/src/molecules/code/code.stories.svelte create mode 100644 packages/storybook/src/molecules/code/fields.ts create mode 100644 packages/storybook/src/molecules/document-card/document-card.stories.svelte create mode 100644 packages/storybook/src/molecules/exercise-question/exercise-question.stories.svelte create mode 100644 packages/storybook/src/molecules/exercise-question/question-fixtures.ts create mode 100644 packages/storybook/src/molecules/exercise-question/question-labels.ts create mode 100644 packages/storybook/src/molecules/exercise-question/question-type-modes.svelte create mode 100644 packages/storybook/src/molecules/file-drop-zone/fields.ts create mode 100644 packages/storybook/src/molecules/file-drop-zone/file-drop-zone.stories.svelte create mode 100644 packages/storybook/src/molecules/pricing-card/fields.ts create mode 100644 packages/storybook/src/molecules/pricing-card/pricing-card.stories.svelte create mode 100644 packages/storybook/src/molecules/pricing-toggle/pricing-toggle.stories.svelte create mode 100644 packages/storybook/src/molecules/radio-option-card/fields.ts create mode 100644 packages/storybook/src/molecules/radio-option-card/radio-option-card.stories.svelte create mode 100644 packages/ui/.prettierignore create mode 100644 packages/ui/README.md create mode 100644 packages/ui/src/base/alert/alert-callout.svelte create mode 100644 packages/ui/src/base/alert/alert-description.svelte create mode 100644 packages/ui/src/base/alert/alert-title.svelte create mode 100644 packages/ui/src/base/alert/alert.svelte create mode 100644 packages/ui/src/base/alert/index.ts create mode 100644 packages/ui/src/base/chart/chart-legend.svelte create mode 100644 packages/ui/src/base/item/item-sub-description.svelte create mode 100644 packages/ui/src/base/toggle-group/index.ts create mode 100644 packages/ui/src/base/toggle-group/toggle-group-context.ts create mode 100644 packages/ui/src/base/toggle-group/toggle-group-item.svelte create mode 100644 packages/ui/src/base/toggle-group/toggle-group.svelte create mode 100644 packages/ui/src/custom/code/code-block.svelte create mode 100644 packages/ui/src/custom/code/code-copy-button.svelte create mode 100644 packages/ui/src/custom/code/code-file-header.svelte create mode 100644 packages/ui/src/custom/code/code-overflow.svelte create mode 100644 packages/ui/src/custom/code/code-utils.svelte.ts create mode 100644 packages/ui/src/custom/code/code.svelte create mode 100644 packages/ui/src/custom/code/index.ts create mode 100644 packages/ui/src/custom/code/lang-from-filename.ts create mode 100644 packages/ui/src/custom/code/shiki.ts create mode 100644 packages/ui/src/custom/code/types.ts create mode 100644 packages/ui/src/custom/document-card/document-card.svelte create mode 100644 packages/ui/src/custom/document-card/index.ts create mode 100644 packages/ui/src/custom/exercise-question/index.ts create mode 100644 packages/ui/src/custom/exercise-question/question-form-root.svelte create mode 100644 packages/ui/src/custom/exercise-question/question-list.svelte create mode 100644 packages/ui/src/custom/exercise-question/question-navigation.svelte create mode 100644 packages/ui/src/custom/exercise-question/question-renderer.svelte create mode 100644 packages/ui/src/custom/exercise-question/question-surface.svelte create mode 100644 packages/ui/src/custom/exercise-question/question-title.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderer-contract.ts create mode 100644 packages/ui/src/custom/exercise-question/renderers/checkbox/edit.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/checkbox/preview.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/checkbox/submission.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/checkbox/take.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/file-upload-types.ts create mode 100644 packages/ui/src/custom/exercise-question/renderers/file-upload/edit.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/file-upload/preview.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/file-upload/submission.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/file-upload/take.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/fill-blank/edit.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/fill-blank/preview.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/fill-blank/submission.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/fill-blank/take.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/hotspot/edit.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/hotspot/preview.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/hotspot/submission.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/hotspot/take.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/link/edit.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/link/preview.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/link/submission.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/link/take.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/matching/edit.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/matching/preview.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/matching/submission.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/matching/take.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/numeric/edit.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/numeric/preview.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/numeric/submission.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/numeric/take.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/option-image-utils.ts create mode 100644 packages/ui/src/custom/exercise-question/renderers/option-image.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/ordering-utils.ts create mode 100644 packages/ui/src/custom/exercise-question/renderers/ordering/edit.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/ordering/preview.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/ordering/submission.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/ordering/take.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/radio/edit.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/radio/preview.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/radio/submission.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/radio/take.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/short-answer/edit.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/short-answer/preview.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/short-answer/submission.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/short-answer/take.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/submission-generic.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/submission-utils.ts create mode 100644 packages/ui/src/custom/exercise-question/renderers/textarea/edit.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/textarea/preview.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/textarea/submission.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/textarea/take.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/true-false/edit.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/true-false/preview.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/true-false/submission.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/true-false/take.svelte create mode 100644 packages/ui/src/custom/file-drop-zone/file-drop-zone-textarea.svelte create mode 100644 packages/ui/src/custom/file-drop-zone/file-drop-zone-trigger.svelte create mode 100644 packages/ui/src/custom/file-drop-zone/file-drop-zone-util.svelte.ts create mode 100644 packages/ui/src/custom/file-drop-zone/file-drop-zone.svelte create mode 100644 packages/ui/src/custom/file-drop-zone/index.ts create mode 100644 packages/ui/src/custom/file-drop-zone/types.ts create mode 100644 packages/ui/src/custom/media-player/index.ts create mode 100644 packages/ui/src/custom/media-player/media-player.svelte create mode 100644 packages/ui/src/custom/media-player/players/constants.ts create mode 100644 packages/ui/src/custom/media-player/players/muse-player.svelte create mode 100644 packages/ui/src/custom/media-player/players/plyr-player.svelte create mode 100644 packages/ui/src/custom/media-player/types.ts create mode 100644 packages/ui/src/custom/media-player/utils.ts create mode 100644 packages/ui/src/custom/moving-icons/analytics.svelte create mode 100644 packages/ui/src/custom/moving-icons/arrow-top-right.svelte create mode 100644 packages/ui/src/custom/moving-icons/attachment.svelte create mode 100644 packages/ui/src/custom/moving-icons/attendance.svelte create mode 100644 packages/ui/src/custom/moving-icons/bell.svelte create mode 100644 packages/ui/src/custom/moving-icons/certificate.svelte create mode 100644 packages/ui/src/custom/moving-icons/close.svelte create mode 100644 packages/ui/src/custom/moving-icons/community.svelte create mode 100644 packages/ui/src/custom/moving-icons/content.svelte create mode 100644 packages/ui/src/custom/moving-icons/course.svelte create mode 100644 packages/ui/src/custom/moving-icons/dashboard.svelte create mode 100644 packages/ui/src/custom/moving-icons/exercise.svelte create mode 100644 packages/ui/src/custom/moving-icons/explore.svelte create mode 100644 packages/ui/src/custom/moving-icons/goal.svelte create mode 100644 packages/ui/src/custom/moving-icons/header.svelte create mode 100644 packages/ui/src/custom/moving-icons/home.svelte create mode 100644 packages/ui/src/custom/moving-icons/hoverable-item.svelte create mode 100644 packages/ui/src/custom/moving-icons/index.ts create mode 100644 packages/ui/src/custom/moving-icons/landingpage.svelte create mode 100644 packages/ui/src/custom/moving-icons/lesson.svelte create mode 100644 packages/ui/src/custom/moving-icons/marks.svelte create mode 100644 packages/ui/src/custom/moving-icons/money.svelte create mode 100644 packages/ui/src/custom/moving-icons/news-feed.svelte create mode 100644 packages/ui/src/custom/moving-icons/people.svelte create mode 100644 packages/ui/src/custom/moving-icons/person.svelte create mode 100644 packages/ui/src/custom/moving-icons/premium.svelte create mode 100644 packages/ui/src/custom/moving-icons/preview.svelte create mode 100644 packages/ui/src/custom/moving-icons/review.svelte create mode 100644 packages/ui/src/custom/moving-icons/settings.svelte create mode 100644 packages/ui/src/custom/moving-icons/setup.svelte create mode 100644 packages/ui/src/custom/moving-icons/submission.svelte create mode 100644 packages/ui/src/custom/moving-icons/upload.svelte create mode 100644 packages/ui/src/custom/pricing-card/index.ts create mode 100644 packages/ui/src/custom/pricing-card/pricing-card.svelte create mode 100644 packages/ui/src/custom/pricing-card/types.ts create mode 100644 packages/ui/src/custom/pricing-toggle/index.ts create mode 100644 packages/ui/src/custom/pricing-toggle/pricing-toggle.svelte create mode 100644 packages/ui/src/custom/radio-option-card/index.ts create mode 100644 packages/ui/src/custom/radio-option-card/radio-option-card-group.svelte create mode 100644 packages/ui/src/custom/radio-option-card/radio-option-card.svelte create mode 100644 packages/ui/src/custom/youtube-link-form/index.ts create mode 100644 packages/ui/src/custom/youtube-link-form/youtube-link-form.svelte create mode 100644 packages/ui/src/custom/youtube-link-form/youtube-link-utils.ts create mode 100644 packages/ui/src/use-clipboard.svelte.ts create mode 100644 packages/ui/src/utils.ts create mode 100644 packages/utils/src/constants/content.ts create mode 100644 packages/utils/src/functions/array.ts create mode 100644 packages/utils/src/license/constants.ts create mode 100644 packages/utils/src/license/index.ts create mode 100644 packages/utils/src/validation/account/profile.ts create mode 100644 packages/utils/src/validation/assets/assets.ts create mode 100644 packages/utils/src/validation/assets/index.ts create mode 100644 packages/utils/src/validation/attendance/attendance.ts create mode 100644 packages/utils/src/validation/attendance/index.ts create mode 100644 packages/utils/src/validation/course/invite.ts create mode 100644 packages/utils/src/validation/course/people.ts create mode 100644 packages/utils/src/validation/course/section.ts create mode 100644 packages/utils/src/validation/exercise/exercise.ts create mode 100644 packages/utils/src/validation/exercise/index.ts create mode 100644 packages/utils/src/validation/lesson/index.ts create mode 100644 packages/utils/src/validation/lesson/language.ts create mode 100644 packages/utils/src/validation/lesson/lesson.ts create mode 100644 packages/utils/src/validation/newsfeed/index.ts create mode 100644 packages/utils/src/validation/newsfeed/newsfeed.ts create mode 100644 packages/utils/src/validation/organization/exercise.ts create mode 100644 packages/utils/src/validation/organization/invite.ts create mode 100644 packages/utils/src/validation/organization/quiz.ts create mode 100644 packages/utils/src/validation/organization/sso.ts create mode 100644 packages/utils/src/validation/organization/token-auth.ts create mode 100644 packages/utils/src/validation/submission/index.ts create mode 100644 packages/utils/src/validation/submission/submission.ts create mode 100644 packages/utils/src/validation/tag/index.ts create mode 100644 packages/utils/src/validation/tag/tag.ts create mode 100644 prd/ai-course-assistant/README.md create mode 100644 prd/ai-course-assistant/implementation-plan.md create mode 100644 prd/classes/README.md create mode 100644 prd/compliance-training-platform/README.md create mode 100644 prd/course-cohorts/README.md create mode 100644 prd/course-import/implementation-plan.md create mode 100644 prd/course-templates/README.md create mode 100644 prd/course-widget-embed/README.md create mode 100644 prd/course-widget-embed/implementation-plan.md create mode 100644 prd/email-template-customization/README.md create mode 100644 prd/enterprise-sso [DONE]/README.md create mode 100644 prd/exercise-question-types [DONE]/README.md create mode 100644 prd/exercise-question-types [DONE]/implementation-plan.md create mode 100644 prd/exercise-sections/README.md create mode 100644 prd/exercise-sections/implementation-plan.md create mode 100644 prd/lesson-edit [DONE]/README.md create mode 100644 prd/lessons-list [DONE]/README.md create mode 100644 prd/media-manager [DONE]/README.md create mode 100644 prd/media-manager [DONE]/backfill-lesson-videos-to-assets.md create mode 100644 prd/media-manager [DONE]/decoupling.md create mode 100644 prd/notification-system/README.md create mode 100644 prd/onboarding-ai-bootstrap/README.md create mode 100644 prd/pricing-improvements/README.md create mode 100644 prd/public-api/README.md create mode 100644 prd/public-courses/README.md create mode 100644 prd/public-courses/implementation-plan.md create mode 100644 prd/secure-student-invites [DONE]/README.md create mode 100644 prd/selective-exercise-assignment/README.md create mode 100644 prd/selective-exercise-assignment/implementation-plan.md create mode 100644 prd/self-host-single-org.md create mode 100644 prd/tags-feature [DONE]/README.md create mode 100644 prd/tags-feature [DONE]/implementation-plan.md create mode 100644 prd/tags-feature [DONE]/ui-format-spec.md create mode 100644 prd/webhooks/README.md create mode 100755 run-docker-full-stack.sh delete mode 100644 supabase/.gitignore delete mode 100644 supabase/config.toml delete mode 100644 supabase/data.sql delete mode 100644 supabase/functions/.vscode/extensions.json delete mode 100644 supabase/functions/.vscode/settings.json delete mode 100644 supabase/functions/_shared/cors.ts delete mode 100644 supabase/functions/_shared/supabaseAdmin.ts delete mode 100644 supabase/functions/_shared/supabaseClient.ts delete mode 100644 supabase/functions/grades-tmp/index.ts delete mode 100644 supabase/functions/import_map.json delete mode 100644 supabase/functions/notify/index.ts delete mode 100644 supabase/migrations/20231115082347_remote_schema.sql delete mode 100644 supabase/migrations/20231118210545_profile.sql delete mode 100644 supabase/migrations/20231207061507_update_get_courses.sql delete mode 100644 supabase/migrations/20231213104402_course_metadata.sql delete mode 100644 supabase/migrations/20231213105201_organizationmember_author_profile_id.sql delete mode 100644 supabase/migrations/20240212090837_course_newsfeed.sql delete mode 100644 supabase/migrations/20240212110855_add_courseid_to_community_question.sql delete mode 100644 supabase/migrations/20240320081809_email_verification.sql delete mode 100644 supabase/migrations/20240323154037_translation.sql delete mode 100644 supabase/migrations/20240406215136_org_billing.sql delete mode 100644 supabase/migrations/20240509080635_ai_grading.sql delete mode 100644 supabase/migrations/20240516092053_customization.sql delete mode 100644 supabase/migrations/20240516153823_course_type.sql delete mode 100644 supabase/migrations/20240518054308_new_certificates.sql delete mode 100644 supabase/migrations/20240524192915_get_courses_for_admin.sql delete mode 100644 supabase/migrations/20240528050512_cascade_delete_lesson.sql delete mode 100644 supabase/migrations/20240610132742_lesson_language_versioning.sql delete mode 100644 supabase/migrations/20240618210526_lesson_versions.sql delete mode 100644 supabase/migrations/20240701085652_show_explore_courses.sql delete mode 100644 supabase/migrations/20240717053936_rls.sql delete mode 100644 supabase/migrations/20240718200323_rls_auth_fix.sql delete mode 100644 supabase/migrations/20240720100738_fix_autosave_lesson.sql delete mode 100644 supabase/migrations/20240811123634_lesson_section.sql delete mode 100644 supabase/migrations/20240824193502_restrict_accounts.sql delete mode 100644 supabase/migrations/20240904050707_custom_domain.sql delete mode 100644 supabase/migrations/20240907090948_open_orgplan_for_landingpage.sql delete mode 100644 supabase/migrations/20241016235827_student_analytics.sql delete mode 100644 supabase/migrations/20241203000001_email_verification_security.sql delete mode 100644 supabase/migrations/20241223012712_validate_mark_as_complete.sql delete mode 100644 supabase/migrations/20241231154543_dashboard_stats.sql delete mode 100644 supabase/migrations/20250106020355_polar_setup.sql delete mode 100644 supabase/migrations/20250808030821_rls_secure_courses.sql delete mode 100644 supabase/migrations/20250808035209_support_polish_language.sql delete mode 100644 supabase/migrations/20250909000000_lesson_pdf.sql delete mode 100644 supabase/migrations/20250917235052_course_analytics.sql delete mode 100644 supabase/migrations/20251013120000_danish_support.sql delete mode 100644 supabase/migrations/20251205045311_modify_profile_policy.sql delete mode 100644 supabase/seed.sql diff --git a/.cursor/plans/ai_assistant_prd_5a6f948c.plan.md b/.cursor/plans/ai_assistant_prd_5a6f948c.plan.md new file mode 100644 index 00000000..7ed04a7e --- /dev/null +++ b/.cursor/plans/ai_assistant_prd_5a6f948c.plan.md @@ -0,0 +1,626 @@ +--- +name: AI assistant PRD +overview: Write a new PRD from scratch for the AI Course Assistant feature, replacing the existing placeholder at prd/ai-course-assistant/README.md. The PRD will document a hybrid execution model, configurable LLM providers, streaming chat, and the full package/API/UI architecture. +todos: + - id: write-prd + content: "Write the full PRD markdown file at prd/ai-course-assistant/README.md with all sections: purpose, architecture, execution model, package design, tools, safeguards, UI spec, self-host gating, streaming, API routes, and implementation phases" + status: pending +isProject: false +--- + +# AI Course Assistant PRD + +## What We're Writing + +Replace [prd/ai-course-assistant/README.md](prd/ai-course-assistant/README.md) with a comprehensive PRD for an AI assistant embedded in the course UI. The PRD will be a single markdown file covering architecture, UI, safeguards, package design, and implementation phases. + +## Key Architectural Decisions + +### Execution Model: Hybrid (Local Reads, API Writes) + +The client already has course content loaded in Svelte stores (`lessonApi`, exercise `questionnaire`, etc.) when the teacher is on a page. The agent should: + +- **Read context from the client** — pass the current lesson text, exercise questions, course structure to the agent as context with each message. No extra API calls to "read" content. +- **Write through the API** — all mutations route through existing API endpoints (`/course/:courseId/lesson`, `/course/:courseId/exercise`, etc.) using the full middleware stack (`authMiddleware` -> `orgMemberMiddleware` -> `courseTeamMemberMiddleware`). This gives us permission enforcement, single source of truth, and auditability for free. +- **Preview for destructive changes** — when the agent rewrites a lesson or makes a large edit, show a diff/preview in the chat with an "Apply" button. For additive actions (generate 5 questions), execute directly and confirm. + +### Agent Architecture: Tool-Calling Pattern + +The agent uses LLM function/tool calling (not free-form text parsing): + +```mermaid +sequenceDiagram + participant Teacher + participant ChatUI as Chat UI + participant AgentAPI as Agent API + participant LLM + participant Services as Existing Services + + Teacher->>ChatUI: "Generate 5 MCQ questions from this lesson" + ChatUI->>AgentAPI: POST /agent/chat (message + context) + AgentAPI->>LLM: System prompt + tools + conversation + LLM->>AgentAPI: tool_call: add_questions(exerciseId, questions[]) + AgentAPI->>Services: exerciseService.addQuestions() + Services->>AgentAPI: Success + AgentAPI->>LLM: Tool result + LLM->>AgentAPI: "Done! I added 5 questions to..." + AgentAPI-->>ChatUI: SSE stream response + ChatUI->>Teacher: Shows confirmation + refreshes exercise +``` + + + +The LLM sees a set of **tools** (mapped to existing service functions) and decides which to call based on the teacher's message. The agent API executes the tool with full permission checks, then feeds the result back to the LLM for a human-friendly summary. + +### SDK: Vercel AI SDK + +The **Vercel AI SDK** (`ai` + `@ai-sdk/svelte` + provider packages) is the right fit: + +- `@ai-sdk/svelte` provides a `Chat` class that uses Svelte 5 runes internally — same class-based reactive pattern as the project's existing `LessonApi` / `BaseApiWithErrors` ([base.svelte.ts](apps/dashboard/src/lib/utils/services/api/base.svelte.ts)). +- `streamText()` returns `toUIMessageStreamResponse()` which works directly as a Hono response — zero glue code ([Hono cookbook](https://sdk.vercel.ai/cookbook/api-servers/hono)). +- Tool calling is built-in with Zod schemas (already used everywhere in the project). The SDK handles the function-calling protocol, multi-step execution, and streaming tool results. +- Provider abstraction lets us swap between `@ai-sdk/openai` and `@ai-sdk/anthropic` by changing one line. +- The wire protocol carries text chunks, tool call events, tool results, and finish signals. The `Chat` class on the client parses all of this automatically. + +**Packages to install:** + +- `ai` — core (`streamText`, `tool`, types) — in `packages/ai-assistant` and `apps/api` +- `@ai-sdk/svelte` — Svelte 5 `Chat` class — in `apps/dashboard` +- `@ai-sdk/openai` — OpenAI provider — in `packages/ai-assistant` +- `@ai-sdk/anthropic` — Anthropic provider — in `packages/ai-assistant` +- `zod` — already installed everywhere + +### Package: `packages/ai-assistant` + +A thin, backend-focused package consumed by `apps/api`. No UI code. The heavy lifting is done by the Vercel AI SDK; this package provides project-specific configuration on top. + +Responsibilities: + +- **Provider factory** — creates the right AI SDK `LanguageModel` from env vars (`AI_PROVIDER`, `AI_API_KEY`, `AI_MODEL`). Wraps `@ai-sdk/openai` and `@ai-sdk/anthropic`. +- **Role-aware tool registry** — `getToolSchemas(role)` returns the right Zod schemas per role. No implementations — those live in the API. +- **Role-aware system prompt** — `buildSystemPrompt(context)` generates different LLM instructions for teachers vs students. +- **Shared types** — `AgentRole`, `AgentContext`, tool names, config types. Used by both `apps/api` and `apps/dashboard`. + +Suggested structure: + +``` +packages/ai-assistant/ + src/ + index.ts # Public exports + types.ts # AgentRole, AgentContext, AIProviderConfig, tool name constants + providers/ + index.ts # createModel(config) factory + tools/ + index.ts # getToolSchemas(role) registry + shared.ts # Read-only tool schemas (both roles) + teacher.ts # Mutation tool schemas (teacher only, v1) + student.ts # Learning tool schemas (student only, v2 stubs) + prompt/ + system-prompt.ts # buildSystemPrompt(context) — role-aware + teacher.ts # Teacher prompt template + student.ts # Student prompt template (v2 stub) + package.json + tsconfig.json +``` + +Note: the agent loop itself is NOT in this package — `streamText()` from the `ai` core package handles the full loop (prompt -> LLM -> tool call -> execute -> respond -> repeat). The Hono route assembles tools (schema from package + execute from services) and calls `streamText`. + +### Foundation Layer: Role-Aware Design + +The agent is not just a teacher tool — it's a **course assistant** that behaves differently per role. The foundation must support this from day one, even though students are gated off in v1. + +`**AgentRole`** — the core discriminator: + +- `'teacher'` — can read AND mutate course content (update lessons, generate questions, create drafts) +- `'student'` — can only read and discuss content (explain concepts, answer questions, quiz on material) + +**Role detection** — server-side, automatic: + +- The API route checks the user's course membership via existing queries (`isCourseTeamMemberOrOrgAdmin`) +- Team members (ADMIN/TUTOR role in `groupmember`) get `role: 'teacher'` +- Regular course members (STUDENT role) get `role: 'student'` +- In v1, `role: 'student'` returns a 403. In v2, it loads the student tool set. + +**Tool registry** — `getToolSchemas(role)` returns the right set: + +- `shared` tools (read-only): `get_course_structure`, `get_lesson_content`, `get_exercise_questions` — available to both roles +- `teacher` tools (mutations): `update_lesson_content`, `create_lesson_draft`, `add_exercise_questions` — teacher only +- `student` tools (v2): `explain_concept`, `quiz_me`, `clarify_exercise` — student only, stubs for now + +Teachers get `shared + teacher`. Students get `shared + student`. This ensures students can never access mutation tools, even via prompt injection. + +**System prompt** — role-aware via `buildSystemPrompt(context)`: + +- Teacher: "You are helping a teacher organize and author course content. You can update lessons, create new content, and generate questions." +- Student (v2): "You are helping a student learn. You can explain concepts, answer questions about the material, and help with exercises. You cannot modify any course content." + +**Single API endpoint** — `/agent/chat` serves both roles. Role is detected server-side (not passed by the client, preventing spoofing). The endpoint loads tools and prompt based on the detected role. + +### Agent Tools + +#### V1 Core Tools (7 tools) + +Start small with the essentials for content authoring. Additional tools are added in later phases. + + +| Tool | Type | Description | Service | +| ----------------------- | ----- | ---------------------------------------------------- | ----------------------------------------------------- | +| `get_course_structure` | read | Get sections, lessons, exercises tree | `getCourseContentItems` + `listCourseSections` | +| `get_lesson_content` | read | Get a specific lesson's note/text | `getLesson` | +| `get_exercise_details` | read | Get exercise with all questions and options | `getExercise` | +| `create_lesson` | write | Create a new lesson in a section (title + content) | `createLesson` | +| `update_lesson_content` | write | Update the note/text content of a lesson | `updateLessonService` + `upsertLessonLanguageService` | +| `create_exercise` | write | Create a new exercise with questions and options | `createExercise` | +| `add_questions` | write | Add questions (with options) to an existing exercise | `updateExerciseService` (diff-based) | + + +#### Phase 2+ Tools (added after v1 is stable) + +**Lessons**: `update_lesson_metadata`, `delete_lesson`, `reorder_lessons`, `update_lesson_translation` +**Sections**: `create_section`, `update_section`, `reorder_sections`, `delete_section` +**Exercises**: `update_exercise_metadata`, `update_question`, `delete_question`, `delete_exercise`, `create_exercise_from_template` +**Other**: `update_course_tags`, `create_newsfeed_post`, `list_exercise_templates` + +#### Phase 4 Student Tools (v2) + +`explain_concept`, `quiz_me`, `clarify_exercise`, `get_my_progress`, `get_my_submissions` + +#### Tool Execution Rules + +Each tool handler in the API will: + +1. Extract IDs from tool arguments +2. Verify the target resource belongs to the course/org in context +3. Verify user is course team member or org admin (teacher tools) +4. **Check token balance** before LLM call (see Token Usage section) +5. Call the existing service function +6. Return structured result to the agent + +#### No-Hallucination Boundary + +The system prompt includes an explicit list of what the agent CANNOT do. When a user asks for any of these, the agent responds with a clear, helpful decline and suggests the closest available action. The agent must **never**: + +- Pretend to perform an action it doesn't have a tool for +- Fabricate data it hasn't retrieved via a tool call +- Claim to have made a change unless a tool call succeeded + +**Explicit CANNOT-do list (included verbatim in system prompt):** + +- Cannot create, delete, or clone courses +- Cannot update course-level settings (title, description, logo, status) +- Cannot manage course members, invitations, or roles +- Cannot grade submissions or assign marks +- Cannot handle payments or attendance +- Cannot upload files, images, videos, or documents +- Cannot manage sections, reorder content, or delete content (v1 — added in Phase 2) +- Cannot manage org settings, members, or billing +- Cannot access data from other courses or organizations +- Cannot browse the internet or fetch external URLs +- Cannot send emails or notifications + +**System prompt template:** + +``` +You are an AI assistant for ClassroomIO. You help teachers create and organize +course content. + +You have access to specific tools listed below. If a user asks you to do something +NOT covered by your tools, respond clearly: +"I can't do that yet. [Brief reason]. Here's what I can help with: [closest action]." + +NEVER pretend to perform an action you don't have a tool for. +NEVER fabricate data you haven't retrieved via a tool. +NEVER claim to have made a change unless a tool call returned success. +When drafting new content, always present it for review before creating. + +Things you explicitly CANNOT do: +[list from above] +``` + +### Token-Based Usage Tracking + +AI usage is metered by tokens. Each org gets a monthly base allowance from their plan, with the option to buy extra credit packs when exhausted. + +#### How It Works + +```mermaid +flowchart LR + subgraph check [Before LLM Call] + CheckBalance["Check: monthly_allowance\n+ credit_balance\n- tokens_used > 0?"] + end + subgraph call [LLM Call] + StreamText["streamText() executes"] + Usage["AI SDK returns\npromptTokens + completionTokens"] + end + subgraph record [After LLM Call] + RecordUsage["Record usage in\nai_token_usage table"] + UpdateBalance["Deduct from monthly\nallowance first,\nthen credits"] + end + + CheckBalance -->|"Yes"| StreamText + CheckBalance -->|"No"| Reject["Return 402:\nToken limit reached"] + StreamText --> Usage + Usage --> RecordUsage + RecordUsage --> UpdateBalance +``` + + + +#### Plan-Based Allowances + +Tied to the existing `organization_plan` table ([schema.ts](packages/db/src/schema.ts) line 1324). Each plan gets a monthly token allowance: + +- **BASIC**: 0 tokens (AI assistant not available on free plan — or a small trial allowance, TBD) +- **EARLY_ADOPTER**: X tokens/month (e.g. 500K tokens — roughly 50-100 conversations) +- **ENTERPRISE**: Y tokens/month (higher, e.g. 2M tokens — or unlimited) + +Allowance resets on the 1st of each month (or billing cycle start). + +#### Extra Credit Packs + +When the monthly allowance is exhausted, the org can buy credit packs: + +- Credits are one-time purchases, do NOT expire, do NOT reset monthly +- Deduction order: monthly allowance first, then credits +- Purchase flow: upgrade modal / settings page -> payment -> credits added to `ai_credit_balance` + +#### Database Design + +New table: `ai_token_usage` — tracks per-request usage for auditing and aggregation. + +```sql +CREATE TABLE ai_token_usage ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + org_id UUID NOT NULL REFERENCES organization(id), + user_id UUID NOT NULL, + course_id UUID NOT NULL, + prompt_tokens INTEGER NOT NULL, + completion_tokens INTEGER NOT NULL, + total_tokens INTEGER GENERATED ALWAYS AS (prompt_tokens + completion_tokens) STORED, + model TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_ai_token_usage_org_month ON ai_token_usage (org_id, created_at); +``` + +New table: `ai_credit_balance` — tracks purchased credits per org. + +```sql +CREATE TABLE ai_credit_balance ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + org_id UUID NOT NULL REFERENCES organization(id) UNIQUE, + balance INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +Allowances live in a config constant (or the plan's `payload` JSONB field), not a separate table. The plan already has `payload: jsonb()` which can store `{ "aiTokenAllowance": 500000 }`. + +#### Token Tracking Flow (in the agent route) + +1. **Before LLM call**: query `ai_token_usage` for current month + `ai_credit_balance`. Calculate: `remaining = (allowance - used_this_month) + credit_balance`. If `remaining <= 0`, return 402 with `{ error: "Token limit reached", upgradeUrl: "..." }`. +2. **After LLM call**: the Vercel AI SDK's `streamText()` result exposes `usage` with `promptTokens` and `completionTokens`. Insert a row into `ai_token_usage`. +3. **Deduction logic**: monthly allowance is consumed first (it's implicit — just the sum of usage this month vs the allowance). Credits are only touched when `used_this_month > allowance`. The credit deduction is: `max(0, total_tokens - max(0, allowance - used_before_this_request))`. + +#### API Endpoints for Token Management + +- `GET /agent/usage` — returns `{ used, allowance, creditBalance, remaining }` for the current org. Used by dashboard to show usage meter. +- `POST /agent/credits` — purchase credits (integrates with payment provider). Protected by `orgAdminMiddleware`. + +#### Dashboard UI + +- **Usage meter**: in the AI assistant sidebar header or org settings, show a progress bar: "X / Y tokens used this month" with remaining count. +- **Exhausted state**: when tokens run out, the chat input is disabled with a message: "Monthly AI token limit reached. [Buy more credits] or wait for reset on [date]." +- **Credits section**: in org settings, show credit balance and "Buy more" button. + +### Safeguards + +Four layers of protection: + +1. **Route-level middleware** — agent API route uses `authMiddleware` + `orgMemberMiddleware` + at minimum `courseMemberMiddleware`. Role detection happens inside the handler. Defined in [apps/api/src/middlewares/](apps/api/src/middlewares/). +2. **Tool-level permission checks** — the tool set itself is the primary guard. Students physically cannot call `update_lesson_content` because it's not in their tool set. Additionally, each tool's execute function re-verifies resource ownership to prevent prompt injection (LLM tricked into passing a different `courseId`). +3. **Token budget enforcement** — every LLM call checks the org's remaining token balance. If exhausted, returns 402 before any LLM call is made. Usage is recorded after each call for accurate tracking. +4. **Rate limiting** — per-user, per-org rate limits on agent API calls (requests/minute). Prevents abuse even within token budget. Self-hosted is uncapped (user's own API key, their cost). + +### Self-Hosted vs Cloud + +- **Cloud**: AI assistant enabled by default. ClassroomIO provides the LLM API key server-side. Teachers see the button, no setup needed. Usage may be metered per plan tier. +- **Self-hosted**: AI assistant **disabled by default**. To enable, the self-hosted admin sets `AI_API_KEY` and optionally `AI_PROVIDER` / `AI_MODEL` in their environment. When configured, the assistant becomes available. When not configured, the button is hidden (check via a server-side flag exposed to the client, e.g. a `GET /agent/status` endpoint or a field in the org config response). + +This is simpler than storing API keys in the database and avoids security concerns around key storage. + +### UI Specification + +- **Trigger**: "AI Assistant" button in the course sidebar navigation ([course-sidebar-navigation.svelte](apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar-navigation.svelte)), below the existing nav items. In v1, only visible to teachers/admins (not students). Hidden when self-hosted and no AI key configured. +- **Panel**: Right sidebar using `@cio/ui` Sheet (`side="right"`, ~420px). Does NOT replace the left sidebar — both can be open simultaneously. +- **Chat interface**: Message list (user/assistant turns), text input with send button, streaming token display, quick-action chips. +- **Role-aware quick actions**: The chat panel accepts the detected `role` to show the right chips: + - Teacher (v1): "Draft a lesson", "Generate questions", "Improve this text" + - Student (v2): "Explain this lesson", "Quiz me on this topic", "Help me with this exercise" +- **Context awareness**: Client passes `courseId`, current `lessonId` or `exerciseId`. The role is NOT sent from the client — it's detected server-side to prevent spoofing. +- **Apply flow**: For large content changes (teacher only), the agent response includes a structured preview. The chat UI renders a diff with "Apply" / "Discard" buttons. Applying triggers the API write and refreshes the relevant store. +- **Existing `ai-button.svelte`**: The hidden [ai-button.svelte](apps/dashboard/src/lib/features/course/components/lesson/ai-button.svelte) component in the lesson editor can be removed or repurposed — the new sidebar chat replaces its functionality. +- **v2 student surface**: The same chat panel component can be rendered in the student course view (course-app). The `Chat` class and Sheet are identical — only the quick-action chips differ, and the server loads different tools/prompts based on role. + +### Streaming + +The Vercel AI SDK provides a structured SSE protocol via `toUIMessageStreamResponse()`. It carries: + +- Text chunks (tokens stream one by one — ChatGPT-like typing) +- Tool call events (when the LLM decides to call a tool) +- Tool results (when the tool execution completes) +- Finish signals + +On the frontend, the `Chat` class from `@ai-sdk/svelte` consumes this protocol automatically. No manual SSE parsing needed. + +### How the Backend Code Works + +The Hono route assembles everything: + +1. **Provider factory** (`packages/ai-assistant`) creates a `LanguageModel` from env vars +2. **Tool schemas** (`packages/ai-assistant`) define Zod input schemas for each tool +3. **Tool execute functions** (inline in the Hono route) call existing services from `apps/api/src/services/` +4. `**streamText()`** (from `ai` core) runs the agent loop: sends system prompt + tools + messages to LLM, executes tool calls, feeds results back, repeats up to `stopWhen: stepCountIs(5)`, streams everything to the client + +The route returns `result.toUIMessageStreamResponse()` directly as the Hono response. + +### How the Frontend Code Works + +The `Chat` class from `@ai-sdk/svelte` is instantiated once per chat panel. It: + +1. Manages `messages` array as reactive `$state` (same pattern as `LessonApi`) +2. `sendMessage({ text })` sends a POST to the Hono endpoint, starts consuming the SSE stream +3. Updates `messages` reactively as tokens arrive — the UI re-renders automatically +4. Exposes `status` (`'idle'` | `'streaming'`) and `stop()` for abort +5. Each message has a `parts` array — `{ type: 'text', text }` for text, `{ type: 'tool-{name}', state: 'call' | 'result', result? }` for tool calls + +The `Chat` class accepts: + +- `api` — URL of the Hono endpoint (e.g., `${PUBLIC_API_URL}/agent/chat`) +- `headers` — we pass `{ 'cio-org-id': orgId }` via a reactive getter +- `body` — extra data merged into every request: `{ courseId, context: { lessonId, exerciseId } }` +- `credentials: 'include'` — sends auth cookies for Better Auth session +- `onFinish` — callback to refresh page data after the agent completes + +### What the Chat UI Looks Like + +**Empty state**: welcome message + quick-action chips ("Draft a lesson", "Generate questions", "Improve this text"). Clicking a chip fills the input and sends. + +**During conversation**: + +- User messages appear right-aligned with `bg-primary` styling +- Assistant messages appear left-aligned with `bg-muted` styling +- Text streams in token by token (automatic via `Chat` class) +- Tool calls render as inline status cards inside the assistant bubble: + - `state: 'call'` shows spinner + "Generating questions..." + - `state: 'result'` shows checkmark + "5 questions added" +- Stop button replaces send button while streaming + +**After agent completes a mutation**: + +- `onFinish` callback fires +- The relevant store refreshes (e.g., `lessonApi.get()` or exercise reload) +- Teacher sees updated content on the page without manual refresh + +**Tool part rendering**: The AI SDK names tool parts as `tool-{toolName}`. In the Svelte template: + +```svelte +{#each message.parts as part} + {#if part.type === 'text'} +
{@html renderMarkdown(part.text)}
+ {:else if part.type === 'tool-update_lesson_content'} + + {:else if part.type === 'tool-add_exercise_questions'} + + {/if} +{/each} +``` + +### Example Agent Flows + +These walk through what actually happens end-to-end for each key use case — which tools fire, what the teacher sees, and design decisions involved. + +#### Flow 1: "Research photosynthesis and write a first draft lesson for Section 2" + +```mermaid +sequenceDiagram + participant Teacher + participant Chat as Chat UI + participant API as Agent API + participant LLM + participant Svc as Services + + Teacher->>Chat: "Research photosynthesis and write a first draft for Section 2" + Chat->>API: POST /agent/chat + API->>LLM: system prompt + tools + message + + Note over LLM: Step 1 - Understand course context + LLM->>API: tool_call: get_course_structure(courseId) + API->>Svc: getCourseStructure() + Svc-->>API: sections, lessons, exercises tree + API->>LLM: tool result + + Note over LLM: Step 2 - Read existing style + LLM->>API: tool_call: get_lesson_content(lessonId) + API->>Svc: getLessonById() + Svc-->>API: lesson HTML content + API->>LLM: tool result + + Note over LLM: Step 3 - Present draft for review + LLM-->>Chat: Streams draft text inline in chat + LLM-->>Chat: "Here's a draft... shall I create it?" + + Teacher->>Chat: "Looks good, create it" + Chat->>API: POST /agent/chat (follow-up) + API->>LLM: conversation continues + + Note over LLM: Step 4 - Create after confirmation + LLM->>API: tool_call: create_lesson_draft(title, content, sectionId) + API->>Svc: createLessonService() + Svc-->>API: { lessonId, title } + API->>LLM: tool result + LLM-->>Chat: "Created 'Photosynthesis' in Section 2" + + Note over Chat: onFinish -> content tree refreshes + Chat->>Teacher: New lesson appears in sidebar +``` + + + +**What "research" means**: The agent does NOT have internet access. "Research" means two things: + +1. The LLM uses its training data knowledge about the topic (extensive for most academic subjects) +2. The agent reads existing course content via `get_course_structure` and `get_lesson_content` to understand the teacher's style, depth, and what's already been covered + +**Confirmation before creation**: The system prompt instructs the LLM to present the draft in the chat first and ask for confirmation before calling `create_lesson_draft`. This is a prompt-engineering pattern, not a code constraint — the system prompt will say: "When drafting a new lesson, first present the full draft in your response and ask the teacher to confirm before creating it. Only call create_lesson_draft after the teacher approves." This gives a natural review gate via multi-turn conversation without requiring a custom "preview mode" in the API. + +**Future consideration**: If real web research is needed, a `web_search` tool can be added to the tool registry. The foundation supports it cleanly — just another tool schema + execute function. Not in v1. + +**What the teacher sees in the chat**: + +1. User bubble: their request +2. Assistant bubble starts streaming: + - Tool card: "Getting course structure..." -> checkmark + - Tool card: "Reading existing lesson..." -> checkmark + - Text streams in: the full draft lesson content, rendered as markdown + - Text: "Would you like me to create this as a lesson in Section 2?" +3. Teacher types "yes" or "looks good, but change X" +4. Assistant bubble: + - Tool card: "Creating lesson draft..." -> checkmark + - Text: "Created 'Photosynthesis: Converting Light to Energy' in Section 2. You can find it in the content tree." +5. Content tree refreshes, new lesson appears + +#### Flow 2: "Generate 5 MCQ questions from this lesson" + +The teacher is viewing a lesson and opens the AI assistant. + +1. Teacher sends: "Generate 5 multiple choice questions from this lesson" +2. The `Chat` class sends `{ courseId, messages, context: { lessonId: 'current-lesson-id' } }` +3. The system prompt includes: "The teacher is currently viewing lesson ID: {lessonId}" +4. LLM calls `get_lesson_content(lessonId)` to read the lesson text +5. LLM generates 5 questions based on the content +6. LLM calls `add_exercise_questions(exerciseId, questions)` — if there's a linked exercise, it adds there. If not, it tells the teacher there's no exercise linked and asks if it should create one. +7. Result streams back with tool status cards and a summary + +**What the teacher sees**: + +- Tool card: "Reading lesson content..." -> checkmark +- Tool card: "Adding 5 questions..." -> checkmark +- Text: "I've added 5 multiple-choice questions to the exercise. Here's a summary: 1. What is the primary function of... [etc]" +- Exercise data refreshes automatically + +#### Flow 3: "Update the introduction paragraph of this lesson" + +The teacher is editing a lesson and wants the AI to improve specific text. + +1. Teacher sends: "Rewrite the introduction paragraph to be more engaging" +2. Context includes the current `lessonId` +3. LLM calls `get_lesson_content(lessonId)` to read the full lesson +4. LLM identifies the introduction paragraph, rewrites it +5. **System prompt instructs**: "When updating existing lesson content, show the proposed changes and ask for confirmation before applying." — this is the "preview for destructive changes" pattern +6. LLM presents the new introduction in the chat +7. Teacher says "apply it" or "make it shorter" +8. LLM calls `update_lesson_content(lessonId, fullUpdatedContent)` +9. Lesson store refreshes, teacher sees the updated text in the editor + +**Important**: `update_lesson_content` replaces the full lesson note. The LLM must reconstruct the complete lesson HTML with only the introduction changed. The system prompt will instruct: "When updating lesson content, always preserve all existing content. Only modify the specific section the teacher asked about." + +#### Flow 4 (v2): Student asks "I don't understand the Calvin cycle" + +1. Student is viewing a lesson about photosynthesis +2. Student sends: "I don't understand the Calvin cycle, can you explain it more simply?" +3. Role detected as `'student'` — student tool set loaded (read-only) +4. LLM calls `get_lesson_content(lessonId)` to read what the lesson says about the Calvin cycle +5. LLM generates a simplified explanation based on the lesson content + its training knowledge +6. Response streams back — pure text, no tool mutations, no changes to any data + +**Key difference from teacher flow**: No mutation tools are available. The agent can only read course content and generate text responses. The tool set IS the security boundary. + +### LLM Provider Abstraction + +Configurable via environment variables: + +- `AI_PROVIDER` — `openai` | `anthropic` (default: `openai`) +- `AI_API_KEY` — the provider API key +- `AI_MODEL` — model name override (default: provider's recommended model) + +The provider factory in `packages/ai-assistant` normalizes this into a single `LanguageModel` instance that `streamText()` consumes. Both OpenAI and Anthropic support function/tool calling natively through the AI SDK. + +### Conversation Persistence + +For v1, conversation history is **session-scoped** — managed entirely in-memory by the `Chat` class on the client. When the teacher closes the sidebar or navigates away, the conversation resets. No database table needed. + +v2 consideration: persist conversations per course in the database for history/continuity. + +## API Routes + +New route domain: `/agent` + +- `GET /agent/status` — returns `{ enabled, role, usage: { used, allowance, creditBalance, remaining } }`. Used by client to show/hide the button, configure chips, and display usage meter. +- `POST /agent/chat` — the single chat endpoint. Accepts `{ courseId, messages, context }`. Checks token balance, detects role, loads tools/prompt, returns SSE stream. Records token usage after completion. +- `GET /agent/usage` — returns detailed usage stats for the current org (current month usage, allowance, credit balance, history). +- `POST /agent/credits` — purchase additional credits. Protected by `orgAdminMiddleware`. + +Registered in [apps/api/src/app.ts](apps/api/src/app.ts) as `.route('/agent', agentRouter)`. + +## Implementation Phases + +### Phase 1: Foundation (scaffold + role-aware chat shell + token tables) + +- Create `packages/ai-assistant` with `AgentRole`, `AgentContext` types, provider factory, role-aware tool registry (v1: 7 core tools), and role-aware system prompt with no-hallucination boundary +- Add `ai_token_usage` and `ai_credit_balance` tables to DB schema, add token allowance to plan payload +- Add `GET /agent/status` and `POST /agent/chat` route skeleton in `apps/api` with role detection (students get 403 in v1) and token balance check +- Add "AI Assistant" button to course sidebar (gated on status endpoint + plan check) +- Build right sidebar Sheet with basic chat UI (hardcoded echo responses) + +### Phase 2: Agent Core (LLM integration + v1 tools + usage tracking) + +- Implement provider factory (OpenAI + Anthropic) using `createModel(config)` +- Wire v1 core tools (7 tools) to existing services via `streamText()` with `stopWhen: stepCountIs(5)` +- Record `promptTokens` + `completionTokens` after each call; enforce balance check before each call +- Connect chat UI to streaming API via `@ai-sdk/svelte` `Chat` class; render streamed tokens and tool status cards +- Add usage meter to AI assistant sidebar header +- Implement `onFinish` store refresh for lesson/exercise data + +### Phase 3: Polish + Safeguards + Credits + +- Add tool-level permission re-validation (verify resource belongs to course/org) +- Add rate limiting (per-user, per-org, requests/minute) +- Add quick-action chips and context-aware suggestions +- Self-host env var detection and `GET /agent/status` logic +- Add conversation context windowing (trim long histories) +- Implement `POST /agent/credits` for purchasing credit packs +- Add credit purchase UI in org settings + exhausted-state UI in chat +- Add `GET /agent/usage` for detailed usage dashboard + +### Phase 4: More Tools + +- Add Phase 2+ tools (sections, reorder, delete, translations, templates, tags, newsfeed) +- Update no-hallucination boundary to remove "cannot manage sections" etc. + +### Phase 5: Student Assistant (v2) + +- Implement student tool schemas and execute functions +- Write student system prompt template +- Remove the v1 student 403 gate +- Add student quick-action chips +- Surface the AI assistant button in the student course view (course-app) +- Student usage counts against the org's token budget (same pool) + +## Files to Create / Modify + +**New files:** + +- `prd/ai-course-assistant/README.md` — this PRD (replace existing) + +**Files referenced (not modified in PRD phase):** + +- [packages/db/src/schema.ts](packages/db/src/schema.ts) — where `ai_token_usage` and `ai_credit_balance` tables will be added (near `organizationPlan` at line 1324) +- [packages/utils/src/plans/constants.ts](packages/utils/src/plans/constants.ts) — where AI token allowance feature constants will be added +- [apps/api/src/app.ts](apps/api/src/app.ts) — where agent route will be registered +- [apps/api/src/middlewares/](apps/api/src/middlewares/) — existing middleware to reuse +- [apps/api/src/services/](apps/api/src/services/) — existing services the agent will call +- [apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar-navigation.svelte](apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar-navigation.svelte) — where the AI button goes +- [apps/dashboard/src/lib/utils/store/org.ts](apps/dashboard/src/lib/utils/store/org.ts) — where plan-based derived stores live (e.g., `isFreePlan`, `currentOrgPlan`) + diff --git a/.cursor/plans/course_enroll_route_migration_883d0aae.plan.md b/.cursor/plans/course_enroll_route_migration_883d0aae.plan.md new file mode 100644 index 00000000..3454fbb5 --- /dev/null +++ b/.cursor/plans/course_enroll_route_migration_883d0aae.plan.md @@ -0,0 +1,199 @@ +--- +name: Course Enroll Route Migration +overview: "Replace `/invite/s/:token` with `/course/:slug/enroll`. One unified enrollment endpoint: free courses enroll directly (no invite); paid courses require invite_token. Remove public-link and invite creation for free enrollment." +todos: [] +isProject: false +--- + +# Course Enroll Route Migration Plan + +## Summary + +Migrate from `/invite/s/:token` to a course-scoped enroll route. **One request for both flows:** + +- **Free courses:** `/course/:slug/enroll` – Join calls `POST /course/:courseId/enroll` (no token). User is enrolled directly; no invite creation. +- **Paid/invited:** `/course/:slug/enroll?invite_token=xxx` – Join calls `POST /course/:courseId/enroll` with `inviteToken`. Without token, request fails. + +## Architecture + +```mermaid +flowchart TB + subgraph free [Free Course Flow] + A1[Landing: Enroll click] --> A2[goto /course/slug/enroll] + A2 --> A3[Page loads, validates free] + A3 --> A4[User auths if needed] + A4 --> A5[Join click] + A5 --> A6["POST /course/:id/enroll (no token)"] + A6 --> A7[Direct enroll: add to org + group] + A7 --> A8[Redirect to LMS] + end + + subgraph paid [Paid/Invited Flow] + B1[Email link] --> B2[course/slug/enroll?invite_token=xxx] + B2 --> B3[Page loads, validates token] + B3 --> B4[User auths if needed] + B4 --> B5[Join click] + B5 --> B6["POST /course/:id/enroll with inviteToken"] + B6 --> B7[Validate token, consume invite, enroll] + B7 --> B8[Redirect to LMS] + end +``` + + + +--- + +## Part 1: New Enroll Route + +### 1.1 Create route files + +**Add:** [apps/dashboard/src/routes/course/[slug]/enroll/+page.server.ts](apps/dashboard/src/routes/course/[slug]/enroll/+page.server.ts) +**Add:** [apps/dashboard/src/routes/course/[slug]/enroll/+page.svelte](apps/dashboard/src/routes/course/[slug]/enroll/+page.svelte) + +**Server load logic – one request per branch:** + +- **With `invite_token`:** Single call to `GET /invite/student/:token`. Response must include course (slug, cost, title, description, allowNewStudent, status, isPublished), organization (siteName, name, theme), invite (status, etc.). No separate course or org fetch. Extend the invite API response to be self-contained (add course.slug, course.cost, org.theme to the query/response). +- **Without `invite_token` (free):** Single call to `GET /course/slug/:slug` (or a dedicated enroll-context endpoint). Response must include course and org (with theme). Extend the course-by-slug response to include org if it does not already, so no separate OrgApiServer.getOrgBySiteName call. +- **Validation rules:** Same as before – 404, requiresPaymentOrInvite, etc. Determine "free" via `isCourseFree(calculatedCost)`. + +**Page component logic:** + +- When `requiresPaymentOrInvite: true`: render message "You need to pay for this course or be invited to get access" with link back to `/course/:slug` +- When `invite` present or free (no invite): show course info + Join button +- On Join: + 1. If not logged in → `goto(/signup?redirect=/course/:slug/enroll)` (preserve redirect, append `?invite_token=` if present) + 2. If logged in → call `**POST /course/:courseId/enroll`** with body `{ inviteToken?: string }`: + - **Free:** no `inviteToken`, backend enrolls directly + - **Paid/invited:** pass `inviteToken` from URL; backend validates and consumes invite + 3. Redirect to `result.data.redirectTo || '/lms'` +- Set theme and currentOrg from load data + +### 1.2 Add route to PUBLIC_ROUTES + +**File:** [apps/dashboard/src/lib/utils/constants/routes.ts](apps/dashboard/src/lib/utils/constants/routes.ts) + +- Add `^/course/.*/enroll` to `PUBLIC_ROUTES` so unauthenticated users can load the enroll page (they will be prompted to auth when clicking Join) + +--- + +## Part 2: Update Landing Page Enroll Flow + +**File:** [apps/dashboard/src/lib/features/ui/course-landing-page/components/pricing-section.svelte](apps/dashboard/src/lib/features/ui/course-landing-page/components/pricing-section.svelte) + +- For **free** course: remove `POST /invite/student/public-link` call; change to `goto(\`/course/${courseData.slug}/enroll)` + +--- + +## Part 3: New Unified Enrollment API + +**Add:** `POST /course/:courseId/enroll` (authenticated). Body: `{ inviteToken?: string }` + +**Logic:** With `inviteToken` → reuse `acceptStudentInvite`; without token → only free courses, direct enroll (no invite). Remove public-link and accept routes. + +**Part 3b – Build Enroll URL for emails:** [apps/api/src/services/course/invite.ts](apps/api/src/services/course/invite.ts) + +- Add `buildEnrollLink(courseSlug: string, token: string, orgSiteName?: string): string` + - Construct: `https://${orgSiteName || 'app'}.classroomio.com/course/${courseSlug}/enroll?invite_token=${encodeURIComponent(token)}` + - Use same base-URL logic as elsewhere (dev vs prod; see [newsfeed](apps/api/src/services/newsfeed/newsfeed.ts) for org subdomain pattern) +- Replace `buildInviteLink` with `buildEnrollLink` in createSingleInvite/email flow; remove buildInviteLink +- Pass `courseSlug` and `orgSiteName` from course/org data in createStudentInvite and createEmailInviteAndSend + - createPublicStudentInvite removed; email flow uses buildEnrollLink for invite URLs in emails + +--- + +## Part 4: Redirect Preservation (from earlier discussion) + +**Files:** [apps/dashboard/src/routes/(auth)/login/+page.svelte](apps/dashboard/src/routes/(auth)/login/+page.svelte), [apps/dashboard/src/routes/(auth)/signup/+page.svelte](apps/dashboard/src/routes/(auth)/signup/+page.svelte) + +- On successful auth, preserve `redirect` query param: `window.location.href = redirect ? \`/?redirect=${encodeURIComponent(redirect)} : '/'` + +**File:** [apps/dashboard/src/lib/features/app/init.svelte.ts](apps/dashboard/src/lib/features/app/init.svelte.ts) + +- In `routeUserToNextPage`, move the `redirect` check **before** the `isOrgSite && pathname === '/'` early return so redirect is honored on org sites + +--- + +## Part 5: Delete invite/s Route and Remove Obsolete Code + +All logic from the old invite page moves to the new enroll route. Delete the invite/s route entirely. + +### 5.1 Delete invite/s route files + +**Delete:** + +- [apps/dashboard/src/routes/invite/s/[hash]/+page.svelte](apps/dashboard/src/routes/invite/s/[hash]/+page.svelte) +- [apps/dashboard/src/routes/invite/s/[hash]/+layout.server.ts](apps/dashboard/src/routes/invite/s/[hash]/+layout.server.ts) +- [apps/dashboard/src/routes/invite/s/](apps/dashboard/src/routes/invite/s/) directory (delete if empty after removing [hash]) + +If [invite/t](apps/dashboard/src/routes/invite/t/) (teacher invite) exists, keep it; only student invite s/ is removed. + +### 5.2 Remove obsolete constants + +**File:** [apps/dashboard/src/lib/utils/constants/routes.ts](apps/dashboard/src/lib/utils/constants/routes.ts) + +- Remove `INVITE_STUDENT: '/invite/s'` from `ROUTE` +- Remove `^${ROUTE.INVITE_STUDENT}/.`* from `ROUTES_TO_HIDE_NAV` +- Add enroll route to `PUBLIC_ROUTES`: `^/course/.*/enroll` (or equivalent) + +### 5.3 Remove obsolete backend function + +**File:** [apps/api/src/services/course/invite.ts](apps/api/src/services/course/invite.ts) + +- Remove `buildInviteLink(token)` (replaced by `buildEnrollLink`) +- Ensure no remaining callers of `buildInviteLink`; update all to use `buildEnrollLink(courseSlug, token, orgSiteName)` + +### 5.4 API routes – add, remove, rename, extend + +- **Add:** `POST /course/:courseId/enroll` (unified endpoint; see Part 3) +- **Remove:** `POST /invite/student/public-link`, `POST /invite/student/:token/accept` +- **Rename:** `GET /invite/student/:token/preview` → `GET /invite/student/:token` +- **Extend** `GET /invite/student/:token` response: include `course.slug`, `course.cost`, `org.theme` so enroll page needs no extra fetches +- **Extend** `GET /course/slug/:slug` response: include `org` (id, name, siteName, theme) so free-enroll page needs no separate org fetch + +--- + +## Part 6: People Page – Email Invite Links + +When teachers send email invites, the link in the email must use the new format. The backend change in Part 3 covers this: `buildEnrollLink(courseSlug, token, orgSiteName)` will be used when generating links for `studentCourseInvite` email. + +--- + +## Part 7: Email-Only Invites Plan (from planning doc) + +Per [email-only-invites-plan.md](planning/secure-student-invites/email-only-invites-plan.md): + +- Add Zod refine to `ZCreateCourseInvite`: require `recipientEmails` or `recipientCsv` (broad scope) +- Remove quick-link tab/section from People invite modal; make email the default +- Update `invitation-modal.svelte` to remove QuickLinkSection and set `activeStudentTab = 'email'` + +--- + +## File Change Summary + + +| File | Action | +| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `routes/course/[slug]/enroll/+page.server.ts` | Create – load course, invite preview, validation | +| `routes/course/[slug]/enroll/+page.svelte` | Create – enroll UI, Join flow | +| `lib/utils/constants/routes.ts` | Add enroll to PUBLIC_ROUTES; remove INVITE_STUDENT | +| `features/ui/course-landing-page/.../pricing-section.svelte` | Free: goto `/course/:slug/enroll` | +| `api/services/course/invite.ts` | Add enrollInCourse, buildEnrollLink; extend previewStudentInvite with slug, cost, org.theme; remove public-link, accept, buildInviteLink | +| `routes/(auth)/login/+page.svelte` | Preserve redirect param | +| `routes/(auth)/signup/+page.svelte` | Preserve redirect param | +| `features/app/init.svelte.ts` | Honor redirect before org-site early return | +| `routes/invite/s/[hash]/+page.svelte` | Delete | +| `routes/invite/s/[hash]/+layout.server.ts` | Delete | +| `packages/utils/validation/course/invite.ts` | Zod refine for recipients | +| `features/course/people/invitation-modal.svelte` | Remove quick-link, default to email | +| `features/course/people/quick-link-section.svelte` | Delete | +| `api/routes/invite/invite.ts` | Rename GET /student/:token/preview to GET /student/:token; remove public-link, accept | +| `api/services/course/course.ts` or course route | Extend getCourse (slug) response to include org with theme for enroll page | + + +--- + +## Dependency Notes + +- Course slug available from `getCourseById`/`getCourseWithOrgData`; ensure slug is selected in queries. +- `acceptStudentInvite` logic is reused when enroll is called with `inviteToken`. + diff --git a/.cursor/plans/course_page_ui_storybook.plan.md b/.cursor/plans/course_page_ui_storybook.plan.md new file mode 100644 index 00000000..6afdde88 --- /dev/null +++ b/.cursor/plans/course_page_ui_storybook.plan.md @@ -0,0 +1,511 @@ +--- +name: "" +overview: "" +todos: [] +isProject: false +--- + +# Course Page UI Extraction and Storybook Integration (Detailed Plan) + +## Overview + +Extract the course page UI (sidebar + content shell) into a reusable `CoursePage` component in `packages/ui`, add a Storybook story under `Pages/CoursePage` that accepts JSON to form the whole page, and wire the dashboard layout to use it. **Content rendering in Storybook is view-mode only** (lesson and exercise). Edit mode stays in the dashboard and is out of scope. + +--- + +## Part 0: Prerequisites and Reference Files to Read + +Before starting, a junior dev should read these files to understand the patterns: + +1. **Exercise Question pattern** (how JSON fixtures drive UI): + - `[packages/storybook/src/molecules/exercise-question/exercise-question.stories.svelte](packages/storybook/src/molecules/exercise-question/exercise-question.stories.svelte)` + - `[packages/storybook/src/molecules/exercise-question/question-fixtures.ts](packages/storybook/src/molecules/exercise-question/question-fixtures.ts)` + - `[packages/ui/src/custom/exercise-question/question-renderer.svelte](packages/ui/src/custom/exercise-question/question-renderer.svelte)` +2. **Current course layout** (what we are extracting): + - `[apps/dashboard/src/routes/courses/[id]/+layout.svelte](apps/dashboard/src/routes/courses/[id]/+layout.svelte)` + - `[apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar-navigation.svelte](apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar-navigation.svelte)` +3. **View vs Edit mode** (view mode only in Storybook): + - Exercise view: `[apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte](apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte)` uses `ExerciseQuestion.QuestionList` with `mode: 'view'` + - Lesson view: `[apps/dashboard/src/lib/features/course/components/lesson/utils.ts](apps/dashboard/src/lib/features/course/components/lesson/utils.ts)` – Note, Slide, Video, Document in view mode + +--- + +## Part 1: Define the JSON Config Schema + +### Step 1.1: Create the types file + +**Create file**: `packages/ui/src/custom/course-page/course-page-types.ts` + +**Purpose**: Define the shape of the JSON that drives the CoursePage. No imports from dashboard or API. + +**Contents** (copy and adapt): + +```typescript +/** + * JSON config for CoursePage. Used by Storybook (fixtures) and Dashboard (built from course/org state). + */ +export interface CoursePageConfig { + course: { id: string; title: string }; + sidebar: CourseSidebarConfig; + /** Used by Storybook for view-mode content. Dashboard uses content snippet instead. */ + content?: CourseContentViewConfig; +} + +export interface CourseSidebarConfig { + backUrl: string; + backLabel: string; + navItems: CourseNavItem[]; +} + +export interface CourseNavItem { + id: string; + title: string; + url: string; + isActive: boolean; + isLesson?: boolean; + icon?: CourseNavIconKey; + contentTree?: CourseContentTreeSection[]; + contentCount?: { lessons: number; exercises: number }; +} + +export type CourseNavIconKey = + | 'newsfeed' + | 'content' + | 'analytics' + | 'attendance' + | 'submissions' + | 'marks' + | 'certificates' + | 'landingpage' + | 'people' + | 'settings' + | 'section'; + +export interface CourseContentTreeSection { + id: string; + title: string; + items: CourseContentTreeItem[]; +} + +export interface CourseContentTreeItem { + id: string; + title: string; + type: 'LESSON' | 'EXERCISE'; + url?: string; + isComplete?: boolean; + isUnlocked?: boolean; +} + +/** + * View-mode content config for Storybook. Dashboard does NOT use this – it renders route children. + */ +export type CourseContentViewConfig = + | { type: 'lesson'; data: LessonViewData } + | { type: 'exercise'; data: ExerciseViewData } + | { type: 'placeholder'; data: { label: string } }; + +export interface LessonViewData { + title: string; + note?: string; // HTML string + slideUrl?: string; + videos?: { link: string; type: string; metadata?: Record }[]; + documents?: { title: string; url: string }[]; +} + +export interface ExerciseViewData { + title: string; + questions: Record[]; + answersByKey?: Record; +} +``` + +**Check**: File compiles with `pnpm exec tsc --noEmit` in `packages/ui`. + +--- + +## Part 2: Create the CoursePage UI Components + +### Step 2.1: Icon lookup map + +**Create file**: `packages/ui/src/custom/course-page/icon-map.ts` + +**Purpose**: Map string keys from config to the actual icon components. + +**Contents**: + +```typescript +import type { Component } from 'svelte'; +import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents'; +import { + NewsFeedIcon, + ContentIcon, + AnalyticsIcon, + AttendanceIcon, + SubmissionIcon, + MarksIcon, + CertificateIcon, + LandingPageIcon, + PeopleIcon, + SettingsIcon +} from '@cio/ui/custom/moving-icons'; +import type { CourseNavIconKey } from './course-page-types'; + +export const COURSE_NAV_ICON_MAP: Record = { + section: TableOfContentsIcon, + newsfeed: NewsFeedIcon, + content: ContentIcon, + analytics: AnalyticsIcon, + attendance: AttendanceIcon, + submissions: SubmissionIcon, + marks: MarksIcon, + certificates: CertificateIcon, + landingpage: LandingPageIcon, + people: PeopleIcon, + settings: SettingsIcon +}; + +export function getCourseNavIcon(key: CourseNavIconKey | undefined): Component | null { + if (!key) return null; + return COURSE_NAV_ICON_MAP[key] ?? null; +} +``` + +**Check**: Imports resolve. Moving-icons live at `packages/ui/src/custom/moving-icons`. + +--- + +### Step 2.2: Course page sidebar + +**Create file**: `packages/ui/src/custom/course-page/course-page-sidebar.svelte` + +**Purpose**: Renders the sidebar from `CourseSidebarConfig`. No dashboard/API deps. + +**Logic**: + +- Use `@cio/ui/base/sidebar` primitives: `Sidebar.Root`, `Sidebar.Header`, `Sidebar.Content`, `Sidebar.Group`, `Sidebar.Menu`, `Sidebar.MenuItem`, `Sidebar.MenuButton`, `Sidebar.MenuSub`, `Sidebar.MenuSubItem`, `Sidebar.MenuSubButton`, `Sidebar.Rail`. +- For each `navItem`: if `isLesson` and `contentTree` exists and item `isActive`, render the collapsible tree; otherwise render a simple link. +- Use `getCourseNavIcon(navItem.icon)` for icons. +- Back button: link to `config.sidebar.backUrl` with label `config.sidebar.backLabel`. +- Sidebar header: render a simple "Course" placeholder (or accept a `sidebarHeader` snippet later). +- Use `resolve` from `$app/paths` for URLs – but CoursePage is in packages/ui which may not have SvelteKit. **Alternative**: pass `basePath` or use plain `href={item.url}` since URLs come from config. + +**Note**: `packages/ui` typically does NOT have `$app/paths` (SvelteKit). Use `item.url` directly as `href={item.url}` since config supplies full URLs. + +**Content tree for Lessons**: Use `Collapsible.Root` + `Collapsible.Trigger` + `Collapsible.Content` from `@cio/ui/base/collapsible`. Each section maps to a collapsible; items inside link to `item.url`. Reuse `LessonIcon` and `ExerciseIcon` from `@cio/ui/custom/moving-icons` for item types. + +**Structure** (pseudocode): + +``` +Sidebar.Root collapsible="icon" + Sidebar.Header + slot or "Course" + Sidebar.Content + Sidebar.Group + Button/link back: backLabel -> backUrl + Sidebar.Menu + for each navItem + if isLesson && isActive && contentTree + render link + CourseContentTree (collapsible sections) + else + render link only + Sidebar.Rail +``` + +**CourseContentTree for config**: Create a small component that renders sections/items from `contentTree` (from config, not courseApi). Each item links to `item.url` or builds path from `courseId` + `item.id` + `item.type`. For Storybook we can use `item.url` from the fixture. + +--- + +### Step 2.3: Course page header + +**Create file**: `packages/ui/src/custom/course-page/course-page-header.svelte` + +**Purpose**: Simple header: Sidebar.Trigger + course title. No Search/notifications (dashboard keeps those). + +**Props**: `title: string` + +**Contents**: + +```svelte + +
+ + +

{title}

+
+``` + +Copy classes from `[CourseHeader](apps/dashboard/src/lib/features/course/components/course-header.svelte)`. + +--- + +### Step 2.4: View-mode content renderer (Lesson + Exercise) + +**Create file**: `packages/ui/src/custom/course-page/course-content-view.svelte` + +**Purpose**: Renders view-mode content from `CourseContentViewConfig`. Used only in Storybook. + +**Props**: `config: CourseContentViewConfig` + +**Logic**: + +- If `config.type === 'lesson'`: render `LessonViewContent` with `config.data` +- If `config.type === 'exercise'`: render `ExerciseQuestion.QuestionList` (or `QuestionRenderer` per question) with `mode: 'view'`, `questions`, `answersByKey`, `disabled: true` +- If `config.type === 'placeholder'`: render a simple card with `config.data.label` + +**Create file**: `packages/ui/src/custom/course-page/lesson-view-content.svelte` + +**Purpose**: Read-only lesson view. Accepts `LessonViewData` as props. + +**Rendering** (copy structure from dashboard view mode, but data-driven): + +- **Note**: Use `sanitizeHtml` from `@cio/ui/tools/sanitize` + `
{@html sanitized}
`. If `!data.note`, render nothing. +- **Slide**: If `data.slideUrl`, render ` -{/if} diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/ComponentVideo.svelte b/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/ComponentVideo.svelte deleted file mode 100644 index a5ec4a37..00000000 --- a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/ComponentVideo.svelte +++ /dev/null @@ -1,84 +0,0 @@ - - -{#if $lesson.materials.videos.length} -
- {#each $lesson.materials.videos as video, index} -
- {#key video.link} -
- {#if video.type === 'youtube'} - - {:else if video.type === 'generic'} - - {:else if video.metadata?.svid} -
- -
- {:else} - - {/if} -
- {/key} -
- {/each} -
-{/if} diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/index.svelte b/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/index.svelte deleted file mode 100644 index da1cb49a..00000000 --- a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/index.svelte +++ /dev/null @@ -1,576 +0,0 @@ - - - { - if (!isOpen) onClose(); - }} -> - - - {$t('course.navItem.lessons.materials.tabs.video.add_video.title')} - - - - - - { - if (!isOpen) onDocumentClose(); - }} -> - - - {$t('course.navItem.lessons.materials.tabs.document.upload_title')} - - - - - - -

- {lessonTitle} -

-
- -{#if $lesson.isFetching} - -{:else if mode === MODES.edit} - - - {#each tabs as tab} - - {#if tab.icon} - - {/if} - {$t(tab.label)} - {#if typeof tab.badgeValue === 'number' && tab.badgeValue > 0} - - {/if} - - {/each} - - -
- - -
- -
- onEditorChange(content)} - onReady={() => - // editor - { - // editorInstance = editor; - // editorWindowRef = editor.view.dom.ownerDocument.defaultView; - }} - placeholder={$t('course.navItem.lessons.materials.tabs.note.placeholder')} - /> -
-
- - - {#if mode === MODES.edit} - ($isLessonDirty = true)} - helperMessage={$t('course.navItem.lessons.materials.tabs.slide.helper_message')} - /> - {/if} - - - - {#if $lesson.materials.videos.length} -
- {#each $lesson.materials.videos as video, index} - {#if mode === MODES.edit} -
- deleteLessonVideo(index)}> - - -
- {/if} -
- {#key video.link} -
- {#if video.type === 'youtube'} - - {:else if video.type === 'generic'} - - {:else if video.metadata?.svid} -
- -
- {:else} - - {/if} -
- {/key} -
- {/each} -
- {/if} -
- - - -
-{:else if !isMaterialsEmpty($lesson.materials, $lessonByTranslation[lessonId])} - {#key lessonId} -
- {#each componentsToRender as Component} - - {/each} - - {#if $currentOrg.customization?.apps?.comments} -
- - - {/if} -
- {/key} -{:else} - - {#if !isStudent} - - {/if} - -{/if} diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/NewLessonModal.svelte b/apps/dashboard/src/lib/components/Course/components/Lesson/NewLessonModal.svelte deleted file mode 100644 index d38cf4c5..00000000 --- a/apps/dashboard/src/lib/components/Course/components/Lesson/NewLessonModal.svelte +++ /dev/null @@ -1,175 +0,0 @@ - - - { - if (!isOpen) handleClose(); - }} -> - - - - {$t( - `course.navItem.lessons.add_lesson.${$handleAddLessonWidget.isSection ? 'modal_heading_section' : 'modal_heading'}` - )} - - -
-
- - {#if $course.type == COURSE_TYPE.LIVE_CLASS} - - {/if} -
-
- -
- -
-
-
diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/functions.ts b/apps/dashboard/src/lib/components/Course/components/Lesson/functions.ts deleted file mode 100644 index de5123fa..00000000 --- a/apps/dashboard/src/lib/components/Course/components/Lesson/functions.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { LessonCompletion } from '$lib/utils/types'; - -export function getUniqueValue(label: string) { - return label.toLowerCase().replace(' ', ''); -} - -export function generateQuestion(questions) { - const generatedQuestions = []; - - for (const question of questions) { - const name = getUniqueValue(question.title); - const options = question.options.map((option) => { - option.value = getUniqueValue(option.label); - return option; - }); - - generatedQuestions.push({ - ...question, - name, - options - }); - } - - return generatedQuestions; -} - -export function getIsLessonComplete(completions: LessonCompletion[], profileId: string | undefined): boolean { - if (!Array.isArray(completions)) return false; - return completions.some((c) => { - return c.is_complete && c.profile_id === profileId; - }); -} diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/store.ts b/apps/dashboard/src/lib/components/Course/components/Lesson/store.ts deleted file mode 100644 index 6ca2189e..00000000 --- a/apps/dashboard/src/lib/components/Course/components/Lesson/store.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { writable } from 'svelte/store'; - -export const handleAddLessonWidget = writable({ - id: '', - open: false, - isSection: false -}); diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/store/answers.ts b/apps/dashboard/src/lib/components/Course/components/Lesson/store/answers.ts deleted file mode 100644 index 2b39997a..00000000 --- a/apps/dashboard/src/lib/components/Course/components/Lesson/store/answers.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { writable } from 'svelte/store'; - -import { STATUS } from '../Exercise/constants'; - -// export const questionnaireMetaData = writable({ -// answers: {}, -// scores: { gates: 60 }, -// currentQuestionIndex: 5, -// isFinished: true, -// progressValue: 100, -// status: STATUS.GRADED, -// }); -const initAnswerState = { - answers: {}, - scores: {}, - grades: {}, - totalPossibleGrade: 0, - finalTotalGrade: 0, - currentQuestionIndex: 0, - isFinished: false, - progressValue: 100, - status: STATUS.PENDING, - comment: '' -}; - -export const questionnaireMetaData = writable(initAnswerState); diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/store/lessons.ts b/apps/dashboard/src/lib/components/Course/components/Lesson/store/lessons.ts deleted file mode 100644 index 3d81fdb4..00000000 --- a/apps/dashboard/src/lib/components/Course/components/Lesson/store/lessons.ts +++ /dev/null @@ -1,296 +0,0 @@ -import type { Course, Lesson, LessonComment, LessonCompletion, LessonPage, LessonSection } from '$lib/utils/types'; -import { - createLesson, - createLessonSection, - deleteLesson, - deleteLessonSection, - updateLesson, - updateLessonSection -} from '$lib/utils/services/courses'; - -import type { RealtimeChannel } from '@supabase/supabase-js'; -import type { TLocale } from '@cio/db/types'; -import type { Writable } from 'svelte/store'; -import { lessonValidation } from '$lib/utils/functions/validator'; -import { snackbar } from '$features/ui/snackbar/store'; -import { writable } from 'svelte/store'; - -export const lessonVideoUpload = writable({ - error: null as string | null, - isCancelled: false, - isModalOpen: false, - isUploading: false, - uploadProgress: 0 -}); - -export const lessonDocUpload = writable({ - error: null as string | null, - isCancelled: false, - isModalOpen: false, - isUploading: false, - uploadedDocument: null, - uploadProgress: 0 -}); - -export const lessons: Writable = writable([]); - -export const lessonSections: Writable = writable([]); - -export const lessonCommentsChannel: Writable = writable(); - -export const lesson = writable({ - id: null, - locale: 'en', - title: '', - totalExercises: 0, - totalComments: 0, - isSaving: false, - isFetching: false, - materials: { - note: '', - slide_url: '', - videos: [], - documents: [] - }, - exercises: [], - lesson_completion: [] -}); - -export const lessonByTranslation = writable<{ - [key: string]: Record; -}>({}); - -export const lessonComments = writable([]); - -export const isLessonDirty = writable(false); - -export function setLesson({ - id, - lessonData, - locale, - totalExercises, - totalComments -}: { - id: string; - lessonData: Lesson & { lesson_language: { locale: TLocale; content: string }[] }; - locale: TLocale; - totalExercises: number; - totalComments: number; -}) { - if (!lessonData) return; - - let lesson_completion: LessonCompletion[] = []; - - if (Array.isArray(lessonData.lesson_completion)) { - lesson_completion = [...lessonData.lesson_completion]; - } - - lesson.update((l) => ({ - ...l, - id: id, - title: lessonData.title, - totalExercises, - totalComments: totalComments, - materials: { - videos: lessonData.videos || [], - note: lessonData.note, - slide_url: lessonData.slide_url, - documents: lessonData.documents || [] - }, - lesson_completion, - exercises: [], - locale: locale - })); - - if (Array.isArray(lessonData.lesson_language)) { - lessonByTranslation.update((lessLocales) => { - return { - ...lessLocales, - [id]: lessonData.lesson_language.reduce( - (acc, cur) => { - acc[cur.locale] = cur.content; - return acc; - }, - {} as Record - ) - }; - }); - } -} - -export function handleAddLesson() { - lessons.update((_lessons) => { - const newLesson = { - id: '', - title: 'Untitled lesson', - lesson_at: new Date().toDateString(), - is_unlocked: false, - course_id: '', - created_at: new Date().toDateString(), - lesson_completion: [] - } as Lesson; - - return [..._lessons, newLesson]; - }); -} - -export async function handleDelete(lessonId: Lesson['id'] | undefined) { - // Need to implement soft delete - if (!lessonId) { - return; - } - const { error } = await deleteLesson(lessonId); - - if (error) { - snackbar.error(error.message); - return console.error('Error deleting course', error); - } - - lessons.update((_lessons) => _lessons.filter((lesson) => lesson.id !== lessonId)); - lessonSections.update((_sections) => - _sections.map((section) => { - section.lessons = section.lessons.filter((lesson) => lesson.id !== lessonId); - return section; - }) - ); - - snackbar.success('snackbar.generic.success_delete'); - - console.log(`lessonId`, lessonId); -} - -export async function handleDeleteSection(sectionId: LessonSection['id'] | undefined) { - // Need to implement soft delete - if (!sectionId) { - return; - } - const { error } = await deleteLessonSection(sectionId); - - if (error) { - snackbar.error(error.message); - return console.error('Error deleting course', error); - } - - lessonSections.update((_sections) => _sections.filter((section) => section.id !== sectionId)); - lessons.update((_lessons) => _lessons.filter((lesson) => lesson.section_id !== sectionId)); - - snackbar.success('snackbar.generic.success_delete'); - - console.log(`sectionId`, sectionId); -} - -export async function handleSaveLesson(lesson: Lesson, courseId: Course['id']) { - const result = lessonValidation(lesson); - - if (Object.keys(result).length) { - return result; - } - - console.log(`handleSaveLesson lesson`, lesson); - const newLesson = { - title: lesson.title, - lesson_at: lesson?.lesson_at, - call_url: lesson?.call_url, - teacher_id: lesson?.profile ? lesson?.profile.id : undefined, - course_id: courseId, - is_unlocked: lesson.is_unlocked, - section_id: lesson.section_id - }; - - let newLessonData: Lesson | null | undefined = null; - - if (lesson.id) { - // No need to get the result of update cause we have all in local state - await updateLesson(newLesson, lesson.id); - } else { - const { data } = await createLesson(newLesson); - - newLessonData = data?.[0]; - } - return newLessonData; -} -export async function handleSaveLessonSection(section: Partial, courseId: Course['id']) { - const result = lessonValidation(section); - - if (Object.keys(result).length) { - return result; - } - console.log(`handleSaveLessonSection lesson`, section); - - const newSection: Partial = { - id: section.id, - title: section.title, - course_id: courseId - }; - - let newSectionData: any[] | null = null; - - if (newSection.id) { - // No need to get the result of update cause we have all in local state - await updateLessonSection(newSection, newSection.id); - } else { - const { data } = await createLessonSection(newSection); - - newSectionData = data; - } - - return newSectionData; -} - -export async function handleUpdateLessonMaterials(lesson: any, lessonId: Lesson['id']) { - const materials = { - ...lesson.materials - }; - delete materials.lesson_completion; - delete materials.profile; - - return await updateLesson(materials, lessonId); -} - -export const deleteLessonVideo = (index: any) => { - lesson.update((currentLesson) => ({ - ...currentLesson, - materials: { - ...currentLesson.materials, - videos: currentLesson.materials.videos.filter((video, i) => i !== index) - } - })); -}; - -export const deleteLessonDocument = (index: any) => { - lesson.update((currentLesson) => ({ - ...currentLesson, - materials: { - ...currentLesson.materials, - documents: (currentLesson.materials.documents || []).filter((document, i) => i !== index) - } - })); -}; - -export function resetDocumentUploadStore() { - lessonDocUpload.set({ - isUploading: false, - isModalOpen: false, - uploadProgress: 0, - uploadedDocument: null, - error: null, - isCancelled: false - }); -} - -export function cancelDocumentUpload() { - lessonDocUpload.update((store) => ({ - ...store, - isCancelled: true, - isUploading: false, - error: 'Upload cancelled by user' - })); -} - -export function cancelVideoUpload() { - lessonVideoUpload.update((store) => ({ - ...store, - isCancelled: true, - isUploading: false - })); -} diff --git a/apps/dashboard/src/lib/components/Course/components/NewsFeed/NewFeedModal.svelte b/apps/dashboard/src/lib/components/Course/components/NewsFeed/NewFeedModal.svelte deleted file mode 100644 index de8e40c0..00000000 --- a/apps/dashboard/src/lib/components/Course/components/NewsFeed/NewFeedModal.svelte +++ /dev/null @@ -1,153 +0,0 @@ - - - { - if (!isOpen) resetEditor(); - }} -> - - - - {edit === true - ? $t('course.navItem.news_feed.heading_button.edit_post') - : $t('course.navItem.news_feed.heading_button.make_a_post')} - - -
- { - newPost = text; - }} - editorClass="max-h-[400px]" - placeholder={$t('course.navItem.news_feed.heading_button.placeholder')} - /> - {#if errors.newPost} -

{errors.newPost}

- {/if} -
-
- - -
-
-
-
-
diff --git a/apps/dashboard/src/lib/components/Course/components/NewsFeed/NewsFeedCard.svelte b/apps/dashboard/src/lib/components/Course/components/NewsFeed/NewsFeedCard.svelte deleted file mode 100644 index 70da7ba7..00000000 --- a/apps/dashboard/src/lib/components/Course/components/NewsFeed/NewsFeedCard.svelte +++ /dev/null @@ -1,265 +0,0 @@ - - -
-
-
-
- -
- users banner -
- -

{feed.author?.profile?.fullname}

-

{calDateDiff(feed.created_at)}

-
-
- - - - - - - onPin(feed.id, feed.isPinned)}> - {feed.isPinned ? 'Unpin' : 'Pin'} - - Edit - (isDeleteFeedModal = true)}> - Delete - - - - -
- {#if !isHtmlValueEmpty(feed.content)} - -
- {@html sanitizeHtml(feed.content)} -
-
- {/if} -
- -
-
- {#each Object.keys(feed.reaction) as reactionType} - {#if reactions[reactionType]} - - {/if} - {/each} -
-
-
- -
- {#if feed.comment.length > 0} - - {/if} -
- {#each feed.comment as comment, index} - {#if comment.content && (areCommentsExpanded || index === feed.comment.length - 1)} -
- -
- users banner -
- -
-

{comment.author?.profile?.fullname}

-

{calDateDiff(comment.created_at)}

-
-

{comment.content}

-
-
- - {#if comment.author?.profile?.id === $profile.id || $isOrgAdmin} - - - - handleDeleteComment(comment.id)}> - Delete - - - - {/if} -
- {/if} - {/each} -
-
-
- users banner -
-
- -
- -
- {#if errors?.newComment} -

{errors?.newComment}

- {/if} -
- -
diff --git a/apps/dashboard/src/lib/components/Course/components/NewsFeed/store.ts b/apps/dashboard/src/lib/components/Course/components/NewsFeed/store.ts deleted file mode 100644 index 5a840c1a..00000000 --- a/apps/dashboard/src/lib/components/Course/components/NewsFeed/store.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { Feed } from '$lib/utils/types/feed'; -import { writable } from 'svelte/store'; - -export const isNewFeedModal = writable({ - open: false -}); - -export const newsFeed = writable([]); diff --git a/apps/dashboard/src/lib/components/Course/components/People/InvitationModal.svelte b/apps/dashboard/src/lib/components/Course/components/People/InvitationModal.svelte deleted file mode 100644 index 57c0e28f..00000000 --- a/apps/dashboard/src/lib/components/Course/components/People/InvitationModal.svelte +++ /dev/null @@ -1,290 +0,0 @@ - - - { - if (!isOpen) closeModal(); - }} -> - - - {$t('course.navItem.people.invite_modal.title')} - -
-
-

{$t('course.navItem.people.invite_modal.invite')}

- - -
- {#if selectedTutors.length > 0} -
- {#each selectedTutors as tutor} - - {tutor.text} - - - {/each} -
- {:else} - {$t('course.navItem.people.invite_modal.select')} - {/if} -
-
- - {#each tutors as tutor} - - {tutor.text} - - {/each} - -
- {#if orgApi.isLoading} -
- - Loading... -
- {:else} - - {$t('course.navItem.people.invite_modal.to_add')} - - {$t('course.navItem.people.invite_modal.go_to')} - - - {/if} -
- -
-
-

- {$t('course.navItem.people.invite_modal.invite_students')} -

-

{$t('course.navItem.people.invite_modal.via_link')}

-
- - - - - - -
{$t('course.navItem.people.invite_modal.success')}
-
-
-
- -
-
- - {$t('course.navItem.people.invite_modal.via_qr')} - - - -
- -
- link qrcode -
-
- -
- -
- -
- -
-
-
-
diff --git a/apps/dashboard/src/lib/components/Course/components/People/ShareQRImage.svelte b/apps/dashboard/src/lib/components/Course/components/People/ShareQRImage.svelte deleted file mode 100644 index bf5692e3..00000000 --- a/apps/dashboard/src/lib/components/Course/components/People/ShareQRImage.svelte +++ /dev/null @@ -1,41 +0,0 @@ - - -
-
-
Scan QR
- qrcode -
-

{course.title}

-

{currentOrg.name}

-
-
- {#if $isFreePlan} -
- logo - ClassroomIO.com -
- {/if} -
diff --git a/apps/dashboard/src/lib/components/Course/components/People/store.ts b/apps/dashboard/src/lib/components/Course/components/People/store.ts deleted file mode 100644 index c7b637c3..00000000 --- a/apps/dashboard/src/lib/components/Course/components/People/store.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { writable } from 'svelte/store'; - -export const deleteMemberModal = writable({ - open: false -}); - -export const qrInviteNodeStore = writable(null); diff --git a/apps/dashboard/src/lib/components/Course/components/People/types.ts b/apps/dashboard/src/lib/components/Course/components/People/types.ts deleted file mode 100644 index 0791158f..00000000 --- a/apps/dashboard/src/lib/components/Course/components/People/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -export interface Profile { - id: string; - fullname: string; - username: string; - email: string; - avatar_url?: string; -} - -export interface Person { - id: string; - email?: string; - role_id: number; - profile?: Profile; - profile_id?: string; - assigned_student_id: string; -} - -export interface ProfileRole { - label: string; - value: string | number; -} diff --git a/apps/dashboard/src/lib/components/Course/constants.ts b/apps/dashboard/src/lib/components/Course/constants.ts deleted file mode 100644 index e5a6d05d..00000000 --- a/apps/dashboard/src/lib/components/Course/constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const ROUTES = { - COURSES: 'courses', - LESSONS: 'lessons' -}; diff --git a/apps/dashboard/src/lib/components/Course/function.ts b/apps/dashboard/src/lib/components/Course/function.ts deleted file mode 100644 index 94d27822..00000000 --- a/apps/dashboard/src/lib/components/Course/function.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { ROUTES } from './constants'; - -export function getGroupMemberId(people, profileId) { - const groupMember = people.find((person) => person.profile_id === profileId); - - return groupMember ? groupMember.id : null; -} - -export function getNavItemRoute(courseId, routeId?: string) { - const path = `/${ROUTES.COURSES}/${courseId}`; - - if (!routeId) { - return path; - } - - return `${path}/${routeId}`; -} - -export function getLessonsRoute(courseId, lessonId?: string) { - const path = getNavItemRoute(courseId, ROUTES.LESSONS); - - if (!lessonId) { - return path; - } - - return `${path}/${lessonId}`; -} - -export function getLectureNo(index, initNo = '0') { - if (index <= 9) { - return `${initNo}${index}`; - } - - return index; -} - -export function formatAnswers(data) { - const answers: Record = {}; - const questionByIdAndName = {}; - - for (const question of data.questions) { - questionByIdAndName[question.id] = question.name; - } - - for (const answer of data.answers) { - const questionName = questionByIdAndName[answer.question_id]; - - answers[questionName] = - Array.isArray(answer.answers) && answer.answers.length ? answer.answers : answer.open_answer; - } - - return answers; -} diff --git a/apps/dashboard/src/lib/components/Course/store.ts b/apps/dashboard/src/lib/components/Course/store.ts deleted file mode 100644 index a36e1b5d..00000000 --- a/apps/dashboard/src/lib/components/Course/store.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { COURSE_TYPE, COURSE_VERSION } from '$lib/utils/types'; -import type { Course, GroupPerson, Lesson, LessonSection } from '$lib/utils/types'; -import { get, writable, type Writable } from 'svelte/store'; -import { lessonSections, lessons } from './components/Lesson/store/lessons'; - -import { ROLE } from '@cio/utils/constants'; -import { fetchCourse } from '$lib/utils/services/courses'; - -export const defaultCourse: Course = { - id: '', - title: '', - description: '', - type: COURSE_TYPE.LIVE_CLASS, - cost: 0, - currency: '', - status: 'ACTIVE', - is_certificate_downloadable: false, - certificate_theme: 'professional', - is_published: false, - created_at: new Date().toDateString(), - updated_at: new Date().toDateString(), - attendance: [], - polls: [], - version: COURSE_VERSION.V2, - metadata: { - requirements: '', - description: '', - goals: '', - videoUrl: '', - showDiscount: false, - discount: 0, - paymentLink: '', - reward: { - show: false, - description: '' - }, - instructor: { - name: '', - role: '', - coursesNo: 0, - description: '', - imgUrl: '' - }, - lessonTabsOrder: [ - { id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' }, - { id: 2, name: 'course.navItem.lessons.materials.tabs.slide.title' }, - { id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' }, - { id: 4, name: 'course.navItem.lessons.materials.tabs.document.title' } - ], - grading: false, - lessonDownload: true, - allowNewStudent: false - } -}; - -export const course: Writable = writable({ ...defaultCourse }); - -class CourseStore { - prevId = writable(''); - isLoading = writable(false); - - async fetch(id: string) { - console.log('----------------FETCHING COURSE----------------'); - console.log('1. fetching course', id); - if (get(this.prevId) === id) { - console.log('2. course already fetched'); - return; - } - - console.log('3. fetching course', id); - - this.isLoading.update(() => true); - this.prevId.set(id); - - course.set(defaultCourse); - lessons.set([]); - - const { data } = await fetchCourse(id); - if (data) { - console.log('4. setting course'); - setCourse(data); - } - console.log('5. setting isLoading to false'); - - this.isLoading.update(() => false); - console.log('--------------FETCHING COURSE COMPLETED------------------'); - } -} -export const courseStore = new CourseStore(); - -export const mockGroupMember = { - id: '434534534535', - group_id: '434534534535', - role_id: 1, - profile_id: 'vcvbcvbcvbb----434534534535', - email: null, - profile: { - id: 'vcvbcvbcvbb----434534534535', - fullname: 'Rotimi Best', - username: 'robertblake', - avatar_url: '', - created_at: '2021-08-08T13:42:13+00:00', - updated_at: '2021-08-08T13:42:13+00:00' - } -}; - -type GroupStore = { - id?: string; - tutors: GroupPerson[]; - students: GroupPerson[]; - people: GroupPerson[]; - members?: GroupPerson[]; - memberId?: string; -}; - -export const group = writable({ - id: '', - tutors: [], - students: [], - people: [] -}); - -export async function setCourse(data: Course, setLesson = true) { - if (!data || !(Object.values(data) && Object.values(data).length)) return; - - if (data.group) { - const copiedGroup = JSON.parse(JSON.stringify(data.group)); - const groupData = Object.assign(copiedGroup, { - id: copiedGroup.id, - tutors: [], - students: [], - people: [] - }); - - if (Array.isArray(data.group.members)) { - for (const member of data.group.members) { - if (member.role_id === ROLE.STUDENT) { - groupData.students.push(member); - } else if (member.profile) { - groupData.tutors.push({ - ...member, - memberId: member.id - }); - } - } - - groupData.people = data.group.members; - } - - group.set(groupData); - } - - if (setLesson) { - const orderedLessons = sortLesson(data.lessons); - lessons.set(orderedLessons); - - console.log('orderedLessons', orderedLessons); - - if (data.lesson_section) { - const sections = data.lesson_section?.map((section) => { - const lessons = (data.lessons || []).filter((lesson) => lesson.section_id === section.id); - section.lessons = sortLesson(lessons); - return section; - }); - - lessonSections.set(sortLessonSection(sections)); - } - } - - delete data.lessons; - delete data?.lesson_section; - - if (data.metadata && !Object.values(data.metadata)) { - data.metadata = { - requirements: '', - description: '', - goals: '', - videoUrl: '', - showDiscount: false, - discount: 0, - reward: { - show: false, - description: '' - }, - instructor: { - name: '', - role: '', - coursesNo: 0, - description: '', - imgUrl: '' - }, - allowNewStudent: false - }; - } - // Ensure lessonTabsOrder includes all tabs (backward compatibility) - if (data.metadata && data.metadata.lessonTabsOrder) { - const existingTabIds = data.metadata.lessonTabsOrder.map((tab) => tab.id); - const allTabs = [ - { id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' }, - { id: 2, name: 'course.navItem.lessons.materials.tabs.slide.title' }, - { id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' }, - { id: 4, name: 'course.navItem.lessons.materials.tabs.document.title' } - ]; - - let hasChanges = false; - // Add missing tabs to existing lessonTabsOrder - allTabs.forEach((tab) => { - if (!existingTabIds.includes(tab.id) && data.metadata.lessonTabsOrder) { - data.metadata.lessonTabsOrder.push(tab); - hasChanges = true; - } - }); - - if (hasChanges) { - // Document tab has been added to lessonTabsOrder - } - } - - if (!data.certificate_theme) { - data.certificate_theme = 'professional'; - } - - console.log('lessons', get(lessons)); - course.set(data); -} - -export function sortLesson(lessons: Lesson[] = []) { - return lessons - .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) - .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); -} - -export function sortLessonSection(sections: LessonSection[] = []) { - return sections - .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) - .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); -} diff --git a/apps/dashboard/src/lib/components/Course/types.ts b/apps/dashboard/src/lib/components/Course/types.ts deleted file mode 100644 index 0da506d1..00000000 --- a/apps/dashboard/src/lib/components/Course/types.ts +++ /dev/null @@ -1,28 +0,0 @@ -export type Question = { - id: number | string; - value: string; - name: string; - title: string; - type: number; - points: number; - order: number; - question_type: { - id: number; - label: string; - }; - question_type_id: number; - code?: string; - answers?: Array; - deleted_at?: string; - created_at?: string; - updated_at?: string; - is_dirty?: boolean; - options: { - id: number | string; - label: string | null; - value: string | null; - is_correct: boolean; - deleted_at?: string; - is_dirty?: boolean; - }[]; -}; diff --git a/apps/dashboard/src/lib/components/CourseContainer/index.svelte b/apps/dashboard/src/lib/components/CourseContainer/index.svelte deleted file mode 100644 index 28c3ce17..00000000 --- a/apps/dashboard/src/lib/components/CourseContainer/index.svelte +++ /dev/null @@ -1,100 +0,0 @@ - - - - {$course.title || 'ClassroomIO Course'} - - -{#if isFetching} - - - -{/if} - - - - - {$t('course.not_permitted.header')} - -
-

- {$t('course.not_permitted.body')} -

- -
- -
-
-
-
- -{#if renderOnlyChildren} - {@render children?.()} -{:else} - {#if isExercisePage} - - {/if} - - - {#if isPermitted} - {@render children?.()} - {/if} -{/if} diff --git a/apps/dashboard/src/lib/components/Navigation/CustomLinks.svelte b/apps/dashboard/src/lib/components/Navigation/CustomLinks.svelte index 75368cf2..77364cd6 100644 --- a/apps/dashboard/src/lib/components/Navigation/CustomLinks.svelte +++ b/apps/dashboard/src/lib/components/Navigation/CustomLinks.svelte @@ -1,4 +1,5 @@ {#if customLinks} diff --git a/apps/dashboard/src/lib/components/Navigation/Logo.svelte b/apps/dashboard/src/lib/components/Navigation/Logo.svelte index 01819212..5251c27f 100644 --- a/apps/dashboard/src/lib/components/Navigation/Logo.svelte +++ b/apps/dashboard/src/lib/components/Navigation/Logo.svelte @@ -1,4 +1,5 @@ diff --git a/apps/dashboard/src/lib/features/course/components/exercise/functions.ts b/apps/dashboard/src/lib/features/course/components/exercise/functions.ts new file mode 100644 index 00000000..8f5e7aff --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/exercise/functions.ts @@ -0,0 +1,166 @@ +import { getQuestionAnswerKey, getQuestionTypeKey, questionTypeSupportsOptions } from './question-type-utils'; + +import { QUESTION_TYPE_KEY } from '@cio/question-types'; +import type { Question } from '$features/course/types'; +import type { QuestionnaireMetaData } from './store'; +import type { TExerciseUpdate } from '@cio/utils/validation/exercise'; +import { toggleConfetti } from '$features/ui/confetti/store'; + +export const isAnswerCorrect = (options, answer) => { + return options.some((option) => option.isCorrect && option.value === answer); +}; + +/* + GRADING RULES + Textarea = no validation + Single = only one should be correct + Multiple = all should be correct +*/ +export function wasCorrectAnswerSelected(currentQuestion: Question, answers, isFinished?: boolean) { + const questionTypeKey = getQuestionTypeKey(currentQuestion); + if (!questionTypeSupportsOptions(questionTypeKey)) { + return true; + } + + const answer = answers[getQuestionAnswerKey(currentQuestion)]; + + const formattedAnswers = typeof answer === 'string' ? [answer] : answer; + + let isCorrect = false; + + if (questionTypeKey === QUESTION_TYPE_KEY.CHECKBOX) { + // Every correct answer should be in the selected answer + isCorrect = currentQuestion.options + .filter((o) => o.isCorrect) + .every((option) => formattedAnswers?.includes(option.value)); + } else if (questionTypeKey === QUESTION_TYPE_KEY.RADIO) { + // At least one correct answer should be selected + isCorrect = formattedAnswers?.some((answer) => isAnswerCorrect(currentQuestion.options, answer)); + } + + if (isCorrect && !isFinished) { + toggleConfetti(); + + setTimeout(toggleConfetti, 100); + } + + return isCorrect; +} + +export function getPropsForQuestion( + questions: Question[], + question: Question, + questionnaireMetaData: QuestionnaireMetaData, + questionIndex: number, + onSubmit, + onPrevious, + preview +) { + const { answers, isFinished } = questionnaireMetaData; + const isLast = questionIndex === questions.length; + const isOpenQuesiton = !questionTypeSupportsOptions(getQuestionTypeKey(question)); + const isCorrect = wasCorrectAnswerSelected(question, answers, isFinished); + const answerKey = getQuestionAnswerKey(question, questionIndex - 1); + + if (!isCorrect && document && document.getElementById('question')) { + const questionElement = document.getElementById('question'); + // Shake if wrong answer was selected + questionElement?.classList.toggle('shake'); + + setTimeout(() => { + // Remove so we can trigger animation again + questionElement?.classList.toggle('shake'); + }, 200); + } + + return { + index: questionIndex + '. ', + title: question.title, + name: answerKey, + options: question.options, + code: question.code, + defaultValue: isOpenQuesiton ? answers[answerKey] || '' : answers[answerKey] || [], + onSubmit, + onPrevious, + disablePreviousButton: questionIndex === 1, + isLast, + isPreview: preview || isFinished, + isCorrect, + disabled: (isFinished && isCorrect) || isFinished, + nextButtonProps: isOpenQuesiton + ? { + label: 'Next', + isActive: true + } + : isCorrect + ? { + label: isLast ? 'Finish' : 'Next', + isActive: true, + disableOptionSelect: true, + isDisabled: isLast ? false : true // Disable Next button because auto next is enabled + } + : { + label: 'Check', + isActive: false + } + }; +} + +export function filterOutDeleted(array) { + return array.filter((item) => !item.deletedAt); +} + +/** + * Transforms questions from the questionnaire format to the API format + * @param questions - Array of questions from the questionnaire store + * @param options - Configuration options + * @param options.shouldFilterEmptyLabels - If true, filters out options with empty labels (for API submission). If false, keeps empty labels (for validation) + * @param options.shouldIncludeDeleted - If true, includes deleted questions and options with deletedAt flag (for API updates). If false, filters them out (for validation) + * @returns Transformed questions in API format + */ +export function transformQuestionsToApiFormat( + questions: Question[], + options: { shouldFilterEmptyLabels?: boolean; shouldIncludeDeleted?: boolean } = {} +): TExerciseUpdate['questions'] { + const { shouldFilterEmptyLabels = false, shouldIncludeDeleted = false } = options; + const getQuestionSettings = (question: Question) => { + const rawSettings = (question as Question & { settings?: Record }).settings; + return rawSettings && typeof rawSettings === 'object' ? { ...rawSettings } : undefined; + }; + const getOptionSettings = (option: Question['options'][number]) => { + const rawSettings = (option as Question['options'][number] & { settings?: Record }).settings; + return rawSettings && typeof rawSettings === 'object' ? { ...rawSettings } : undefined; + }; + + const normalizeId = (id: unknown) => (id && !isNaN(Number(id)) ? Number(id) : undefined); + const formatOptions = (opts: Question['options']) => { + const filtered = shouldIncludeDeleted ? opts || [] : (opts || []).filter((opt) => !opt.deletedAt); + + return filtered + .filter((opt) => { + // For validation, keep empty labels. For API, filter them unless deleted + if (!shouldIncludeDeleted && opt.deletedAt) return false; + return shouldFilterEmptyLabels && !opt.deletedAt ? opt.label?.trim() : true; + }) + .map((opt) => ({ + id: normalizeId(opt.id), + label: shouldFilterEmptyLabels && !opt.deletedAt ? opt.label?.trim() || '' : opt.label || '', + isCorrect: opt.isCorrect || false, + settings: getOptionSettings(opt), + ...(shouldIncludeDeleted && opt.deletedAt ? { deletedAt: opt.deletedAt } : {}) + })); + }; + + const filteredQuestions = shouldIncludeDeleted ? questions : questions.filter((q) => !q.deletedAt); + + return filteredQuestions.map((q, index) => ({ + id: normalizeId(q.id), + question: q.title, + points: q.points || 0, + questionTypeId: q.questionTypeId, + order: typeof (q as { order?: number }).order === 'number' ? (q as { order: number }).order : index + 1, + settings: getQuestionSettings(q), + options: questionTypeSupportsOptions(getQuestionTypeKey(q)) ? formatOptions(q.options) : [], + ...(shouldIncludeDeleted && q.deletedAt ? { deletedAt: q.deletedAt } : {}) + })); +} diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/GradingLoader.svelte b/apps/dashboard/src/lib/features/course/components/exercise/grading-loader.svelte similarity index 100% rename from apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/GradingLoader.svelte rename to apps/dashboard/src/lib/features/course/components/exercise/grading-loader.svelte diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/MarkExerciseModal.svelte b/apps/dashboard/src/lib/features/course/components/exercise/mark-exercise-modal.svelte similarity index 53% rename from apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/MarkExerciseModal.svelte rename to apps/dashboard/src/lib/features/course/components/exercise/mark-exercise-modal.svelte index 19a6a63c..2cfa71e4 100644 --- a/apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/MarkExerciseModal.svelte +++ b/apps/dashboard/src/lib/features/course/components/exercise/mark-exercise-modal.svelte @@ -7,16 +7,16 @@ import { STATUS } from './constants'; import { t } from '$lib/utils/functions/translations'; - import { snackbar } from '$features/ui/snackbar/store'; - import type { SubmissionIdData } from '$lib/utils/types/submission'; + import type { SubmissionIdData } from '$features/course/utils/types'; + import type { AnswerData } from '@cio/question-types'; - import Preview from './Preview.svelte'; + import Preview from './preview.svelte'; import * as Dialog from '@cio/ui/base/dialog'; import { TextareaField } from '@cio/ui/custom/textarea-field'; import { Button } from '@cio/ui/base/button'; // import { useCompletion } from 'ai/svelte'; - // import { QUESTION_TYPE } from '$lib/components/Question/constants'; + // import { QUESTION_TYPE } from '$features/ui/question/constants'; interface Props { open?: boolean; @@ -25,7 +25,6 @@ isGradeWithAI?: boolean; data: SubmissionIdData; deleteSubmission?: any; - updateStatus?: any; isSaving?: boolean; } @@ -36,7 +35,6 @@ isGradeWithAI = $bindable(false), data = $bindable(), deleteSubmission = async (_id: string, _statusId: number) => {}, - updateStatus = (_arg: { submissionId: string; prevStatusId: number; nextStatusId: number; total: number }) => {}, isSaving = false }: Props = $props(); @@ -61,16 +59,29 @@ let openDeletePrompt = $state(false); let isDeleting = $state(false); - const total = calculateTotal(data.questionAnswerByPoint); const maxPoints = $derived(getMaxPoints(data.questions)); const sortedQuestions = $derived( - Array.isArray(data.questions) ? [...data.questions].sort((a, b) => a.order - b.order) : [] + Array.isArray(data.questions) + ? [...data.questions] + .sort((a, b) => a.order - b.order) + .map((q) => ({ + ...q, + questionType: { id: q.questionTypeId } + })) + : [] ); + let answersToDisplay = $state>({}); + + $effect(() => { + answersToDisplay = data?.answers ? (data.answers as Record) : {}; + }); + function getMaxPoints(questions) { return (questions || []).reduce((acc, question) => acc + question.points, 0); } + let total = $derived(calculateTotal(data.questionAnswerByPoint)); function calculateTotal(grades: Record): number { if (!grades) return 0; return Object.values(grades).reduce((acc, grade) => acc + parseInt(grade || '0'), 0); @@ -78,24 +89,11 @@ function handleStatusChange(value: string) { const newSelectedId = parseInt(value); - const prevStatusId = data.statusId; - - // Find the new status const newStatus = SELECTABLE_STATUS.find((s) => s.id === newSelectedId); if (newStatus) { - // Update state status = newStatus; data.statusId = newSelectedId; - - updateStatus({ - submissionId: data.id, - prevStatusId: prevStatusId, - nextStatusId: newSelectedId, - total - }); - - snackbar.success(`${$t('snackbar.exercise.submission_updated')} '${newStatus.text}'`); } } @@ -236,108 +234,114 @@ if (!isOpen) onClose(); }} > - + {data.title} -
- {#if openDeletePrompt} -
-

- {$t('delete_modal.content')} -

+
+
+ {#if openDeletePrompt} +
+

+ {$t('delete_modal.content')} +

-
- - +
+ + +
-
- {:else} - - {/if} -
-
-
-
-

- {$t('course.navItem.submissions.grading_modal.details')} - {#if data.isEarly} - - {$t('course.navItem.submissions.grading_modal.early')} + {/if} +

+ +
+
+
+

+ {$t('course.navItem.submissions.grading_modal.details')} + {#if data.isEarly} + + {$t('course.navItem.submissions.grading_modal.early')} + + {:else} + + {$t('course.navItem.submissions.grading_modal.late')} + + {/if} +

+ + + - {:else} - - {$t('course.navItem.submissions.grading_modal.late')} - - {/if} -

+ +
+ + { + openDeletePrompt = true; + }} + > + {$t('delete_modal.label')} + + +
+
- - - - - - { - openDeletePrompt = true; - }} - > - {$t('delete_modal.label')} - - - -
+
+

+ {$t('course.navItem.submissions.grading_modal.total_grade')}: +

-
-

- {$t('course.navItem.submissions.grading_modal.total_grade')}: -

- - - {total}/{maxPoints} - -
- -
-

- {$t('course.navItem.submissions.grading_modal.student')}: -

- {#if data.student} -
- Student avatar -

- {data.student.fullname} -

-
- {/if} -
- -
-

- {$t('course.navItem.submissions.grading_modal.status')}: -

- { - if (value) { - handleStatusChange(value); - } - }} - > - - {status.text} - - - {#each SELECTABLE_STATUS as statusOption} - - {statusOption.text} - - {/each} - - -
- -
-

- {$t('course.navItem.submissions.grading_modal.add_comment')}: -

- -
- -
- - -
- +
- - diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/OrderModal.svelte b/apps/dashboard/src/lib/features/course/components/exercise/order-modal.svelte similarity index 92% rename from apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/OrderModal.svelte rename to apps/dashboard/src/lib/features/course/components/exercise/order-modal.svelte index 5beb0d19..896d9fa3 100644 --- a/apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/OrderModal.svelte +++ b/apps/dashboard/src/lib/features/course/components/exercise/order-modal.svelte @@ -8,7 +8,7 @@ import { flip } from 'svelte/animate'; import * as Dialog from '@cio/ui/base/dialog'; - import { questionnaireOrder, questionnaire } from '../store/exercise'; + import { questionnaireOrder, questionnaire } from './store'; import { filterOutDeleted } from './functions'; import { t } from '$lib/utils/functions/translations'; @@ -27,7 +27,7 @@ items = filterOutDeleted(questions).map((q) => ({ id: q.id, name: q.title, - type: q.question_type.id + type: q.questionType.id })); }); } @@ -45,7 +45,7 @@ const order = index + 1; $questionnaire.questions.some((q) => { if (q.id === item.id) { - q.is_dirty = true; + q.isDirty = true; q.order = order; return true; } @@ -86,7 +86,7 @@ {#each items as item (item.id)}
{#if item.type === 1} diff --git a/apps/dashboard/src/lib/features/course/components/exercise/order-utils.ts b/apps/dashboard/src/lib/features/course/components/exercise/order-utils.ts new file mode 100644 index 00000000..8d5e36f4 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/exercise/order-utils.ts @@ -0,0 +1,26 @@ +/** + * If multiple questions have order 0 (or null), they are considered unordered. + * Normalizes by sorting by id and assigning incrementing order values. + * Returns questions with order set; original array is not mutated. + */ +export function normalizeQuestionOrder(questions: T[]): T[] { + if (questions.length === 0) return questions; + + const withOrderZero = questions.filter((q) => q.order == null || q.order === 0); + const isUnordered = withOrderZero.length > 1; + + const toNumericId = (id: number | string | undefined): number => + typeof id === 'number' ? id : typeof id === 'string' ? parseInt(id, 10) || 0 : 0; + + if (!isUnordered) { + return [...questions].sort((a, b) => { + const orderA = a.order ?? 0; + const orderB = b.order ?? 0; + if (orderA !== orderB) return orderA - orderB; + return toNumericId(a.id) - toNumericId(b.id); + }); + } + + const sortedById = [...questions].sort((a, b) => toNumericId(a.id) - toNumericId(b.id)); + return sortedById.map((q, index) => ({ ...q, order: index + 1 })); +} diff --git a/apps/dashboard/src/lib/features/course/components/exercise/preview.svelte b/apps/dashboard/src/lib/features/course/components/exercise/preview.svelte new file mode 100644 index 00000000..e221487c --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/exercise/preview.svelte @@ -0,0 +1,114 @@ + + +{#if disableGrading && !Object.keys(grades || {}).length} + +{:else if disableGrading && Object.keys(grades || {}).length} + {#each questions as currentQuestion, index} + {@const questionModel = questionModels[index]} +
+
+

Q{index + 1}

+ +
+ + +
+ {/each} +{:else} + {#each questions as currentQuestion, index} + {@const questionModel = questionModels[index]} +
+
+

Q{index + 1}

+ +
+ + + + {#if isGradeWithAI} + rejectGradeSuggestion(currentQuestion.id)} + /> + {/if} +
+ {/each} +{/if} diff --git a/apps/dashboard/src/lib/features/course/components/exercise/question-labels.ts b/apps/dashboard/src/lib/features/course/components/exercise/question-labels.ts new file mode 100644 index 00000000..a31d1498 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/exercise/question-labels.ts @@ -0,0 +1,285 @@ +import type { ExerciseQuestionLabels } from '@cio/question-types'; +import { t } from '$lib/utils/functions/translations'; + +/** + * Returns translated labels for ExerciseQuestion UI components. + * + * Note: The same label keys exist in packages/storybook/src/molecules/exercise-question/question-labels.ts + * with hardcoded English values for Storybook demos. When adding or changing labels for a question type, + * update both files to keep them in sync. + */ +export function getExerciseQuestionLabels(): ExerciseQuestionLabels { + return { + 'common.option_prefix': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.common.option_prefix' + ), + 'common.remove': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.common.remove'), + 'common.add_option': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.common.add_option'), + 'common.correct_badge': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.common.correct_badge' + ), + 'common.not_set': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.common.not_set'), + 'navigation.previous': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.navigation.previous'), + 'navigation.next': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.navigation.next'), + 'navigation.finish': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.navigation.finish'), + 'question.edit.title_label': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.title_label' + ), + 'question.edit.title_placeholder': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.title_placeholder' + ), + 'question.edit.description_label': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.description_label' + ), + 'question.edit.description_placeholder': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.description_placeholder' + ), + 'question.edit.add_description': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.add_description' + ), + 'question.edit.remove_description': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.remove_description' + ), + 'question.edit.media_menu_sr': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.media_menu_sr' + ), + 'question.edit.add_image': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.add_image' + ), + 'question.edit.add_video': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.add_video' + ), + 'question.edit.remove_image_tooltip': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.remove_image_tooltip' + ), + 'question.edit.remove_image_sr': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.remove_image_sr' + ), + 'question.edit.remove_video_tooltip': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.remove_video_tooltip' + ), + 'question.edit.remove_video_sr': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.remove_video_sr' + ), + 'question.edit.image_alt': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.image_alt' + ), + 'question.edit.video_modal_title': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.video_modal_title' + ), + 'question.edit.video_link_label': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.video_link_label' + ), + 'question.edit.video_link_placeholder': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.video_link_placeholder' + ), + 'question.edit.video_add_button': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.video_add_button' + ), + 'question.edit.video_invalid_link': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.video_invalid_link' + ), + 'question.edit.upload_error_only_images': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.upload_error_only_images' + ), + 'question.edit.upload_error_skipped': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.upload_error_skipped' + ), + 'question.edit.upload_error_failed': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.upload_error_failed' + ), + 'radio.edit.helper': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.radio.edit.helper'), + 'radio.edit.correct_selected_tooltip': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.radio.edit.correct_selected_tooltip' + ), + 'radio.edit.mark_correct_tooltip': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.radio.edit.mark_correct_tooltip' + ), + 'radio.edit.mark_correct_sr': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.radio.edit.mark_correct_sr' + ), + 'checkbox.edit.helper': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.checkbox.edit.helper' + ), + 'checkbox.edit.correct_selected_tooltip': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.checkbox.edit.correct_selected_tooltip' + ), + 'checkbox.edit.toggle_correct_tooltip': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.checkbox.edit.toggle_correct_tooltip' + ), + 'checkbox.edit.toggle_correct_sr': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.checkbox.edit.toggle_correct_sr' + ), + 'true_false.true_label': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.true_false.true_label' + ), + 'true_false.false_label': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.true_false.false_label' + ), + 'true_false.edit.correct_answer_label': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.true_false.edit.correct_answer_label' + ), + 'true_false.preview.correct_value_label': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.true_false.preview.correct_value_label' + ), + 'short_answer.edit.instructions_placeholder': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.short_answer.edit.instructions_placeholder' + ), + 'short_answer.take.placeholder': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.short_answer.take.placeholder' + ), + 'short_answer.preview.helper': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.short_answer.preview.helper' + ), + 'numeric.edit.correct_value_label': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.edit.correct_value_label' + ), + 'numeric.edit.correct_value_info': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.edit.correct_value_info' + ), + 'numeric.edit.tolerance_label': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.edit.tolerance_label' + ), + 'numeric.edit.tolerance_info': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.edit.tolerance_info' + ), + 'numeric.edit.correct_value_placeholder': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.edit.correct_value_placeholder' + ), + 'numeric.edit.tolerance_placeholder': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.edit.tolerance_placeholder' + ), + 'numeric.take.placeholder': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.take.placeholder' + ), + 'numeric.preview.correct_value_label': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.preview.correct_value_label' + ), + 'numeric.preview.tolerance_label': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.preview.tolerance_label' + ), + 'fill_blank.edit.accepted_answers_placeholder': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.edit.accepted_answers_placeholder' + ), + 'fill_blank.take.placeholder': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.take.placeholder' + ), + 'fill_blank.preview.accepted_answers_label': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.preview.accepted_answers_label' + ), + 'file_upload.edit.accepted_types_placeholder': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.edit.accepted_types_placeholder' + ), + 'file_upload.edit.max_size_placeholder': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.edit.max_size_placeholder' + ), + 'file_upload.take.selected_file_label': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.take.selected_file_label' + ), + 'file_upload.take.upload_button': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.take.upload_button' + ), + 'file_upload.take.upload_progress': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.take.upload_progress' + ), + 'file_upload.take.upload_error': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.take.upload_error' + ), + 'file_upload.take.view': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.take.view' + ), + 'file_upload.take.download': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.take.download' + ), + 'file_upload.preview.accepted_types_label': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.preview.accepted_types_label' + ), + 'file_upload.preview.max_size_label': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.preview.max_size_label' + ), + 'link.edit.instructions_placeholder': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.link.edit.instructions_placeholder' + ), + 'link.edit.helper': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.link.edit.helper'), + 'link.take.helper': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.link.take.helper'), + 'link.take.placeholder': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.link.take.placeholder' + ), + 'link.take.remove_tooltip': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.link.take.remove_tooltip' + ), + 'link.take.remove_sr': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.link.take.remove_sr'), + 'link.take.invalid_url_error': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.link.take.invalid_url_error' + ), + 'link.take.add_button': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.link.take.add_button' + ), + 'link.preview.empty': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.link.preview.empty'), + 'link.preview.item_label': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.link.preview.item_label' + ), + 'matching.edit.helper': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.matching.edit.helper' + ), + 'matching.take.placeholder': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.matching.take.placeholder' + ), + 'matching.preview.helper': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.matching.preview.helper' + ), + 'ordering.fallback_step_prefix': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.fallback_step_prefix' + ), + 'ordering.edit.helper': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.edit.helper' + ), + 'ordering.edit.add_step': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.edit.add_step' + ), + 'ordering.edit.empty': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.ordering.edit.empty'), + 'ordering.edit.step_placeholder': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.edit.step_placeholder' + ), + 'ordering.edit.remove_step_tooltip': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.edit.remove_step_tooltip' + ), + 'ordering.take.helper': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.take.helper' + ), + 'ordering.take.empty': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.ordering.take.empty'), + 'ordering.preview.submitted_label': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.preview.submitted_label' + ), + 'ordering.preview.expected_label': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.preview.expected_label' + ), + 'ordering.preview.matches': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.preview.matches' + ), + 'ordering.preview.differs': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.preview.differs' + ), + 'hotspot.edit.helper': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.hotspot.edit.helper'), + 'hotspot.take.placeholder': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.hotspot.take.placeholder' + ), + 'hotspot.preview.helper': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.hotspot.preview.helper' + ), + 'textarea.edit.placeholder': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.textarea.edit.placeholder' + ), + 'textarea.take.placeholder': t.get( + 'course.navItem.lessons.exercises.all_exercises.shared_question.textarea.take.placeholder' + ), + 'submission.common.no_answer': t.get('course.navItem.lessons.exercises.all_exercises.analytics.individual.no'), + 'submission.common.other': t.get('course.navItem.lessons.exercises.all_exercises.analytics.summary.other'), + 'submission.chart.responses': t.get('course.navItem.lessons.exercises.all_exercises.analytics.summary.responses'), + 'submission.chart.no_data': t.get('course.navItem.lessons.exercises.all_exercises.analytics.summary.no_responses'), + 'submission.list.responses': t.get('course.navItem.lessons.exercises.all_exercises.analytics.summary.responses'), + 'submission.list.no_responses': t.get( + 'course.navItem.lessons.exercises.all_exercises.analytics.summary.no_responses' + ) + }; +} diff --git a/apps/dashboard/src/lib/features/course/components/exercise/question-type-utils.ts b/apps/dashboard/src/lib/features/course/components/exercise/question-type-utils.ts new file mode 100644 index 00000000..22e38e5d --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/exercise/question-type-utils.ts @@ -0,0 +1,71 @@ +import { + QUESTION_TYPE_KEY, + getExerciseQuestionContractKey, + getQuestionTypeById, + type ExerciseQuestionModel, + type ExerciseQuestionOption, + type ExerciseQuestionTypeKey +} from '@cio/question-types'; +import type { Question } from '$features/course/types'; +import { DEFAULT_QUESTION_TYPE, QUESTION_TYPES } from '$features/ui/question/constants'; + +type QuestionTypeOption = (typeof QUESTION_TYPES)[number]; + +const OPTION_QUESTION_TYPE_KEYS = new Set([ + QUESTION_TYPE_KEY.RADIO, + QUESTION_TYPE_KEY.CHECKBOX, + QUESTION_TYPE_KEY.TRUE_FALSE +]); + +export function getQuestionTypeId(question: Partial): number { + const value = Number(question.questionTypeId ?? question.questionType?.id ?? DEFAULT_QUESTION_TYPE?.id ?? 1); + return Number.isNaN(value) ? (DEFAULT_QUESTION_TYPE?.id ?? 1) : value; +} + +export function getQuestionTypeOptionById(questionTypeId: number): QuestionTypeOption { + return QUESTION_TYPES.find((questionType) => questionType.id === questionTypeId) ?? DEFAULT_QUESTION_TYPE; +} + +export function getQuestionTypeKeyFromId(questionTypeId: number): ExerciseQuestionTypeKey { + return getQuestionTypeById(questionTypeId)?.key ?? QUESTION_TYPE_KEY.RADIO; +} + +export function getQuestionTypeKey(question: Partial): ExerciseQuestionTypeKey { + return getQuestionTypeKeyFromId(getQuestionTypeId(question)); +} + +export function questionTypeSupportsOptions(questionTypeKey: ExerciseQuestionTypeKey): boolean { + return OPTION_QUESTION_TYPE_KEYS.has(questionTypeKey); +} + +export function toExerciseQuestionModel(question: Question): ExerciseQuestionModel { + const options: ExerciseQuestionOption[] = (question.options || []) + .filter((option) => !option.deletedAt) + .map((option) => ({ + id: option.id, + label: option.label || String(option.value ?? ''), + value: option.value ?? undefined, + isCorrect: option.isCorrect, + settings: + option.settings && typeof option.settings === 'object' && !Array.isArray(option.settings) + ? { ...option.settings } + : undefined + })); + + const rawSettings = (question as Question & { settings?: Record }).settings; + const settings = rawSettings && typeof rawSettings === 'object' ? rawSettings : {}; + + return { + id: question.id, + key: question.name ? String(question.name) : question.id ? String(question.id) : undefined, + title: question.title || '', + questionType: getQuestionTypeKey(question), + points: Number(question.points || 0), + options, + settings + }; +} + +export function getQuestionAnswerKey(question: Question, fallbackIndex = 0): string { + return getExerciseQuestionContractKey(toExerciseQuestionModel(question), fallbackIndex); +} diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/store/exercise.ts b/apps/dashboard/src/lib/features/course/components/exercise/store.ts similarity index 55% rename from apps/dashboard/src/lib/components/Course/components/Lesson/store/exercise.ts rename to apps/dashboard/src/lib/features/course/components/exercise/store.ts index f32bbe06..4c46fa6c 100644 --- a/apps/dashboard/src/lib/components/Course/components/Lesson/store/exercise.ts +++ b/apps/dashboard/src/lib/features/course/components/exercise/store.ts @@ -1,9 +1,10 @@ -import { QUESTION_TEMPLATE, QUESTION_TYPE, QUESTION_TYPES } from '$lib/components/Question/constants'; +import { DEFAULT_QUESTION_TYPE, QUESTION_TEMPLATE } from '$features/ui/question/constants'; +import type { AnswerData } from '@cio/question-types'; -import type { Question } from '$lib/components/Course/types'; +import type { Question } from '$features/course/types'; +import { STATUS } from './constants'; import type { Writable } from 'svelte/store'; import { isUUID } from '$lib/utils/functions/isUUID'; -import { questionnaireMetaData } from './answers'; import { writable } from 'svelte/store'; export const isQuestionnaireFetching = writable(false); @@ -13,65 +14,112 @@ export const questionnaireOrder = writable({ open: false }); // {'question-id': { option: '', title: '', ... }} export const questionnaireValidation = writable({}); +export interface QuestionnaireMetaData { + answers: Record; + scores: Record; + grades: Record; + totalPossibleGrade: number; + finalTotalGrade: number; + currentQuestionIndex: number; + isFinished: boolean; + progressValue: number; + status: number; + comment: string; + /** Set when exercise is finished so route knows not to overwrite questionnaire */ + exerciseId: string | null; +} + +const initAnswerState: QuestionnaireMetaData = { + answers: {}, + scores: {}, + grades: {}, + totalPossibleGrade: 0, + finalTotalGrade: 0, + currentQuestionIndex: 0, + isFinished: false, + progressValue: 0, + status: STATUS.PENDING, + comment: '', + exerciseId: null +}; + +export const questionnaireMetaData: Writable = writable(initAnswerState); + export const questionnaire: Writable<{ - title?: string; - due_by?: string; - is_due_by_dirty?: boolean; - is_title_dirty?: boolean; - description?: string; - is_description_dirty?: boolean; + title?: string | null; + dueBy?: string | null; + isDueByDirty?: boolean; + isTitleDirty?: boolean; + description?: string | null; + isDescriptionDirty?: boolean; questions: Question[]; totalSubmissions: number; }> = writable({ title: '', - due_by: '', - is_due_by_dirty: false, - is_title_dirty: false, + dueBy: '', + isDueByDirty: false, + isTitleDirty: false, description: '', - is_description_dirty: false, + isDescriptionDirty: false, questions: [], totalSubmissions: 0 }); -export function reset() { - questionnaireMetaData.update((metaData) => { - metaData.answers = {}; - metaData.currentQuestionIndex = 0; - metaData.isFinished = false; - return metaData; - }); +export function clearQuestionnaireValidation() { + questionnaireValidation.set({}); } -export function validateQuestionnaire(questions) { - const errors = {}; +export function reset() { + questionnaireMetaData.update((metaData) => ({ + ...metaData, + answers: {}, + scores: {}, + grades: {}, + currentQuestionIndex: 0, + isFinished: false, + exerciseId: null, + totalPossibleGrade: 0, + finalTotalGrade: 0, + status: STATUS.PENDING, + comment: '', + progressValue: 100 + })); - for (const question of questions) { - if (question.question_type.id === QUESTION_TYPE.TEXTAREA || question.deleted_at) { - continue; - } - const qErrors = errors[question.id] || {}; + clearQuestionnaireValidation(); +} - if (question.question_type.id !== QUESTION_TYPE.TEXTAREA) { - const hasEmptyOptionLabel = question.options - .filter((option) => !option.deleted_at) - .some((option) => option.label.trim() === ''); +/** + * Maps Zod validation errors to UI format: { questionId: { option: 'error message' } } + * This is used to display errors in the edit-mode component + */ +export function mapZodErrorsToQuestionErrors( + zodErrors: Record, + questions: Question[] +): Record { + const errors: Record = {}; + for (const [path, message] of Object.entries(zodErrors)) { + const pathParts = path.split('.'); + const questionIndex = pathParts[0] === 'questions' ? parseInt(pathParts[1]) : -1; + + if (questionIndex >= 0 && questionIndex < questions.length) { + const question = questions[questionIndex]; + if (question && !question.deletedAt) { + const qErrors = errors[question.id] || {}; + + // Check if error is related to options + if (path.includes('options')) { + qErrors.option = message; + } else if (path.includes('question')) { + qErrors.title = message; + } else if (path.includes('points')) { + qErrors.points = message; + } - if (hasEmptyOptionLabel) { - qErrors.option = 'Please enter a label for all options'; errors[question.id] = { ...qErrors }; } } - - const hasAnswer = question.options.filter((o) => !o.deleted_at).some((option) => option.is_correct); - - if (!hasAnswer) { - qErrors.option = 'Please mark an option as the correct answer'; - errors[question.id] = { ...qErrors }; - } } - questionnaireValidation.set(errors); - return errors; } @@ -89,14 +137,14 @@ export function handleAddQuestion() { value: '', points: 0, order: questions.length, - question_type: QUESTION_TYPES[0], - question_type_id: QUESTION_TYPES[0].id, + questionType: DEFAULT_QUESTION_TYPE, + questionTypeId: DEFAULT_QUESTION_TYPE.id, options: [ { id: '1-form', label: '', value: null, - is_correct: false + isCorrect: false } ] } @@ -121,7 +169,7 @@ export function handleAddOption(questionId) { id: `${new Date().getTime()}-form`, label: '', value: '', - is_correct: false + isCorrect: false } ] }; @@ -143,9 +191,9 @@ export function handleRemoveOption(questionId, optionId) { const optionsIndex = q.questions[questionIndex].options.findIndex((oItem) => oItem.id === optionId); if (optionsIndex === -1) return q; - q.questions[questionIndex].options[optionsIndex].deleted_at = new Date().toString(); - q.questions[questionIndex].options[optionsIndex].is_dirty = true; - q.questions[questionIndex].is_dirty = true; // Mark as dirty if needed + q.questions[questionIndex].options[optionsIndex].deletedAt = new Date().toString(); + q.questions[questionIndex].options[optionsIndex].isDirty = true; + q.questions[questionIndex].isDirty = true; // Mark as dirty if needed return q; }); @@ -158,7 +206,7 @@ export function handleRemoveQuestion(questionId) { const questionIndex = q.questions.findIndex((qItem) => qItem.id === questionId); if (questionIndex === -1) return q; - q.questions[questionIndex].deleted_at = new Date().toString(); + q.questions[questionIndex].deletedAt = new Date().toString(); return q; }); @@ -190,8 +238,8 @@ export function handleAnswerSelect(questionId, optionId) { ...question, options: question.options.map((option) => { if (option.id === optionId) { - option.is_correct = !option.is_correct; - option.is_dirty = true; + option.isCorrect = !option.isCorrect; + option.isDirty = true; } return option; }) @@ -225,7 +273,7 @@ export function addDynamicValue(questionId, optionId) { } option.label = label; - option.is_dirty = true; + option.isDirty = true; } return option; diff --git a/apps/dashboard/src/lib/features/course/components/exercise/submissions/individual.svelte b/apps/dashboard/src/lib/features/course/components/exercise/submissions/individual.svelte new file mode 100644 index 00000000..a1c91559 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/exercise/submissions/individual.svelte @@ -0,0 +1,95 @@ + + +{#if isLoading} + +{:else if $submissions?.length} +
+ {#each $submissions as student, i (student.id)} + + {/each} +
+ +

+ {$submissions[studentSelected].groupmember?.profile.fullname ?? ''}'s {$t( + 'course.navItem.lessons.exercises.all_exercises.analytics.individual.answers' + )} +

+ {#if $submissions[studentSelected]} + {#if $questionnaire.questions} + {#each $questionnaire.questions as q, i (`${q.id}-${i}`)} +
+ +
+ {/each} + {/if} + {/if} +{/if} diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/Submissions/store.ts b/apps/dashboard/src/lib/features/course/components/exercise/submissions/store.ts similarity index 63% rename from apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/Submissions/store.ts rename to apps/dashboard/src/lib/features/course/components/exercise/submissions/store.ts index c9fd24a1..c53e1153 100644 --- a/apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/Submissions/store.ts +++ b/apps/dashboard/src/lib/features/course/components/exercise/submissions/store.ts @@ -1,4 +1,4 @@ +import type { ExerciseSubmissions } from './types'; import { writable } from 'svelte/store'; -import type { ExerciseSubmissions } from '$lib/utils/types'; export const submissions = writable([]); diff --git a/apps/dashboard/src/lib/features/course/components/exercise/submissions/submissions.svelte b/apps/dashboard/src/lib/features/course/components/exercise/submissions/submissions.svelte new file mode 100644 index 00000000..a1c95fd4 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/exercise/submissions/submissions.svelte @@ -0,0 +1,79 @@ + + +{#if $submissions.length > 0} + + + + {$t('course.navItem.lessons.exercises.all_exercises.analytics.summary.heading')} + + + {$t('course.navItem.lessons.exercises.all_exercises.analytics.individual.heading')} + + + + + + + + + + + +{:else} + +{/if} diff --git a/apps/dashboard/src/lib/features/course/components/exercise/submissions/summary.svelte b/apps/dashboard/src/lib/features/course/components/exercise/submissions/summary.svelte new file mode 100644 index 00000000..7b9c6584 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/exercise/submissions/summary.svelte @@ -0,0 +1,84 @@ + + +{#if isLoading} + +{:else if browser} +
+

+ {$t('course.navItem.lessons.exercises.all_exercises.analytics.summary.question_chart')} +

+ + +
+{/if} diff --git a/apps/dashboard/src/lib/features/course/components/exercise/submissions/types.ts b/apps/dashboard/src/lib/features/course/components/exercise/submissions/types.ts new file mode 100644 index 00000000..eb6380cf --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/exercise/submissions/types.ts @@ -0,0 +1,21 @@ +import type { SubmissionAnswer, SubmissionListItem } from '$features/course/utils/types'; + +type ApiProfile = NonNullable['profile']>; +type ExerciseSubmissionProfile = Required>; + +type ExerciseSubmissionAnswer = { + id: SubmissionAnswer['id']; + questionId: SubmissionAnswer['questionId']; + answerData?: SubmissionAnswer['answerData']; + point: NonNullable; + submissionId: NonNullable; + groupMemberId: NonNullable; +}; + +/** Normalized submission for exercise submissions UI (profile/answer defaults filled by transform) */ +export type ExerciseSubmissions = { + id: SubmissionListItem['id']; + statusId: NonNullable; + groupmember: { profile: ExerciseSubmissionProfile } | null; + answers: ExerciseSubmissionAnswer[]; +}; diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/UpdateDescription.svelte b/apps/dashboard/src/lib/features/course/components/exercise/update-description.svelte similarity index 70% rename from apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/UpdateDescription.svelte rename to apps/dashboard/src/lib/features/course/components/exercise/update-description.svelte index 2ffc0676..9cfbe4c7 100644 --- a/apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/UpdateDescription.svelte +++ b/apps/dashboard/src/lib/features/course/components/exercise/update-description.svelte @@ -1,11 +1,11 @@
- {#if !preview} - ($questionnaire.is_title_dirty = true)} - /> - { - $questionnaire.is_due_by_dirty = true; - }} - /> - -
-

- {$t('course.navItem.lessons.exercises.all_exercises.description.heading')} -

- - { - $questionnaire.is_description_dirty = true; - $questionnaire.description = html; - }} - editorClass="max-h-[300px]" - placeholder={$t('course.navItem.lessons.exercises.all_exercises.description.describe')} - /> -
- {:else if preview} + {#if preview}

{$questionnaire.title}

@@ -71,11 +41,11 @@

{$t('course.navItem.lessons.exercises.all_exercises.view_mode.all')}

- {#if $questionnaire.due_by} + {#if $questionnaire.dueBy} |

{$t('course.navItem.lessons.exercises.all_exercises.view_mode.due')}: - {new Date($questionnaire.due_by).toLocaleString()} + {new Date($questionnaire.dueBy).toLocaleString()}

{/if}
@@ -85,6 +55,41 @@ $questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.description.no') )} + {:else} + ($questionnaire.isTitleDirty = true)} + /> + { + $questionnaire.dueBy = e.currentTarget.value; + $questionnaire.isDueByDirty = true; + }} + /> + +
+

+ {$t('course.navItem.lessons.exercises.all_exercises.description.heading')} +

+ + {#key $questionnaire.description} + { + $questionnaire.isDescriptionDirty = true; + $questionnaire.description = html; + }} + editorClass="max-h-[100px]" + placeholder={$t('course.navItem.lessons.exercises.all_exercises.description.describe')} + /> + {/key} +
{/if}
diff --git a/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte b/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte new file mode 100644 index 00000000..6340f398 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte @@ -0,0 +1,523 @@ + + +{#if !preview && $questionnaire.questions.length && !$questionnaireMetaData.isFinished} +
+ +
+{/if} + + + +{#if preview} + + + +{:else if !$questionnaire.questions.length} + + +

+ {$t('course.navItem.lessons.exercises.all_exercises.view_mode.empty_edit_hint_prefix')} + + + {$t('course.navItem.lessons.exercises.all_exercises.view_mode.edit')} + + + {$t('course.navItem.lessons.exercises.all_exercises.view_mode.empty_edit_hint_suffix')} +

+
+
+{:else if $questionnaireMetaData.currentQuestionIndex === 0} + +
+

{$questionnaire.title}

+ +
+

+ {$questionnaire.questions.length} + {$t('course.navItem.lessons.exercises.all_exercises.view_mode.questions')} +

+ | +

+ {getTotalPossibleGrade($questionnaire.questions)} + {$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')}. +

+ | +

{$t('course.navItem.lessons.exercises.all_exercises.view_mode.all')}

+ {#if $questionnaire.dueBy} + | +

+ {$t('course.navItem.lessons.exercises.all_exercises.view_mode.due')}: + {new Date($questionnaire.dueBy).toLocaleString()} +

+ {/if} +
+ +
+ {@html sanitizeHtml( + $questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.view_mode.no_description') + )} +
+ + +
+
+{:else if $questionnaireMetaData.isFinished} + {#if !isLoadingAutoSavedData} +
+ {#if STATUS.GRADED === $questionnaireMetaData.status} +
+ + {$t('course.navItem.lessons.exercises.all_exercises.view_mode.graded')} + + +
+ {#if $questionnaireMetaData.comment} + + {/if} +
+

+ {$questionnaireMetaData.finalTotalGrade}/ + + {$questionnaireMetaData.totalPossibleGrade} +

+
+
+
+ {:else if courseApi.course?.type === 'SELF_PACED'} + + {$t('course.navItem.lessons.exercises.all_exercises.view_mode.submitted')} + + {:else} + + {$t('course.navItem.lessons.exercises.all_exercises.view_mode.pending')} + + {/if} +
+ + a.order - b.order)} + questionnaireMetaData={$questionnaireMetaData} + grades={$questionnaireMetaData.grades} + disableGrading={true} + /> + {/if} +{:else if currentQuestion && currentQuestion?.id} +
+
+ {#key currentQuestion.id} +
+ {#if sharedQuestionModel} + + {/if} +
+ {/key} +
+
+ 1} + canGoNext={canGoNextForSharedQuestion && !isSubmitting} + isLast={$questionnaireMetaData.currentQuestionIndex === $questionnaire.questions.length} + {isSubmitting} + previousLabel={t.get('course.navItem.lessons.exercises.all_exercises.previous')} + nextLabel={t.get('course.navItem.lessons.exercises.all_exercises.next')} + finishLabel={t.get('course.navItem.lessons.exercises.all_exercises.finish')} + {onPrevious} + onNext={onSharedNext} + /> +
+
+{/if} diff --git a/apps/dashboard/src/lib/features/course/components/index.ts b/apps/dashboard/src/lib/features/course/components/index.ts index a8875212..58e408bd 100644 --- a/apps/dashboard/src/lib/features/course/components/index.ts +++ b/apps/dashboard/src/lib/features/course/components/index.ts @@ -3,7 +3,10 @@ export { default as CourseCardList } from './card-list.svelte'; export { default as CourseCardLoader } from './card-loader.svelte'; export { default as CopyCourseModal } from './copy-course-modal.svelte'; export { default as CourseHeader } from './course-header.svelte'; +export { default as CourseFilterPopover } from './course-filter-popover.svelte'; +export { default as CourseTagPicker } from './course-tag-picker.svelte'; export { default as CreateCourseButton } from './create-course-button.svelte'; export { default as CourseList } from './list.svelte'; export { default as CourseListLoader } from './list-loader.svelte'; export { default as NewCourseModal } from './new-course-modal.svelte'; +export { default as QuestionContainer } from './question-container.svelte'; diff --git a/apps/dashboard/src/lib/features/course/components/lesson/ai-button.svelte b/apps/dashboard/src/lib/features/course/components/lesson/ai-button.svelte new file mode 100644 index 00000000..1b13f614 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/ai-button.svelte @@ -0,0 +1,54 @@ + + + + diff --git a/apps/dashboard/src/lib/features/course/components/lesson/comments.svelte b/apps/dashboard/src/lib/features/course/components/lesson/comments.svelte new file mode 100644 index 00000000..0c9e9d88 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/comments.svelte @@ -0,0 +1,208 @@ + + + { + if (!deleteCommentId) return; + lessonApi.deleteComment(courseId, lessonId, String(deleteCommentId)); + deleteCommentId = null; + }} +/> + +
+
+

+ {$t('course.navItem.lessons.comments.title')} ({paginationCount}) +

+
+
+
+ + + {shortenName($profile.fullname) || 'U'} + +
+ +
+
+ +
+ +
+
+ +
+ {#each comments as commentItem (commentItem.id)} +
+ + + {shortenName(commentItem.name) || 'U'} + + +
+
+

+ {commentItem.name} + + {calDateDiff(commentItem.commentAt)} + +

+ + {#if courseApi.group.memberId === commentItem.groupmemberId} + + + + + + (editCommentId = commentItem.id)}>Edit + { + deleteCommentId = commentItem.id; + openDeleteModal = true; + }} + > + Delete + + + + {/if} +
+ + {#if editCommentId === commentItem.id} + +
+ + +
+ {:else} +
+ {commentItem.comment} +
+ {/if} +
+
+ {/each} +
+ {#if hasMore} +
+ +
+ {/if} +
diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/constants.ts b/apps/dashboard/src/lib/features/course/components/lesson/constants.ts similarity index 89% rename from apps/dashboard/src/lib/components/Course/components/Lesson/Materials/constants.ts rename to apps/dashboard/src/lib/features/course/components/lesson/constants.ts index 9fb434fe..93797849 100644 --- a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/constants.ts +++ b/apps/dashboard/src/lib/features/course/components/lesson/constants.ts @@ -2,7 +2,7 @@ import BookOpenIcon from '@lucide/svelte/icons/book-open'; import type { Component } from 'svelte'; import FileTextIcon from '@lucide/svelte/icons/file-text'; import PresentationIcon from '@lucide/svelte/icons/presentation'; -import type { Tabs } from '$lib/utils/types'; +import type { Tabs } from '$features/course/utils/types'; import VideoIcon from '@lucide/svelte/icons/video'; interface MaterialTab { @@ -53,7 +53,5 @@ export function orderedTabs(tabs: MaterialTab[], settingTabs?: Tabs[]) { } }); - const filteredReorderedTabs = reorderedTabs.filter((tab) => tab !== undefined); - - return filteredReorderedTabs; + return reorderedTabs.filter((tab) => tab !== undefined); } diff --git a/apps/dashboard/src/lib/features/course/components/lesson/content-action-helpers.ts b/apps/dashboard/src/lib/features/course/components/lesson/content-action-helpers.ts new file mode 100644 index 00000000..ab6e95b4 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/content-action-helpers.ts @@ -0,0 +1,109 @@ +import { ContentType } from '@cio/utils/constants/content'; + +export type ContentActionItem = { + type: ContentType; + contentId: string; + title?: string | null; + isUnlocked?: boolean | null; + sectionId?: string | null; + order?: number | null; +}; + +type CourseApiErrorMap = Record | undefined | null; + +type LessonApi = { + update: (courseId: string, lessonId: string, payload: Record) => Promise; + delete: (courseId: string, lessonId: string) => Promise; + errors?: CourseApiErrorMap; + success?: boolean; +}; + +type ExerciseApi = { + update: (courseId: string, exerciseId: string, payload: Record) => Promise; + delete: (courseId: string, exerciseId: string) => Promise; + errors?: CourseApiErrorMap; + success?: boolean; +}; + +function normalizeErrors(errors: CourseApiErrorMap): Record { + if (!errors) return {}; + const entries = Object.entries(errors).filter(([, value]) => Boolean(value)); + return entries.length ? Object.fromEntries(entries) : {}; +} + +function buildContentPayload(item: ContentActionItem, overrides: Record = {}) { + const normalizedSectionId = item.sectionId === '' ? null : item.sectionId; + + return { + title: item.title || '', + isUnlocked: item.isUnlocked ?? undefined, + sectionId: normalizedSectionId ?? undefined, + order: item.order ?? undefined, + ...overrides + }; +} + +export async function toggleLock({ + item, + courseId, + lessonApi, + exerciseApi +}: { + item: ContentActionItem; + courseId: string; + lessonApi: LessonApi; + exerciseApi: ExerciseApi; +}) { + item.isUnlocked = !(item.isUnlocked ?? false); + const payload = buildContentPayload(item, { order: undefined }); + + if (item.type === ContentType.Exercise) { + await exerciseApi.update(courseId, item.contentId, payload); + return normalizeErrors(exerciseApi.errors); + } + + await lessonApi.update(courseId, item.contentId, payload); + return normalizeErrors(lessonApi.errors); +} + +export async function saveContent({ + item, + courseId, + lessonApi, + exerciseApi +}: { + item: ContentActionItem; + courseId: string; + lessonApi: LessonApi; + exerciseApi: ExerciseApi; +}) { + const payload = buildContentPayload(item); + + if (item.type === ContentType.Exercise) { + await exerciseApi.update(courseId, item.contentId, payload); + return normalizeErrors(exerciseApi.errors); + } + + await lessonApi.update(courseId, item.contentId, payload); + return normalizeErrors(lessonApi.errors); +} + +export async function deleteContent({ + item, + courseId, + lessonApi, + exerciseApi +}: { + item: ContentActionItem; + courseId: string; + lessonApi: LessonApi; + exerciseApi: ExerciseApi; +}) { + if (item.type === ContentType.Exercise) { + await exerciseApi.delete(courseId, item.contentId); + return Boolean(exerciseApi.success); + } + + await lessonApi.delete(courseId, item.contentId); + return Boolean(lessonApi.success); +} diff --git a/apps/dashboard/src/lib/features/course/components/lesson/content-actions.svelte b/apps/dashboard/src/lib/features/course/components/lesson/content-actions.svelte new file mode 100644 index 00000000..42eb6e23 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/content-actions.svelte @@ -0,0 +1,73 @@ + + + +
+ {#if isEditing} + + + {:else} + + + + + + {#if showLock} + + {lockLabel} + + {/if} + + {$t('course.navItem.lessons.add_lesson.edit')} + + {#if showDelete} + + + {$t('course.navItem.lessons.add_lesson.delete')} + + {/if} + + + {/if} +
+
diff --git a/apps/dashboard/src/lib/features/course/components/lesson/content-list.svelte b/apps/dashboard/src/lib/features/course/components/lesson/content-list.svelte new file mode 100644 index 00000000..fa173719 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/content-list.svelte @@ -0,0 +1,474 @@ + + + { + if (!lessonToDelete?.id) return; + const activeCourseId = courseApi.course?.id; + if (!activeCourseId) return; + + const wasDeleted = await deleteContent({ + item: { + type: lessonToDelete.type, + contentId: lessonToDelete.id, + title: lessonToDelete.title, + isUnlocked: lessonToDelete.isUnlocked, + sectionId: lessonToDelete.sectionId, + order: lessonToDelete.order + }, + courseId: activeCourseId, + lessonApi, + exerciseApi + }); + + if (wasDeleted && profileId) { + await courseApi.refreshCourse(activeCourseId, profileId); + } + }} +/> + +
+ {#each contentItems as item (item.id)} + {@const isEditingItem = $contentEditingStore === item.contentId} + {@const itemLocked = isLockedForStudent(item, isStudentView)} + {@const lockLabel = item.isUnlocked + ? $t('course.navItem.lessons.add_lesson.lock') + : $t('course.navItem.lessons.add_lesson.unlock')} + {@const canRenderJoinButton = + item.type === ContentType.Lesson && isLiveCourse && Boolean(item.callUrl) && !itemLocked} + {@const canRenderContinueButton = isStudentView && !itemLocked} + {@const isLockPending = Boolean(lockPendingItemKeys[getItemLockKey(item)])} + {@const actionDisabled = Boolean($contentEditingStore && !isEditingItem)} +
+
+ +
+ + {#if item.type === ContentType.Lesson} + + {#snippet meta()} + {@const hasVideos = (item.videosCount ?? 0) > 0} + {@const hasDocuments = (item.documentsCount ?? 0) > 0} + {@const hasNote = item.hasNoteContent === true} + {@const hasSlide = item.hasSlideContent === true} + {@const hasAnyMaterials = hasVideos || hasDocuments || hasNote || hasSlide} +
+ {#if hasVideos} + + + {item.videosCount} + + {/if} + + {#if hasDocuments} + + + {item.documentsCount} + + {/if} + + {#if hasNote} + + + {$t('course.navItem.lessons.materials.tabs.note.title')} + + {/if} + + {#if hasSlide} + + + {$t('course.navItem.lessons.materials.tabs.slide.title')} + + {/if} + + {#if !hasAnyMaterials} + + {#snippet children(isHovered)} + + + {$t('course.navItem.lessons.materials.no_content')} + + {/snippet} + + {/if} + + {#if isLiveCourse && item.lessonAt} + + + {formatMetaDate(item.lessonAt)} + + {/if} + + {#if isStudentView} + + {item.isComplete ? $t('course.navItem.lessons.complete') : $t('course.navItem.lessons.incomplete')} + + {/if} +
+ {/snippet} +
+ {:else if item.type === ContentType.Exercise} + + {#snippet meta()} +
+ {#if !isStudentView && item.questionCount !== null} + + + {item.questionCount} + {$t('course.navItem.lessons.exercises.all_exercises.questions')} + + {/if} + + {#if isSelfPacedCourse && item.dueBy} + + + {$t('course.navItem.lessons.exercises.all_exercises.view_mode.due')}: {formatMetaDate(item.dueBy)} + + {/if} +
+ {/snippet} +
+ {/if} + +
+ {#if isStudentView} + + {#if itemLocked} + + {:else} + + {/if} + + {:else} + + {/if} + + {#if !isStudentView && canRenderJoinButton} + + + + {/if} + + {#if isStudentView && canRenderContinueButton && !isEditingItem} + {#if canRenderJoinButton} + + + + {:else} + + + + {/if} + {/if} + + {#if !isStudentView} + { + contentEditingStore.set(item.contentId); + prevTitle = item.title; + }} + onSave={() => saveLesson(item)} + onCancel={() => { + item.title = prevTitle ?? item.title; + resetEdit(); + }} + onDelete={() => { + lessonToDelete = item; + openDeleteModal = true; + }} + onToggleLock={() => handleToggleItemLock(item)} + /> + {/if} +
+
+ {:else} +

{$t('course.navItem.lessons.no_lesson')}

+ {/each} +
diff --git a/apps/dashboard/src/lib/features/course/components/lesson/content-navigation-actions.svelte b/apps/dashboard/src/lib/features/course/components/lesson/content-navigation-actions.svelte new file mode 100644 index 00000000..bf47aa19 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/content-navigation-actions.svelte @@ -0,0 +1,157 @@ + + +
+ {#if showMarkComplete && lessonId} + + {/if} + +
+ + + +
+
diff --git a/apps/dashboard/src/lib/features/course/components/lesson/content-row.svelte b/apps/dashboard/src/lib/features/course/components/lesson/content-row.svelte new file mode 100644 index 00000000..cac5c89e --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/content-row.svelte @@ -0,0 +1,90 @@ + + +{#if isEditing} + +{:else} +
+
+ {#if showIcon} + + {/if} + + + {title} + + + {#if showStatus} + {#if isLocked} + + {:else if isComplete} + + + + {/if} + {/if} + + {@render inline?.()} +
+ {@render meta?.()} +
+{/if} diff --git a/apps/dashboard/src/lib/features/course/components/lesson/content-section-list.svelte b/apps/dashboard/src/lib/features/course/components/lesson/content-section-list.svelte new file mode 100644 index 00000000..c5fce629 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/content-section-list.svelte @@ -0,0 +1,698 @@ + + + + +
+ {#each sections as section (section.id)} + {@const isUngrouped = section.id === 'ungrouped'} + {@const hasSectionItems = section.items.length > 0} + {@const sectionHasUnlocked = section.items.some((item) => item.isUnlocked ?? false)} + {@const sectionLockLabel = hasSectionItems + ? sectionHasUnlocked + ? `${$t('course.navItem.lessons.add_lesson.lock')} ${$t('course.navItem.lessons.add_lesson.all')}` + : `${$t('course.navItem.lessons.add_lesson.unlock')} ${$t('course.navItem.lessons.add_lesson.all')}` + : undefined} +
+ onEdit({ sectionId: section.id, sectionTitle: section.title! })} + onSave={() => onSave({ sectionId: section.id })} + onCancel={() => { + section.title = prevTitle ?? section.title; + resetEdit(); + }} + onAddContent={() => openContentModal(section.id)} + onToggleLock={!isUngrouped && hasSectionItems ? () => toggleSectionLock(section.id) : undefined} + onDelete={() => { + if (!isUngrouped) { + onDelete({ sectionId: section.id }); + } + }} + /> + +
handleDndConsiderCards(section.id, e)} + onfinalize={(e) => handleDndFinalizeCards(section.id, e)} + > + {#each section.items as item (item.id)} + {@const isEditingItem = $contentEditingStore === item.contentId} + {@const itemLocked = isLockedForStudent(item)} + {@const itemLockLabel = item.isUnlocked + ? $t('course.navItem.lessons.add_lesson.lock') + : $t('course.navItem.lessons.add_lesson.unlock')} + {@const canRenderJoinButton = + item.type === ContentType.Lesson && isLiveCourse && Boolean(item.callUrl) && !itemLocked} + {@const canRenderContinueButton = isStudentView && !itemLocked} + {@const isLockPending = Boolean(lockPendingItemKeys[getItemLockKey(item)])} + {@const actionDisabled = Boolean($contentEditingStore && !isEditingItem)} + +
+ {#if item.type === ContentType.Lesson} + + {#snippet meta()} + {@const hasVideos = (item.videosCount ?? 0) > 0} + {@const hasDocuments = (item.documentsCount ?? 0) > 0} + {@const hasNote = item.hasNoteContent === true} + {@const hasSlide = item.hasSlideContent === true} + {@const hasAnyMaterials = hasVideos || hasDocuments || hasNote || hasSlide} +
+ {#if hasVideos} + + + {item.videosCount} + + {/if} + + {#if hasDocuments} + + + {item.documentsCount} + + {/if} + + {#if hasNote} + + + {$t('course.navItem.lessons.materials.tabs.note.title')} + + {/if} + + {#if hasSlide} + + + {$t('course.navItem.lessons.materials.tabs.slide.title')} + + {/if} + + {#if !hasAnyMaterials} + + {#snippet children(isHovered)} + + + {$t('course.navItem.lessons.materials.no_content')} + + {/snippet} + + {/if} + + {#if isLiveCourse && item.lessonAt} + + + {formatMetaDate(item.lessonAt)} + + {/if} + + {#if isStudentView} + + {item.isComplete + ? $t('course.navItem.lessons.complete') + : $t('course.navItem.lessons.incomplete')} + + {/if} +
+ {/snippet} +
+ {:else if item.type === ContentType.Exercise} + + {#snippet meta()} +
+ {#if !isStudentView && item.questionCount !== null} + + + {item.questionCount} + {$t('course.navItem.lessons.exercises.all_exercises.questions')} + + {/if} + + {#if isSelfPacedCourse && item.dueBy} + + + {$t('course.navItem.lessons.exercises.all_exercises.view_mode.due')}: {formatMetaDate( + item.dueBy + )} + + {/if} +
+ {/snippet} +
+ {/if} + +
+ {#if isStudentView} + + {#if itemLocked} + + {:else} + + {/if} + + {:else} + + {/if} + + {#if !isStudentView && canRenderJoinButton} + + {/if} + + {#if isStudentView && canRenderContinueButton && !isEditingItem} + {#if canRenderJoinButton} + + {:else} + + + + {/if} + {/if} + + {#if !isStudentView} + onEdit({ lessonId: item.contentId, lessonTitle: item.title })} + onSave={() => onSave({ lessonId: item.contentId }, item)} + onCancel={() => { + item.title = prevTitle ?? item.title; + resetEdit(); + }} + onDelete={() => onDelete({ lessonId: item.contentId })} + onToggleLock={() => handleToggleItemLock(item)} + /> + {/if} +
+
+ {:else} + {$t('course.navItem.lessons.no_lesson')} + {/each} +
+
+ {/each} +
diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/DeleteLessonConfirmation.svelte b/apps/dashboard/src/lib/features/course/components/lesson/delete-lesson-confirmation.svelte similarity index 96% rename from apps/dashboard/src/lib/components/Course/components/Lesson/DeleteLessonConfirmation.svelte rename to apps/dashboard/src/lib/features/course/components/lesson/delete-lesson-confirmation.svelte index 17114237..574182c5 100644 --- a/apps/dashboard/src/lib/components/Course/components/Lesson/DeleteLessonConfirmation.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/delete-lesson-confirmation.svelte @@ -22,7 +22,7 @@ if (!isOpen) openDeleteModal = false; }} > - + {$t('delete_modal.label')} diff --git a/apps/dashboard/src/lib/features/course/components/lesson/document/add-document-modal.svelte b/apps/dashboard/src/lib/features/course/components/lesson/document/add-document-modal.svelte new file mode 100644 index 00000000..284bf7bb --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/document/add-document-modal.svelte @@ -0,0 +1,331 @@ + + + { + if (!isOpen) onClose(); + }} +> + + + {$t('course.navItem.lessons.materials.tabs.document.upload_title')} + + ($lessonDocUpload.isModalOpen = false)}> + {$t('course.navItem.lessons.materials.tabs.document.upgrade')} + + +
+ + {#if selectedFile} +
+ +
+

{selectedFile.name}

+

{formatFileSize(selectedFile.size)}

+
+ +
+ {:else} +
+ + $t('course.navItem.lessons.materials.tabs.document.upload_description')} + formatMaxFilesAndSize={(size) => `(up to ${size})`} + formatMaxSize={(size) => + $t('course.navItem.lessons.materials.tabs.document.upload_description') + ` (${size})`} + /> + +
+ {/if} + + + {#if $lessonDocUpload.error} +
+

{$lessonDocUpload.error}

+
+ {/if} + + + {#if $lessonDocUpload.isUploading} +
+
+ {$t('course.navItem.lessons.materials.tabs.document.uploading')} + {$lessonDocUpload.uploadProgress}% +
+
+
+
+
+ +
+
+ {/if} + + + {#if selectedFile && !$lessonDocUpload.isUploading} +
+ + +
+ {/if} + + + {#if uploadedDocument} +
+

+ {$t('course.navItem.lessons.materials.tabs.document.upload_success')} +

+
+ {/if} +
+
+
diff --git a/apps/dashboard/src/lib/features/course/components/lesson/document/document-card-dropdown.svelte b/apps/dashboard/src/lib/features/course/components/lesson/document/document-card-dropdown.svelte new file mode 100644 index 00000000..47b8ba7a --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/document/document-card-dropdown.svelte @@ -0,0 +1,28 @@ + + + + e.stopPropagation()} + > + + + + + + + {$t('course.navItem.lessons.materials.tabs.document.remove_document')} + + + diff --git a/apps/dashboard/src/lib/features/course/components/lesson/document/document-card.svelte b/apps/dashboard/src/lib/features/course/components/lesson/document/document-card.svelte new file mode 100644 index 00000000..f7739e60 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/document/document-card.svelte @@ -0,0 +1,72 @@ + + + + {#snippet header()} + {#if isEditMode} + + {/if} + {/snippet} + {#snippet actions()} + {#if doc.type === 'pdf'} + + {:else} + + {$t('course.navItem.lessons.materials.tabs.document.view_document')} + + {/if} + + {/snippet} + diff --git a/apps/dashboard/src/lib/features/course/components/lesson/document/document-list.svelte b/apps/dashboard/src/lib/features/course/components/lesson/document/document-list.svelte new file mode 100644 index 00000000..720f1cb8 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/document/document-list.svelte @@ -0,0 +1,65 @@ + + +{#if mode === MODES.edit} +
+ +
+{/if} + +{#if !isEmpty(displayDocuments)} + {@const isEditMode = mode === MODES.edit} + + {#each displayDocuments as document, index} + (isEditMode ? requestRemoveDocument(index) : undefined)} + {onViewPDF} + onDownload={downloadDocument} + /> + {/each} + +{:else if mode === MODES.edit} + +{/if} diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/ComponentDocument.svelte b/apps/dashboard/src/lib/features/course/components/lesson/document/document.svelte similarity index 88% rename from apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/ComponentDocument.svelte rename to apps/dashboard/src/lib/features/course/components/lesson/document/document.svelte index 6aad1d32..dfe271df 100644 --- a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/ComponentDocument.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/document/document.svelte @@ -1,7 +1,7 @@ + + {#if pdfViewerOpen} -
+
@@ -349,7 +368,7 @@ - + {Math.round(scale * 100)}% @@ -359,7 +378,7 @@
{/if} - +
diff --git a/apps/dashboard/src/lib/features/course/components/lesson/index.ts b/apps/dashboard/src/lib/features/course/components/lesson/index.ts new file mode 100644 index 00000000..934e3555 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/index.ts @@ -0,0 +1,11 @@ +export { default as ContentNavigationActions } from './content-navigation-actions.svelte'; +export { default as LanguageSelector } from './language-selector.svelte'; +export { default as LessonPageEditHeader } from './lesson-page-edit-header.svelte'; +export { default as Loader } from './loader.svelte'; +export { default as Comments } from './comments.svelte'; +export { default as Note } from './note/note.svelte'; +export { default as Slide } from './slide/slide.svelte'; +export { default as Video } from './video/video.svelte'; +export { default as Document } from './document/document.svelte'; +export { default as AddVideoModal } from './video/add-video-modal.svelte'; +export { default as AddDocumentModal } from './document/add-document-modal.svelte'; diff --git a/apps/dashboard/src/lib/features/course/components/lesson/language-selector.svelte b/apps/dashboard/src/lib/features/course/components/lesson/language-selector.svelte new file mode 100644 index 00000000..a7503084 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/language-selector.svelte @@ -0,0 +1,18 @@ + + + + + {LANGUAGES.find((lang) => lang.id === lessonApi.currentLocale)?.text || 'Language'} + + + + {#each LANGUAGES as lang (lang.id)} + {lang.text} + {/each} + + + diff --git a/apps/dashboard/src/lib/features/course/components/lesson/lesson-page-edit-header.svelte b/apps/dashboard/src/lib/features/course/components/lesson/lesson-page-edit-header.svelte new file mode 100644 index 00000000..39b63aef --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/lesson-page-edit-header.svelte @@ -0,0 +1,86 @@ + + +
+
+ {#if mode === MODES.edit && !$globalStore.isStudent} + + onTitleChange(e.currentTarget.value)} + /> + + {:else} + {title} + {/if} +
+ + {#if mode === MODES.edit && !$globalStore.isStudent} + +
+ + {#if isUnlocked} + + {:else} + + {/if} + + + (openDeleteModal = true)} + disabled={isDeletingLesson} + aria-label={$t('course.navItem.lessons.add_lesson.delete')} + title={$t('course.navItem.lessons.add_lesson.delete')} + > + {#if isDeletingLesson} + + {:else} + + {/if} + +
+
+ {/if} +
+ + diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/LessonVersionHistory.svelte b/apps/dashboard/src/lib/features/course/components/lesson/lesson-version-history.svelte similarity index 83% rename from apps/dashboard/src/lib/components/Course/components/Lesson/LessonVersionHistory.svelte rename to apps/dashboard/src/lib/features/course/components/lesson/lesson-version-history.svelte index 569a3e9d..7d640c93 100644 --- a/apps/dashboard/src/lib/components/Course/components/Lesson/LessonVersionHistory.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/lesson-version-history.svelte @@ -1,14 +1,13 @@ @@ -186,25 +197,25 @@ {/if}
- +

{lessonTitle}

-
+
{#key lessonId} - +
{@html sanitizeHtml(content)}
-
+
{/key}

{$t('course.navItem.lessons.version_history.title')} diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Loader.svelte b/apps/dashboard/src/lib/features/course/components/lesson/loader.svelte similarity index 100% rename from apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Loader.svelte rename to apps/dashboard/src/lib/features/course/components/lesson/loader.svelte diff --git a/apps/dashboard/src/lib/features/course/components/lesson/note/note.svelte b/apps/dashboard/src/lib/features/course/components/lesson/note/note.svelte new file mode 100644 index 00000000..b6027f8c --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/note/note.svelte @@ -0,0 +1,80 @@ + + +{#if mode === MODES.edit} + +

+ +
+ + +
+ onEditorChange(content)} + placeholder={$t('course.navItem.lessons.materials.tabs.note.placeholder')} + /> +
+{:else} + + {#if !isHtmlValueEmpty(content)} + + {@html sanitizeHtml(content)} + + {:else if hasAtLeastOneTranslation} +

+ {$t('course.navItem.lessons.materials.no_translation')} +

+ {/if} +{/if} diff --git a/apps/dashboard/src/lib/features/course/components/lesson/section-header.svelte b/apps/dashboard/src/lib/features/course/components/lesson/section-header.svelte new file mode 100644 index 00000000..cf1792c8 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/section-header.svelte @@ -0,0 +1,100 @@ + + +
+ {#if isEditing} +
+ + +
+ {:else} +
+ +

{section.title}

+ +
+ {/if} + + {#if showActions} + +
+ {#if !isEditing && showAddContent} + + + + {/if} + + onToggleLock?.()} + /> +
+
+ {/if} +
diff --git a/apps/dashboard/src/lib/features/course/components/lesson/slide/slide.svelte b/apps/dashboard/src/lib/features/course/components/lesson/slide/slide.svelte new file mode 100644 index 00000000..f2acf9e5 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/slide/slide.svelte @@ -0,0 +1,53 @@ + + +{#if mode === MODES.edit} + + {@const slideUrlValue = lessonApi.lesson?.slideUrl || ''} + { + lessonApi.updateLessonState('slideUrl', e.currentTarget.value); + }} + helperMessage={$t('course.navItem.lessons.materials.tabs.slide.helper_message')} + /> +{:else} + + {#if url} + + {/if} +{/if} diff --git a/apps/dashboard/src/lib/features/course/components/lesson/store.ts b/apps/dashboard/src/lib/features/course/components/lesson/store.ts new file mode 100644 index 00000000..dee6ba0d --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/store.ts @@ -0,0 +1,127 @@ +import type { CourseSection, Lesson, ListLessons } from '$features/course/utils/types'; +import { get, writable } from 'svelte/store'; + +import type { Course } from '$features/course/types'; +import { courseApi } from '$features/course/api'; +import { lessonApi } from '$features/course/api'; +import { lessonValidation } from '$lib/utils/functions/validator'; +import { profile } from '$lib/utils/store/user'; +import { snackbar } from '$features/ui/snackbar/store'; + +export const lessonVideoUpload = writable({ + error: null as string | null, + isCancelled: false, + isModalOpen: false, + isUploading: false, + uploadProgress: 0 +}); + +export const lessonDocUpload = writable({ + error: null as string | null, + isCancelled: false, + isModalOpen: false, + isUploading: false, + uploadProgress: 0 +}); + +export async function handleDelete(lessonId: Lesson['id'] | undefined) { + // Need to implement soft delete + if (!lessonId) { + return; + } + const courseId = courseApi.course?.id; + if (!courseId) return; + + await lessonApi.delete(courseId, lessonId); + + if (lessonApi.success) { + const profileId = get(profile)?.id; + if (profileId) { + await courseApi.refreshCourse(courseId, profileId); + } + + snackbar.success('snackbar.generic.success_delete'); + } +} + +export async function handleSaveLesson(lesson: Partial, courseId: Course['id']) { + const result = lessonValidation(lesson); + + if (Object.keys(result).length) { + return { validationRes: result, lesson: null }; + } + + const lessonData = { + title: lesson.title || '', + lessonAt: lesson.lessonAt!, + callUrl: lesson.callUrl!, + teacherId: lesson.teacherId!, + isUnlocked: lesson.isUnlocked!, + sectionId: lesson.sectionId! + }; + + if (lesson.id) { + await lessonApi.update(courseId, lesson.id, lessonData); + if (lessonApi.success && lessonApi.lesson) { + return { lesson: lessonApi.lesson as Lesson }; + } + } else { + await lessonApi.create(courseId, { ...lessonData, courseId }); + if (lessonApi.success && lessonApi.lesson) { + return { lesson: lessonApi.lesson as Lesson }; + } + } + return { validationRes: null, lesson: null }; +} +export async function handleSaveCourseSection(section: Partial, courseId: Course['id']) { + const result = lessonValidation(section); + + if (Object.keys(result).length) { + return result; + } + + const sectionData = { + title: section.title! + }; + + if (section.id) { + await lessonApi.updateSection(courseId, section.id, sectionData); + if (lessonApi.success && lessonApi.lesson) { + return [lessonApi.lesson]; + } + } else { + await lessonApi.createSection(courseId, { ...sectionData, courseId }); + if (lessonApi.success && lessonApi.lesson) { + return [lessonApi.lesson]; + } + } + + return null; +} + +export function resetDocumentUploadStore() { + lessonDocUpload.set({ + isUploading: false, + isModalOpen: false, + uploadProgress: 0, + error: null, + isCancelled: false + }); +} + +export function cancelDocumentUpload() { + lessonDocUpload.update((store) => ({ + ...store, + isCancelled: true, + isUploading: false, + error: 'Upload cancelled by user' + })); +} + +export function cancelVideoUpload() { + lessonVideoUpload.update((store) => ({ + ...store, + isCancelled: true, + isUploading: false + })); +} diff --git a/apps/dashboard/src/lib/features/course/components/lesson/utils.ts b/apps/dashboard/src/lib/features/course/components/lesson/utils.ts new file mode 100644 index 00000000..9ac2be8c --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/utils.ts @@ -0,0 +1,25 @@ +import * as CONSTANTS from './constants'; + +import type { Component } from 'svelte'; +import Document from './document/document.svelte'; +import Note from './note/note.svelte'; +import Slide from './slide/slide.svelte'; +import Video from './video/video.svelte'; + +export function getViewModeComponents(tabs = CONSTANTS.tabs): Component[] { + const componentMap: Record = { + 1: Note, + 2: Slide, + 3: Video, + 4: Document + }; + + const componentNames = tabs + .map((tab) => { + const component = componentMap[tab.value]; + return component || null; + }) + .filter((component): component is Component => component !== null); + + return componentNames; +} diff --git a/apps/dashboard/src/lib/features/course/components/lesson/video/add-video-modal.svelte b/apps/dashboard/src/lib/features/course/components/lesson/video/add-video-modal.svelte new file mode 100644 index 00000000..77c4b82a --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/add-video-modal.svelte @@ -0,0 +1,62 @@ + + + { + if (!isOpen) onClose(); + }} +> + + + {$t('course.navItem.lessons.materials.tabs.video.add_video.title')} + + + + + {#each tabs as item (item.value)} + + {#if $isFreePlan && item.value === 3} + + {:else} + + {/if} + {$t(item.title)} + + {/each} + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Video/constants.ts b/apps/dashboard/src/lib/features/course/components/lesson/video/constants.ts similarity index 76% rename from apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Video/constants.ts rename to apps/dashboard/src/lib/features/course/components/lesson/video/constants.ts index 462a38d4..a80cabe1 100644 --- a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Video/constants.ts +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/constants.ts @@ -1,4 +1,5 @@ import FileSymlinkIcon from '@lucide/svelte/icons/file-symlink'; +import LibraryIcon from '@lucide/svelte/icons/library'; import VideoIcon from '@lucide/svelte/icons/video'; import YoutubeIcon from '@lucide/svelte/icons/youtube'; @@ -17,5 +18,10 @@ export const videoTabs = [ value: 3, title: 'course.navItem.lessons.materials.tabs.video.upload', icon: VideoIcon + }, + { + value: 4, + title: 'course.navItem.lessons.materials.tabs.video.library', + icon: LibraryIcon } ]; diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Video/EmbedLink.svelte b/apps/dashboard/src/lib/features/course/components/lesson/video/embed-link.svelte similarity index 58% rename from apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Video/EmbedLink.svelte rename to apps/dashboard/src/lib/features/course/components/lesson/video/embed-link.svelte index eef28065..72d55e0c 100644 --- a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Video/EmbedLink.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/embed-link.svelte @@ -4,34 +4,66 @@ import TrashIcon from '@lucide/svelte/icons/trash'; import { t } from '$lib/utils/functions/translations'; - import type { LessonVideoType } from '$lib/utils/types'; - import { isLessonDirty, lesson } from '$lib/components/Course/components/Lesson/store/lessons'; + import type { LessonVideoType } from '$features/course/utils/types'; + import { lessonApi } from '$features/course/api'; + import { mediaApi } from '$features/media/api'; import { copyToClipboard, getVideoUrls, removeVideo } from '$lib/utils/functions/formatYoutubeVideo'; import { IconButton } from '@cio/ui/custom/icon-button'; import { InputField } from '@cio/ui/custom/input-field'; import { Button } from '@cio/ui/base/button'; + interface Props { + lessonId?: string; + } + + let { lessonId = '' }: Props = $props(); + let genericLinks = $state(''); let error = $state(''); - function addVideo() { + async function addVideo() { const links = getVideoUrls(genericLinks); const validLinks = links.filter(isValidLink); if (validLinks.length === 0) { error = $t('course.navItem.lessons.materials.tabs.video.add_video.invalid_link'); } else { - const existingLinks = $lesson?.materials?.videos || []; + if (!lessonApi.lesson) return; - $lesson.materials.videos = [ - ...existingLinks, - ...validLinks.map((link = '') => ({ - type: 'generic' as LessonVideoType, - link, - metadata: {} - })) - ]; + const existingCount = Array.isArray(lessonApi.lesson.videos) ? lessonApi.lesson.videos.length : 0; + const newVideos = await Promise.all( + validLinks.map(async (link = '', index) => { + const createdAt = new Date().toISOString(); + const asset = await mediaApi.createAsset({ + kind: 'video', + provider: 'generic', + storageProvider: 'external', + sourceUrl: link, + isExternal: true, + title: link, + metadata: { createdAt } + }); + + if (asset && lessonId) { + await mediaApi.attachAsset(asset.id, { + targetType: 'lesson', + targetId: lessonId, + slotType: 'lesson_video', + position: existingCount + index + }); + } + + return { + type: 'generic' as LessonVideoType, + link, + assetId: asset?.id, + metadata: { createdAt } as { svid?: string; createdAt?: string } + }; + }) + ); + + lessonApi.updateLessonState('videos', newVideos, { append: true }); genericLinks = ''; error = ''; } @@ -60,7 +92,7 @@ label={$t('course.navItem.lessons.materials.tabs.video.embed_link')} bind:value={genericLinks} className="flex-1" - onchange={() => ($isLessonDirty = true)} + onchange={() => (lessonApi.isDirty = true)} placeholder="https://www.videoplayer.com/" errorMessage={error} /> @@ -71,11 +103,11 @@

{$t('course.navItem.lessons.materials.tabs.video.add_video.videos_added')}: - {$lesson?.materials?.videos.filter((v) => v.type === 'generic').length || 0} + {lessonApi.lesson?.videos?.filter((v) => v.type === 'generic').length || 0}

- {#each $lesson?.materials?.videos as video, index} + {#each lessonApi.lesson?.videos || [] as video, index} {#if video.type === 'generic'}
diff --git a/apps/dashboard/src/lib/features/course/components/lesson/video/library-video.svelte b/apps/dashboard/src/lib/features/course/components/lesson/video/library-video.svelte new file mode 100644 index 00000000..827fdd75 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/library-video.svelte @@ -0,0 +1,91 @@ + + +
+
+ + +
+ + {#if isLoading} +

+ {$t('course.navItem.lessons.materials.tabs.video.add_video.loading_library')} +

+ {:else if assets.length === 0} +

+ {$t('course.navItem.lessons.materials.tabs.video.add_video.no_library_videos')} +

+ {:else} +
+ {#each assets as asset (asset.id)} +
+
+

{asset.title || asset.storageKey || asset.id}

+

{asset.provider}

+
+ +
+ {/each} +
+ {/if} +
diff --git a/apps/dashboard/src/lib/features/course/components/lesson/video/media-uploader.ts b/apps/dashboard/src/lib/features/course/components/lesson/video/media-uploader.ts new file mode 100644 index 00000000..b8ceb78a --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/media-uploader.ts @@ -0,0 +1,33 @@ +import { classroomio } from '$lib/utils/services/api'; +import { generateVideoThumbnail } from './video-thumbnail-utils'; + +export type VideoThumbnailUploadResult = { url: string; durationSeconds: number }; + +/** + * Handles media uploads (e.g. video thumbnails) via the media API. + */ +export class MediaUploader { + /** + * Generates a thumbnail from the video file and uploads it to the media bucket. + * Returns the public thumbnail URL and video duration, or null if generation or upload fails. + */ + async uploadVideoThumbnail(videoFile: File): Promise { + const thumbnailResult = await generateVideoThumbnail(videoFile); + if (!thumbnailResult) return null; + + const thumbnailFileName = videoFile.name.replace(/\.[^.]+$/, '') + '-thumbnail.jpg'; + const file = new File([thumbnailResult.blob], thumbnailFileName, { type: 'image/jpeg' }); + + try { + const response = await classroomio.media.image.$post({ form: { file } }); + const data = await response.json(); + if (!data.success || !data.url) return null; + return { + url: data.url, + durationSeconds: thumbnailResult.durationSeconds + }; + } catch { + return null; + } + } +} diff --git a/apps/dashboard/src/lib/features/course/components/lesson/video/upload-video.svelte b/apps/dashboard/src/lib/features/course/components/lesson/video/upload-video.svelte new file mode 100644 index 00000000..6f3ed63c --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/upload-video.svelte @@ -0,0 +1,308 @@ + + + ($lessonVideoUpload.isModalOpen = false)}> + {$t(`${ADD_VIDEO}.upgrade`)} + + +{#if !isLoaded} + {#if $lessonVideoUpload.isUploading} +
+
+

+ {$t(`${ADD_VIDEO}.uploading`)} +

+ +

{helperText}

+
+ +
+
+
+ {:else} +
+ + $t(`${ADD_VIDEO}.max_files`, { count })} + formatMaxFilesAndSize={(size) => $t(`${ADD_VIDEO}.max_files_and_size`, { size })} + formatMaxSize={() => $t(`${ADD_VIDEO}.size`)} + /> + +
+ {/if} +{:else if formRes?.type === 'FILE_TOO_LARGE'} +
+ upload error + +

+ {$t(`${ADD_VIDEO}.oops`)} +

+

+ {$t(`${ADD_VIDEO}.big_file`)}
+ {$t(`${ADD_VIDEO}.maximum_size`)} +

+
+ +
+{:else if formRes?.status !== 200} +
+ upload error + +

+ {$t(`${ADD_VIDEO}.oops`)} +

+

+ {$t(`${ADD_VIDEO}.unsupported_format`)}
+ {$t(`${ADD_VIDEO}.format`)} +

+
+ +
+{:else} +
+
+
+ {#if formRes.thumbnailUrl} + {fileName} + {:else} + + {/if} +

{fileName}

+
+ +
+
+{/if} diff --git a/apps/dashboard/src/lib/features/course/components/lesson/video/video-card-dropdown.svelte b/apps/dashboard/src/lib/features/course/components/lesson/video/video-card-dropdown.svelte new file mode 100644 index 00000000..c180a946 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/video-card-dropdown.svelte @@ -0,0 +1,28 @@ + + + + e.stopPropagation()} + > + + + + + + + {$t('course.navItem.lessons.materials.tabs.video.remove_video')} + + + diff --git a/apps/dashboard/src/lib/features/course/components/lesson/video/video-card-utils.ts b/apps/dashboard/src/lib/features/course/components/lesson/video/video-card-utils.ts new file mode 100644 index 00000000..8fced561 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/video-card-utils.ts @@ -0,0 +1,100 @@ +import { getYoutubeVideoId } from '@cio/ui/custom/media-player'; + +export type LessonVideo = NonNullable[number]; + +type VideoMetadata = { + title?: string; + fileName?: string; + aspectRatio?: string; + duration?: number; + createdAt?: string; + thumbnailUrl?: string; +}; + +/** + * Thumbnail URL for a lesson video. YouTube uses img.youtube.com; uploads use metadata.thumbnailUrl when present. + */ +export function getVideoThumbnailUrl(video: LessonVideo): string | null { + if (video.type === 'youtube' && video.link) { + const id = getYoutubeVideoId(video.link); + return id ? `https://img.youtube.com/vi/${id}/mqdefault.jpg` : null; + } + if (video.type === 'upload') { + const meta = video.metadata as VideoMetadata | undefined; + if (meta?.thumbnailUrl && typeof meta.thumbnailUrl === 'string') return meta.thumbnailUrl; + } + return null; +} + +/** + * Format duration in seconds as M:SS. Returns null if no duration. + */ +export function formatVideoDuration(seconds: number | undefined | null): string | null { + if (seconds == null || typeof seconds !== 'number' || seconds < 0) return null; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, '0')}`; +} + +/** + * Fallback: derive display name from upload key (format: "nanoid-filename.ext"). + * Used only for older uploads that don't have top-level fileName. + */ +function getFileNameFromKey(key: string | undefined): string | null { + if (!key || typeof key !== 'string') return null; + const dashIndex = key.indexOf('-'); + if (dashIndex === -1) return null; + return key.slice(dashIndex + 1).trim() || null; +} + +/** + * Display title for a video. + * Order: top-level fileName → metadata.title → (upload only) derive from key → type fallback → "Video N". + */ +export function getVideoTitle(video: LessonVideo, index: number): string { + const topLevelName = (video as LessonVideo & { fileName?: string }).fileName; + if (topLevelName && typeof topLevelName === 'string') return topLevelName; + + const meta = video.metadata as VideoMetadata | undefined; + + if (meta?.title && typeof meta.title === 'string') return meta.title; + + if (video.type === 'upload' && video.key) { + const fromKey = getFileNameFromKey(video.key); + + if (fromKey) return fromKey; + } + + if (video.type === 'youtube') return 'YouTube video'; + if (video.type === 'generic') return 'Embedded video'; + + return `Video ${index + 1}`; +} + +/** + * Duration in seconds from metadata, if present. + */ +export function getVideoDurationSeconds(video: LessonVideo): number | null { + const meta = video.metadata as VideoMetadata | undefined; + if (meta?.duration != null && typeof meta.duration === 'number') return meta.duration; + return null; +} + +/** + * Created at ISO string from metadata, if present. + */ +export function getVideoCreatedAt(video: LessonVideo): string | null { + const meta = video.metadata as VideoMetadata | undefined; + if (meta?.createdAt && typeof meta.createdAt === 'string') return meta.createdAt; + return null; +} + +/** + * Format created-at for display (e.g. "Feb 15, 2026"). + */ +export function formatVideoCreatedAt(isoString: string | null | undefined): string | null { + if (!isoString || typeof isoString !== 'string') return null; + const d = new Date(isoString); + if (Number.isNaN(d.getTime())) return null; + return new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(d); +} diff --git a/apps/dashboard/src/lib/features/course/components/lesson/video/video-card.svelte b/apps/dashboard/src/lib/features/course/components/lesson/video/video-card.svelte new file mode 100644 index 00000000..aa174674 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/video-card.svelte @@ -0,0 +1,96 @@ + + + +
+ {#if isEditMode} + + {/if} + + + + {#if thumbnailUrl} + {title} + {:else} + + {/if} + + {#if durationFormatted} +
+ {durationFormatted} +
+ {/if} +
+
+ + + + {#if isYoutubeWithLink} + + {title} + + {:else} + {title} + {/if} + +

+ {#if createdAtFormatted && durationFormatted} + {createdAtFormatted} · {durationFormatted} + {:else if createdAtFormatted} + {createdAtFormatted} + {:else if durationFormatted} + {durationFormatted} + {:else} + – + {/if} +

+
+
+
diff --git a/apps/dashboard/src/lib/features/course/components/lesson/video/video-thumbnail-utils.ts b/apps/dashboard/src/lib/features/course/components/lesson/video/video-thumbnail-utils.ts new file mode 100644 index 00000000..b5637390 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/video-thumbnail-utils.ts @@ -0,0 +1,79 @@ +const JPEG_QUALITY = 0.85; +const DEFAULT_SEEK_SECONDS = 1; +const THUMBNAIL_TIMEOUT_MS = 15_000; + +export type ThumbnailResult = { blob: Blob; durationSeconds: number }; + +/** + * Generates a thumbnail from a video file by capturing a frame to canvas. + * Seeks to 1s or duration/4 for short clips. Returns null on failure. + */ +export function generateVideoThumbnail(videoFile: File): Promise { + return new Promise((resolve) => { + const video = document.createElement('video'); + video.muted = true; + video.playsInline = true; + video.preload = 'metadata'; + + const objectUrl = URL.createObjectURL(videoFile); + video.src = objectUrl; + + const cleanup = () => { + video.removeAttribute('src'); + video.load(); + URL.revokeObjectURL(objectUrl); + }; + + const timeoutId = setTimeout(() => { + cleanup(); + resolve(null); + }, THUMBNAIL_TIMEOUT_MS); + + const finish = (result: ThumbnailResult | null) => { + clearTimeout(timeoutId); + cleanup(); + resolve(result); + }; + + video.addEventListener('error', () => finish(null)); + video.addEventListener('loadedmetadata', () => { + const duration = video.duration; + const seekTo = !Number.isFinite(duration) || duration <= 0 ? 0 : Math.min(DEFAULT_SEEK_SECONDS, duration / 4); + video.currentTime = seekTo; + }); + + video.addEventListener('seeked', () => { + try { + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + if (canvas.width <= 0 || canvas.height <= 0) { + finish(null); + return; + } + const ctx = canvas.getContext('2d'); + if (!ctx) { + finish(null); + return; + } + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + const durationSeconds = video.duration; + canvas.toBlob( + (blob) => { + if (blob) { + finish({ blob, durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : 0 }); + } else { + finish(null); + } + }, + 'image/jpeg', + JPEG_QUALITY + ); + } catch { + finish(null); + } + }); + + video.load(); + }); +} diff --git a/apps/dashboard/src/lib/features/course/components/lesson/video/video.svelte b/apps/dashboard/src/lib/features/course/components/lesson/video/video.svelte new file mode 100644 index 00000000..4cad7186 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/video.svelte @@ -0,0 +1,93 @@ + + +{#snippet content(video)} + {#key video.link} + + {/key} +{/snippet} + +{#if mode === MODES.edit} + + + + {#if videos.length} + + {#each videos as video, index} + requestRemoveVideo(index)} /> + {/each} + + {:else} + + {/if} + + +{:else} + + {#if videos.length} +
+ {#each videos as video} +
+ {@render content(video)} +
+ {/each} +
+ {/if} +{/if} diff --git a/apps/dashboard/src/lib/features/course/components/lesson/video/youtube-video.svelte b/apps/dashboard/src/lib/features/course/components/lesson/video/youtube-video.svelte new file mode 100644 index 00000000..b9fe2ec6 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/youtube-video.svelte @@ -0,0 +1,103 @@ + + + (lessonApi.isDirty = true)} + onSubmit={addVideo} +/> +

+ {$t('course.navItem.lessons.materials.tabs.video.add_video.videos_added')}: + + {lessonApi.lesson?.videos?.filter((v) => v.type === 'youtube' && isValidYoutubeLink(v.link)).length || 0} + +

+
+ {#each lessonApi.lesson?.videos || [] as video, index} + {#if video.type === 'youtube'} +
+ + {video.link} + + copyToClipboard(video.link)}> + + + removeVideo(index)}> + + +
+ {/if} + {/each} +
diff --git a/apps/dashboard/src/lib/features/course/components/list.svelte b/apps/dashboard/src/lib/features/course/components/list.svelte index c8b222bb..b42df0ac 100644 --- a/apps/dashboard/src/lib/features/course/components/list.svelte +++ b/apps/dashboard/src/lib/features/course/components/list.svelte @@ -16,6 +16,12 @@ isPublished?: boolean; totalLessons?: number; totalStudents?: number; + tags?: Array<{ + id: string; + name: string; + slug: string; + color?: string | null; + }>; } let { @@ -25,7 +31,8 @@ description = '', isPublished = false, totalLessons = 0, - totalStudents = 0 + totalStudents = 0, + tags = [] }: Props = $props(); function handleCloneCourse(e) { @@ -60,6 +67,24 @@ {#if !isMobileStore.current} {type} + + {#if tags.length === 0} + - + {:else} +
+ {#each tags as tag (tag.id)} + + + {tag.name} + + {/each} +
+ {/if} +
{totalLessons} {totalStudents} diff --git a/apps/dashboard/src/lib/features/course/components/new-course-modal.svelte b/apps/dashboard/src/lib/features/course/components/new-course-modal.svelte index b82e6e88..b10055ac 100644 --- a/apps/dashboard/src/lib/features/course/components/new-course-modal.svelte +++ b/apps/dashboard/src/lib/features/course/components/new-course-modal.svelte @@ -3,28 +3,17 @@ import { goto } from '$app/navigation'; import { page } from '$app/state'; - import { validateForm } from '../utils/functions'; - import { courses, createCourseModal } from '../utils/store'; + import { createCourseModal } from '../utils/store'; import { TextareaField } from '@cio/ui/custom/textarea-field'; import { InputField } from '@cio/ui/custom/input-field'; import * as Dialog from '@cio/ui/base/dialog'; import { Button } from '@cio/ui/base/button'; - import { ROLE } from '@cio/utils/constants'; import * as Field from '@cio/ui/base/field'; - import * as RadioGroup from '@cio/ui/base/radio-group'; - import { supabase } from '$lib/utils/functions/supabase'; + import { RadioOptionCardGroup } from '@cio/ui/custom/radio-option-card'; import { t } from '$lib/utils/functions/translations'; - import { addDefaultNewsFeed, addGroupMember } from '$lib/utils/services/courses'; - import { capturePosthogEvent } from '$lib/utils/services/posthog'; - import { currentOrg } from '$lib/utils/store/org'; - import { profile } from '$lib/utils/store/user'; - import { COURSE_TYPE, COURSE_VERSION } from '$lib/utils/types'; + import type { TCourseType } from '@cio/db/types'; + import { courseApi } from '../api'; - let isLoading = $state(false); - let errors = $state({ - title: '', - description: '' - }); let step = $state(0); const options = [ @@ -32,17 +21,24 @@ id: 'live-class', title: $t('new_course_modal.live_class_label'), subtitle: $t('new_course_modal.live_class_subtitle'), - type: COURSE_TYPE.LIVE_CLASS, + type: 'LIVE_CLASS' as TCourseType, isDisabled: false }, { id: 'self-paced', title: $t('new_course_modal.self_paced_label'), subtitle: $t('new_course_modal.self_paced_subtitle'), - type: COURSE_TYPE.SELF_PACED, + type: 'SELF_PACED' as TCourseType, isDisabled: false } ]; + const courseTypeOptionsForGroup = options.map((o) => ({ + id: o.id, + title: o.title, + description: o.subtitle, + value: o.type + })); + let type = $state(options[0].type); function onClose(redirectTo) { @@ -59,105 +55,20 @@ } async function createCourse() { - isLoading = true; - const { hasError, fieldErrors } = validateForm($createCourseModal); - - errors = fieldErrors; - if (hasError) return; - - const { title, description } = $createCourseModal; - // 1. Create group - const { data: newGroup } = await supabase - .from('group') - .insert({ name: title, description, organization_id: $currentOrg.id }) - .select(); - - console.log(`newGroup`, newGroup); - - if (!newGroup) return; - - const { id: group_id } = newGroup[0]; - - // 2. Create course with group_id - const { data: newCourseData } = await supabase - .from('course') - .insert({ - title, - description, - type: type, - version: COURSE_VERSION.V2, - group_id - }) - .select(); - console.log(`newCourse data`, newCourseData); - - if (!newCourseData) return; - - const newCourse = newCourseData[0]; - courses.update((_courses) => [..._courses, newCourse]); - - capturePosthogEvent('course_created', { - course_id: newCourse.id, - course_title: newCourse.title, - course_description: newCourse.description, - organization_id: $currentOrg.id, - organization_name: $currentOrg.name, - user_id: $profile.id, - user_email: $profile.email + await courseApi.create({ + title: $createCourseModal.title, + description: $createCourseModal.description, + type: type }); - - // 3. Add group members - const { data } = await addGroupMember({ - profile_id: $profile.id, - email: $profile.email, - group_id, - role_id: ROLE.TUTOR - }); - - // 4. Add default news feed. - if (Array.isArray(data) && data.length) { - const { id: authorId } = data[0]; - console.log('Add news feed into course'); - - await addDefaultNewsFeed({ - content: `

Welcome to this course 🎉 

-

Thank you for joining this course and I hope you get the best out of it.

`, - course_id: newCourse.id, - is_pinned: true, - author_id: authorId - }); - } - - onClose(`/courses/${newCourse.id}`); - isLoading = false; } - $inspect(type); - let open = $derived(new URLSearchParams(page.url.search).get('create') === 'true'); - - Create a new course - - {#snippet course_type_selector()} {$t('courses.new_course_modal.type_selector_title')} - - {#each options as option (option.id)} - - - - {option.title} - {option.subtitle} - - - - - - {/each} - + {/snippet}
@@ -201,7 +112,7 @@ placeholder={$t('courses.new_course_modal.short_description_placeholder')} className="mb-4" isRequired={true} - errorMessage={errors.description} + errorMessage={courseApi.errors.description} isAIEnabled={true} initAIPrompt="Write a 30 word description for a course titled: {$createCourseModal.title}" /> @@ -210,7 +121,7 @@ - diff --git a/apps/dashboard/src/lib/features/course/components/newsfeed/card/card.svelte b/apps/dashboard/src/lib/features/course/components/newsfeed/card/card.svelte new file mode 100644 index 00000000..fcd7969e --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/newsfeed/card/card.svelte @@ -0,0 +1,103 @@ + + +
+
(isDeleteFeedModal = true)} /> + + + + { + await addNewComment(content, feed.id); + }} + onDeleteComment={(commentId) => { + if (courseId) deleteComment(feed.id, String(commentId)); + }} + /> + + +
diff --git a/apps/dashboard/src/lib/features/course/components/newsfeed/card/comments.svelte b/apps/dashboard/src/lib/features/course/components/newsfeed/card/comments.svelte new file mode 100644 index 00000000..8f82506a --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/newsfeed/card/comments.svelte @@ -0,0 +1,229 @@ + + +
+
+ + + {#if shownCount > 1} + + {/if} +
+ +
+ {#if comments} + {#each comments.items.slice(0, shownCount) as commentItem (commentItem.id)} + {#if commentItem.content} +
+ +
+ {$t('course.navItem.news_feed.user_avatar_alt')} +
+ +
+

{commentItem.authorFullname || ''}

+

{calDateDiff(commentItem.createdAt)}

+
+

{commentItem.content}

+
+
+ + {#if commentItem.authorProfileId === $profile.id || $isOrgAdmin} + + + + onDeleteComment(commentItem.id)}> + {$t('course.navItem.news_feed.comments.delete')} + + + + {/if} +
+ {/if} + {/each} + {/if} +
+ + +
+
+ {$t('course.navItem.news_feed.user_avatar_alt')} +
+
+ +
+ +
+ + {#if errors?.newComment} +

{errors?.newComment}

+ {/if} +
diff --git a/apps/dashboard/src/lib/components/Course/components/NewsFeed/DeleteFeedConfirmation.svelte b/apps/dashboard/src/lib/features/course/components/newsfeed/card/delete-confirmation.svelte similarity index 100% rename from apps/dashboard/src/lib/components/Course/components/NewsFeed/DeleteFeedConfirmation.svelte rename to apps/dashboard/src/lib/features/course/components/newsfeed/card/delete-confirmation.svelte diff --git a/apps/dashboard/src/lib/features/course/components/newsfeed/card/header.svelte b/apps/dashboard/src/lib/features/course/components/newsfeed/card/header.svelte new file mode 100644 index 00000000..3780346f --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/newsfeed/card/header.svelte @@ -0,0 +1,66 @@ + + +
+
+ +
+ {$t('course.navItem.news_feed.user_avatar_alt')} +
+ +

{feed.authorFullname || ''}

+

{calDateDiff(feed.createdAt)}

+
+
+ + + + + + + + onPin(feed.id, feed.isPinned)}> + {feed.isPinned ? $t('course.navItem.news_feed.card.unpin') : $t('course.navItem.news_feed.card.pin')} + + {$t('course.navItem.news_feed.card.edit')} + {$t('course.navItem.news_feed.card.delete')} + + + +
+ + {#if !isHtmlValueEmpty(feed.content || '')} + +
+ {@html sanitizeHtml(feed.content || '')} +
+
+ {/if} +
diff --git a/apps/dashboard/src/lib/features/course/components/newsfeed/card/reactions.svelte b/apps/dashboard/src/lib/features/course/components/newsfeed/card/reactions.svelte new file mode 100644 index 00000000..c0a3a6b1 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/newsfeed/card/reactions.svelte @@ -0,0 +1,43 @@ + + + +
+
+ {#each Object.keys(feed.reaction || {}) as reactionType} + {#if reactions[reactionType]} + {@const isAuthorReacted = (feed.reaction?.[reactionType] || []).includes(authorId)} + + {/if} + {/each} +
+
diff --git a/apps/dashboard/src/lib/features/course/components/newsfeed/index.ts b/apps/dashboard/src/lib/features/course/components/newsfeed/index.ts new file mode 100644 index 00000000..b8320c4a --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/newsfeed/index.ts @@ -0,0 +1,3 @@ +export { default as NewFeedModal } from './new-feed-modal.svelte'; +export { default as NewsFeedCard } from './card/card.svelte'; +export { default as NewsFeedLoader } from './news-feed-loader.svelte'; diff --git a/apps/dashboard/src/lib/features/course/components/newsfeed/new-feed-modal.svelte b/apps/dashboard/src/lib/features/course/components/newsfeed/new-feed-modal.svelte new file mode 100644 index 00000000..194b22bd --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/newsfeed/new-feed-modal.svelte @@ -0,0 +1,105 @@ + + + { + if (!isOpen) { + newsfeedApi.closeNewFeedModal(); + resetEditor(); + } + }} +> + + + + {edit === true + ? $t('course.navItem.news_feed.heading_button.edit_post') + : $t('course.navItem.news_feed.heading_button.make_a_post')} + + +
+ { + newPost = text; + }} + editorClass="max-h-[400px]" + placeholder={$t('course.navItem.news_feed.heading_button.placeholder')} + /> + {#if errors.newPost} +

{errors.newPost}

+ {/if} +
+ + + + +
+
diff --git a/apps/dashboard/src/lib/components/Course/components/NewsFeed/NewsFeedLoader.svelte b/apps/dashboard/src/lib/features/course/components/newsfeed/news-feed-loader.svelte similarity index 100% rename from apps/dashboard/src/lib/components/Course/components/NewsFeed/NewsFeedLoader.svelte rename to apps/dashboard/src/lib/features/course/components/newsfeed/news-feed-loader.svelte diff --git a/apps/dashboard/src/lib/features/course/components/people/bulk-email-section.svelte b/apps/dashboard/src/lib/features/course/components/people/bulk-email-section.svelte new file mode 100644 index 00000000..9bce0a03 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/people/bulk-email-section.svelte @@ -0,0 +1,90 @@ + + +
+

{$t(`${INVITE_MODAL}.direct_email_bulk`)}

+

+ {$t(`${INVITE_MODAL}.direct_email_description`)} +

+ + +
+ +
+
diff --git a/apps/dashboard/src/lib/components/Course/components/People/DeleteConfirmation.svelte b/apps/dashboard/src/lib/features/course/components/people/delete-confirmation.svelte similarity index 100% rename from apps/dashboard/src/lib/components/Course/components/People/DeleteConfirmation.svelte rename to apps/dashboard/src/lib/features/course/components/people/delete-confirmation.svelte diff --git a/apps/dashboard/src/lib/features/course/components/people/invitation-modal.svelte b/apps/dashboard/src/lib/features/course/components/people/invitation-modal.svelte new file mode 100644 index 00000000..73d6cf8a --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/people/invitation-modal.svelte @@ -0,0 +1,185 @@ + + + { + if (!open) closeModal(); + }} +> + + + {$t('course.navItem.people.invite_modal.title')} + + + + + + {$t(`${INVITE_MODAL}.invite`)} + + + {$t(`${INVITE_MODAL}.invite_students`)} + + + + +
+ + +
+ +
+
+
+ + +
+ + + + + + {$t(`${INVITE_MODAL}.direct_email_bulk`)} + + + {$t(`${INVITE_MODAL}.existing_invites`)} + + + + +
+ +
+
+ + +
+ +
+
+
+
+
+
+
+
diff --git a/apps/dashboard/src/lib/features/course/components/people/invite-list-section.svelte b/apps/dashboard/src/lib/features/course/components/people/invite-list-section.svelte new file mode 100644 index 00000000..dce887b2 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/people/invite-list-section.svelte @@ -0,0 +1,163 @@ + + +
+
+

{$t(`${INVITE_MODAL}.existing_invites`)}

+ +
+ + {#if isLoading} +
+ + {$t(`${INVITE_MODAL}.loading_invites`)} +
+ {:else if invites.length === 0} +

{$t(`${INVITE_MODAL}.no_invites_yet`)}

+ {:else} +
+ {#each invites as invite} +
+
+
+
+ {shortId(invite.id)} + {invite.status} +
+
{$t(`${INVITE_MODAL}.created`)}: {formatDate(invite.createdAt)}
+
{$t(`${INVITE_MODAL}.expires`)}: {formatDate(invite.expiresAt)}
+
+ {$t(`${INVITE_MODAL}.uses`)}: {invite.usedCount}/{invite.maxUses} ({$t(`${INVITE_MODAL}.remaining`)}: {invite.usesRemaining}) +
+
+ {$t(`${INVITE_MODAL}.opened`)}: {invite.activity.previewedCount} | {$t(`${INVITE_MODAL}.joined`)}: {invite + .activity.acceptedCount} +
+
+ {$t(`${INVITE_MODAL}.email_sent`)}: {invite.activity.emailSentCount} | {$t( + `${INVITE_MODAL}.email_failed` + )}: {invite.activity.emailFailedCount} +
+
+ +
+ + {#if invite.status === 'ACTIVE'} + + {/if} + +
+
+ + {#if expandedInviteId === invite.id} +
+ {#if !inviteAuditById[invite.id]?.length} +

{$t(`${INVITE_MODAL}.no_audit_events`)}

+ {:else} + {#each inviteAuditById[invite.id] as event} +
+
{event.eventType}
+
{$t(`${INVITE_MODAL}.audit_at`)}: {formatDate(event.createdAt)}
+ {#if event.targetEmail} +
{$t(`${INVITE_MODAL}.audit_email`)}: {event.targetEmail}
+ {/if} + {#if event.actor?.email} +
{$t(`${INVITE_MODAL}.audit_actor`)}: {event.actor.email}
+ {/if} + {#if event.ipAddress} +
{$t(`${INVITE_MODAL}.audit_ip`)}: {event.ipAddress}
+ {/if} +
+ {/each} + {/if} +
+ {/if} +
+ {/each} +
+ {/if} +
diff --git a/apps/dashboard/src/lib/features/course/components/people/invite-settings-section.svelte b/apps/dashboard/src/lib/features/course/components/people/invite-settings-section.svelte new file mode 100644 index 00000000..ed431a81 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/people/invite-settings-section.svelte @@ -0,0 +1,53 @@ + + +
+

{$t('course.navItem.people.invite_modal.student_invite_settings')}

+

+ {$t('course.navItem.people.invite_modal.student_invite_settings_description')} +

+
+ + {$t('course.navItem.people.invite_modal.preset')} + + + {$t(getPresetLabelKey($inviteSettingsStore.preset))} + + + {#each INVITE_PRESET_OPTIONS as option} + {$t(option.labelKey)} + {/each} + + + + + {#if $inviteSettingsStore.preset === 'CUSTOM'} +
+ + +
+ {/if} +
+
diff --git a/apps/dashboard/src/lib/features/course/components/people/invite-utils.ts b/apps/dashboard/src/lib/features/course/components/people/invite-utils.ts new file mode 100644 index 00000000..4bb360de --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/people/invite-utils.ts @@ -0,0 +1,62 @@ +import type { InvitePreset, InviteStatus } from './types'; + +const INVITE_PRESET_LABEL_KEYS: Record = { + ONE_TIME_24H: 'course.navItem.people.invite_modal.presets.one_time_24h', + MULTI_USE_7D: 'course.navItem.people.invite_modal.presets.multi_use_7d', + MULTI_USE_30D: 'course.navItem.people.invite_modal.presets.multi_use_30d', + CUSTOM: 'course.navItem.people.invite_modal.presets.custom' +}; + +export function formatDate(date: string | null | undefined): string { + if (!date) return 'n/a'; + return new Date(date).toLocaleString(); +} + +export function shortId(id: string): string { + return `${id.slice(0, 8)}...`; +} + +export function getStatusClass(status: InviteStatus): string { + if (status === 'ACTIVE') return 'bg-green-100 text-green-700'; + if (status === 'REVOKED') return 'bg-red-100 text-red-700'; + if (status === 'EXPIRED') return 'bg-yellow-100 text-yellow-700'; + return 'bg-gray-100 text-gray-700'; +} + +export function getPresetLabelKey(preset: InvitePreset): string { + return INVITE_PRESET_LABEL_KEYS[preset]; +} + +export interface InvitePayloadInput { + preset: InvitePreset; + customExpiresAt: string; + customMaxUses: number; +} + +export function getInvitePayload(input: InvitePayloadInput): { + preset: InvitePreset; + expiresAt?: string; + maxUses?: number; +} { + const { preset, customExpiresAt, customMaxUses } = input; + const payload: { preset: InvitePreset; expiresAt?: string; maxUses?: number } = { + preset + }; + + if (preset === 'CUSTOM') { + if (!customExpiresAt) { + throw new Error('Custom expiry date is required'); + } + payload.expiresAt = new Date(customExpiresAt).toISOString(); + payload.maxUses = customMaxUses; + } + + return payload; +} + +export const INVITE_PRESET_OPTIONS: Array<{ value: InvitePreset; labelKey: string }> = [ + { value: 'ONE_TIME_24H', labelKey: 'course.navItem.people.invite_modal.presets.one_time_24h' }, + { value: 'MULTI_USE_7D', labelKey: 'course.navItem.people.invite_modal.presets.multi_use_7d' }, + { value: 'MULTI_USE_30D', labelKey: 'course.navItem.people.invite_modal.presets.multi_use_30d' }, + { value: 'CUSTOM', labelKey: 'course.navItem.people.invite_modal.presets.custom' } +]; diff --git a/apps/dashboard/src/lib/features/course/components/people/share-qr-image.svelte b/apps/dashboard/src/lib/features/course/components/people/share-qr-image.svelte new file mode 100644 index 00000000..0d6e2abf --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/people/share-qr-image.svelte @@ -0,0 +1,40 @@ + + +
+
+
{$t('course.navItem.people.invite_modal.scan_qr')}
+ qrcode +
+

{courseApi.course?.title}

+

{$currentOrg.name}

+
+
+ {#if $isFreePlan} +
+ logo + {$t('course.navItem.people.invite_modal.classroomio_brand')} +
+ {/if} +
diff --git a/apps/dashboard/src/lib/features/course/components/people/store.ts b/apps/dashboard/src/lib/features/course/components/people/store.ts new file mode 100644 index 00000000..546e5bf4 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/people/store.ts @@ -0,0 +1,38 @@ +import { writable } from 'svelte/store'; +import type { InvitePreset } from './types'; + +export const deleteMemberModal = writable({ + open: false +}); + +export const qrInviteNodeStore = writable(null); + +export interface InviteSettingsState { + preset: InvitePreset; + customExpiresAt: string; + customMaxUses: number; +} + +export const DEFAULT_INVITE_SETTINGS_STATE: InviteSettingsState = { + preset: 'MULTI_USE_30D', + customExpiresAt: '', + customMaxUses: 1 +}; + +export const inviteSettingsStore = writable({ ...DEFAULT_INVITE_SETTINGS_STATE }); + +export interface StudentInviteLinkState { + link: string; + qrImage: string; + isCreating: boolean; + isLoadingQRDownload: boolean; +} + +export const DEFAULT_STUDENT_INVITE_LINK_STATE: StudentInviteLinkState = { + link: '', + qrImage: '', + isCreating: false, + isLoadingQRDownload: false +}; + +export const studentInviteLinkStore = writable({ ...DEFAULT_STUDENT_INVITE_LINK_STATE }); diff --git a/apps/dashboard/src/lib/features/course/components/people/tutor-select-section.svelte b/apps/dashboard/src/lib/features/course/components/people/tutor-select-section.svelte new file mode 100644 index 00000000..cd401b11 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/people/tutor-select-section.svelte @@ -0,0 +1,75 @@ + + +
+

{$t('course.navItem.people.invite_modal.invite')}

+ + + +
+ {#if selectedTutors.length > 0} +
+ {#each selectedTutors as tutor} + + {tutor.text} + + + {/each} +
+ {:else} + {$t('course.navItem.people.invite_modal.select')} + {/if} +
+
+ + {#each tutors as tutor} + + {tutor.text} + + {/each} + +
+ {#if isLoading} +
+ + {$t('course.navItem.people.invite_modal.loading')} +
+ {:else} + + {$t('course.navItem.people.invite_modal.to_add')} + + {$t('course.navItem.people.invite_modal.go_to')} + + + {/if} +
diff --git a/apps/dashboard/src/lib/features/course/components/people/types.ts b/apps/dashboard/src/lib/features/course/components/people/types.ts new file mode 100644 index 00000000..e84583fb --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/people/types.ts @@ -0,0 +1,39 @@ +import type { StudentInviteListItem, StudentInviteAuditItem } from '../../utils/types'; +import type { OrganizationTeamMembers } from '$features/org/utils/types'; +import type { TCourseInvitePreset } from '@cio/utils/validation/course/invite'; + +export interface Profile { + id: string; + fullname: string; + username: string; + email: string; + avatar_url?: string; +} + +export interface Person { + id: string; + email?: string; + role_id: number; + profile?: Profile; + profile_id?: string; + assigned_student_id: string; +} + +export interface ProfileRole { + label: string; + value: string | number; +} + +/** UI shape for tutor picker, derived from org team API response */ +type OrgTeamMemberItem = OrganizationTeamMembers[number]; +export type Tutor = Pick & { + text: OrgTeamMemberItem['fullname']; +}; + +/** Re-export API-driven invite types from course utils */ +export type InviteListItem = StudentInviteListItem; +export type InviteAuditItem = StudentInviteAuditItem; +export type InviteStatus = StudentInviteListItem['status']; + +/** Re-export preset from validation (API contract) */ +export type InvitePreset = TCourseInvitePreset; diff --git a/apps/dashboard/src/lib/components/QuestionContainer/index.svelte b/apps/dashboard/src/lib/features/course/components/question-container.svelte similarity index 86% rename from apps/dashboard/src/lib/components/QuestionContainer/index.svelte rename to apps/dashboard/src/lib/features/course/components/question-container.svelte index 20d44f4d..50619ab1 100644 --- a/apps/dashboard/src/lib/components/QuestionContainer/index.svelte +++ b/apps/dashboard/src/lib/features/course/components/question-container.svelte @@ -10,6 +10,7 @@ scrollToQuestion?: boolean; points?: any; hasError?: boolean; + errorMsg?: string | null; onPointsChange?: any; key?: string; children?: import('svelte').Snippet; @@ -21,8 +22,8 @@ scrollToQuestion = false, points = $bindable(undefined), hasError = false, + errorMsg = null, onPointsChange = () => {}, - key, children }: Props = $props(); @@ -37,17 +38,11 @@ }); } }); - - $effect(() => { - console.log('container key', key); - });
{#if isTitle}
@@ -57,7 +52,7 @@
{#if typeof points !== 'undefined'} -
+

{$t('course.navItem.lessons.exercises.new_exercise_modal.points')}: @@ -70,6 +65,10 @@ />

+ {#if errorMsg} +

{errorMsg}

+ {/if} + {#if onClose && !isTitle} diff --git a/apps/dashboard/src/lib/features/course/components/sidebar/course-content-sidebar-navigation.svelte b/apps/dashboard/src/lib/features/course/components/sidebar/course-content-sidebar-navigation.svelte new file mode 100644 index 00000000..14b2df14 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/sidebar/course-content-sidebar-navigation.svelte @@ -0,0 +1,97 @@ + + + + + + + + + {#snippet child({ props })} + + {$t('course.navItems.nav_content')} + +
+ { + event.preventDefault(); + event.stopPropagation(); + openContentModal(id); + }} + /> +
+
+ {/snippet} +
+
+
+ + openContentModal(id, sectionId)} + onEditSection={(sectionId) => openSectionEditor(id, sectionId)} + /> +
+ + diff --git a/apps/dashboard/src/lib/features/course/components/sidebar/course-content-tree.svelte b/apps/dashboard/src/lib/features/course/components/sidebar/course-content-tree.svelte new file mode 100644 index 00000000..4d2eb4d1 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/sidebar/course-content-tree.svelte @@ -0,0 +1,169 @@ + + + + {#if contentData.grouped} + {#each contentData.sections as section (section.id)} + + {#snippet child({ props })} + + + {#snippet child({ props })} + + + + {section.title} + +
+ {#if onOpenContentModal} + { + event.preventDefault(); + event.stopPropagation(); + onOpenContentModal(section.id); + }} + > + + + {/if} + + + + +
+
+ {/snippet} +
+ + + {#each section.items as contentItem (contentItem.id)} + + + {#snippet child({ props })} + {@const isContentLocked = (contentItem.isUnlocked ?? true) === false} + {@const isLockedForStudent = isStudent && isContentLocked} + { + if (isLockedForStudent) { + event.preventDefault(); + } + }} + {...props} + > + + {contentItem.title} +
+ {#if contentItem.isComplete} + + + + {/if} + {#if isContentLocked} + + + + {/if} +
+
+ {/snippet} +
+
+ {/each} +
+
+
+ {/snippet} +
+ {/each} + {:else} + {#each contentData.items as contentItem (contentItem.id)} + + + {#snippet child({ props })} + {@const isContentLocked = (contentItem.isUnlocked ?? true) === false} + {@const isLockedForStudent = isStudent && isContentLocked} + { + if (isLockedForStudent) { + event.preventDefault(); + } + }} + {...props} + > + + {contentItem.title} +
+ {#if contentItem.isComplete} + + + + {/if} + {#if isContentLocked} + + + + {/if} +
+
+ {/snippet} +
+
+ {/each} + {/if} +
+ + diff --git a/apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar-navigation.svelte b/apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar-navigation.svelte index b4051b0c..a78318aa 100644 --- a/apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar-navigation.svelte +++ b/apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar-navigation.svelte @@ -1,57 +1,75 @@ - {#each navItems as item (item.id)} - - {#snippet child({ props })} - - {#if item.isLesson && item.items} - - {#snippet child({ props })} - - + {#if item.isLesson} + + + {#snippet child({ props })} + + {#snippet children(isHovered)} + + {#if item.icon} {@const Icon = item.icon} - - {item.title} + + {/if} -
- addLesson(false, item.id)} - /> + {item.title} +
+ { + event.preventDefault(); + event.stopPropagation(); + openContentModal(id); + }} + > + + + {#if showContentCount} + + {/if} + + -
- + +
{/snippet} -
- - - {#if $course.version === COURSE_VERSION.V1} - {#each item.items as lessonItem} - - - {#snippet child({ props })} - - {#if lessonItem.isV1 && 'index' in lessonItem} - - {/if} - {lessonItem.lesson.title} - {#if !lessonItem.lesson.is_unlocked} - - {:else if getIsLessonComplete(lessonItem.lesson.lesson_completion, $profile.id)} - - - - {/if} - - {/snippet} - - - {/each} - {:else} - {#each $lessonSections as section} - - {#snippet child({ props })} - - - {#snippet child({ props })} - - {@const Icon = getNavIcon(NAV_IDS.SECTION)} - - {section.title} -
- addLesson(true, section.id)} - /> + + {/snippet} + - -
-
- {/snippet} -
- - - {#each section.lessons as lesson} - - - {#snippet child({ props })} - - {lesson.title} - {#if !lesson.is_unlocked} - - {:else if getIsLessonComplete(lesson.lesson_completion, $profile.id)} - - - - {/if} - - {/snippet} - - - {/each} - - -
- {/snippet} -
- {/each} - {/if} -
-
- {:else} - - + {#if item.isActive} + openContentModal(id, sectionId)} + onEditSection={(sectionId) => openSectionEditor(id, sectionId)} + /> + {/if} +
+ {:else} + + + {#snippet child({ props })} + + {#snippet children(isHovered)} {@const Icon = item.icon} - - {item.title} - - - {/if} - - {/snippet} -
+ + {#if Icon === TableOfContentsIcon} + + {:else} + + {/if} + {item.title} + + {/snippet} + + {/snippet} + + + {/if} {/each}
diff --git a/apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar.svelte b/apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar.svelte index 29458db4..85442f1c 100644 --- a/apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar.svelte +++ b/apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar.svelte @@ -1,5 +1,7 @@ {#if !isOrgLoaded} @@ -31,7 +40,26 @@ - + {#if isCourseReady} + + {:else} + +
+ + +
+ + {#each Array(SIDEBAR_ITEM_SKELETON_COUNT) as _, i (i)} + +
+ + +
+
+ {/each} +
+
+ {/if}
diff --git a/apps/dashboard/src/lib/features/course/components/sidebar/sidebar-history.ts b/apps/dashboard/src/lib/features/course/components/sidebar/sidebar-history.ts new file mode 100644 index 00000000..724ffe2a --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/sidebar/sidebar-history.ts @@ -0,0 +1,134 @@ +import { browser } from '$app/environment'; +import { + getCourseBaseRoute, + isCourseContentRouteForId, + isCourseRouteForId +} from '$features/course/utils/sidebar-routes'; + +const STORAGE_KEY = 'course-sidebar:last-non-content-route'; + +let cache: Record | null = null; + +function getPathname(route: string) { + if (!route) return ''; + + const [pathWithoutHash] = route.split('#'); + const [pathWithoutQuery] = pathWithoutHash.split('?'); + + if (!pathWithoutQuery) return ''; + + if (pathWithoutQuery.startsWith('/')) { + return pathWithoutQuery; + } + + if (!browser) { + return `/${pathWithoutQuery}`; + } + + try { + const parsedUrl = new URL(route, window.location.origin); + return parsedUrl.pathname; + } catch { + return `/${pathWithoutQuery}`; + } +} + +function readStorage() { + if (cache) { + return cache; + } + + if (!browser) { + cache = {}; + return cache; + } + + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + + if (!raw) { + cache = {}; + return cache; + } + + const parsed = JSON.parse(raw); + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + cache = {}; + return cache; + } + + cache = parsed as Record; + return cache; + } catch { + cache = {}; + return cache; + } +} + +function writeStorage(state: Record) { + cache = state; + + if (!browser) return; + + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + // Ignore storage write failures (private browsing, quota limits). + } +} + +function isValidRememberedRoute(courseId: string, route: string) { + const pathname = getPathname(route); + + if (!pathname) { + return false; + } + + if (!isCourseRouteForId(pathname, courseId)) { + return false; + } + + return !isCourseContentRouteForId(pathname, courseId); +} + +export function rememberLastNonContentCourseRoute(courseId: string, route: string) { + if (!courseId || !route) return; + + if (!isValidRememberedRoute(courseId, route)) { + return; + } + + const state = readStorage(); + const nextState = { + ...state, + [courseId]: route + }; + + writeStorage(nextState); +} + +export function getLastNonContentCourseRoute(courseId: string) { + if (!courseId) return null; + + const state = readStorage(); + const route = state[courseId]; + + if (!route) { + return null; + } + + if (!isValidRememberedRoute(courseId, route)) { + const nextState = { ...state }; + delete nextState[courseId]; + writeStorage(nextState); + + return null; + } + + return route; +} + +export function getCourseSidebarBackRoute(courseId: string) { + return getLastNonContentCourseRoute(courseId) ?? getCourseBaseRoute(courseId); +} diff --git a/apps/dashboard/src/lib/features/course/components/truncated-with-tooltip.svelte b/apps/dashboard/src/lib/features/course/components/truncated-with-tooltip.svelte new file mode 100644 index 00000000..1b13a63e --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/truncated-with-tooltip.svelte @@ -0,0 +1,22 @@ + + + + + + {text} + + + + {text} + + diff --git a/apps/dashboard/src/lib/features/course/pages/analytics.svelte b/apps/dashboard/src/lib/features/course/pages/analytics.svelte index 5862a297..8bb95e12 100644 --- a/apps/dashboard/src/lib/features/course/pages/analytics.svelte +++ b/apps/dashboard/src/lib/features/course/pages/analytics.svelte @@ -1,64 +1,35 @@ {#if isLoading} diff --git a/apps/dashboard/src/lib/features/course/pages/attendance.svelte b/apps/dashboard/src/lib/features/course/pages/attendance.svelte index 02d404a0..a933ddaf 100644 --- a/apps/dashboard/src/lib/features/course/pages/attendance.svelte +++ b/apps/dashboard/src/lib/features/course/pages/attendance.svelte @@ -1,45 +1,31 @@ -
+

@@ -145,21 +140,17 @@

-
- - - -
+
-
+
{$t('course.navItem.attendance.student')} - {#each $lessons as _lesson, index} + {#each lessonItems as _lesson, index} {$t('course.navItem.attendance.lesson')} 0{getLectureNo(index + 1)} @@ -170,18 +161,14 @@ {#each paginatedStudents as student} - {student.profile.fullname} + {student.profile?.fullname} - {#each $lessons as lesson} + {#each lessonItems as lesson}
{ const e = { target: { checked } }; handleAttendanceChange(e, student, lesson); diff --git a/apps/dashboard/src/lib/features/course/pages/certificates.svelte b/apps/dashboard/src/lib/features/course/pages/certificates.svelte index 2f265842..5f904856 100644 --- a/apps/dashboard/src/lib/features/course/pages/certificates.svelte +++ b/apps/dashboard/src/lib/features/course/pages/certificates.svelte @@ -1,8 +1,8 @@ diff --git a/apps/dashboard/src/lib/features/course/pages/courses.svelte b/apps/dashboard/src/lib/features/course/pages/courses.svelte index 68b15533..2b183a34 100644 --- a/apps/dashboard/src/lib/features/course/pages/courses.svelte +++ b/apps/dashboard/src/lib/features/course/pages/courses.svelte @@ -19,26 +19,24 @@ } from '$features/course/components'; import { DeleteModal } from '$features/ui'; - import type { Course } from '$lib/utils/types'; import { t } from '$lib/utils/functions/translations'; - import { snackbar } from '$features/ui/snackbar/store'; - import { deleteCourse } from '$lib/utils/services/courses'; - import { - deleteCourseModal, - deleteCourseModalInitialState, - courses as coursesStore, - courseMetaDeta - } from '../utils/store'; + import { courseApi } from '$features/course/api'; + import { deleteCourseModal, deleteCourseModalInitialState, courseMetaDeta } from '../utils/store'; import { browser } from '$app/environment'; + import type { OrgCourses, UserEnrolledCourses } from '$features/course/types'; + import type { Snippet } from 'svelte'; interface Props { - courses?: Course[]; + courses?: OrgCourses | UserEnrolledCourses; emptyTitle?: string; emptyDescription?: string; isExplore?: boolean; isLMS?: boolean; searchValue?: string; selectedId?: string; + isLoading?: boolean; + showSortSelect?: boolean; + filterControls?: Snippet; } let { @@ -48,7 +46,10 @@ isExplore = false, isLMS = false, searchValue = $bindable(''), - selectedId = $bindable('0') + selectedId = $bindable('0'), + isLoading = false, + showSortSelect = true, + filterControls }: Props = $props(); const filterOptions = $derived([ @@ -73,24 +74,13 @@ $deleteCourseModal.isDeleting = true; - try { - await deleteCourse($deleteCourseModal.id); + await courseApi.delete($deleteCourseModal.id); - // Remove the course from the courses store - $coursesStore = $coursesStore.filter((course) => course.id !== $deleteCourseModal.id); - - // Show success message - snackbar.success('snackbar.course_deleted'); - - // Close modal and reset state + if (courseApi.success) { deleteCourseModal.set(deleteCourseModalInitialState); - } catch (error) { - console.error('Error deleting course:', error); - snackbar.error('snackbar.course_settings.error.went_wrong'); - - // Stop deleting state on error - $deleteCourseModal.isDeleting = false; } + + $deleteCourseModal.isDeleting = false; } @@ -106,30 +96,36 @@ - - -

{selectedId ? selectedLabel : filterOptions[0].label}

-
- - {#each filterOptions as option (option.label)} - {option.label} - {/each} - -
- {#if $courseMetaDeta.view === 'list'} - setViewPreference('grid')}> - - - {:else} - setViewPreference('list')}> - - + {#if showSortSelect} + + +

{selectedId ? selectedLabel : filterOptions[0].label}

+
+ + {#each filterOptions as option (option.label)} + {option.label} + {/each} + +
+ {/if} + {@render filterControls?.()} + + {#if !isLMS} + {#if $courseMetaDeta.view === 'list'} + setViewPreference('grid')}> + + + {:else} + setViewPreference('list')}> + + + {/if} {/if}
- {#if $courseMetaDeta.isLoading} -
+ {#if isLoading} +
@@ -146,12 +142,13 @@ {$t('courses.course_card.list_view.title')} - {$t('courses.course_card.list_view.description')} + {$t('courses.course_card.list_view.description')} {$t('courses.course_card.list_view.type')} - {$t('courses.course_card.list_view.lessons')} - {$t('courses.course_card.list_view.students')} - {$t('courses.course_card.list_view.published')} - + {$t('courses.course_card.list_view.tags')} + {$t('courses.course_card.list_view.lessons')} + {$t('courses.course_card.list_view.students')} + {$t('courses.course_card.list_view.published')} + @@ -159,11 +156,17 @@ } /> {/each} diff --git a/apps/dashboard/src/lib/features/course/pages/exercise.svelte b/apps/dashboard/src/lib/features/course/pages/exercise.svelte new file mode 100644 index 00000000..95943b49 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/pages/exercise.svelte @@ -0,0 +1,316 @@ + + + + + {$questionnaire.title || 'Exercise'} + + +
+ {#if $globalStore.isStudent && courseApi.course?.id && exerciseId} + + {/if} + + + {#if selectedTab === 'questions'} +
+
+ + (preview = !preview)} + tooltip={preview + ? $t('course.navItem.lessons.exercises.all_exercises.view_mode.edit') + : $t('course.navItem.lessons.exercises.all_exercises.preview')} + tooltipSide="bottom" + > + {#if preview} + + {:else} + + {/if} + + + + + + + + + + ($questionnaireOrder.open = true)}> + {$t('course.navItem.lessons.exercises.all_exercises.reorder')} + + + (shouldDeleteExercise = true)} + > + {$t('course.navItem.lessons.exercises.all_exercises.delete_exercise')} + + + +
+
+ {/if} +
+
+
+
+ + + {#snippet child()} +
+ {#if $globalStore.isStudent} + {#if isExerciseUnlocked} + + {:else} + + {/if} + {:else} + + + + {$t('course.navItem.lessons.exercises.all_exercises.questions')} + + + {$t('course.navItem.lessons.exercises.all_exercises.analytics.submissions')} + + {submissions.length} + + + + + {#if !preview} + + {:else} + + {/if} + + + + + + {/if} +
+ {/snippet} +
diff --git a/apps/dashboard/src/lib/features/course/pages/index.ts b/apps/dashboard/src/lib/features/course/pages/index.ts index 66f2dc11..b7eb292e 100644 --- a/apps/dashboard/src/lib/features/course/pages/index.ts +++ b/apps/dashboard/src/lib/features/course/pages/index.ts @@ -1,10 +1,12 @@ -export { default as CoursesPage } from './courses.svelte'; +export { default as AnalyticsPage } from './analytics.svelte'; +export { default as AttendancePage } from './attendance.svelte'; +export { default as CertificatesPage } from './certificates.svelte'; export { default as CourseSettingsPage } from './settings.svelte'; +export { default as CoursesPage } from './courses.svelte'; +export { default as ExercisePage } from './exercise.svelte'; +export { default as LessonPage } from './lesson.svelte'; +export { default as LessonsPage } from './lessons.svelte'; +export { default as MarksPage } from './marks.svelte'; export { default as NewsFeedPage } from './newsfeed.svelte'; export { default as PeoplePage } from './people.svelte'; export { default as SubmissionsPage } from './submissions.svelte'; -export { default as LessonsPage } from './lessons.svelte'; -export { default as AnalyticsPage } from './analytics.svelte'; -export { default as AttendancePage } from './attendance.svelte'; -export { default as MarksPage } from './marks.svelte'; -export { default as CertificatesPage } from './certificates.svelte'; diff --git a/apps/dashboard/src/lib/features/course/pages/lesson.svelte b/apps/dashboard/src/lib/features/course/pages/lesson.svelte new file mode 100644 index 00000000..5be7ffd3 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/pages/lesson.svelte @@ -0,0 +1,455 @@ + + + { + if ($lessonVideoUpload.isUploading) return; + + $lessonVideoUpload.isModalOpen = false; + + triggerAutoSave(); + }} +/> + + { + if ($lessonDocUpload.isUploading) return; + + $lessonDocUpload.isModalOpen = false; + $lessonDocUpload.error = null; + + triggerAutoSave(); + }} +/> + + + + + + +
+ {#if mode === MODES.view && $globalStore.isStudent} + + {/if} + + + {#if mode === MODES.edit && window.innerWidth >= 1024} + (isVersionDrawerOpen = true)}> + + + {/if} + +
+ + {#if mode === MODES.edit} + + {:else} + + {/if} + +
+ + +
+
+
+
+ + + {#snippet child()} +
+ {#if $globalStore.isStudent && lessonApi.lesson && !isLessonUnlocked} + + {:else if mode === MODES.edit} + { + setTabQueryParam(e); + }} + > + + + {#each tabs as tab} + + {#if tab.icon} + + {/if} + {$t(tab.label)} + {#if typeof tab.badgeValue === 'number' && tab.badgeValue > 0} + + {/if} + + {/each} + + + + + + + + + + + + + + + + + + + + + + + + + + + {:else if lessonApi.lesson && !isMaterialsEmpty} + {#key lessonId} +
+ {#each viewModeComponents as Component} + + {/each} + + {#if $currentOrg.customization?.apps?.comments} +
+ + + {/if} +
+ {/key} + {:else} + + + + {/if} +
+ {/snippet} +
+ +{#if isVersionDrawerOpen && window.innerWidth >= 1024} + (isVersionDrawerOpen = false)} + on:restore={refetchDataAfterVersionRestore} + /> +{/if} diff --git a/apps/dashboard/src/lib/features/course/pages/lessons.svelte b/apps/dashboard/src/lib/features/course/pages/lessons.svelte index 17ace15f..b5ea7275 100644 --- a/apps/dashboard/src/lib/features/course/pages/lessons.svelte +++ b/apps/dashboard/src/lib/features/course/pages/lessons.svelte @@ -1,105 +1,80 @@ - - - - - handleDelete(lessonToDelete?.id)} /> - -{#if shouldGoToNextLesson} +{#if shouldShowNextPlaceholder} -{:else if lessonsLength > 0} +{:else if contentLength > 0} {#if reorder} -

+

{$t('course.navItem.lessons.drag')}

{/if} - {#if $course.version === COURSE_VERSION.V1} - - {:else if $course.version === COURSE_VERSION.V2} - + {#if contentData.grouped} + + {:else} + {/if} {:else} import { Empty } from '@cio/ui/custom/empty'; import UserXIcon from '@lucide/svelte/icons/user-x'; - import { course } from '$lib/components/Course/store'; + import * as Avatar from '@cio/ui/base/avatar'; + import * as Table from '@cio/ui/base/table'; + import * as Tooltip from '@cio/ui/base/tooltip'; import { t } from '$lib/utils/functions/translations'; - import { fetchMarks } from '$lib/utils/services/marks'; - import { snackbar } from '$features/ui/snackbar/store'; - import { getLectureNo } from '$lib/components/Course/function.js'; - import { fetchExercisesByMarks } from '$lib/utils/services/courses'; - import { lessons } from '$lib/components/Course/components/Lesson/store/lessons'; - import type { GroupPerson } from '$lib/utils/types'; + import { shortenName } from '$lib/utils/functions/string'; + import { calculateStudentAverage, type MarksPageData, type ExerciseInfo } from '$features/course/utils/marks-utils'; + import TruncatedWithTooltip from '$features/course/components/truncated-with-tooltip.svelte'; - let borderBottomGrey = 'border-r-0 border-t-0 border-b border-l-0 border-gray-300'; - let borderleftGrey = 'border-r-0 border-t-0 border-b-0 border-l border-gray-300'; - let students: GroupPerson[] = $state([]); - let lessonMapping: Record> = $state({}); // { lessonId: { exerciseId: exerciseTitle, ... }, ... } - let studentMarksByExerciseId: Record> = $state({}); // { groupMemberId: { exerciseId: `total_gotten/points`, ... }, ... } - let hasFetched = $state(false); - - function calculateStudentTotal(studentExerciseData) { - if (!studentExerciseData) return 0; - - return Object.values(studentExerciseData).reduce((total: number, point) => (total += parseInt(point as string)), 0); + interface Props { + marksData: MarksPageData | null; } - async function firstRender(courseId?: string) { - if (!courseId || hasFetched) return; + let { marksData }: Props = $props(); - hasFetched = true; + const students = $derived(marksData?.students ?? []); + const exercises = $derived(marksData?.exercises ?? []); + const studentMarksByExerciseId = $derived(marksData?.studentMarksByExerciseId ?? {}); - const { data: marks, error } = await fetchMarks(courseId); - if (error) { - console.error('Error fetching marks', error); - snackbar.error('snackbar.marks.error'); - return; - } - - if (!marks || !Array.isArray(marks)) return; - - marks.forEach((mark) => { - const { groupmember_id, exercise_id, total_points_gotten } = mark; - - if (studentMarksByExerciseId[groupmember_id]) { - studentMarksByExerciseId[groupmember_id] = { - ...studentMarksByExerciseId[groupmember_id], - [exercise_id]: total_points_gotten - }; - } else { - studentMarksByExerciseId[groupmember_id] = { - [exercise_id]: total_points_gotten - }; - } - }); - - const { data: exercises } = await fetchExercisesByMarks(courseId); - - // Map exercises to { lessonId: { exerciseId: exerciseTitle, ... }, ... } - exercises.forEach((exercise) => { - const { lesson_id, exercise_id, exercise_title, points } = exercise; - - if (lessonMapping[lesson_id]) { - lessonMapping[lesson_id] = { - ...lessonMapping[lesson_id], - [exercise_id]: { title: exercise_title, points } - }; - } else { - lessonMapping[lesson_id] = { - [exercise_id]: { title: exercise_title, points } - }; - } - }); + function gradePercent(pointsStr: string | undefined, exercise: ExerciseInfo): number | null { + if (pointsStr == null || exercise.points === 0) return null; + const n = parseInt(pointsStr, 10); + if (Number.isNaN(n)) return null; + return Math.round((n / exercise.points) * 100); } - $effect(() => { - firstRender($course.id); - }); + /** Text and background classes for grade cells (faint tint + matching text) */ + function gradeCellClass(percent: number | null): string { + if (percent == null) return 'ui:bg-transparent ui:text-muted-foreground'; + if (percent >= 90) return 'ui:bg-green-50 ui:text-green-700 dark:ui:bg-green-950/30 dark:ui:text-green-400'; + if (percent >= 80) return 'ui:bg-blue-50 ui:text-blue-700 dark:ui:bg-blue-950/30 dark:ui:text-blue-400'; + if (percent >= 70) return 'ui:bg-amber-50 ui:text-amber-800 dark:ui:bg-amber-950/30 dark:ui:text-amber-400'; + return 'ui:bg-red-50 ui:text-red-700 dark:ui:bg-red-950/30 dark:ui:text-red-400'; + } -
-
-
-

{$t('course.navItem.marks.student')}

-
- {#each $lessons as lesson, index} - {#if lessonMapping[lesson.id]} -
-

- {getLectureNo(index + 1)} -

-
- {#each Object.keys(lessonMapping[lesson.id]) as exerciseId, index} -

0} +

+ + + + + + {$t('course.navItem.marks.student')} + + {#each exercises as exercise} + - {lessonMapping[lesson.id][exerciseId].title} - ({lessonMapping[lesson.id][exerciseId].points}) -

+ +
{/each} -
-
- {/if} - {/each} -
-

{$t('course.navItem.marks.total')}

-
+ + {$t('course.navItem.marks.avg_grade')} + + + + + {#each students as student} + {@const marks = studentMarksByExerciseId[student.id]} + {@const avg = calculateStudentAverage(marks, exercises)} + + +
+ + + + {shortenName(student.profile?.fullname) ?? 'S'} + + +
+

+ {student.profile?.fullname ?? '-'} +

+
+
+
+ {#each exercises as exercise} + {@const pointsStr = marks?.[exercise.id]} + {@const pct = gradePercent(pointsStr, exercise)} + + + {pct != null ? pct : pointsStr != null ? pointsStr : '-'} + + + {/each} + + + {avg != null ? avg : '-'} + + +
+ {/each} +
+ +
- - {#each students as student} -
-
- Student avatar -
-

- {student.profile.fullname} -

-

- {`${student.assigned_student_id ? '#' + student.assigned_student_id : '-'}`} -

-
-
- {#each $lessons as lesson} - {#if lessonMapping[lesson.id]} -
- {#each Object.keys(lessonMapping[lesson.id]) as exerciseId} -

- {studentMarksByExerciseId[student.id] ? studentMarksByExerciseId[student.id][exerciseId] || '-' : '-'} -

- {/each} -
- {/if} - {/each} - -
-
-

- {calculateStudentTotal(studentMarksByExerciseId[student.id])} -

-
-
-
- {:else} - - {/each} -
- - +{:else} + +{/if} diff --git a/apps/dashboard/src/lib/features/course/pages/newsfeed.svelte b/apps/dashboard/src/lib/features/course/pages/newsfeed.svelte index 67290028..3bad5385 100644 --- a/apps/dashboard/src/lib/features/course/pages/newsfeed.svelte +++ b/apps/dashboard/src/lib/features/course/pages/newsfeed.svelte @@ -1,33 +1,21 @@ @@ -238,33 +144,25 @@ }} > - { - $newsFeed = [newFeed, ...$newsFeed]; - }} - {onEdit} - /> + - {#if isLoading} + {#if isListing}
- {:else if !$newsFeed.length} + {:else if !sortedFeeds.length} {:else} - {#each $newsFeed as feed} + {#each sortedFeeds as feed} {#if feed.isPinned} -
+

{$t('course.navItem.news_feed.pinned')}

@@ -272,6 +170,8 @@ {/if} {/each} {/if} diff --git a/apps/dashboard/src/lib/features/course/pages/people.svelte b/apps/dashboard/src/lib/features/course/pages/people.svelte index 3509ca92..62c03d3a 100644 --- a/apps/dashboard/src/lib/features/course/pages/people.svelte +++ b/apps/dashboard/src/lib/features/course/pages/people.svelte @@ -1,37 +1,36 @@ - + {$t('course.navItem.settings.cover_image')} {$t('course.navItem.settings.optional_image')} @@ -227,7 +301,7 @@ {/if} -
+
Course cover { hasUnsavedChanges = true; @@ -263,7 +337,7 @@ placeholder={$t('course.navItem.settings.placeholder')} className="w-full" isRequired - bind:value={$settings.course_description} + bind:value={$settings.courseDescription} errorMessage={errors?.description} onchange={() => { hasUnsavedChanges = true; @@ -271,18 +345,21 @@ /> - {$t('course.navItem.settings.link')} -
- - - - - goto(courseLink)}> - - -
-
- {#if $course.slug} + {$t('course.navItem.settings.link')} + +
+ + + + goto(courseLink)}> + + +
+
+ +
+ {#if courseApi.course?.slug}

{courseLink}

{ @@ -292,7 +369,7 @@ {:else} -

Setup landing page to get course link

+

{$t('course.navItem.settings.setup_landing_for_link')}

{/if}
@@ -301,6 +378,49 @@ + + {$t('course.navItem.settings.tags.title')} + {$t('course.navItem.settings.tags.description')} + +
+
+ {#if !selectedTagChips.length} +

{$t('course.navItem.settings.tags.empty')}

+ {:else} + {#each selectedTagChips as tag (tag.id)} + + + {tag.name} + + + {/each} + {/if} + + +
+
+
+
+ + + {$t('course.navItem.settings.order')} {$t('course.navItem.settings.drag')} @@ -315,6 +435,29 @@ + + {$t('course.navItem.settings.content_grouping_title')} + {$t('course.navItem.settings.content_grouping_description')} + + { + console.log('checked', checked); + $settings.isContentGroupingEnabled = checked; + hasUnsavedChanges = true; + }} + /> + + + + + + {$t('course.navItem.settings.lesson_download')} {$t('course.navItem.settings.available')} @@ -325,14 +468,14 @@
{ - $settings.lesson_download = checked; + $settings.lessonDownload = checked; hasUnsavedChanges = true; }} /> -
{/if} @@ -364,17 +507,17 @@ { - $settings.type = value as COURSE_TYPE; + $settings.type = value as TCourseType; if (hasUnsavedChanges) return; hasUnsavedChanges = true; }} >
- +
- +
@@ -389,14 +532,14 @@ { - $settings.allow_new_students = checked; + $settings.allowNewStudents = checked; hasUnsavedChanges = true; }} /> -
@@ -409,17 +552,14 @@ { + $settings.isPublished = checked; hasUnsavedChanges = true; - $settings.allow_new_students = checked; - if (!$course.slug) { - generateNewCourseLink(); - } }} /> -
@@ -430,7 +570,13 @@ {$t('course.navItem.settings.delete')} {$t('course.navItem.settings.delete_text')} - diff --git a/apps/dashboard/src/lib/features/course/pages/submissions.svelte b/apps/dashboard/src/lib/features/course/pages/submissions.svelte index 0541c013..6f4b1942 100644 --- a/apps/dashboard/src/lib/features/course/pages/submissions.svelte +++ b/apps/dashboard/src/lib/features/course/pages/submissions.svelte @@ -4,117 +4,59 @@ import { goto } from '$app/navigation'; import { dndzone } from 'svelte-dnd-action'; - import { - deleteSubmission, - fetchSubmissions, - updateQuestionAnswer, - updateSubmission - } from '$lib/utils/services/submissions'; - import { course } from '$lib/components/Course/store'; - import { t } from '$lib/utils/functions/translations'; - import formatDate from '$lib/utils/functions/formatDate'; + import { submissionApi, courseApi } from '$features/course/api'; import { snackbar } from '$features/ui/snackbar/store'; - import { formatAnswers } from '$lib/components/Course/function.js'; - import { currentOrg, currentOrgDomain } from '$lib/utils/store/org'; - import isSubmissionEarly from '$lib/utils/functions/isSubmissionEarly'; - import { NOTIFICATION_NAME, triggerSendEmail } from '$lib/utils/services/notification/notification'; - import type { SubmissionIdData, SubmissionItem, SubmissionSection } from '$lib/utils/types/submission'; + import type { SubmissionIdData, SubmissionItem, SubmissionSection } from '$features/course/utils/types'; + import { t } from '$lib/utils/functions/translations'; - import { Skeleton } from '@cio/ui/base/skeleton'; import { Chip } from '@cio/ui/custom/chip'; - import MarkExerciseModal from '$lib/components/Course/components/Lesson/Exercise/MarkExerciseModal.svelte'; + import MarkExerciseModal from '$features/course/components/exercise/mark-exercise-modal.svelte'; + import { STATUS } from '$features/course/components/exercise/constants'; + import { onMount } from 'svelte'; interface Props { courseId: string; + sections: SubmissionSection[]; + submissionIdData: { [key: string]: SubmissionIdData }; } - let { courseId }: Props = $props(); + let { courseId, sections: initialSections = [], submissionIdData: initialSubmissionIdData = {} }: Props = $props(); const flipDurationMs = 300; - let exerciseDetails: { id: string; title: string }; - let lessonDetails: { id: string; title: string }; - let totalMark = 0; - let maxMark = 0; - let submissionIdData: { [key: number]: SubmissionIdData } = $state({}); + let sections = $state([]); + let submissionIdData = $state>({}); let isGradeWithAI = $state(false); - let fetching = $state(false); let isSaving = $state(false); - let hasFetched = $state(false); + + onMount(() => { + sections = initialSections; + submissionIdData = { ...initialSubmissionIdData }; + }); + + const ALLOWED_BOARD_TRANSITIONS: Record = { + 1: [2], + 2: [1, 3], + 3: [] + }; const submissionId = $derived(new URLSearchParams(page.url.search).get('submissionId') ?? ''); let openExercise = $derived.by(() => { return !!submissionId && !!submissionIdData[submissionId]; }); - const submissionStatus: { [key: number]: string } = { - 1: $t('course.navItem.submissions.submission_status.submitted'), - 2: $t('course.navItem.submissions.submission_status.in_progress'), - 3: $t('course.navItem.submissions.submission_status.graded') - }; - - let sections: SubmissionSection[] = $state([ - { - id: 1, - title: $t('course.navItem.submissions.submission_status.submitted'), - value: 0, - items: [] - }, - { - id: 2, - title: $t('course.navItem.submissions.submission_status.in_progress'), - value: 0, - items: [] - }, - { - id: 3, - title: $t('course.navItem.submissions.submission_status.graded'), - value: 10, - items: [] - } - ]); - - function getMaxPoints(questions) { - return (questions || []).reduce((acc, question) => acc + question.points, 0); + function canTransitionBoardStatus(previousStatusId: number, nextStatusId: number): boolean { + if (previousStatusId === nextStatusId) return true; + return ALLOWED_BOARD_TRANSITIONS[previousStatusId]?.includes(nextStatusId) ?? false; } - function calculateTotal(grades: string[]): number { - if (!grades) return 0; - return Object.values(grades).reduce((acc, grade) => acc + parseInt(grade), 0); + function getWorkflowHintKey(item: SubmissionItem): string { + if (item.statusId !== 2) return ''; + if (item.gradingState === 'awaiting_manual') return 'course.navItem.submissions.workflow.awaiting_manual_hint'; + if (item.gradingState === 'failed') return 'course.navItem.submissions.workflow.failed_hint'; + return ''; } - const notifyStudent = (submissionData) => { - maxMark = getMaxPoints(submissionData?.questions); - totalMark = calculateTotal(submissionData?.questionAnswerByPoint); - - const { fullname, email } = submissionData?.student; - const { title, statusId }: { title: string; statusId: number } = submissionData; - const exerciseLink = `${$currentOrgDomain}/courses/${courseId}/lessons/${lessonDetails.id}/exercises/${exerciseDetails.id}`; - - const content = ` -

Hello ${fullname},

-

The status of your submitted exercise on ${title} has been updated to ${ - submissionStatus[statusId] - }

- ${ - statusId == 3 - ? `

Your score was ${totalMark}/${maxMark}

- View your Result - ` - : `Open Exercise` - } -

This exercise is for ${ - lessonDetails.title - } in a course you are taking titled ${$course.title}

- `; - - triggerSendEmail(NOTIFICATION_NAME.SUBMISSION_UPDATE, { - to: email, - content, - orgName: $currentOrg?.name - }); - }; - - function handleItemFinalize( + async function handleItemFinalize( columnIdx: number, newItems: { map: (arg0: (item: SubmissionItem) => SubmissionItem) => SubmissionItem[] } ) { @@ -122,36 +64,43 @@ const { id } = sections[columnIdx]; - // Set column in the UI - sections[columnIdx].items = newItems.map((item) => { + // Set column in the UI (immutable update for reactivity) + const mappedItems = newItems.map((item) => { if (item.statusId !== id) { + if (!canTransitionBoardStatus(item.statusId, id)) { + snackbar.error('course.navItem.submissions.workflow.invalid_transition'); + return item; + } + itemToWithNewStatus = item; - item.statusId = id; + return { ...item, statusId: id }; } return item; }); + sections = sections.map((section, i) => (i === columnIdx ? { ...section, items: mappedItems } : section)); + // Update backend if (itemToWithNewStatus) { - // Update key mapping for each submission also - submissionIdData[itemToWithNewStatus.id] = { - ...submissionIdData[itemToWithNewStatus.id], - statusId: itemToWithNewStatus.statusId + const newStatusId = id; + submissionIdData = { + ...submissionIdData, + [itemToWithNewStatus.id]: { + ...submissionIdData[itemToWithNewStatus.id], + statusId: newStatusId + } }; - notifyStudent(submissionIdData[itemToWithNewStatus.id]); - - updateSubmission({ - id: itemToWithNewStatus.id, - status_id: itemToWithNewStatus.statusId - }).then((res) => console.log('Updated submission', res)); + await submissionApi.update(courseId, itemToWithNewStatus.id, { + statusId: newStatusId + }); } } function handleDndConsiderCards(columnIdx: number) { return function (e) { - sections[columnIdx].items = e.detail.items; + sections = sections.map((section, i) => (i === columnIdx ? { ...section, items: e.detail.items } : section)); }; } @@ -164,192 +113,90 @@ goto(page.url.pathname); } - // Via dialog - function updateStatus({ - submissionId, - prevStatusId, - nextStatusId, - total - }: { - submissionId: string; - prevStatusId: number; - nextStatusId: number; - total: number; - }) { - let itemToWithNewStatus: SubmissionItem | undefined; - - // Remove from current column - const { items } = sections[prevStatusId - 1]; - sections[prevStatusId - 1].items = items?.filter((item) => { - if (item.id === submissionId) { - itemToWithNewStatus = Object.assign(item); - if (itemToWithNewStatus) { - itemToWithNewStatus.statusId = nextStatusId; - } - return false; - } - - return true; - }); - // Move to right column - if (itemToWithNewStatus) { - sections[nextStatusId - 1].items = [...sections[nextStatusId - 1].items, itemToWithNewStatus]; - } - - // If something changed - if (itemToWithNewStatus) { - // Update key mapping for each submission also - submissionIdData[itemToWithNewStatus.id] = { - ...submissionIdData[itemToWithNewStatus.id], - statusId: itemToWithNewStatus.statusId - }; - - notifyStudent(submissionIdData[itemToWithNewStatus.id]); - // Update backend - updateSubmission({ - id: itemToWithNewStatus.id, - status_id: itemToWithNewStatus.statusId, - total - }).then((res) => console.log('Updated submission', res)); - } - } - async function handleDeleteSubmission(id: string, statusId: number) { - const { items } = sections[statusId - 1]; + const sectionIdx = statusId - 1; + sections = sections.map((section, i) => + i === sectionIdx ? { ...section, items: section.items.filter((item) => item.id !== id) } : section + ); - sections[statusId - 1].items = items?.filter((item) => { - return item.id === id ? false : true; - }); + const { [id]: _, ...rest } = submissionIdData; + submissionIdData = rest; - submissionIdData[id] = undefined; + await submissionApi.delete(courseId, id); - const { error } = await deleteSubmission(id); - - if (error) { - console.error('Error deleting submission', error); + if (submissionApi.success) { + snackbar.success('course.navItem.submissions.grading_modal.delete_success'); + } else { snackbar.error('course.navItem.submissions.grading_modal.delete_error'); return; } - snackbar.success('course.navItem.submissions.grading_modal.delete_success'); - handleModalClose(); } - async function handleSave(submission: { questionAnswerByPoint: any; questionAnswers: any; feedback: any }) { + async function handleSave(submission: { + id?: string; + questionAnswerByPoint: Record; + feedback?: string; + }) { isSaving = true; - const { questionAnswerByPoint, questionAnswers, feedback } = submission; + const { questionAnswerByPoint, feedback } = submission; + const subId = submission.id ?? submissionId; - let totalPoints = 0; + const answers = Object.entries(questionAnswerByPoint ?? {}).map(([questionId, point]) => ({ + questionId: Number(questionId), + points: Number(point) + })); + const total = answers.reduce((sum, { points }) => sum + points, 0); - for (const questionId in questionAnswerByPoint) { - if (Object.prototype.hasOwnProperty.call(questionAnswerByPoint, questionId)) { - const questionAnswer = questionAnswers.find( - (answer: { question_id: string }) => answer.question_id == questionId - ); - const point = questionAnswerByPoint[questionId]; - - totalPoints += parseInt(point, 10); - - const qaResponse = await updateQuestionAnswer({ point }, { id: questionAnswer?.id }); - - if (qaResponse.error) { - console.error('Error saving', qaResponse.error); - snackbar.error(`snackbar.something`); - - return; - } - } - } - - await updateSubmission({ - id: submissionId, - total: totalPoints, - feedback: feedback + await submissionApi.updateGrades(courseId, subId, { + answers, + total, + feedback, + statusId: STATUS.GRADED }); - snackbar.success('snackbar.submissions.success.grading'); + if (!submissionApi.success) { + snackbar.error('snackbar.something'); + isSaving = false; + return; + } + + // Backend auto-sets status to Graded; move card to Graded column and sync submissionIdData + const gradedStatusId = STATUS.GRADED; + const prevSection = sections.find((s) => s.items.some((item) => item.id === subId)); + const prevStatusId = prevSection?.id ?? gradedStatusId; + + if (prevStatusId !== gradedStatusId) { + const prevIdx = prevStatusId - 1; + const nextIdx = gradedStatusId - 1; + const prevItems = sections[prevIdx]?.items ?? []; + const found = prevItems.find((item) => item.id === subId); + const itemToWithNewStatus = found ? { ...found, statusId: gradedStatusId } : undefined; + + sections = sections.map((section, i) => { + if (i === prevIdx) { + return { ...section, items: prevItems.filter((item) => item.id !== subId) }; + } + if (i === nextIdx && itemToWithNewStatus) { + return { ...section, items: [...section.items, itemToWithNewStatus] }; + } + return section; + }); + } + + submissionIdData = { + ...submissionIdData, + [subId]: { + ...submissionIdData[subId], + statusId: gradedStatusId, + questionAnswerByPoint: submission.questionAnswerByPoint ?? submissionIdData[subId]?.questionAnswerByPoint, + feedback: feedback ?? submissionIdData[subId]?.feedback + } + }; isSaving = false; } - - async function firstRender(courseId?: string) { - if (!courseId || hasFetched) return; - - hasFetched = true; - - fetching = true; - const { data: submissions } = await fetchSubmissions(courseId); - const sectionById: { [key: number]: SubmissionSection[] } = {}; - - if (submissions) { - for (const submission of submissions) { - const { id, created_at, answers, groupmember, feedback, exercise } = submission; - - const isEarly = isSubmissionEarly(created_at, exercise.due_by); - - const submissionItem = { - id, - statusId: submission.status_id, - isEarly, - feedback, - submittedAt: formatDate(created_at), - exercise: { - id: exercise.id, - title: exercise.title - }, - answers, - student: groupmember && groupmember.profile ? groupmember.profile : {}, - lesson: { - id: exercise.lesson.id, - title: exercise.lesson.title - } - }; - - exerciseDetails = { id: submissionItem.exercise.id, title: submissionItem.exercise.title }; - - lessonDetails = { - id: submissionItem.lesson.id, - title: submissionItem.lesson.title - }; - - submissionIdData[id] = { - id, - statusId: submissionItem.statusId, - feedback, - isEarly, - title: exercise.title, - student: submissionItem.student, - questions: exercise.questions, - answers: formatAnswers({ questions: exercise.questions, answers }), - questionAnswers: answers, - questionAnswerByPoint: answers.reduce((acc, answer) => { - acc[answer.question_id] = answer.point; - - return acc; - }, {}) - }; - - if (Array.isArray(sectionById[submission.status_id])) { - sectionById[submission.status_id].push(submissionItem); - } else { - sectionById[submission.status_id] = [submissionItem]; - } - } - } - - sections = sections.map((section, index) => ({ - ...section, - value: Array.isArray(sectionById[index + 1]) ? sectionById[index + 1].length : 0, - items: Array.isArray(sectionById[index + 1]) ? sectionById[index + 1] : [] - })); - - fetching = false; - } - - $effect(() => { - firstRender($course.id); - });

{title}

- {#if fetching} - - - - {:else} -
- {#each items as item (item.id)} -
+ {#each items as item (item.id)} + - {/each} -
- {/if} + + + {item.exercise.title} + + +

{item.exercise.title}

+
+ {#if item.lesson} +

#{item.lesson.title}

+ {/if} + {#if getWorkflowHintKey(item)} +

+ {$t(getWorkflowHintKey(item))} +

+ {/if} +

+ {item.submittedAt} +

+
+ {/each} +
{/each}
diff --git a/apps/dashboard/src/lib/features/course/store.ts b/apps/dashboard/src/lib/features/course/store.ts new file mode 100644 index 00000000..61630346 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/store.ts @@ -0,0 +1,104 @@ +import type { TCourseType } from '@cio/db/types'; +import type { Course } from '$features/course/types'; +import type { CourseMembers, CourseSectionWithLessons, ListLessons } from '$features/course/utils/types'; +import { writable, type Writable } from 'svelte/store'; + +export const defaultCourse = { + id: '', + title: '', + description: '', + overview: null, + type: 'LIVE_CLASS' as TCourseType, + cost: 0, + currency: 'USD', + status: 'ACTIVE', + isCertificateDownloadable: false, + certificateTheme: 'professional', + isPublished: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + groupId: null, + isTemplate: true, + logo: '', + slug: null, + bannerImage: null, + attendance: [], + group: null, + course_section: [], + sections: [], + lessons: [], + exercises: [], + content: { + grouped: false, + sections: [], + items: [] + }, + metadata: { + requirements: '', + description: '', + goals: '', + videoUrl: '', + showDiscount: false, + discount: 0, + paymentLink: '', + reward: { + show: false, + description: '' + }, + instructor: { + name: '', + role: '', + coursesNo: 0, + description: '', + imgUrl: '' + }, + lessonTabsOrder: [ + { id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' }, + { id: 2, name: 'course.navItem.lessons.materials.tabs.slide.title' }, + { id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' }, + { id: 4, name: 'course.navItem.lessons.materials.tabs.document.title' } + ], + grading: false, + lessonDownload: true, + allowNewStudent: false, + isContentGroupingEnabled: true + } +}; + +/** + * @deprecated Use courseApi.course from '$features/course/api' instead + * This store is kept for backward compatibility but should be migrated to use courseApi state + */ +export const course: Writable = writable({ ...defaultCourse }); + +type GroupStore = { + id?: string; + tutors: CourseMembers; + students: CourseMembers; + people: CourseMembers; + members?: CourseMembers; + memberId?: string; +}; + +/** + * @deprecated Use courseApi.group from '$features/course/api' instead + * This store is kept for backward compatibility but should be migrated to use courseApi state + */ +export const group = writable({ + id: '', + tutors: [], + students: [], + people: [] +}); + +export function sortLesson(lessons: ListLessons = []) { + return [...lessons] + .sort((a, b) => new Date(a.createdAt || '').getTime() - new Date(b.createdAt || '').getTime()) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); +} + +export function sortCourseSection(sections: CourseSectionWithLessons[] = []): CourseSectionWithLessons[] { + return [...sections] + .sort((a, b) => new Date(a.createdAt || '').getTime() - new Date(b.createdAt || '').getTime()) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); +} diff --git a/apps/dashboard/src/lib/features/course/types.ts b/apps/dashboard/src/lib/features/course/types.ts new file mode 100644 index 00000000..e3ef582d --- /dev/null +++ b/apps/dashboard/src/lib/features/course/types.ts @@ -0,0 +1,45 @@ +// Question type inferred from API Exercise response +import type { Course as CourseDetails, Exercise } from './utils/types'; +import type { InferResponseType } from '@cio/api/rpc-types'; +import { classroomio } from '$lib/utils/services/api'; + +// Org Courses types +export type GetOrgCoursesRequest = typeof classroomio.organization.courses.$get; +export type OrgCoursesResponse = InferResponseType | null; +export type OrgCoursesSuccess = Extract, { success: true }>; +export type OrgCourses = OrgCoursesSuccess['data']; + +// User Enrolled Courses types +export type GetUserEnrolledCoursesRequest = typeof classroomio.organization.courses.enrolled.$get; +export type UserEnrolledCoursesResponse = InferResponseType | null; +export type UserEnrolledCoursesSuccess = Extract, { success: true }>; +export type UserEnrolledCourses = UserEnrolledCoursesSuccess['data']; + +// Recommended Courses types +export type GetRecommendedCoursesRequest = typeof classroomio.organization.courses.recommended.$get; +export type RecommendedCoursesResponse = InferResponseType | null; +export type RecommendedCoursesSuccess = Extract, { success: true }>; +export type RecommendedCourses = RecommendedCoursesSuccess['data']; + +// Get course types +export type GetCourseRequest = (typeof classroomio.course)[':courseId']['$get']; +export type GetCourseResponse = InferResponseType; +export type GetCourseSuccess = Extract; +export type Course = CourseDetails; + +// Base question type from API +type ApiQuestion = NonNullable[number]; +type ApiOption = ApiQuestion['options'][number]; + +// Extended option type with client-side fields +type QuestionOption = ApiOption & { + isDirty?: boolean; +}; + +// Extended question type with client-side fields +export type Question = Omit & { + isDirty?: boolean; + deletedAt?: string; + options: QuestionOption[]; + settings?: Record; +}; diff --git a/apps/dashboard/src/lib/features/course/utils/constants.ts b/apps/dashboard/src/lib/features/course/utils/constants.ts index dd07c420..5043b49c 100644 --- a/apps/dashboard/src/lib/features/course/utils/constants.ts +++ b/apps/dashboard/src/lib/features/course/utils/constants.ts @@ -1,10 +1,23 @@ -export const statuses = [ - { - chipClassName: 'bg-green-600 text-white', - chipLabel: 'In progress' - }, - { - chipClassName: 'bg-red-600 text-white', - chipLabel: 'Completed' - } -]; +export const ROUTES = { + COURSES: 'courses', + LESSONS: 'lessons' +}; + +export const EXERCISE_TEMPLATE_TAGS = { + HTML: 'HTML', + CSS: 'CSS', + JS: 'JS', + Typescript: 'Typescript', + ReactJS: 'ReactJS', + VueJS: 'VueJS', + NodeJS: 'NodeJS', + Python: 'Python', + PHP: 'PHP', + GIT: 'GIT' +}; + +export const COURSE_SORT_OPTIONS = [ + { value: '0', label: 'courses.course_filter.date_created' }, + { value: '1', label: 'courses.course_filter.published' }, + { value: '2', label: 'courses.course_filter.lessons' } +] as const; diff --git a/apps/dashboard/src/lib/features/course/utils/content.ts b/apps/dashboard/src/lib/features/course/utils/content.ts new file mode 100644 index 00000000..8267ed6a --- /dev/null +++ b/apps/dashboard/src/lib/features/course/utils/content.ts @@ -0,0 +1,55 @@ +import type { Course, CourseContentItem, CourseContentSection } from './types'; +import { ExerciseIcon, LessonIcon } from '@cio/ui/custom/moving-icons'; + +import { ContentType as CourseContentType } from '@cio/utils/constants/content'; +import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents'; + +export type ContentItem = CourseContentItem; +export type ContentSection = CourseContentSection; + +export const CONTENT_DEFINITIONS = { + [CourseContentType.Section]: { + label: 'Section', + icon: TableOfContentsIcon + }, + [CourseContentType.Lesson]: { + label: 'Lesson', + icon: LessonIcon + }, + [CourseContentType.Exercise]: { + label: 'Exercise', + icon: ExerciseIcon + } +} as const; + +const EMPTY_CONTENT = { + grouped: false, + sections: [] as ContentSection[], + items: [] as ContentItem[] +}; + +export function getContentRoute(courseId: string, item: ContentItem) { + if (item.type === CourseContentType.Lesson) { + return `/courses/${courseId}/lessons/${item.id}`; + } + + if (item.type === CourseContentType.Exercise) { + return `/courses/${courseId}/exercises/${item.id}`; + } + + return ''; +} + +export function getCourseContent(course: Course | null) { + return course?.content ?? EMPTY_CONTENT; +} + +const NAVIGABLE_CONTENT_TYPES = [CourseContentType.Lesson, CourseContentType.Exercise] as const; + +export function getOrderedNavigableContent(course: Course | null): ContentItem[] { + const content = getCourseContent(course); + const items = content.grouped ? content.sections.flatMap((s) => s.items) : content.items; + return items.filter((item) => + NAVIGABLE_CONTENT_TYPES.includes(item.type as (typeof NAVIGABLE_CONTENT_TYPES)[number]) + ); +} diff --git a/apps/dashboard/src/lib/features/course/utils/functions.ts b/apps/dashboard/src/lib/features/course/utils/functions.ts index a019875d..e23cbb3f 100644 --- a/apps/dashboard/src/lib/features/course/utils/functions.ts +++ b/apps/dashboard/src/lib/features/course/utils/functions.ts @@ -1,25 +1,41 @@ -export function validateForm(fields) { - let hasError = false; - const { title, description } = fields; - const fieldErrors = { - title: '', - description: '' - }; +import type { CourseMembers, SubmissionAnswer } from './types'; - if (!title) { - fieldErrors.title = 'Title is required'; - hasError = true; +import type { AnswerData } from '@cio/question-types'; +import type { Question } from '../types'; +import { ROUTES } from './constants'; + +export function getGroupMemberId(people: CourseMembers, profileId: string): string | undefined { + const groupMember = people.find((person) => person.profileId === profileId); + + return groupMember?.id; +} + +export function getNavItemRoute(courseId, routeId?: string) { + const path = `/${ROUTES.COURSES}/${courseId}`; + + if (!routeId) { + return path; } - if (!description) { - fieldErrors.description = 'Description is required'; - hasError = true; + return `${path}/${routeId}`; +} + +export function getLessonsRoute(courseId, lessonId?: string) { + const path = getNavItemRoute(courseId, ROUTES.LESSONS); + + if (!lessonId) { + return path; } - return { - hasError, - fieldErrors - }; + return `${path}/${lessonId}`; +} + +export function getLectureNo(index, initNo = '0') { + if (index <= 9) { + return `${initNo}${index}`; + } + + return index; } export function calcProgressRate(progressRate?: number, totalLessons?: number): number { @@ -29,3 +45,44 @@ export function calcProgressRate(progressRate?: number, totalLessons?: number): return Math.round((progressRate / totalLessons) * 100); } + +/** + * Calculates combined course progress from lessons and exercises. + * Uses (lessonsCompleted + exercisesCompleted) / (totalLessons + totalExercises) when both exist. + * Falls back to lesson-only progress when there are no exercises. + */ +export function calcCourseProgress(params: { + lessonsCompleted: number; + totalLessons: number; + exercisesCompleted?: number; + totalExercises?: number; +}): number { + const { lessonsCompleted, totalLessons, exercisesCompleted = 0, totalExercises = 0 } = params; + const totalItems = totalLessons + totalExercises; + if (totalItems === 0) return 0; + + const completedItems = lessonsCompleted + exercisesCompleted; + + return Math.round((completedItems / totalItems) * 100); +} + +export function formatAnswers(data: { + questions: Question[]; + answers: SubmissionAnswer[]; +}): Record { + const answers: Record = {}; + const questionByIdAndName: Record = {}; + + for (const question of data.questions) { + questionByIdAndName[String(question.id)] = question.name ?? ''; + } + + for (const answer of data.answers) { + const questionName = questionByIdAndName[String(answer.questionId)]; + if (questionName && answer.answerData) { + answers[questionName] = answer.answerData; + } + } + + return answers; +} diff --git a/apps/dashboard/src/lib/features/course/utils/marks-utils.ts b/apps/dashboard/src/lib/features/course/utils/marks-utils.ts new file mode 100644 index 00000000..b69c060d --- /dev/null +++ b/apps/dashboard/src/lib/features/course/utils/marks-utils.ts @@ -0,0 +1,230 @@ +import type { CourseContentItem, CourseMembers } from '$features/course/utils/types'; +import type { Marks } from './types'; +import { ContentType } from '@cio/utils/constants/content'; +import Papa from 'papaparse'; +import autoTable from 'jspdf-autotable'; +import jsPDF from 'jspdf'; + +/** Exercise information for display in marks table (linear, no lesson grouping) */ +export interface ExerciseInfo { + id: string; + title: string; + points: number; +} + +/** Maps groupMemberId -> exerciseId -> points obtained (as string) */ +export type StudentMarksByExercise = { + [groupMemberId: string]: { + [exerciseId: string]: string; + }; +}; + +/** Student's exercise data for calculating totals */ +export type StudentExerciseData = + | { + [exerciseId: string]: string; + } + | undefined; + +/** Processed marks data structure returned from server */ +export interface MarksPageData { + studentMarksByExerciseId: StudentMarksByExercise; + exercises: ExerciseInfo[]; + students: CourseMembers; +} + +/** + * Calculate total points for a student from their exercise data + * @param studentExerciseData Student's marks by exercise ID + * @returns Total points as number + */ +export function calculateStudentTotal(studentExerciseData: StudentExerciseData): number { + if (!studentExerciseData) return 0; + + return Object.values(studentExerciseData).reduce((total: number, point) => { + return (total += parseInt(point as string, 10)); + }, 0); +} + +/** + * Calculate average grade as percentage (points earned / total points possible) for a student + * @param studentExerciseData Student's marks by exercise ID + * @param exercises Ordered list of exercises with points + * @returns Rounded percentage 0-100, or null if no exercises with points + */ +export function calculateStudentAverage( + studentExerciseData: StudentExerciseData, + exercises: ExerciseInfo[] +): number | null { + if (!studentExerciseData || exercises.length === 0) return null; + const totalPossible = exercises.reduce((sum, ex) => sum + ex.points, 0); + if (totalPossible === 0) return null; + const totalEarned = exercises.reduce((sum, ex) => { + const pts = studentExerciseData[ex.id]; + return sum + (pts != null ? parseInt(pts, 10) : 0); + }, 0); + return Math.round((totalEarned / totalPossible) * 100); +} + +/** + * Transform marks array into nested object structure mapping groupMemberId -> exerciseId -> points + * @param marks Array of mark records from API + * @returns Nested object structure for student marks + */ +export function processMarksIntoStudentMarksByExerciseId(marks: Marks): StudentMarksByExercise { + const result: StudentMarksByExercise = {}; + + if (!Array.isArray(marks)) return result; + + marks.forEach((mark) => { + const { groupmemberId, exerciseId, totalPointsGotten } = mark; + + if (!groupmemberId || !exerciseId) return; + + if (result[groupmemberId]) { + result[groupmemberId] = { + ...result[groupmemberId], + [exerciseId]: totalPointsGotten?.toString() || '0' + }; + } else { + result[groupmemberId] = { + [exerciseId]: totalPointsGotten?.toString() || '0' + }; + } + }); + + return result; +} + +/** + * Build ordered list of exercises for gradebook from course content and marks. + * When contentItems has no exercises (e.g. course fetch failed), derives order from marks. + * @param marks Array of mark records from API + * @param contentItems Flat or section-flattened content items (lesson + exercise) in display order; may be empty + * @returns Ordered ExerciseInfo[] (exercise id, title, points) + */ +export function processMarksIntoExercises(marks: Marks, contentItems: CourseContentItem[]): ExerciseInfo[] { + const marksByExerciseId = new Map(); + const exerciseOrder: string[] = []; + if (Array.isArray(marks)) { + marks.forEach((mark) => { + const { exerciseId, exerciseTitle, exercisePoints } = mark; + if (!exerciseId) return; + if (!marksByExerciseId.has(exerciseId)) { + exerciseOrder.push(exerciseId); + marksByExerciseId.set(exerciseId, { + title: exerciseTitle || '', + points: exercisePoints ?? 0 + }); + } + }); + } + + const exerciseItems = contentItems.filter((item) => item.type === ContentType.Exercise); + if (exerciseItems.length > 0) { + return exerciseItems.map((item) => { + const fromMarks = marksByExerciseId.get(item.id); + return { + id: item.id, + title: item.title ?? fromMarks?.title ?? '', + points: fromMarks?.points ?? 0 + }; + }); + } + + // No course content (e.g. course fetch failed): use exercises from marks, order by first appearance + return exerciseOrder.map((id) => { + const fromMarks = marksByExerciseId.get(id)!; + return { + id, + title: fromMarks?.title ?? '', + points: fromMarks?.points ?? 0 + }; + }); +} + +/** + * Generate and download CSV export of marks (student by exercise + avg grade) + * @param students Array of students + * @param exercises Ordered exercises + * @param studentMarksByExerciseId Student marks by exercise + * @param courseTitle Course title for filename + */ +export function generateMarksCSV( + students: CourseMembers, + exercises: ExerciseInfo[], + studentMarksByExerciseId: StudentMarksByExercise, + courseTitle: string +): void { + const exportData = students.map((student) => { + const marks = studentMarksByExerciseId[student.id] || {}; + const rowData: Record = { + name: student.profile?.fullname || '', + email: student.profile?.email || '' + }; + exercises.forEach((ex) => { + rowData[ex.title] = marks[ex.id] ?? '-'; + }); + const avg = calculateStudentAverage(marks, exercises); + rowData['Avg grade'] = avg != null ? avg : '-'; + return rowData; + }); + + const csv = Papa.unparse(exportData); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + link.setAttribute('href', url); + link.setAttribute('download', `${courseTitle}.csv`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +/** + * Generate and download PDF export of marks (student by exercise + avg grade) + * @param students Array of students + * @param exercises Ordered exercises + * @param studentMarksByExerciseId Student marks by exercise + * @param courseTitle Course title for filename and header + */ +export function generateMarksPDF( + students: CourseMembers, + exercises: ExerciseInfo[], + studentMarksByExerciseId: StudentMarksByExercise, + courseTitle: string +): void { + try { + const doc = new jsPDF({ orientation: 'landscape' }); + const head = [['Student', ...exercises.map((ex) => ex.title), 'Avg grade']]; + + const body = students.map((student) => { + const marks = studentMarksByExerciseId[student.id] || {}; + const row: string[] = [student?.profile?.fullname ?? '']; + exercises.forEach((ex) => { + row.push(marks[ex.id] ?? '-'); + }); + const avg = calculateStudentAverage(marks, exercises); + row.push(avg != null ? String(avg) : '-'); + return row; + }); + + autoTable(doc, { + head, + body, + startY: 20, + styles: { fontSize: 8 }, + headStyles: { fillColor: [22, 160, 133] }, + footStyles: { fillColor: [22, 160, 133] }, + didDrawPage: function (data) { + doc.setFontSize(14); + doc.setTextColor(40); + doc.text(`${courseTitle} - Marks`, data.settings.margin.left, 10); + } + }); + doc.save(`${courseTitle}-marks.pdf`); + } catch (error) { + console.error('Error generating PDF:', error); + throw error; + } +} diff --git a/apps/dashboard/src/lib/features/course/utils/settings-store.ts b/apps/dashboard/src/lib/features/course/utils/settings-store.ts index 8263fb40..6c70c4f6 100644 --- a/apps/dashboard/src/lib/features/course/utils/settings-store.ts +++ b/apps/dashboard/src/lib/features/course/utils/settings-store.ts @@ -1,19 +1,33 @@ -import { COURSE_TYPE } from '$lib/utils/types'; +import type { TCourseType } from '@cio/db/types'; import { writable } from 'svelte/store'; -export const settings = writable({ +type CourseSettings = { + logo: string; + courseTitle: string; + courseDescription: string; + grading: boolean; + type: 'LIVE_CLASS' | 'SELF_PACED'; + allowNewStudents: boolean; + tabs: { id: number; name: string }[]; + lessonDownload: boolean; + isPublished: boolean; + isContentGroupingEnabled: boolean; +}; + +export const settings = writable({ logo: '', - course_title: '', - course_description: '', + courseTitle: '', + courseDescription: '', grading: false, - type: COURSE_TYPE.LIVE_CLASS, - allow_new_students: false, + type: 'LIVE_CLASS' as TCourseType, + allowNewStudents: false, tabs: [ { id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' }, { id: 2, name: 'course.navItem.lessons.materials.tabs.slide.title' }, { id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' }, { id: 4, name: 'course.navItem.lessons.materials.tabs.document.title' } ], - lesson_download: false, - is_published: false + lessonDownload: false, + isPublished: false, + isContentGroupingEnabled: true }); diff --git a/apps/dashboard/src/lib/features/course/utils/sidebar-routes.ts b/apps/dashboard/src/lib/features/course/utils/sidebar-routes.ts new file mode 100644 index 00000000..c7873564 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/utils/sidebar-routes.ts @@ -0,0 +1,55 @@ +import { ROUTES } from './constants'; + +const COURSE_ROOT = `/${ROUTES.COURSES}`; +const LESSONS_SEGMENT = `/${ROUTES.LESSONS}`; +const EXERCISES_SEGMENT = '/exercises'; + +function normalizePath(path = '') { + if (!path) return ''; + + if (path.startsWith('/')) { + return path; + } + + return `/${path}`; +} + +export function getCourseBaseRoute(courseId: string) { + return `${COURSE_ROOT}/${courseId}`; +} + +export function isCourseRoute(path = '') { + const pathname = normalizePath(path); + + if (!pathname.startsWith(`${COURSE_ROOT}/`)) { + return false; + } + + return pathname.split('/').filter(Boolean).length >= 2; +} + +export function isCourseRouteForId(path = '', courseId = '') { + if (!courseId) return false; + + const pathname = normalizePath(path); + const baseRoute = getCourseBaseRoute(courseId); + + return pathname === baseRoute || pathname.startsWith(`${baseRoute}/`); +} + +export function isCourseContentRoute(path = '') { + const pathname = normalizePath(path); + + if (!isCourseRoute(pathname)) { + return false; + } + + const parts = pathname.split('/').filter(Boolean); + const section = parts[2] ? `/${parts[2]}` : ''; + + return section === LESSONS_SEGMENT || section === EXERCISES_SEGMENT; +} + +export function isCourseContentRouteForId(path = '', courseId = '') { + return isCourseRouteForId(path, courseId) && isCourseContentRoute(path); +} diff --git a/apps/dashboard/src/lib/features/course/utils/store.ts b/apps/dashboard/src/lib/features/course/utils/store.ts index 1a3e3bb0..ca7219f9 100644 --- a/apps/dashboard/src/lib/features/course/utils/store.ts +++ b/apps/dashboard/src/lib/features/course/utils/store.ts @@ -1,28 +1,8 @@ -import { writable, derived } from 'svelte/store'; -import type { Course } from '$lib/utils/types'; +import { writable } from 'svelte/store'; -export const courses = writable([]); - -export const view = writable('grid'); -export const coursesInProgress = derived(courses, ($courses) => - $courses.length > 0 - ? $courses.filter((course) => { - return course.total_lessons !== course.progress_rate; - }) - : [] -); -export const coursesComplete = derived(courses, ($courses) => - $courses.length > 0 - ? $courses.filter((course) => { - return course.total_lessons === course.progress_rate; - }) - : [] -); export const courseMetaDeta = writable<{ - isLoading: boolean; view: 'grid' | 'list'; }>({ - isLoading: true, view: 'grid' }); diff --git a/apps/dashboard/src/lib/features/course/utils/submissions-utils.ts b/apps/dashboard/src/lib/features/course/utils/submissions-utils.ts new file mode 100644 index 00000000..692264ae --- /dev/null +++ b/apps/dashboard/src/lib/features/course/utils/submissions-utils.ts @@ -0,0 +1,46 @@ +/** + * Determines if a submission was submitted before the due date + * @param createdAt Submission creation timestamp + * @param dueBy Exercise due date (can be null) + * @returns true if submission is early or on time, false if late + */ +export function isSubmissionEarly(createdAt: string, dueBy: string | null): boolean { + if (!dueBy) return true; + return new Date(createdAt).getTime() <= new Date(dueBy).getTime(); +} + +/** + * Calculates total points from a grades map + * @param grades Map of question IDs to point values + * @returns Sum of all points + */ +export function calculateTotalPoints(grades: { [questionId: string]: number }): number { + if (!grades) return 0; + return Object.values(grades).reduce( + (acc, grade) => acc + (typeof grade === 'number' ? grade : parseInt(String(grade), 10) || 0), + 0 + ); +} + +/** + * Gets maximum possible points from questions array + * @param questions Array of questions with points property + * @returns Sum of all question points + */ +export function getMaxPoints(questions: Array<{ points: number }>): number { + if (!questions || !Array.isArray(questions)) return 0; + return questions.reduce((acc, question) => acc + (question.points || 0), 0); +} + +/** + * Formats a date string for display + * @param date ISO date string + * @returns Formatted date string + */ +export function formatSubmittedDate(date: string): string { + const d = new Date(date); + return new Intl.DateTimeFormat('en-US', { + dateStyle: 'full', + timeStyle: 'medium' + }).format(d); +} diff --git a/apps/dashboard/src/lib/features/course/utils/types.ts b/apps/dashboard/src/lib/features/course/utils/types.ts new file mode 100644 index 00000000..b46c5e8c --- /dev/null +++ b/apps/dashboard/src/lib/features/course/utils/types.ts @@ -0,0 +1,409 @@ +import { classroomio, type InferResponseType } from '$lib/utils/services/api'; + +// List lessons types +export type ListLessonsRequest = (typeof classroomio.course)[':courseId']['lesson']['$get']; +export type ListLessonsResponse = InferResponseType | null; +export type ListLessonsSuccess = Extract, { success: true }>; +export type ListLessons = ListLessonsSuccess['data']; + +// Get lesson types +export type GetLessonRequest = (typeof classroomio.course)[':courseId']['lesson'][':lessonId']['$get']; +export type GetLessonResponse = InferResponseType | null; +export type GetLessonSuccess = Extract, { success: true }>; +export type Lesson = GetLessonSuccess['data']; + +// Create lesson types +export type CreateLessonRequest = (typeof classroomio.course)[':courseId']['lesson']['$post']; +export type CreateLessonResponse = InferResponseType; +export type CreateLessonSuccess = Extract; +export type CreateLessonData = CreateLessonSuccess['data']; + +// Update lesson types +export type UpdateLessonRequest = (typeof classroomio.course)[':courseId']['lesson'][':lessonId']['$put']; +export type UpdateLessonResponse = InferResponseType; +export type UpdateLessonSuccess = Extract; +export type UpdateLessonData = UpdateLessonSuccess['data']; + +// Delete lesson types +export type DeleteLessonRequest = (typeof classroomio.course)[':courseId']['lesson'][':lessonId']['$delete']; +export type DeleteLessonResponse = InferResponseType; +export type DeleteLessonSuccess = Extract; +export type DeleteLessonData = DeleteLessonSuccess['data']; + +// Create course section types +export type CreateCourseSectionRequest = (typeof classroomio.course)[':courseId']['section']['$post']; +export type CreateCourseSectionResponse = InferResponseType; +export type CreateCourseSectionSuccess = Extract; +export type CreateCourseSectionData = CreateCourseSectionSuccess['data']; + +// Update course section types +export type UpdateCourseSectionRequest = (typeof classroomio.course)[':courseId']['section'][':sectionId']['$put']; +export type UpdateCourseSectionResponse = InferResponseType; +export type UpdateCourseSectionSuccess = Extract; +export type UpdateCourseSectionData = UpdateCourseSectionSuccess['data']; + +// Delete course section types +export type DeleteCourseSectionRequest = (typeof classroomio.course)[':courseId']['section'][':sectionId']['$delete']; +export type DeleteCourseSectionResponse = InferResponseType; +export type DeleteCourseSectionSuccess = Extract; +export type DeleteCourseSectionData = DeleteCourseSectionSuccess['data']; + +// Reorder course sections types +export type ReorderCourseSectionsRequest = (typeof classroomio.course)[':courseId']['section']['reorder']['$post']; +export type ReorderCourseSectionsResponse = InferResponseType; +export type ReorderCourseSectionsSuccess = Extract; +export type ReorderCourseSectionsData = ReorderCourseSectionsSuccess['data']; + +// Promote ungrouped section types +export type PromoteUngroupedSectionRequest = + (typeof classroomio.course)[':courseId']['section']['promote-ungrouped']['$post']; +export type PromoteUngroupedSectionResponse = InferResponseType; +export type PromoteUngroupedSectionSuccess = Extract; +export type PromoteUngroupedSectionData = PromoteUngroupedSectionSuccess['data']; + +// Reorder lessons types +export type ReorderLessonsRequest = (typeof classroomio.course)[':courseId']['lesson']['reorder']['$post']; +export type ReorderLessonsResponse = InferResponseType; +export type ReorderLessonsSuccess = Extract; +export type ReorderLessonsData = ReorderLessonsSuccess['data']; + +// Course Section type (derived from Course API response which includes course_section array) +// Since sections are returned as part of Course, we can use CreateCourseSectionData or UpdateCourseSectionData +// But for consistency, we'll use the create response as the base type +export type CourseSection = CreateCourseSectionData; + +// Course Section with lessons (used when sections are populated with their lessons) +export interface CourseSectionWithLessons extends CourseSection { + lessons: ListLessons; +} + +// Get lesson comments types +export type GetLessonCommentsRequest = + (typeof classroomio.course)[':courseId']['lesson'][':lessonId']['comment']['$get']; +export type GetLessonCommentsResponse = InferResponseType | null; +export type GetLessonCommentsSuccess = Extract, { success: true }>; +export type LessonCommentsData = GetLessonCommentsSuccess['data']; +export type LessonComments = LessonCommentsData['items']; + +// Create lesson comment types +export type CreateLessonCommentRequest = + (typeof classroomio.course)[':courseId']['lesson'][':lessonId']['comment']['$post']; +export type CreateLessonCommentResponse = InferResponseType; +export type CreateLessonCommentSuccess = Extract; +export type CreateLessonComment = CreateLessonCommentSuccess['data']; + +// Delete lesson comment types +export type DeleteLessonCommentRequest = + (typeof classroomio.course)[':courseId']['lesson']['comment'][':commentId']['$delete']; +export type DeleteLessonCommentResponse = InferResponseType; +export type DeleteLessonCommentSuccess = Extract; +export type DeleteLessonCommentData = DeleteLessonCommentSuccess['data']; + +// Update lesson comment types +export type UpdateLessonCommentRequest = + (typeof classroomio.course)[':courseId']['lesson']['comment'][':commentId']['$put']; +export type UpdateLessonCommentResponse = InferResponseType; +export type UpdateLessonCommentSuccess = Extract; +export type UpdateLessonCommentData = UpdateLessonCommentSuccess['data']; + +// Payment request types +export type CreatePaymentRequestRequest = (typeof classroomio.course)[':courseId']['payment-request']['$post']; +export type CreatePaymentRequestResponse = InferResponseType; +export type CreatePaymentRequestSuccess = Extract; +export type PaymentRequestData = CreatePaymentRequestSuccess['data']; + +// Get lesson completion types +export type GetLessonCompletionRequest = + (typeof classroomio.course)[':courseId']['lesson'][':lessonId']['completion']['$get']; +export type GetLessonCompletionResponse = InferResponseType | null; +export type GetLessonCompletionSuccess = Extract, { success: true }>; +export type LessonCompletion = GetLessonCompletionSuccess['data']; + +// Update lesson completion types +export type UpdateLessonCompletionRequest = + (typeof classroomio.course)[':courseId']['lesson'][':lessonId']['completion']['$put']; +export type UpdateLessonCompletionResponse = InferResponseType; +export type UpdateLessonCompletionSuccess = Extract; +export type UpdateLessonCompletionData = UpdateLessonCompletionSuccess['data']; + +// Get lesson history types +export type GetLessonHistoryRequest = + (typeof classroomio.course)[':courseId']['lesson'][':lessonId']['history']['$get']; +export type GetLessonHistoryResponse = InferResponseType | null; +export type GetLessonHistorySuccess = Extract, { success: true }>; +export type LessonHistory = GetLessonHistorySuccess['data']; + +// Lesson language types +export type GetLessonLanguageRequest = + (typeof classroomio.course)[':courseId']['lesson'][':lessonId']['language']['$get']; +export type GetLessonLanguageResponse = InferResponseType | null; +export type GetLessonLanguageSuccess = Extract, { success: true }>; +export type LessonLanguage = GetLessonLanguageSuccess['data']; + +// Exercise types +export type GetExerciseRequest = (typeof classroomio.course)[':courseId']['exercise'][':exerciseId']['$get']; +export type GetExerciseResponse = InferResponseType | null; +export type GetExerciseSuccess = Extract, { success: true }>; + +type ApiExercise = GetExerciseSuccess['data']; +export type Exercise = Omit; + +// export type UpdateExercisePayload = Omit & { +// courseId?: string | null; +// sectionId?: string | null; +// order?: number | null; +// title?: string; +// description?: string; +// lessonId?: string; +// dueBy?: string; +// questions?: Exercise['questions']; +// }; + +export type CreateExerciseRequest = (typeof classroomio.course)[':courseId']['exercise']['$post']; +export type CreateExerciseFromTemplateRequest = + (typeof classroomio.course)[':courseId']['exercise']['from-template']['$post']; +export type CreateExerciseFromTemplateResponse = InferResponseType; +export type CreateExerciseFromTemplateSuccess = Extract; +export type CreateExerciseFromTemplateData = CreateExerciseFromTemplateSuccess['data']; + +export type UpdateExerciseRequest = (typeof classroomio.course)[':courseId']['exercise'][':exerciseId']['$put']; +export type DeleteExerciseRequest = (typeof classroomio.course)[':courseId']['exercise'][':exerciseId']['$delete']; +export type SubmitExerciseRequest = + (typeof classroomio.course)[':courseId']['exercise'][':exerciseId']['submission']['$post']; + +// Newsfeed types +export type ListNewsfeedRequest = (typeof classroomio.course)[':courseId']['newsfeed']['$get']; +export type ListNewsfeedResponse = InferResponseType | null; +export type ListNewsfeedSuccess = Extract, { success: true }>; +export type ListNewsfeedPaginated = ListNewsfeedSuccess['data']; +export type ListNewsfeed = ListNewsfeedPaginated['items']; + +export type GetNewsfeedRequest = (typeof classroomio.course)[':courseId']['newsfeed'][':feedId']['$get']; +export type CreateNewsfeedRequest = (typeof classroomio.course)[':courseId']['newsfeed']['$post']; +export type UpdateNewsfeedRequest = (typeof classroomio.course)[':courseId']['newsfeed'][':feedId']['$put']; + +export type ReactToNewsfeedRequest = (typeof classroomio.course)[':courseId']['newsfeed'][':feedId']['react']['$put']; +export type ReactToNewsfeedResponse = InferResponseType; +export type ReactToNewsfeedSuccess = Extract; +export type ReactToNewsfeed = ReactToNewsfeedSuccess['data']; + +export type DeleteNewsfeedRequest = (typeof classroomio.course)[':courseId']['newsfeed'][':feedId']['$delete']; +export type GetNewsfeedCommentsRequest = + (typeof classroomio.course)[':courseId']['newsfeed'][':feedId']['comments']['$get']; +export type GetNewsfeedCommentsResponse = InferResponseType | null; +export type GetNewsfeedCommentsSuccess = Extract, { success: true }>; +export type NewsfeedCommentsResponse = GetNewsfeedCommentsSuccess['data']; + +export type CreateNewsfeedCommentRequest = + (typeof classroomio.course)[':courseId']['newsfeed'][':feedId']['comment']['$post']; +export type UpdateNewsfeedCommentRequest = + (typeof classroomio.course)[':courseId']['newsfeed']['comment'][':commentId']['$put']; +export type DeleteNewsfeedCommentRequest = + (typeof classroomio.course)[':courseId']['newsfeed']['comment'][':commentId']['$delete']; + +// Get single newsfeed response types (includes full structure with author) +export type GetNewsfeedResponse = InferResponseType | null; +export type GetNewsfeedSuccess = Extract, { success: true }>; +export type GetNewsfeedData = GetNewsfeedSuccess['data']; + +// Extract feed types from API responses +export type Feed = ListNewsfeed[number]; + +export type NewsfeedComment = NewsfeedCommentsResponse['items'][number]; +export type Reaction = NonNullable; + +// Attendance types +// Note: Only POST (upsert) route exists, no GET or PUT routes +export type UpsertAttendanceRequest = (typeof classroomio.course)[':courseId']['attendance']['$post']; + +// Mark types +export type GetMarksRequest = (typeof classroomio.course)[':courseId']['mark']['$get']; +export type GetMarksResponse = InferResponseType | null; +export type GetMarksSuccess = Extract, { success: true }>; +export type Marks = GetMarksSuccess['data']; + +// Submission types — from exercise submissions (mySubmission/allSubmissions) +export type ListExerciseSubmissionsRequest = + (typeof classroomio.course)[':courseId']['exercise'][':exerciseId']['submissions']['$get']; +export type ListExerciseSubmissionsSuccess = Extract< + InferResponseType, + { success: true } +>; +export type ExerciseSubmissionsData = ListExerciseSubmissionsSuccess['data']; + +/** Submission item with answers, groupmember. Used in exercise submissions UI and ViewMode. */ +export type SubmissionListItem = ExerciseSubmissionsData['allSubmissions'][number]; + +// List submissions for grading types +export type ListSubmissionsForGradingRequest = + (typeof classroomio.course)[':courseId']['submission']['for-grading']['$get']; +export type ListSubmissionsForGradingResponse = InferResponseType | null; +export type ListSubmissionsForGradingSuccess = Extract< + InferResponseType, + { success: true } +>; +export type SubmissionsForGradingData = ListSubmissionsForGradingSuccess['data']; + +// Extract types from SubmissionsForGradingData +export type SubmissionSection = SubmissionsForGradingData['sections'][number]; +export type SubmissionItem = SubmissionSection['items'][number]; +export type SubmissionIdData = SubmissionsForGradingData['submissionIdData'][string]; + +export type UpdateSubmissionRequest = (typeof classroomio.course)[':courseId']['submission'][':submissionId']['$put']; +export type DeleteSubmissionRequest = + (typeof classroomio.course)[':courseId']['submission'][':submissionId']['$delete']; +export type UpdateSubmissionAnswerRequest = + (typeof classroomio.course)[':courseId']['submission'][':submissionId']['answer']['$put']; +export type UpdateSubmissionGradesRequest = + (typeof classroomio.course)[':courseId']['submission'][':submissionId']['grades']['$put']; + +// Course types +export type CreateCourseRequest = typeof classroomio.course.$post; +export type GetCourseRequest = (typeof classroomio.course)[':courseId']['$get']; +export type GetCourseBySlugRequest = (typeof classroomio.course)['slug'][':slug']['$get']; +export type UpdateCourseRequest = (typeof classroomio.course)[':courseId']['$put']; +export type DeleteCourseRequest = (typeof classroomio.course)[':courseId']['$delete']; +export type UpdateCourseContentRequest = (typeof classroomio.course)[':courseId']['content']['$put']; +export type DeleteCourseContentRequest = (typeof classroomio.course)[':courseId']['content']['$delete']; +export type GetCourseProgressRequest = (typeof classroomio.course)[':courseId']['progress']['$get']; +export type CloneCourseRequest = (typeof classroomio.course)[':courseId']['clone']['$post']; +export type EnrollCourseRequest = (typeof classroomio.course)[':courseId']['enroll']['$post']; + +// Course response types +export type GetCourseResponse = InferResponseType | null; +export type GetCourseSuccess = Extract, { success: true }>; + +type ApiCourse = GetCourseSuccess['data']; + +export type CourseMetadata = (NonNullable extends object + ? NonNullable + : Record) & { + isContentGroupingEnabled?: boolean; +}; +export type CourseContent = NonNullable; +export type CourseContentSection = CourseContent['sections'][number]; +export type CourseContentItem = CourseContent['items'][number]; + +export type Course = Omit & { + metadata?: CourseMetadata | null; + content: CourseContent; +}; + +export type UpdateCourseResponse = InferResponseType; +export type UpdateCourseSuccess = Extract; +export type UpdateCourseData = UpdateCourseSuccess['data']; + +export type UpdateCourseContentResponse = InferResponseType; +export type UpdateCourseContentSuccess = Extract; + +export type DeleteCourseResponse = InferResponseType; +export type DeleteCourseSuccess = Extract; +export type DeleteCourseData = DeleteCourseSuccess['data']; + +export type DeleteCourseContentResponse = InferResponseType; +export type DeleteCourseContentSuccess = Extract; + +export type GetCourseProgressResponse = InferResponseType | null; +export type GetCourseProgressSuccess = Extract, { success: true }>; +export type CourseProgress = GetCourseProgressSuccess['data']; + +// Course Members types +export type ListPeopleRequest = (typeof classroomio.course)[':courseId']['members']['$get']; +export type ListPeopleResponse = InferResponseType | null; +export type ListPeopleSuccess = Extract, { success: true }>; +export type CourseMembers = ListPeopleSuccess['data']; +export type CourseMember = CourseMembers[number]; + +export type AddPeopleRequest = (typeof classroomio.course)[':courseId']['members']['$post']; +export type AddPeopleResponse = InferResponseType; +export type AddPeopleSuccess = Extract; +export type AddPeopleData = AddPeopleSuccess['data']; + +export type CreateStudentInviteRequest = (typeof classroomio.course)[':courseId']['invites']['$post']; +export type CreateStudentInviteResponse = InferResponseType; +export type CreateStudentInviteSuccess = Extract; +export type CreateStudentInviteData = CreateStudentInviteSuccess['data']; + +export type ListStudentInvitesRequest = (typeof classroomio.course)[':courseId']['invites']['$get']; +export type ListStudentInvitesResponse = InferResponseType; +export type ListStudentInvitesSuccess = Extract; +export type ListStudentInvitesData = ListStudentInvitesSuccess['data']; +export type StudentInviteListItem = ListStudentInvitesData[number]; + +export type RevokeStudentInviteRequest = + (typeof classroomio.course)[':courseId']['invites'][':inviteId']['revoke']['$post']; +export type RevokeStudentInviteResponse = InferResponseType; +export type RevokeStudentInviteSuccess = Extract; +export type RevokeStudentInviteData = RevokeStudentInviteSuccess['data']; + +export type GetStudentInviteAuditRequest = + (typeof classroomio.course)[':courseId']['invites'][':inviteId']['audit']['$get']; +export type GetStudentInviteAuditResponse = InferResponseType; +export type GetStudentInviteAuditSuccess = Extract; +export type StudentInviteAuditData = GetStudentInviteAuditSuccess['data']; +export type StudentInviteAuditItem = StudentInviteAuditData[number]; + +export type UpdatePeopleRequest = (typeof classroomio.course)[':courseId']['members'][':memberId']['$put']; +export type UpdatePeopleResponse = InferResponseType; +export type UpdatePeopleSuccess = Extract; +export type UpdatePeopleData = UpdatePeopleSuccess['data']; + +export type DeletePeopleRequest = (typeof classroomio.course)[':courseId']['members'][':memberId']['$delete']; +export type DeletePeopleResponse = InferResponseType; +export type DeletePeopleSuccess = Extract; +export type DeletePeopleData = DeletePeopleSuccess['data']; + +export type GetUserCourseAnalyticsRequest = + (typeof classroomio.course)[':courseId']['members'][':userId']['analytics']['$get']; +export type GetUserCourseAnalyticsResponse = InferResponseType; +export type GetUserCourseAnalyticsSuccess = Extract; +export type UserCourseAnalytics = GetUserCourseAnalyticsSuccess['data']; + +export type GetCourseAnalyticsRequest = (typeof classroomio.course)[':courseId']['analytics']['$get']; +export type GetCourseAnalyticsResponse = InferResponseType; +export type GetCourseAnalyticsSuccess = Extract; +export type CourseAnalytics = GetCourseAnalyticsSuccess['data']; + +// Derived types from Lesson +export type LessonVideoType = NonNullable[number]['type']; +export type LessonDocument = NonNullable[number]; + +// Derived types from Course metadata +export type Tabs = NonNullable['lessonTabsOrder']>[number]; +export type Review = NonNullable['reviews']>[number]; + +/** Submission answer element (from API). Used when formatting answers by question name. */ +export type SubmissionAnswer = NonNullable[number]; + +// Exercise template types +export type GetAllTemplatesMetadataRequest = (typeof classroomio.course)[':courseId']['exercise']['template']['$get']; +export type GetAllTemplatesMetadataSuccess = Extract< + InferResponseType, + { success: true } +>; +export type GetAllTemplatesMetadataData = GetAllTemplatesMetadataSuccess['data']; + +export type GetTemplateByTagRequest = + (typeof classroomio.course)[':courseId']['exercise']['template']['tag'][':tag']['$get']; +export type GetTemplateByTagSuccess = Extract, { success: true }>; +export type GetTemplateByTagData = GetTemplateByTagSuccess['data']; + +export type GetTemplateByIdRequest = (typeof classroomio.course)[':courseId']['exercise']['template'][':id']['$get']; +export type GetTemplateByIdSuccess = Extract, { success: true }>; +export type GetTemplateByIdData = GetTemplateByIdSuccess['data']; + +// Presign types +export type DocumentUploadPresignRequest = (typeof classroomio.course)['presign']['document']['upload']['$post']; +export type DocumentUploadPresignSuccess = Extract, { success: true }>; + +export type DocumentDownloadPresignRequest = (typeof classroomio.course)['presign']['document']['download']['$post']; +export type DocumentDownloadPresignSuccess = Extract< + InferResponseType, + { success: true } +>; + +export type VideoUploadPresignRequest = (typeof classroomio.course)['presign']['video']['upload']['$post']; +export type VideoUploadPresignSuccess = Extract, { success: true }>; + +export type VideoDownloadPresignRequest = (typeof classroomio.course)['presign']['video']['download']['$post']; +export type VideoDownloadPresignSuccess = Extract, { success: true }>; diff --git a/apps/dashboard/src/lib/features/exercise-template/api/index.ts b/apps/dashboard/src/lib/features/exercise-template/api/index.ts deleted file mode 100644 index 90ff031d..00000000 --- a/apps/dashboard/src/lib/features/exercise-template/api/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './template.svelte'; diff --git a/apps/dashboard/src/lib/features/exercise-template/api/template.svelte.ts b/apps/dashboard/src/lib/features/exercise-template/api/template.svelte.ts deleted file mode 100644 index a46feb03..00000000 --- a/apps/dashboard/src/lib/features/exercise-template/api/template.svelte.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { BaseApi, classroomio } from '$lib/utils/services/api'; -import type { TExerciseTemplate } from '@cio/db/types'; -import type { GetTemplateByIdResponse, GetTemplateByTagData, GetTemplateByTagResponse } from '../utils/types'; - -/** - * API class for exercise templates - */ -class ExerciseTemplateApi extends BaseApi { - templates = $state([]); - template = $state({} as TExerciseTemplate); - - /** - * Fetches an exercise template metadata - */ - async fetchTemplateById(id: string) { - await this.execute({ - requestFn: () => classroomio.exercise.template[':id'].$get({ param: { id } }), - logContext: 'fetching exercise template by id', - onSuccess: (response) => { - this.template = response.data; - } - }); - } - - /** - * Fetches an exercise template metadata - */ - async fetchTemplatesByTag(tag: string) { - await this.execute({ - requestFn: () => classroomio.exercise.template.tag[':tag'].$get({ param: { tag } }), - logContext: 'fetching exercise template by tag', - onSuccess: (response) => { - this.templates = response.data; - } - }); - } -} - -export const exerciseTemplateApi = new ExerciseTemplateApi(); diff --git a/apps/dashboard/src/lib/features/exercise-template/utils/constants.ts b/apps/dashboard/src/lib/features/exercise-template/utils/constants.ts deleted file mode 100644 index d2b8907e..00000000 --- a/apps/dashboard/src/lib/features/exercise-template/utils/constants.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const TAGS = { - HTML: 'HTML', - CSS: 'CSS', - JS: 'JS', - Typescript: 'Typescript', - ReactJS: 'ReactJS', - VueJS: 'VueJS', - NodeJS: 'NodeJS', - Python: 'Python', - PHP: 'PHP', - GIT: 'GIT' -}; diff --git a/apps/dashboard/src/lib/features/exercise-template/utils/types.ts b/apps/dashboard/src/lib/features/exercise-template/utils/types.ts deleted file mode 100644 index 7a5d821a..00000000 --- a/apps/dashboard/src/lib/features/exercise-template/utils/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { classroomio, type InferResponseType } from '$lib/utils/services/api'; - -export type GetAllTemplatesMetadataResponse = typeof classroomio.exercise.template.$get; -export type GetAllTemplatesMetadataSuccess = Extract< - InferResponseType, - { success: true } ->; -export type GetAllTemplatesMetadataData = GetAllTemplatesMetadataSuccess['data']; - -export type GetTemplateByTagResponse = (typeof classroomio.exercise.template)['tag'][':tag']['$get']; -export type GetTemplateByTagSuccess = Extract, { success: true }>; -export type GetTemplateByTagData = GetTemplateByTagSuccess['data']; - -export type GetTemplateByIdResponse = (typeof classroomio.exercise.template)[':id']['$get']; -export type GetTemplateByIdSuccess = Extract, { success: true }>; -export type GetTemplateByIdData = GetTemplateByIdSuccess['data']; diff --git a/apps/dashboard/src/lib/features/license/api/license.svelte.ts b/apps/dashboard/src/lib/features/license/api/license.svelte.ts new file mode 100644 index 00000000..f318556a --- /dev/null +++ b/apps/dashboard/src/lib/features/license/api/license.svelte.ts @@ -0,0 +1,37 @@ +import { LICENSE_FEATURE_IDS, type LicenseFeatureId } from '@cio/utils/license'; + +class LicenseApi { + features = $state([]); + isResolved = $state(false); + + setFeatures(features: string[] = []) { + this.features = this.sanitize(features); + this.isResolved = true; + } + + hasAccess(feature: LicenseFeatureId): boolean { + return this.features.includes(feature); + } + + reset() { + this.features = []; + this.isResolved = false; + } + + private sanitize(features: string[]): LicenseFeatureId[] { + const deduped: LicenseFeatureId[] = []; + + for (const feature of features) { + if (LICENSE_FEATURE_IDS.includes(feature)) { + const typedFeature = feature as LicenseFeatureId; + if (!deduped.includes(typedFeature)) { + deduped.push(typedFeature); + } + } + } + + return deduped; + } +} + +export const licenseApi = new LicenseApi(); diff --git a/apps/dashboard/src/lib/features/lms/api/exercises.server.ts b/apps/dashboard/src/lib/features/lms/api/exercises.server.ts new file mode 100644 index 00000000..3c55e733 --- /dev/null +++ b/apps/dashboard/src/lib/features/lms/api/exercises.server.ts @@ -0,0 +1,32 @@ +import type { LMSExercise } from './exercises.svelte'; +import { classroomio } from '$lib/utils/services/api'; +import { getApiKeyHeaders } from '$lib/utils/services/api/server'; + +/** + * Server-side API methods for LMS exercises + * These methods use API key authentication and should only be used in server-side files + */ +export class LMSExercisesApiServer { + /** + * Fetches LMS exercises for a student in an organization (server-side) + * @param orgId - Organization ID + * @param profileId - Profile ID of the student + * @returns Array of exercises with submissions and related data, or null on error + */ + static async fetchLMSExercises(orgId: string): Promise { + try { + const response = await classroomio.organization[':orgId'].exercises.lms.$get( + { + param: { orgId } + }, + getApiKeyHeaders() + ); + + const data = await response.json(); + return data.success && data.data ? data.data : null; + } catch (error) { + console.error('Error fetching LMS exercises (server):', error); + return null; + } + } +} diff --git a/apps/dashboard/src/lib/features/lms/api/exercises.svelte.ts b/apps/dashboard/src/lib/features/lms/api/exercises.svelte.ts new file mode 100644 index 00000000..9529c204 --- /dev/null +++ b/apps/dashboard/src/lib/features/lms/api/exercises.svelte.ts @@ -0,0 +1,33 @@ +import { BaseApiWithErrors, classroomio, type InferResponseType } from '$lib/utils/services/api'; + +export type GetLMSExercisesRequest = (typeof classroomio.organization)[':orgId']['exercises']['lms']['$get']; +export type GetLMSExercisesResponse = InferResponseType; +export type GetLMSExercisesSuccess = Extract; +export type LMSExercise = GetLMSExercisesSuccess['data'][number]; +export type LMSExercises = GetLMSExercisesSuccess['data']; + +/** + * API class for LMS exercises + */ +class LMSExercisesApi extends BaseApiWithErrors { + exercises = $state([]); + + /** + * Fetches LMS exercises for a student in an organization + * @param orgId - Organization ID + * @returns Array of exercises with submissions and related data + */ + async fetchLMSExercises(orgId: string) { + return this.execute({ + requestFn: () => classroomio.organization[':orgId'].exercises.lms.$get({ param: { orgId } }), + logContext: 'fetching LMS exercises', + onSuccess: (response) => { + if (response.data) { + this.exercises = response.data; + } + } + }); + } +} + +export const lmsExercisesApi = new LMSExercisesApi(); diff --git a/apps/dashboard/src/lib/features/lms/components/learning.svelte b/apps/dashboard/src/lib/features/lms/components/learning.svelte index 2442a40b..408a9b33 100644 --- a/apps/dashboard/src/lib/features/lms/components/learning.svelte +++ b/apps/dashboard/src/lib/features/lms/components/learning.svelte @@ -1,17 +1,32 @@
@@ -41,12 +56,7 @@ {$t('dashboard.continue')} -
-
-
+
{/each}
diff --git a/apps/dashboard/src/lib/features/lms/pages/dashboard.svelte b/apps/dashboard/src/lib/features/lms/pages/dashboard.svelte index 39c1136a..1fc5ed30 100644 --- a/apps/dashboard/src/lib/features/lms/pages/dashboard.svelte +++ b/apps/dashboard/src/lib/features/lms/pages/dashboard.svelte @@ -1,49 +1,39 @@
-

+

{$currentOrg.customization.dashboard.bannerText ? $currentOrg.customization.dashboard.bannerText : $t('dashboard.lms_dashboard_hero')} @@ -84,7 +74,7 @@ {#if totalLessons > 0}

{totalCompleted}/{totalLessons} - {$t('dashboard.lessons_completed')} + {$t('dashboard.items_completed')}

{:else}

@@ -92,7 +82,7 @@

{/if} -

+

{progressPercentage} %

diff --git a/apps/dashboard/src/lib/features/lms/pages/exercises.svelte b/apps/dashboard/src/lib/features/lms/pages/exercises.svelte index ad8098f1..f0a44fda 100644 --- a/apps/dashboard/src/lib/features/lms/pages/exercises.svelte +++ b/apps/dashboard/src/lib/features/lms/pages/exercises.svelte @@ -4,8 +4,7 @@ import { profile } from '$lib/utils/store/user'; import { currentOrg } from '$lib/utils/store/org'; import { snackbar } from '$features/ui/snackbar/store'; - import { fetchLMSExercises } from '$lib/utils/services/lms/exercises'; - import type { LMSExercise } from '$lib/utils/services/lms/exercises'; + import { lmsExercisesApi, type LMSExercise } from '$features/lms/api/exercises.svelte'; import { calDateDiff } from '$lib/utils/functions/date'; import { t } from '$lib/utils/functions/translations'; @@ -55,7 +54,7 @@ grade: string; lessonNo: string; lessonTitle: string; - lessonURL: string; + lessonURL: string | null; submissionStatus: number; submissionUpdatedAt: string; } @@ -73,8 +72,8 @@ }; const courseURL = `/courses/${lesson.course.id}`; - const lessonURL = `${courseURL}/lessons/${lesson.id}`; - const exerciseURL = `${lessonURL}/exercises/${id}`; + const lessonURL = lesson.id ? `${courseURL}/lessons/${lesson.id}` : null; + const exerciseURL = `${courseURL}/exercises/${id}`; const grade = `${submissionItem.total}/${questions.reduce((acc, cur) => (acc += cur.points), 0)}`; @@ -105,18 +104,16 @@ hasFetched = true; - const { exercises, error } = await fetchLMSExercises(profileId, orgId); - console.log('exercises', exercises); - console.log('error', error); + await lmsExercisesApi.fetchLMSExercises(orgId); - if (error) { + if (!lmsExercisesApi.success) { snackbar.error('snackbar.exercise.error_fetching'); return; } - if (!exercises) return; + if (!lmsExercisesApi.exercises || lmsExercisesApi.exercises.length === 0) return; - sections = generateSections(exercises); + sections = generateSections(lmsExercisesApi.exercises); console.log('sections', sections); } @@ -128,13 +125,13 @@
{#each sections as { title, items, className, id }}

{title}

-
+
{#each items as item}
@@ -146,11 +143,13 @@ {/if} {item.exerciseTitle} - -

- {$t('exercises.lesson')} {item.lessonTitle} -

-
+ {#if item.lessonURL} + +

+ {$t('exercises.lesson')} {item.lessonTitle} +

+
+ {/if}

{item.submissionUpdatedAt}

diff --git a/apps/dashboard/src/lib/features/lms/pages/explore.svelte b/apps/dashboard/src/lib/features/lms/pages/explore.svelte index 329bfb15..082e9a6c 100644 --- a/apps/dashboard/src/lib/features/lms/pages/explore.svelte +++ b/apps/dashboard/src/lib/features/lms/pages/explore.svelte @@ -1,45 +1,22 @@ @@ -77,6 +58,7 @@ emptyDescription={$t('explore.empty_description')} isExplore={true} isLMS={true} + isLoading={coursesApi.isLoading} bind:searchValue bind:selectedId /> diff --git a/apps/dashboard/src/lib/features/lms/pages/mylearning.svelte b/apps/dashboard/src/lib/features/lms/pages/mylearning.svelte index 374e7621..f2f897f9 100644 --- a/apps/dashboard/src/lib/features/lms/pages/mylearning.svelte +++ b/apps/dashboard/src/lib/features/lms/pages/mylearning.svelte @@ -1,50 +1,41 @@ + + onEdit(asset)} + onkeydown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onEdit(asset); + } + }} +> + + + event.stopPropagation()} + > + + + event.stopPropagation()}> + onEdit(asset)}> + {$t('media_manager.actions.edit')} + + onUsage(asset)}> + {$t('media_manager.actions.where_used')} + + onDownload(asset)} disabled={downloadingAssetId === asset.id}> + + + {$t('media_manager.actions.download')} + + + + + +
+ {#if asset.thumbnailUrl} + {$t('media_manager.form.thumbnail_alt')} + {:else} + {@const KindIcon = getKindIcon(asset.kind)} +
+ +

{$t(`media_manager.kind.${asset.kind}`)}

+
+ {/if} +
+
+ + +
+ + {getAssetDisplayName(asset)} + +
+ +
+ {$t(`media_manager.kind.${asset.kind}`)} + {$t(`media_manager.provider.${asset.provider}`)} + {$t(`media_manager.status.${asset.status}`)} +
+ +
+
+

{$t('media_manager.table.size')}

+

{formatBytes(asset.byteSize)}

+
+
+

{$t('media_manager.table.updated_at')}

+

{formatUsageDate(asset.updatedAt)}

+
+
+
+
diff --git a/apps/dashboard/src/lib/features/media/components/asset-usage-dialog.svelte b/apps/dashboard/src/lib/features/media/components/asset-usage-dialog.svelte new file mode 100644 index 00000000..b3145fa1 --- /dev/null +++ b/apps/dashboard/src/lib/features/media/components/asset-usage-dialog.svelte @@ -0,0 +1,137 @@ + + + handleOpenChange(isOpen)}> + + + {$t('media_manager.usage.title')} + + {$t('media_manager.usage.description')} + {#if selectedAsset} + {getAssetDisplayName(selectedAsset)} + {/if} + + + + {#if selectedAsset} + + +
+
+ {#if selectedAsset.thumbnailUrl} + {$t('media_manager.form.thumbnail_alt')} + {:else} + {@const KindIcon = getKindIcon(selectedAsset.kind)} + + {/if} +
+ +
+ {getAssetDisplayName(selectedAsset)} +
+ {$t(`media_manager.kind.${selectedAsset.kind}`)} + {$t(`media_manager.provider.${selectedAsset.provider}`)} + {$t(`media_manager.status.${selectedAsset.status}`)} +
+ +
+
+

{$t('media_manager.table.size')}

+

{formatBytes(selectedAsset.byteSize)}

+
+
+

{$t('media_manager.table.updated_at')}

+

{formatUsageDate(selectedAsset.updatedAt)}

+
+
+
+
+
+
+ {/if} + + {#if isLoading} +

{$t('media_manager.usage.loading')}

+ {:else if isUsageEmpty} +

{$t('media_manager.usage.empty')}

+ {:else if usageData} +

+ {$t('media_manager.usage.total')} + {usageData.usageCount} +

+
+
+ {#each usageData.usages as usage (usage.id)} +
+
+

{$t('media_manager.usage.table.target_type')}

+

{getTargetTypeLabel(usage.targetType)}

+
+
+

{$t('media_manager.usage.table.target_id')}

+

{usage.targetId}

+
+
+

{$t('media_manager.usage.table.slot')}

+

{usage.slotType}{usage.slotKey ? ` (${usage.slotKey})` : ''}

+
+
+

{$t('media_manager.usage.table.position')}

+

{usage.position ?? t.get('media_manager.common.not_available')}

+
+
+

{$t('media_manager.usage.table.added')}

+

{formatUsageDate(usage.createdAt)}

+
+
+ {/each} +
+
+ {/if} +
+
diff --git a/apps/dashboard/src/lib/features/media/components/edit-asset-dialog.svelte b/apps/dashboard/src/lib/features/media/components/edit-asset-dialog.svelte new file mode 100644 index 00000000..21e44365 --- /dev/null +++ b/apps/dashboard/src/lib/features/media/components/edit-asset-dialog.svelte @@ -0,0 +1,176 @@ + + + { + if (!isOpen) { + resetForm(); + } + }} +> + + + {$t('media_manager.edit.title')} + {$t('media_manager.edit.description')} + + + {#if asset} +
+ + + +
+

{$t('media_manager.form.thumbnail_upload')}

+
+ + $t('media_manager.form.thumbnail_drop_size')} + /> + +
+
+ + {#if thumbnailUrl} +
+

{$t('media_manager.form.thumbnail_preview')}

+ {$t('media_manager.form.thumbnail_alt')} +
+ {/if} + +
+

{$t('media_manager.form.status')}

+ + +

{$t(`media_manager.status.${status}`)}

+
+ + {$t('media_manager.status.active')} + {$t('media_manager.status.archived')} + +
+
+
+ {/if} + + + + + +
+
diff --git a/apps/dashboard/src/lib/features/media/components/index.ts b/apps/dashboard/src/lib/features/media/components/index.ts new file mode 100644 index 00000000..687f8b40 --- /dev/null +++ b/apps/dashboard/src/lib/features/media/components/index.ts @@ -0,0 +1,5 @@ +export { default as AssetCard } from './asset-card.svelte'; +export { default as AssetUsageDialog } from './asset-usage-dialog.svelte'; +export { default as EditAssetDialog } from './edit-asset-dialog.svelte'; +export { default as MediaFilters } from './media-filters.svelte'; +export { default as StorageCards } from './storage-cards.svelte'; diff --git a/apps/dashboard/src/lib/features/media/components/media-filters.svelte b/apps/dashboard/src/lib/features/media/components/media-filters.svelte new file mode 100644 index 00000000..1faa537c --- /dev/null +++ b/apps/dashboard/src/lib/features/media/components/media-filters.svelte @@ -0,0 +1,75 @@ + + + + + {#each ASSET_KIND_OPTIONS as option (option)} + + {$t(`media_manager.filters.kind_options.${option}`)} + + {/each} + + + +
+ + + +

{$t(`media_manager.filters.status_options.${status}`)}

+
+ + {#each ASSET_STATUS_OPTIONS as option (option)} + + {$t(`media_manager.filters.status_options.${option}`)} + + {/each} + +
+ + + + +
diff --git a/apps/dashboard/src/lib/features/media/components/storage-cards.svelte b/apps/dashboard/src/lib/features/media/components/storage-cards.svelte new file mode 100644 index 00000000..dcc64e23 --- /dev/null +++ b/apps/dashboard/src/lib/features/media/components/storage-cards.svelte @@ -0,0 +1,48 @@ + + +{#if storageSummary} +
+ {#each storageCards as card} + + {/each} +
+{/if} diff --git a/apps/dashboard/src/lib/features/media/index.ts b/apps/dashboard/src/lib/features/media/index.ts new file mode 100644 index 00000000..dfa5b54a --- /dev/null +++ b/apps/dashboard/src/lib/features/media/index.ts @@ -0,0 +1,4 @@ +export * from './api'; +export * from './components'; +export * from './pages'; +export * from './utils'; diff --git a/apps/dashboard/src/lib/features/media/pages/index.ts b/apps/dashboard/src/lib/features/media/pages/index.ts new file mode 100644 index 00000000..ba170340 --- /dev/null +++ b/apps/dashboard/src/lib/features/media/pages/index.ts @@ -0,0 +1 @@ +export { default as MediaPage } from './media.svelte'; diff --git a/apps/dashboard/src/lib/features/media/pages/media.svelte b/apps/dashboard/src/lib/features/media/pages/media.svelte new file mode 100644 index 00000000..ceaeaf3d --- /dev/null +++ b/apps/dashboard/src/lib/features/media/pages/media.svelte @@ -0,0 +1,190 @@ + + + + + + refreshAssets(1)} + onRefresh={refreshMediaData} + /> + + +{#if assets.length === 0} + +{:else} + + {#each assets as asset (asset.id)} + + {/each} + +{/if} + +{#if pagination && pagination.totalPages > 1} +
+ +

+ {$t('media_manager.pagination.page')} + {pagination.page} + / {pagination.totalPages} +

+ +
+{/if} + + + + diff --git a/apps/dashboard/src/lib/features/media/utils/constants.ts b/apps/dashboard/src/lib/features/media/utils/constants.ts new file mode 100644 index 00000000..467980de --- /dev/null +++ b/apps/dashboard/src/lib/features/media/utils/constants.ts @@ -0,0 +1,24 @@ +import type { Component } from 'svelte'; +import FileIcon from '@lucide/svelte/icons/file'; +import FileTextIcon from '@lucide/svelte/icons/file-text'; +import FileVideoIcon from '@lucide/svelte/icons/file-video'; +import ImageIcon from '@lucide/svelte/icons/image'; +import Music2Icon from '@lucide/svelte/icons/music-2'; + +import type { AssetKindFilter, AssetStatusFilter } from './types'; + +export const ASSET_KIND_OPTIONS: AssetKindFilter[] = ['all', 'video', 'document', 'image', 'audio', 'other']; + +export const ASSET_STATUS_OPTIONS: AssetStatusFilter[] = ['all', 'active', 'archived']; + +export const KIND_ICON_MAP: Record = { + video: FileVideoIcon, + document: FileTextIcon, + image: ImageIcon, + audio: Music2Icon, + other: FileIcon +}; + +export function getKindIcon(kindValue: string): Component { + return KIND_ICON_MAP[kindValue] ?? FileIcon; +} diff --git a/apps/dashboard/src/lib/features/media/utils/index.ts b/apps/dashboard/src/lib/features/media/utils/index.ts new file mode 100644 index 00000000..45072531 --- /dev/null +++ b/apps/dashboard/src/lib/features/media/utils/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './constants'; +export * from './media-manager-utils'; diff --git a/apps/dashboard/src/lib/features/media/utils/media-manager-utils.ts b/apps/dashboard/src/lib/features/media/utils/media-manager-utils.ts new file mode 100644 index 00000000..cdd089cf --- /dev/null +++ b/apps/dashboard/src/lib/features/media/utils/media-manager-utils.ts @@ -0,0 +1,89 @@ +import { t } from '$lib/utils/functions/translations'; +import type { OrganizationAsset } from './types'; + +type LessonVideoType = 'upload' | 'youtube' | 'generic'; + +export function formatBytes(bytes: number | null | undefined): string { + if (bytes == null || Number.isNaN(bytes) || bytes < 0) { + return '-'; + } + + if (bytes === 0) { + return '0 B'; + } + + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const size = bytes / Math.pow(1024, exponent); + + return `${size.toFixed(size >= 100 || exponent === 0 ? 0 : 2)} ${units[exponent]}`; +} + +export function mapAssetProviderToLessonVideoType(provider: string): LessonVideoType { + if (provider === 'youtube') return 'youtube'; + if (provider === 'generic' || provider === 'external_url') return 'generic'; + return 'upload'; +} + +export function mapAssetToLessonVideo( + asset: OrganizationAsset, + options: { url: string; key?: string | null } +): { + type: LessonVideoType; + link: string; + key?: string; + assetId: string; + fileName?: string; + metadata?: { + title?: string; + description?: string; + thumbnailUrl?: string; + duration?: number; + aspectRatio?: string; + createdAt?: string; + }; +} { + return { + type: mapAssetProviderToLessonVideoType(asset.provider), + link: options.url, + key: options.key ?? asset.storageKey ?? undefined, + assetId: asset.id, + fileName: asset.title ?? undefined, + metadata: { + title: asset.title ?? undefined, + description: asset.description ?? undefined, + thumbnailUrl: asset.thumbnailUrl ?? undefined, + duration: asset.durationSeconds ?? undefined, + aspectRatio: asset.aspectRatio ?? undefined, + createdAt: asset.createdAt ?? undefined + } + }; +} + +export function getAssetDisplayName(asset: OrganizationAsset): string { + return asset.title?.trim() || asset.storageKey || asset.id; +} + +export function getTargetTypeLabel(targetType: string): string { + const keyMap: Record = { + lesson: 'media_manager.usage.target.lesson', + exercise: 'media_manager.usage.target.exercise', + question: 'media_manager.usage.target.question' + }; + return t.get(keyMap[targetType] ?? 'media_manager.usage.target.unknown'); +} + +export function formatUsageDate(value: string | null | undefined): string { + if (!value) { + return t.get('media_manager.common.not_available'); + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return t.get('media_manager.common.not_available'); + } + return new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric' + }).format(date); +} diff --git a/apps/dashboard/src/lib/features/media/utils/types.ts b/apps/dashboard/src/lib/features/media/utils/types.ts new file mode 100644 index 00000000..c06cf990 --- /dev/null +++ b/apps/dashboard/src/lib/features/media/utils/types.ts @@ -0,0 +1,56 @@ +import { classroomio, type InferResponseType } from '$lib/utils/services/api'; + +export type ListAssetsRequest = (typeof classroomio.organization)['assets']['$get']; +export type ListAssetsResponse = InferResponseType; +export type ListAssetsSuccess = Extract; +export type OrganizationAssets = ListAssetsSuccess['data']; + +export type CreateAssetRequest = (typeof classroomio.organization)['assets']['$post']; +export type CreateAssetResponse = InferResponseType; +export type CreateAssetSuccess = Extract; +export type OrganizationAsset = CreateAssetSuccess['data']; + +export type GetAssetRequest = (typeof classroomio.organization)['assets'][':assetId']['$get']; +export type GetAssetResponse = InferResponseType; +export type GetAssetSuccess = Extract; + +export type UpdateAssetRequest = (typeof classroomio.organization)['assets'][':assetId']['$put']; +export type UpdateAssetResponse = InferResponseType; +export type UpdateAssetSuccess = Extract; +export type UpdateAssetData = UpdateAssetSuccess['data']; + +export type DeleteAssetRequest = (typeof classroomio.organization)['assets'][':assetId']['$delete']; +export type DeleteAssetResponse = InferResponseType; +export type DeleteAssetSuccess = Extract; + +export type GetAssetUsageRequest = (typeof classroomio.organization)['assets'][':assetId']['usage']['$get']; +export type GetAssetUsageResponse = InferResponseType; +export type GetAssetUsageSuccess = Extract; +export type AssetUsageGraph = GetAssetUsageSuccess['data']; +export type OrganizationAssetUsage = AssetUsageGraph['usages'][number]; + +export type AttachAssetRequest = (typeof classroomio.organization)['assets'][':assetId']['attach']['$post']; +export type AttachAssetResponse = InferResponseType; +export type AttachAssetSuccess = Extract; +export type AssetUsage = AttachAssetSuccess['data']; + +export type DetachAssetRequest = (typeof classroomio.organization)['assets'][':assetId']['detach']['$post']; +export type DetachAssetResponse = InferResponseType; +export type DetachAssetSuccess = Extract; + +export type ExportAssetsRequest = (typeof classroomio.organization)['assets']['export']['$get']; +export type ExportAssetsResponse = InferResponseType; +export type ExportAssetsSuccess = Extract; + +export type GetAssetStorageRequest = (typeof classroomio.organization)['assets']['storage']['$get']; +export type GetAssetStorageResponse = InferResponseType; +export type GetAssetStorageSuccess = Extract; +export type AssetStorageSummary = GetAssetStorageSuccess['data']; + +export type GetYouTubeMetadataRequest = (typeof classroomio.organization)['assets']['youtube-metadata']['$get']; +export type GetYouTubeMetadataResponse = InferResponseType; +export type GetYouTubeMetadataSuccess = Extract; +export type YouTubeMetadata = GetYouTubeMetadataSuccess['data']; + +export type AssetKindFilter = 'all' | 'video' | 'document' | 'image' | 'audio' | 'other'; +export type AssetStatusFilter = 'all' | 'active' | 'archived'; diff --git a/apps/dashboard/src/lib/features/onboarding/components/verify-email-modal.svelte b/apps/dashboard/src/lib/features/onboarding/components/verify-email-modal.svelte index 56be36fd..c691010f 100644 --- a/apps/dashboard/src/lib/features/onboarding/components/verify-email-modal.svelte +++ b/apps/dashboard/src/lib/features/onboarding/components/verify-email-modal.svelte @@ -104,7 +104,7 @@ - + {$t('verify_email_modal.heading')} diff --git a/apps/dashboard/src/lib/features/onboarding/components/welcome-modal.svelte b/apps/dashboard/src/lib/features/onboarding/components/welcome-modal.svelte index 6aa69985..b7c49611 100644 --- a/apps/dashboard/src/lib/features/onboarding/components/welcome-modal.svelte +++ b/apps/dashboard/src/lib/features/onboarding/components/welcome-modal.svelte @@ -33,11 +33,11 @@ if (!isOpen && !isLoading) closeModal(); }} > - + Welcome -

+

{$t('welcome_modal.we_at')} ClassroomIO {$t('welcome_modal.small_team')} diff --git a/apps/dashboard/src/lib/features/org/api/org.server.ts b/apps/dashboard/src/lib/features/org/api/org.server.ts index c7c671b1..aa902bd5 100644 --- a/apps/dashboard/src/lib/features/org/api/org.server.ts +++ b/apps/dashboard/src/lib/features/org/api/org.server.ts @@ -1,57 +1,80 @@ import { classroomio } from '$lib/utils/services/api'; -import { getApiKeyHeaders } from '$lib/utils/services/api/server'; /** - * Server-side API methods for organization operations - * These methods use API key authentication and should only be used in server-side files - * (+server.ts, +layout.server.ts) to prevent API keys from being exposed to the client. + * Server-side organization API functions. + * These are stateless functions (not a class with $state) to avoid cross-request + * data leakage in SSR. Each call is independent. + * + * Use these in +layout.server.ts, +server.ts, and other server-side files. + * For client-side usage, use orgApi from './org.svelte' instead. */ -export class OrgApiServer { - /** - * Gets organization by siteName (server-side) - * @param siteName Organization site name - * @returns Organization or null - */ - static async getOrgBySiteName(siteName: string) { - try { - const response = await classroomio.organization.$get( - { - query: { siteName } - }, - getApiKeyHeaders() - ); - const data = await response.json(); - return data.success && data.data && data.data.length > 0 ? data.data[0] : null; - } catch (error) { - console.error('Error fetching organization by siteName (server):', error); - return null; - } +/** + * Gets the first organization (for self-hosted single-org mode) + * @param apiKeyHeaders API key headers for server-side authentication + * @returns First organization or null + */ +export async function getFirstOrg(apiKeyHeaders: { headers: Record }) { + try { + const response = await classroomio.organization.first.$get(undefined, apiKeyHeaders); + const data = await response.json(); + return data.success && data.data && data.data.length > 0 ? data.data[0] : null; + } catch (error) { + console.error('Error fetching first organization (server):', error); + return null; } +} - /** - * Gets organizations by custom domain (server-side) - * @param customDomain Custom domain - * @param isCustomDomainVerified Whether the domain is verified - * @returns Array of organizations - */ - static async getOrgsByCustomDomain(customDomain: string, isCustomDomainVerified = true) { - try { - const response = await classroomio.organization.$get( - { - query: { - customDomain, - isCustomDomainVerified - } - }, - getApiKeyHeaders() - ); +/** + * Gets organization by siteName (server-side) + * @param siteName Organization site name + * @param apiKeyHeaders API key headers for server-side authentication + * @returns Organization or null + */ +export async function getOrgBySiteName(siteName: string, apiKeyHeaders: { headers: Record }) { + try { + const response = await classroomio.organization.$get( + { + query: { siteName } + }, + apiKeyHeaders + ); - const data = await response.json(); - return data.success && data.data ? data.data : []; - } catch (error) { - console.error('Error fetching organizations by custom domain (server):', error); - return []; - } + const data = await response.json(); + return data.success && data.data && data.data.length > 0 ? data.data[0] : null; + } catch (error) { + console.error('Error fetching organization by siteName (server):', error); + return null; + } +} + +/** + * Gets organizations by custom domain (server-side) + * @param customDomain Custom domain + * @param isCustomDomainVerified Whether the domain is verified + * @param apiKeyHeaders API key headers for server-side authentication + * @returns Array of organizations + */ +export async function getOrgsByCustomDomain( + customDomain: string, + isCustomDomainVerified: boolean, + apiKeyHeaders: { headers: Record } +) { + try { + const response = await classroomio.organization.$get( + { + query: { + customDomain, + isCustomDomainVerified + } + }, + apiKeyHeaders + ); + + const data = await response.json(); + return data.success && data.data ? data.data : []; + } catch (error) { + console.error('Error fetching organizations by custom domain (server):', error); + return []; } } diff --git a/apps/dashboard/src/lib/features/org/api/org.svelte.ts b/apps/dashboard/src/lib/features/org/api/org.svelte.ts index b65331e1..9fea866a 100644 --- a/apps/dashboard/src/lib/features/org/api/org.svelte.ts +++ b/apps/dashboard/src/lib/features/org/api/org.svelte.ts @@ -1,10 +1,11 @@ import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api'; import type { - CoursesByOrganizationSiteName, - DeleteTeamResponse, - GetAudienceResponse, - GetCoursesBySiteNameResponse, - InviteTeamResponse, + DeleteTeamRequest, + DomainRequestRequest, + GetAudienceRequest, + GetOrgPublicCoursesRequest, + InviteTeamRequest, + OrgPublicCourses, OrganizationAudience, OrganizationTeamMembers } from '../utils/types'; @@ -13,10 +14,10 @@ import { ZCreateOrganization, ZUpdateOrganization } from '@cio/utils/validation/ import { currentOrg, orgs } from '$lib/utils/store/org'; import type { AccountOrg } from '$features/app/types'; -import type { GetTeamResponse } from '../utils/types'; +import type { GetTeamRequest } from '../utils/types'; import { ROLE } from '@cio/utils/constants'; import { ROLE_LABEL } from '$lib/utils/constants/roles'; -import type { UpdateOrganizationRequestType } from './types'; +import type { UpdateOrganizationRequest } from '../utils/types'; import { get } from 'svelte/store'; import { goto } from '$app/navigation'; import { mapZodErrorsToTranslations } from '$lib/utils/validation'; @@ -34,6 +35,11 @@ export interface TOrgUpdateForm { customDomain?: string | null; isCustomDomainVerified?: boolean; customization?: AccountOrg['customization']; + disableSignup?: boolean; + disableSignupMessage?: string; + disableEmailPassword?: boolean; + disableGoogleAuth?: boolean; + settings?: { signup?: { inviteOnly?: boolean } }; } /** @@ -42,19 +48,17 @@ export interface TOrgUpdateForm { class OrgApi extends BaseApiWithErrors { teamMembers = $state([]); audience = $state([]); - orgSiteCourses = $state([]); + publicCourses: OrgPublicCourses = $state([]); + + isFetchingOrgPublicCourses = $state(false); /** * Gets organization team members (non-students) - * @param orgId Organization ID * @returns Team members array */ - async getOrgTeam(orgId: string) { - return this.execute({ - requestFn: () => - classroomio.organization[':orgId'].team.$get({ - param: { orgId } - }), + async getOrgTeam() { + return this.execute({ + requestFn: () => classroomio.organization.team.$get(), logContext: 'fetching organization team', onSuccess: (response) => { // Map API response to include role and isAdmin directly in the API layer @@ -79,11 +83,8 @@ class OrgApi extends BaseApiWithErrors { async getOrgAudience(orgId?: string) { if (!orgId) return; - return this.execute({ - requestFn: () => - classroomio.organization[':orgId'].audience.$get({ - param: { orgId } - }), + return this.execute({ + requestFn: () => classroomio.organization.audience.$get(), logContext: 'fetching organization audience', onSuccess: (response) => { this.audience = response.data; @@ -92,21 +93,25 @@ class OrgApi extends BaseApiWithErrors { } /** - * Gets courses by organization siteName + * Gets public courses by organization siteName (for landing pages) * @param siteName Organization site name - * @returns Courses array + * @returns Published courses array */ - async getCourseBySiteName(siteName: string) { - return this.execute({ + async getPublicCoursesBySiteName(siteName: string) { + this.isFetchingOrgPublicCourses = true; + + await this.execute({ requestFn: () => - classroomio.organization.courses.$get({ + classroomio.organization.courses.public.$get({ query: { siteName } }), - logContext: 'fetching courses', + logContext: 'fetching public courses', onSuccess: (response) => { - this.orgSiteCourses = response.data; + this.publicCourses = response.data; } }); + + this.isFetchingOrgPublicCourses = false; } /** @@ -137,7 +142,7 @@ class OrgApi extends BaseApiWithErrors { } }); - return result; + return result?.data?.[0] ?? null; } /** @@ -223,14 +228,13 @@ class OrgApi extends BaseApiWithErrors { fields.avatar = undefined; const updates: TUpdateOrganization = { ...fields, + landingpage: fields.landingpage ?? undefined, avatarUrl }; - await this.execute({ + await this.execute({ requestFn: () => - classroomio.organization[':orgId'].$put({ - param: { orgId }, - // @ts-expect-error - the json type is not inferred correctly + classroomio.organization.$put({ json: updates }), logContext: 'updating organization', @@ -240,7 +244,16 @@ class OrgApi extends BaseApiWithErrors { } if (options.onSuccess) { - return options.onSuccess(response.data); + return options.onSuccess({ + name: response.data.name, + avatarUrl: response.data.avatarUrl ?? undefined, + theme: response.data.theme ?? undefined, + landingpage: response.data.landingpage ?? undefined, + siteName: response.data.siteName ?? undefined, + customDomain: response.data.customDomain, + isCustomDomainVerified: response.data.isCustomDomainVerified ?? undefined, + customization: response.data.customization ?? undefined + }); } orgs.update((_orgs) => @@ -279,21 +292,19 @@ class OrgApi extends BaseApiWithErrors { /** * Invites team members to the organization - * @param orgId Organization ID + * * @param emails Array of email addresses * @param roleId Role ID (ADMIN or TUTOR) */ - async inviteTeamMembers(orgId: string, emails: string[], roleId: number) { - return this.execute({ + async inviteTeamMembers(emails: string[], roleId: number) { + return this.execute({ requestFn: () => - classroomio.organization[':orgId']['team']['invite']['$post']({ - param: { orgId }, - // @ts-expect-error - the json type is not inferred correctly + classroomio.organization.team.invite.$post({ json: { emails, roleId } }), logContext: 'inviting team members', onSuccess: () => { - this.getOrgTeam(orgId); + this.getOrgTeam(); }, onError: (result) => { if (typeof result === 'string') { @@ -309,18 +320,48 @@ class OrgApi extends BaseApiWithErrors { /** * Removes a team member from the organization - * @param orgId Organization ID + * * @param memberId Member ID to remove */ - async removeTeamMember(orgId: string, memberId: number) { - return this.execute({ + async removeTeamMember(memberId: number) { + return this.execute({ requestFn: () => - classroomio.organization[':orgId'].team[':memberId'].$delete({ - param: { orgId, memberId: memberId.toString() } + classroomio.organization.team[':memberId'].$delete({ + param: { memberId: memberId.toString() } }), logContext: 'removing team member', onSuccess: () => { - this.getOrgTeam(orgId); + this.getOrgTeam(); + }, + onError: (result) => { + if (typeof result === 'string') { + snackbar.error(result); + } + } + }); + } + + /** + * Sends a domain request (verify, add, or remove domain) + * @param key Domain operation key: 'verify_domain', 'add_domain', or 'remove_domain' + * @param domain Domain name + * @returns Domain request response data + */ + async sendDomainRequest(key: 'verify_domain' | 'add_domain' | 'remove_domain', domain: string) { + return this.execute({ + requestFn: () => + classroomio.domain.$post({ + json: { + params: { + key, + domain + } + } + }), + logContext: `processing domain request: ${key}`, + onSuccess: () => { + this.success = true; + this.errors = {}; }, onError: (result) => { if (typeof result === 'string') { diff --git a/apps/dashboard/src/lib/features/org/api/quiz.svelte.ts b/apps/dashboard/src/lib/features/org/api/quiz.svelte.ts new file mode 100644 index 00000000..bb488072 --- /dev/null +++ b/apps/dashboard/src/lib/features/org/api/quiz.svelte.ts @@ -0,0 +1,157 @@ +import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api'; + +import { snackbar } from '$features/ui/snackbar/store'; + +export interface Quiz { + id: string; + title: string; + questions?: any[]; + timelimit?: string; + theme?: string; + organizationId: string; + createdAt: string; + updatedAt: string; +} + +export type QuizListRequest = (typeof classroomio.organization)[':orgId']['quiz']['$get']; +export type QuizGetRequest = (typeof classroomio.organization)[':orgId']['quiz'][':quizId']['$get']; +export type QuizCreateRequest = (typeof classroomio.organization)[':orgId']['quiz']['$post']; +export type QuizUpdateRequest = (typeof classroomio.organization)[':orgId']['quiz'][':quizId']['$put']; +export type QuizDeleteRequest = (typeof classroomio.organization)[':orgId']['quiz'][':quizId']['$delete']; + +/** + * API class for quiz operations + */ +export class QuizApi extends BaseApiWithErrors { + quizzes = $state([]); + quiz = $state(null); + + /** + * Lists quizzes for an organization + */ + async list(orgId: string) { + await this.execute({ + requestFn: () => + classroomio.organization[':orgId'].quiz.$get({ + param: { orgId } + }), + logContext: 'listing quizzes', + onSuccess: (response) => { + if (response.data) { + this.quizzes = response.data; + this.success = true; + this.errors = {}; + } + }, + onError: () => { + snackbar.error('Failed to list quizzes'); + } + }); + } + + /** + * Gets a quiz by ID + */ + async get(orgId: string, quizId: string) { + await this.execute({ + requestFn: () => + classroomio.organization[':orgId'].quiz[':quizId'].$get({ + param: { orgId, quizId } + }), + logContext: 'fetching quiz', + onSuccess: (response) => { + if (response.data) { + this.quiz = response.data; + this.success = true; + this.errors = {}; + } + }, + onError: () => { + snackbar.error('Failed to fetch quiz'); + } + }); + } + + /** + * Creates a new quiz + */ + async create(orgId: string, data: { title: string; questions?: any[]; timelimit?: string; theme?: string }) { + await this.execute({ + requestFn: () => + classroomio.organization[':orgId'].quiz.$post({ + param: { orgId }, + json: data + }), + logContext: 'creating quiz', + onSuccess: (response) => { + if (response.data) { + this.quizzes = [...this.quizzes, response.data]; + this.success = true; + this.errors = {}; + } + }, + onError: () => { + snackbar.error('Failed to create quiz'); + } + }); + + return this.success ? this.quizzes[this.quizzes.length - 1] : null; + } + + /** + * Updates a quiz + */ + async update( + orgId: string, + quizId: string, + data: { title?: string; questions?: any[]; timelimit?: string; theme?: string } + ) { + await this.execute({ + requestFn: () => + classroomio.organization[':orgId'].quiz[':quizId'].$put({ + param: { orgId, quizId }, + json: data + }), + logContext: 'updating quiz', + onSuccess: (response) => { + if (response.data) { + this.quizzes = this.quizzes.map((q) => (q.id === quizId ? response.data : q)); + if (this.quiz?.id === quizId) { + this.quiz = response.data; + } + this.success = true; + this.errors = {}; + } + }, + onError: () => { + snackbar.error('Failed to update quiz'); + } + }); + } + + /** + * Deletes a quiz + */ + async delete(orgId: string, quizId: string) { + await this.execute({ + requestFn: () => + classroomio.organization[':orgId'].quiz[':quizId'].$delete({ + param: { orgId, quizId } + }), + logContext: 'deleting quiz', + onSuccess: () => { + this.quizzes = this.quizzes.filter((q) => q.id !== quizId); + if (this.quiz?.id === quizId) { + this.quiz = null; + } + this.success = true; + this.errors = {}; + }, + onError: () => { + snackbar.error('Failed to delete quiz'); + } + }); + } +} + +export const quizApi = new QuizApi(); diff --git a/apps/dashboard/src/lib/features/org/api/sso.svelte.ts b/apps/dashboard/src/lib/features/org/api/sso.svelte.ts new file mode 100644 index 00000000..0fa0e4aa --- /dev/null +++ b/apps/dashboard/src/lib/features/org/api/sso.svelte.ts @@ -0,0 +1,194 @@ +import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api'; +import { ZCreateSsoConnection, ZUpdateSsoConnection, ZUpdateSsoPolicy } from '@cio/utils/validation/organization/sso'; +import type { + TCreateSsoConnection, + TUpdateSsoConnection, + TUpdateSsoPolicy +} from '@cio/utils/validation/organization/sso'; + +import { mapZodErrorsToTranslations } from '$lib/utils/validation'; +import { snackbar } from '$features/ui/snackbar/store'; +import { t } from '$lib/utils/functions/translations'; +import { normalizeSsoConfig, normalizeSsoPolicy } from '../utils/sso'; +import type { + ActivateSsoConnectionRequest, + CreateSsoConnectionRequest, + DeleteSsoConnectionRequest, + GetOrgSsoInfoRequest, + GetSsoConfigRequest, + OrgSsoInfo, + SsoConfig, + SsoDiscoveryRequest, + SsoDiscoveryResult, + UpdateSsoConnectionRequest, + UpdateSsoPolicyRequest +} from '../utils/types'; + +/** + * API class for SSO operations + */ +class SsoApi extends BaseApiWithErrors { + config = $state(null); + + /** + * Fetch SSO configuration for current org + */ + async getSsoConfig() { + return this.execute({ + requestFn: () => classroomio.organization.sso.$get(), + logContext: 'fetching SSO configuration', + onSuccess: (response) => { + if (response.data != null && typeof response.data === 'object') { + this.config = normalizeSsoConfig(response.data); + } else { + this.config = null; + } + } + }); + } + + /** + * Create new SSO connection + */ + async createConnection(data: TCreateSsoConnection) { + const result = ZCreateSsoConnection.safeParse(data); + if (!result.success) { + this.errors = mapZodErrorsToTranslations(result.error); + return; + } + + return this.execute({ + requestFn: () => + classroomio.organization.sso.$post({ + json: result.data + }), + logContext: 'creating SSO connection', + onSuccess: (response) => { + snackbar.success(t.get('snackbar.success_update')); + this.config = { + config: response.data, + policy: this.config?.policy ?? null + }; + }, + onError: (error) => { + if (typeof error === 'string') { + snackbar.error(error); + } else if ('error' in error) { + snackbar.error(error.error); + } + } + }); + } + + /** + * Update SSO connection + */ + async updateConnection(data: TUpdateSsoConnection) { + const result = ZUpdateSsoConnection.safeParse(data); + if (!result.success) { + this.errors = mapZodErrorsToTranslations(result.error); + return; + } + + return this.execute({ + requestFn: () => + classroomio.organization.sso.$put({ + json: result.data + }), + logContext: 'updating SSO connection', + onSuccess: (response) => { + snackbar.success(t.get('snackbar.success_update')); + if (this.config?.config) { + this.config.config = { ...this.config.config, ...response.data }; + } + } + }); + } + + /** + * Delete SSO connection + */ + async deleteConnection() { + return this.execute({ + requestFn: () => classroomio.organization.sso.$delete(), + logContext: 'deleting SSO connection', + onSuccess: () => { + snackbar.success(t.get('snackbar.success_update')); + this.config = null; + } + }); + } + + /** + * Activate SSO connection + */ + async activateConnection() { + return this.execute({ + requestFn: () => classroomio.organization.sso.activate.$post(), + logContext: 'activating SSO connection', + onSuccess: (response) => { + snackbar.success('SSO connection activated'); + if (this.config?.config) { + this.config.config.isActive = response.data?.isActive ?? true; + } + } + }); + } + + /** + * Update SSO policy + */ + async updatePolicy(data: TUpdateSsoPolicy) { + const result = ZUpdateSsoPolicy.safeParse(data); + if (!result.success) { + this.errors = mapZodErrorsToTranslations(result.error); + return; + } + + return this.execute({ + requestFn: () => + classroomio.organization.sso.policy.$put({ + json: result.data + }), + logContext: 'updating SSO policy', + onSuccess: (response) => { + snackbar.success(t.get('snackbar.success_update')); + if (this.config) { + this.config.policy = normalizeSsoPolicy(response.data); + } + } + }); + } + + /** + * Discover SSO for email + */ + async discoverSso(email: string): Promise { + const result = await this.execute({ + requestFn: () => + classroomio.sso.discover.$get({ + query: { email } + }), + logContext: 'discovering SSO' + }); + + return result?.data ?? null; + } + + /** + * Get SSO info for org + */ + async getOrgSsoInfo(orgId: string): Promise { + const result = await this.execute({ + requestFn: () => + classroomio.sso.org[':orgId'].$get({ + param: { orgId } + }), + logContext: 'fetching org SSO info' + }); + + return result?.data ?? null; + } +} + +export const ssoApi = new SsoApi(); diff --git a/apps/dashboard/src/lib/features/org/api/token-auth.svelte.ts b/apps/dashboard/src/lib/features/org/api/token-auth.svelte.ts new file mode 100644 index 00000000..162bbdf8 --- /dev/null +++ b/apps/dashboard/src/lib/features/org/api/token-auth.svelte.ts @@ -0,0 +1,101 @@ +import type { + ActivateTokenAuthRequest, + CreateTokenAuthRequest, + DeleteTokenAuthRequest, + GetTokenAuthRequest, + RotateTokenAuthRequest +} from '$features/org/utils/types'; +import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api'; + +import { snackbar } from '$features/ui/snackbar/store'; + +export interface TokenAuthStatus { + isActive: boolean; + createdAt: string; + secretLast4: string | null; +} + +const tokenAuthClient = ( + classroomio.organization as { + 'token-auth': { + $get: () => Promise; + $post: () => Promise; + $delete: () => Promise; + rotate: { $post: () => Promise }; + activate: { $put: (opts: { json: { isActive: boolean } }) => Promise }; + }; + } +)['token-auth']; + +class TokenAuthApi extends BaseApiWithErrors { + tokenAuth = $state(null); + generatedSecret = $state(null); + isFetched = $state(false); + + async getStatus() { + return this.execute({ + requestFn: () => tokenAuthClient.$get(), + logContext: 'fetching token auth status', + onSuccess: (response) => { + this.tokenAuth = response.data; + this.isFetched = true; + } + }); + } + + async generateSecret() { + return this.execute({ + requestFn: () => tokenAuthClient.$post(), + logContext: 'creating token auth', + onSuccess: (response) => { + this.generatedSecret = response.data?.secret ?? null; + this.tokenAuth = response.data + ? { + isActive: response.data.isActive, + createdAt: response.data.createdAt, + secretLast4: response.data.secret ? response.data.secret.slice(-4) : null + } + : null; + snackbar.success('snackbar.token_auth.created'); + } + }); + } + + async rotateSecret() { + return this.execute({ + requestFn: () => tokenAuthClient.rotate.$post(), + logContext: 'rotating token auth secret', + onSuccess: (response) => { + this.generatedSecret = response.data?.secret ?? null; + snackbar.success('snackbar.token_auth.rotated'); + } + }); + } + + async deleteTokenAuth() { + return this.execute({ + requestFn: () => tokenAuthClient.$delete(), + logContext: 'deleting token auth', + onSuccess: () => { + this.tokenAuth = null; + this.generatedSecret = null; + snackbar.success('snackbar.token_auth.deleted'); + } + }); + } + + async activateTokenAuth(isActive: boolean) { + return this.execute({ + requestFn: () => tokenAuthClient.activate.$put({ json: { isActive } }), + logContext: 'updating token auth activation', + onSuccess: () => { + if (this.tokenAuth) { + this.tokenAuth = { ...this.tokenAuth, isActive }; + } + snackbar.success(isActive ? 'snackbar.token_auth.activated' : 'snackbar.token_auth.deactivated'); + } + }); + } +} + +export const tokenAuthApi = new TokenAuthApi(); diff --git a/apps/dashboard/src/lib/features/org/api/types.ts b/apps/dashboard/src/lib/features/org/api/types.ts deleted file mode 100644 index c147fad3..00000000 --- a/apps/dashboard/src/lib/features/org/api/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { classroomio, type InferResponseType } from '$lib/utils/services/api'; - -export type UpdateOrganizationRequestType = (typeof classroomio.organization)[':orgId']['$put']; -export type UpdateOrganizationResponse = InferResponseType; -export type UpdateOrganizationSuccess = Extract; diff --git a/apps/dashboard/src/lib/features/org/components/landing-page/landing-page.svelte b/apps/dashboard/src/lib/features/org/components/landing-page/landing-page.svelte index 210450e3..dc775454 100644 --- a/apps/dashboard/src/lib/features/org/components/landing-page/landing-page.svelte +++ b/apps/dashboard/src/lib/features/org/components/landing-page/landing-page.svelte @@ -1,6 +1,7 @@ diff --git a/apps/dashboard/src/lib/features/org/components/quiz/loader.svelte b/apps/dashboard/src/lib/features/org/components/quiz/loader.svelte index 350eb7c6..2220dfed 100644 --- a/apps/dashboard/src/lib/features/org/components/quiz/loader.svelte +++ b/apps/dashboard/src/lib/features/org/components/quiz/loader.svelte @@ -3,7 +3,7 @@

diff --git a/apps/dashboard/src/lib/features/org/components/quiz/new-quiz-modal.svelte b/apps/dashboard/src/lib/features/org/components/quiz/new-quiz-modal.svelte index c634593b..333cd3b2 100644 --- a/apps/dashboard/src/lib/features/org/components/quiz/new-quiz-modal.svelte +++ b/apps/dashboard/src/lib/features/org/components/quiz/new-quiz-modal.svelte @@ -1,4 +1,4 @@ -
- {#if isLoading} + {#if quizApi.isLoading} {:else} - {#each $quizesStore as quiz} + {#each quizApi.quizzes as quiz} {:else} = writable({ { id: 2, label: 'Courses', - url: '#courses', + url: '/courses', openInNewTab: false }, { diff --git a/apps/dashboard/src/lib/features/org/store/sso.svelte.ts b/apps/dashboard/src/lib/features/org/store/sso.svelte.ts new file mode 100644 index 00000000..ed5af97a --- /dev/null +++ b/apps/dashboard/src/lib/features/org/store/sso.svelte.ts @@ -0,0 +1,118 @@ +import { ssoApi } from '../api/sso.svelte'; +import type { SsoConfig } from '../utils/types'; +import { normalizeSsoConfig } from '../utils/sso'; + +/** + * SSO Store - Manages SSO configuration state across the app + * Uses Svelte 5 runes for reactive state management + */ +class SsoStore { + // The SSO configuration data + config = $state(null); + + // Loading states + isLoading = $state(false); + isInitialized = $state(false); + + // Error state + error = $state(null); + + /** + * Load SSO config from API (only if not already loaded) + * Call this on first page render + */ + async loadConfig(force = false) { + // Skip if already loaded and not forcing refresh + if (this.isInitialized && !force) { + return; + } + + // Skip if already loading + if (this.isLoading) { + return; + } + + this.isLoading = true; + this.error = null; + + try { + const result = await ssoApi.getSsoConfig(); + if (result?.data) { + this.config = normalizeSsoConfig(result.data); + } + this.isInitialized = true; + } catch (err) { + this.error = err instanceof Error ? err.message : 'Failed to load SSO config'; + console.error('Failed to load SSO config:', err); + } finally { + this.isLoading = false; + } + } + + /** + * Refresh the config from API + * Use this after creating/updating/deleting connections + */ + async refreshConfig() { + return this.loadConfig(true); + } + + /** + * Update local config after policy changes + */ + updatePolicy(policy: SsoConfig['policy']) { + if (this.config) { + this.config = { + ...this.config, + policy + }; + } + } + + /** + * Update local config after connection changes + */ + updateConfig(config: SsoConfig['config']) { + if (this.config) { + this.config = { + ...this.config, + config + }; + } else { + this.config = { config, policy: null }; + } + } + + /** + * Clear the store (e.g., on logout) + */ + clear() { + this.config = null; + this.isInitialized = false; + this.error = null; + } + + /** + * Check if SSO is configured for current org + */ + get hasConfig() { + return this.config?.config !== null; + } + + /** + * Check if SSO is active + */ + get isActive() { + return this.config?.config?.isActive ?? false; + } + + /** + * Check if Force SSO is enabled + */ + get forceSso() { + return this.config?.policy?.forceSso ?? false; + } +} + +// Export singleton instance +export const ssoStore = new SsoStore(); diff --git a/apps/dashboard/src/lib/features/org/utils/sso.ts b/apps/dashboard/src/lib/features/org/utils/sso.ts new file mode 100644 index 00000000..42030f1e --- /dev/null +++ b/apps/dashboard/src/lib/features/org/utils/sso.ts @@ -0,0 +1,34 @@ +import type { SsoConfig } from './types'; + +/** API returns policy.roleMapping as JSONValue; normalize to SsoConfig. */ +export function normalizeSsoConfig( + raw: { + config: SsoConfig['config']; + policy: (Omit, 'roleMapping'> & { roleMapping?: unknown }) | null; + } | null +): SsoConfig { + if (raw == null || typeof raw !== 'object') { + return { config: null, policy: null } as SsoConfig; + } + const policy = raw.policy; + const roleMapping: Record = + policy && policy.roleMapping != null && typeof policy.roleMapping === 'object' && !Array.isArray(policy.roleMapping) + ? (policy.roleMapping as Record) + : {}; + return { + config: raw.config, + policy: policy ? { ...policy, roleMapping } : null + }; +} + +/** Normalize policy.roleMapping from API (JSONValue) to Record. */ +export function normalizeSsoPolicy( + policy: (Omit, 'roleMapping'> & { roleMapping?: unknown }) | null +): SsoConfig['policy'] { + if (!policy) return null; + const roleMapping: Record = + policy.roleMapping != null && typeof policy.roleMapping === 'object' && !Array.isArray(policy.roleMapping) + ? (policy.roleMapping as Record) + : {}; + return { ...policy, roleMapping }; +} diff --git a/apps/dashboard/src/lib/features/org/utils/types.ts b/apps/dashboard/src/lib/features/org/utils/types.ts index 53d0a5ea..6fbf32e4 100644 --- a/apps/dashboard/src/lib/features/org/utils/types.ts +++ b/apps/dashboard/src/lib/features/org/utils/types.ts @@ -1,9 +1,9 @@ import { classroomio, type InferResponseType } from '$lib/utils/services/api'; // Team member types -export type GetTeamResponse = (typeof classroomio.organization)[':orgId']['team']['$get']; -export type OrganizationTeamResponse = InferResponseType | null; -export type OrganizationTeamSuccess = Extract, { success: true }>; +export type GetTeamRequest = (typeof classroomio.organization)['team']['$get']; +export type OrganizationTeamResponse = InferResponseType | null; +export type OrganizationTeamSuccess = Extract, { success: true }>; export type OrganizationTeamMembersRaw = OrganizationTeamSuccess['data']; @@ -17,30 +17,75 @@ export type OrganizationTeamMembers = Array< >; // Audience types -export type GetAudienceResponse = (typeof classroomio.organization)[':orgId']['audience']['$get']; -export type OrganizationAudienceResponse = InferResponseType | null; -export type OrganizationAudienceSuccess = Extract, { success: true }>; +export type GetAudienceRequest = (typeof classroomio.organization)['audience']['$get']; +export type OrganizationAudienceResponse = InferResponseType | null; +export type OrganizationAudienceSuccess = Extract, { success: true }>; export type OrganizationAudience = OrganizationAudienceSuccess['data']; // Org Public Courses types -export type GetCoursesBySiteNameResponse = typeof classroomio.organization.courses.$get; -export type CoursesByOrganizationSiteNameResponse = InferResponseType | null; -export type CoursesByOrganizationSiteNameSuccess = Extract< - InferResponseType, - { success: true } ->; - -export type CoursesByOrganizationSiteName = CoursesByOrganizationSiteNameSuccess['data']; +export type GetOrgPublicCoursesRequest = typeof classroomio.organization.courses.public.$get; +export type OrgPublicCoursesResponse = InferResponseType | null; +export type OrgPublicCoursesSuccess = Extract, { success: true }>; +export type OrgPublicCourses = OrgPublicCoursesSuccess['data']; // dashboard analytics types -export type GetDashStatsResponse = typeof classroomio.dash.stats.$get; -export type DashStatsResponse = InferResponseType | null; -export type DashStatsSuccess = Extract, { success: true }>; +export type GetDashStatsRequest = typeof classroomio.dash.stats.$get; +export type DashStatsResponse = InferResponseType | null; +export type DashStatsSuccess = Extract, { success: true }>; -export type InviteTeamResponse = (typeof classroomio.organization)[':orgId']['team']['invite']['$post']; -export type InviteTeamSuccess = Extract, { success: true }>; +// invite team types +export type InviteTeamRequest = (typeof classroomio.organization)['team']['invite']['$post']; +export type InviteTeamSuccess = Extract, { success: true }>; export type InviteTeamData = InviteTeamSuccess['data']; -export type DeleteTeamResponse = (typeof classroomio.organization)[':orgId']['team'][':memberId']['$delete']; -export type DeleteTeamSuccess = Extract, { success: true }>; +export type DeleteTeamRequest = (typeof classroomio.organization)['team'][':memberId']['$delete']; +export type DeleteTeamSuccess = Extract, { success: true }>; + +// update organization types +export type UpdateOrganizationRequest = (typeof classroomio.organization)['$put']; +export type UpdateOrganizationResponse = InferResponseType; +export type UpdateOrganizationSuccess = Extract; + +// domain request types +export type DomainRequestRequest = typeof classroomio.domain.$post; +export type DomainRequestResponse = InferResponseType; +export type DomainRequestSuccess = Extract; + +// SSO types +export type GetSsoConfigRequest = (typeof classroomio.organization)['sso']['$get']; +export type CreateSsoConnectionRequest = (typeof classroomio.organization)['sso']['$post']; +export type UpdateSsoConnectionRequest = (typeof classroomio.organization)['sso']['$put']; +export type DeleteSsoConnectionRequest = (typeof classroomio.organization)['sso']['$delete']; +export type ActivateSsoConnectionRequest = (typeof classroomio.organization)['sso']['activate']['$post']; +export type GetSsoPolicyRequest = (typeof classroomio.organization)['sso']['policy']['$get']; +export type UpdateSsoPolicyRequest = (typeof classroomio.organization)['sso']['policy']['$put']; + +export type SsoDiscoveryRequest = typeof classroomio.sso.discover.$get; +export type GetOrgSsoInfoRequest = (typeof classroomio.sso)['org'][':orgId']['$get']; + +export type GetSsoConfigSuccess = Extract, { success: true }>; +type SsoConfigData = NonNullable; +type NormalizedPolicy = SsoConfigData extends { policy: infer P } + ? P extends object + ? Omit & { roleMapping: Record } + : null + : null; +/** Normalized SSO config: API returns policy.roleMapping as JSONValue; this uses Record. */ +export type SsoConfig = { + config: SsoConfigData['config'] | null; + policy: NormalizedPolicy; +}; + +export type GetSsoDiscoverySuccess = Extract, { success: true }>; +export type SsoDiscoveryResult = GetSsoDiscoverySuccess['data']; + +export type GetOrgSsoInfoSuccess = Extract, { success: true }>; +export type OrgSsoInfo = GetOrgSsoInfoSuccess['data']; + +// Token auth types +export type CreateTokenAuthRequest = (typeof classroomio.organization)['token-auth']['$post']; +export type GetTokenAuthRequest = (typeof classroomio.organization)['token-auth']['$get']; +export type RotateTokenAuthRequest = (typeof classroomio.organization)['token-auth']['rotate']['$post']; +export type DeleteTokenAuthRequest = (typeof classroomio.organization)['token-auth']['$delete']; +export type ActivateTokenAuthRequest = (typeof classroomio.organization)['token-auth']['activate']['$put']; diff --git a/apps/dashboard/src/lib/features/settings/components/auth-general.svelte b/apps/dashboard/src/lib/features/settings/components/auth-general.svelte new file mode 100644 index 00000000..f173a221 --- /dev/null +++ b/apps/dashboard/src/lib/features/settings/components/auth-general.svelte @@ -0,0 +1,165 @@ + + + + + + + {$t('settings.auth.general.heading')} + + + {$t('settings.auth.general.description')} + + +
+ + + +
+ {$t('settings.auth.general.disable_signup.label')} + + {$t('settings.auth.general.disable_signup.description')} + +
+
+ + + + +
+ {$t('settings.auth.general.allow_public_signups.label')} + + {$t('settings.auth.general.allow_public_signups.description')} + +
+
+ + + {#if disableSignup} + + {$t('settings.auth.general.disable_signup_message.label')} + + {$t('settings.auth.general.disable_signup_message.description')} + + diff --git a/packages/storybook/src/molecules/page/fields.ts b/packages/storybook/src/molecules/page/fields.ts index abd7342a..b464054c 100644 --- a/packages/storybook/src/molecules/page/fields.ts +++ b/packages/storybook/src/molecules/page/fields.ts @@ -1 +1 @@ -export const FIELDS = ['class'] as string[]; +export const FIELDS = ['class', 'isSticky'] as string[]; diff --git a/packages/storybook/src/molecules/page/page.stories.svelte b/packages/storybook/src/molecules/page/page.stories.svelte index 55ff723a..24e9aa55 100644 --- a/packages/storybook/src/molecules/page/page.stories.svelte +++ b/packages/storybook/src/molecules/page/page.stories.svelte @@ -43,7 +43,7 @@ {#snippet template()} - + Courses diff --git a/packages/storybook/src/molecules/pricing-card/fields.ts b/packages/storybook/src/molecules/pricing-card/fields.ts new file mode 100644 index 00000000..af36f01f --- /dev/null +++ b/packages/storybook/src/molecules/pricing-card/fields.ts @@ -0,0 +1,12 @@ +export const FIELDS = [ + 'plan', + 'planName', + 'isPopular', + 'isYearlyPlan', + 'isLoadingPlan', + 'handleClick', + 'popularLabel', + 'perOrgLabel', + 'ctaLabel', + 'isDisabled' +] as string[]; diff --git a/packages/storybook/src/molecules/pricing-card/pricing-card.stories.svelte b/packages/storybook/src/molecules/pricing-card/pricing-card.stories.svelte new file mode 100644 index 00000000..3719a2fd --- /dev/null +++ b/packages/storybook/src/molecules/pricing-card/pricing-card.stories.svelte @@ -0,0 +1,322 @@ + + + + {#snippet template()} +
+ +
+ {/snippet} +
+ + + {#snippet template()} +
+ +
+ {/snippet} +
+ + + {#snippet template()} +
+ +
+ {/snippet} +
+ + + {#snippet template()} +
+ +
+ {/snippet} +
+ + + {#snippet template()} +
+ +
+ {/snippet} +
+ + + {#snippet template()} +
+ +
+ {/snippet} +
+ + + {#snippet template()} +
+ +
+ {/snippet} +
+ + + {#snippet template()} +
+ + + +
+ {/snippet} +
+ + + {#snippet template()} +
+
+ + + Current: {isYearlyPlan ? 'Yearly' : 'Monthly'} + +
+
+ + + +
+
+ {/snippet} +
+ + + {#snippet template()} +
+ +

+ Website version uses "Signup Now" and is enabled. +

+
+ {/snippet} +
+ + + {#snippet template()} +
+ +

The CTA button is disabled (current plan)

+
+ {/snippet} +
diff --git a/packages/storybook/src/molecules/pricing-toggle/pricing-toggle.stories.svelte b/packages/storybook/src/molecules/pricing-toggle/pricing-toggle.stories.svelte new file mode 100644 index 00000000..8bf0fc60 --- /dev/null +++ b/packages/storybook/src/molecules/pricing-toggle/pricing-toggle.stories.svelte @@ -0,0 +1,37 @@ + + + + {#snippet template()} + + {/snippet} + + + + {#snippet template()} + + {/snippet} + + + + {#snippet template()} + + {/snippet} + + + + {#snippet template()} + + {/snippet} + diff --git a/packages/storybook/src/molecules/radio-option-card/fields.ts b/packages/storybook/src/molecules/radio-option-card/fields.ts new file mode 100644 index 00000000..d4041b86 --- /dev/null +++ b/packages/storybook/src/molecules/radio-option-card/fields.ts @@ -0,0 +1,2 @@ +/** Props shown in Storybook controls (class overrides layout/columns per card) */ +export const FIELDS = ['class'] as string[]; diff --git a/packages/storybook/src/molecules/radio-option-card/radio-option-card.stories.svelte b/packages/storybook/src/molecules/radio-option-card/radio-option-card.stories.svelte new file mode 100644 index 00000000..3566197e --- /dev/null +++ b/packages/storybook/src/molecules/radio-option-card/radio-option-card.stories.svelte @@ -0,0 +1,167 @@ + + + + {#snippet template()} +
+ + Choose how your course will be delivered to students. + + +

Selected: {courseType}

+
+ {/snippet} +
+ + + {#snippet template()} +
+ + Add a new item to your course. Sections group lessons and exercises. + + +

Selected: {contentType}

+
+ {/snippet} +
+ + + {#snippet template()} +
+ + How would you like to create your exercise? Use the titleSuffix(option) snippet to render per-option + content (e.g. Coming soon for disabled). + + + {#snippet titleSuffix(option)} + {#if option.disabled} + Coming soon + {/if} + {/snippet} + +

Selected: {exerciseCreateType}

+
+ {/snippet} +
+ + + {#snippet template()} +
+ + Override grid with the class prop on RadioOptionCardGroup (uses cn so your + classes win). + + +

Selected: {deliveryMethod}

+
+ {/snippet} +
+ + + {#snippet template()} +
+ + When using RadioOptionCard inside RadioGroup.Root, override per card with the card + class + prop (e.g. ui:lg:col-span-2). + + + + + + + +

Selected: {deliveryMethod}

+
+ {/snippet} +
diff --git a/packages/ui/.prettierignore b/packages/ui/.prettierignore new file mode 100644 index 00000000..147d7d24 --- /dev/null +++ b/packages/ui/.prettierignore @@ -0,0 +1 @@ +output.css \ No newline at end of file diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 00000000..17e7f5a0 --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1,228 @@ +# @cio/ui + +UI component library for ClassroomIO, built with Svelte 5 and Tailwind CSS. + +## Package Information + +- **Name**: `@cio/ui` +- **Type**: Svelte component library +- **Framework**: Svelte 5 +- **Styling**: Tailwind CSS with custom `ui` prefix + +## Folder Structure + +``` +packages/ui/ +├── src/ +│ ├── base/ # Components from shadcn-svelte +│ ├── custom/ # Components from other sources or built on top of base components +│ ├── hooks/ # Reusable Svelte hooks +│ ├── tools/ # Utility functions (cn, sanitize, etc.) +│ ├── index.css # Global styles +│ └── index.ts # Main export file +├── package.json +├── components.json # shadcn-svelte configuration +└── jsrepo.json # jsrepo configuration +``` + +## Content Structure + +### Base Components (`src/base/`) + +All components in the `base` directory are primarily from [shadcn-svelte](https://www.shadcn-svelte.com/). These are the foundational UI components that follow the shadcn design system. + +**Guidelines:** + +- Any new component from shadcn-svelte should be added to the `base` directory +- Components are organized in their own folders with an `index.ts` file for exports +- Each component folder typically contains the main component file(s) and an `index.ts` export file + +**Examples:** + +- `base/button/` - Button component from shadcn-svelte +- `base/dialog/` - Dialog component from shadcn-svelte +- `base/tooltip/` - Tooltip component from shadcn-svelte + +### Custom Components (`src/custom/`) + +Components in the `custom` directory come from various sources or are built on top of base components: + +1. **External Sources:** + - Components from [shadcn-svelte-extras](https://www.shadcn-svelte-extras.com/) + - Components from [Edra Editor](http://edra.tsuzat.com/) (rich text editor) + - Other third-party component libraries + +2. **Extended Components:** + - Any component that builds on top of shadcn-svelte base components + - Custom implementations that extend base component functionality + +**Examples:** + +- `custom/code/` - From shadcn-svelte-extras (syntax-highlighted code block with copy and overflow) +- `custom/underline-tabs/` - From shadcn-svelte-extras +- `custom/editor/` - Rich text editor based on Edra/Tiptap +- `custom/image-cropper/` - Custom image cropping component +- `custom/checkbox-field/` - Field component built on top of base Checkbox + +### Hooks (`src/hooks/`) + +Reusable Svelte hooks are located in the `src/hooks/` directory. These are Svelte 5 runes-based utilities that can be used across components. + +**Available Hooks:** + +- `is-mobile.svelte.ts` - Media query hook for mobile breakpoints +- `use-clipboard.svelte.ts` - Clipboard copy functionality with state management + +**Usage:** + +```svelte + +``` + +### Tools (`src/tools/`) + +Utility functions and helpers are located in `src/tools/`. The main utility is the `cn` function for class name merging. + +## Importing `cn` + +The `cn` utility function is used throughout the codebase for merging Tailwind CSS classes. It combines `clsx` and `tailwind-merge` to handle class conflicts intelligently. + +**Import:** + +```typescript +import { cn } from '../../tools'; +``` + +**Example usage:** + +```svelte + + +
Content
+``` + +The `cn` function is exported from `src/tools/index.ts` and is also available as a named export from the main package: + +```typescript +import { cn } from '@cio/ui'; +``` + +## Tailwind Prefix + +All Tailwind CSS classes in this package use a custom prefix: **`ui`** + +This ensures that styles don't conflict with other Tailwind configurations in consuming applications. + +**Usage:** + +```svelte +
Content
+``` + +**Note:** When adding new components or styles, always use the `ui:` prefix for all Tailwind classes. + +## Component Exports + +Components are exported from the main `src/index.ts` file in an alphabetical order. There are two main export patterns: + +### Base Components + +Base components are exported as namespaced exports: + +```typescript +export * as Button from './base/button'; +export * as Dialog from './base/dialog'; +export * as Tooltip from './base/tooltip'; +``` + +**Usage:** + +```svelte + + +Click me +... +``` + +### Custom Components + +Custom components may use namespaced exports or direct exports depending on their structure: + +```typescript +// Namespaced +export * as UnderlineTabs from './custom/underline-tabs'; + +// Direct exports +export { Chip } from './custom/chip'; +export { IconButton } from './custom/icon-button'; +``` + +**Usage:** + +```svelte + +``` + +### Component Index Files + +Each component directory contains an `index.ts` file that exports the component and its types: + +```typescript +// base/button/index.ts +import Root, { type ButtonProps, buttonVariants } from './button.svelte'; + +export { Root, Root as Button, type ButtonProps, buttonVariants }; +``` + +## Adding New Components + +When adding a new component to this package, follow these steps: + +1. **Determine the location:** + - If it's from shadcn-svelte → add to `src/base/` + - If it's from another source or extends base components → add to `src/custom/` + +2. **Create the component structure:** + - Create a folder with the component name (kebab-case) + - Add the component file(s) + - Create an `index.ts` file for exports + +3. **Export from main index:** + - Add the export to `src/index.ts` + +4. **Add Tailwind prefix:** + - Run `pnpm prefix` to automatically add the `ui:` prefix to all Tailwind classes + +5. **Create a Storybook story:** + - **Required:** After adding a new component, you must add a story for it in `packages/storybook` + - See `packages/storybook/README.md` for detailed instructions on how to create stories + - Stories help document component usage and enable visual testing + +## Development + +### Scripts + +- `pnpm generate-css` - Generate CSS from Tailwind +- `pnpm build` - Build the package +- `pnpm dev` - Watch mode for CSS generation +- `pnpm format` - Format code with Prettier +- `pnpm prefix:list` - List files that need the `ui:` prefix (dry-run) +- `pnpm prefix` - Add `ui:` prefix to all Tailwind classes and format + +### Configuration + +- `components.json` - shadcn-svelte configuration +- `jsrepo.json` - jsrepo configuration for component installation +- `svelte.config.js` - Svelte configuration +- `tsconfig.json` - TypeScript configuration diff --git a/packages/ui/jsrepo.json b/packages/ui/jsrepo.json index d87b0aa6..349a86f5 100644 --- a/packages/ui/jsrepo.json +++ b/packages/ui/jsrepo.json @@ -9,6 +9,7 @@ "paths": { "*": "$src", "ui": "$src/base", + "code": "src/custom", "actions": "$src/actions", "hooks": "$src/hooks", "utils": "$src/tools" diff --git a/packages/ui/package.json b/packages/ui/package.json index bbf6d061..09e79078 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -3,7 +3,7 @@ "version": "0.0.1", "type": "module", "scripts": { - "generate-css": "echo 'generating CSS' && pnpx @tailwindcss/cli -i ./src/index.css -o ./src/output.css --minify", + "generate-css": "echo 'generating CSS' && tailwindcss -i ./src/index.css -o ./src/output.css --minify", "build": "pnpm generate-css", "dev": "pnpm generate-css --watch", "format": "prettier --write .", @@ -12,20 +12,26 @@ }, "devDependencies": { "@internationalized/date": "^3.8.1", - "@lucide/svelte": "^0.544.0", + "@lucide/svelte": "^0.562.0", + "@shikijs/langs": "^3.22.0", + "@shikijs/themes": "^3.22.0", "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/kit": "^2.5.27", "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@tailwindcss/cli": "^4.1.18", "@tailwindcss/typography": "^0.5.18", "@types/dompurify": "^3.2.0", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", - "bits-ui": "^2.14.4", + "bits-ui": "^2.16.1", "clsx": "^2.1.1", + "isomorphic-dompurify": "2.25.0", + "jsrepo": "^3.5.1", "layerchart": "2.0.0-next.27", "mode-watcher": "^1.1.0", - "runed": "^0.36.0", + "runed": "^0.37.1", + "shiki": "^3.22.0", "svelte": "^5.39.11", "svelte-easy-crop": "^5.0.0", "svelte-sonner": "^1.0.5", @@ -37,6 +43,7 @@ "vaul-svelte": "1.0.0-next.7" }, "dependencies": { + "@cio/question-types": "workspace:*", "@floating-ui/dom": "^1.7.4", "@tiptap/core": "^3.10.7", "@tiptap/extension-bubble-menu": "^3.10.7", @@ -61,6 +68,9 @@ "dompurify": "^3.3.0", "katex": "^0.16.25", "lowlight": "^3.3.0", + "plyr": "^3.7.8", + "simple-icons": "^16.9.0", + "svelte-dnd-action": "^0.9.68", "svelte-tiptap": "^2.2.0", "tippy.js": "^6.3.7", "tiptap-extension-auto-joiner": "^0.1.3" diff --git a/packages/ui/src/base/alert/alert-callout.svelte b/packages/ui/src/base/alert/alert-callout.svelte new file mode 100644 index 00000000..01683144 --- /dev/null +++ b/packages/ui/src/base/alert/alert-callout.svelte @@ -0,0 +1,34 @@ + + + + + {title} + {description} + diff --git a/packages/ui/src/base/alert/alert-description.svelte b/packages/ui/src/base/alert/alert-description.svelte new file mode 100644 index 00000000..1729f4c6 --- /dev/null +++ b/packages/ui/src/base/alert/alert-description.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/packages/ui/src/base/alert/alert-title.svelte b/packages/ui/src/base/alert/alert-title.svelte new file mode 100644 index 00000000..bac0916c --- /dev/null +++ b/packages/ui/src/base/alert/alert-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/packages/ui/src/base/alert/alert.svelte b/packages/ui/src/base/alert/alert.svelte new file mode 100644 index 00000000..de47928d --- /dev/null +++ b/packages/ui/src/base/alert/alert.svelte @@ -0,0 +1,42 @@ + + + + + diff --git a/packages/ui/src/base/alert/index.ts b/packages/ui/src/base/alert/index.ts new file mode 100644 index 00000000..a4b358eb --- /dev/null +++ b/packages/ui/src/base/alert/index.ts @@ -0,0 +1,16 @@ +import Root from './alert.svelte'; +import Description from './alert-description.svelte'; +import Title from './alert-title.svelte'; +import Callout from './alert-callout.svelte'; +export { alertVariants, type AlertVariant } from './alert.svelte'; + +export { + Root, + Description, + Title, + Callout, + // + Root as Alert, + Description as AlertDescription, + Title as AlertTitle +}; diff --git a/packages/ui/src/base/badge/badge.svelte b/packages/ui/src/base/badge/badge.svelte index 448a7d65..460d9d66 100644 --- a/packages/ui/src/base/badge/badge.svelte +++ b/packages/ui/src/base/badge/badge.svelte @@ -9,6 +9,10 @@ secondary: 'ui:bg-secondary ui:text-secondary-foreground ui:[a&]:hover:bg-secondary/90 ui:border-transparent', destructive: 'ui:bg-destructive ui:[a&]:hover:bg-destructive/90 ui:focus-visible:ring-destructive/20 ui:dark:focus-visible:ring-destructive/40 ui:dark:bg-destructive/70 ui:border-transparent ui:text-white', + warning: + 'ui:bg-amber-600 ui:text-white ui:[a&]:hover:bg-amber-600/90 ui:border-transparent ui:dark:bg-amber-500 ui:dark:text-amber-950', + success: + 'ui:bg-emerald-600 ui:text-white ui:[a&]:hover:bg-emerald-600/90 ui:border-transparent ui:dark:bg-emerald-500 ui:dark:text-emerald-950', outline: 'ui:text-foreground ui:[a&]:hover:bg-accent ui:[a&]:hover:text-accent-foreground' } }, diff --git a/packages/ui/src/base/button-group/button-group-text.svelte b/packages/ui/src/base/button-group/button-group-text.svelte index ed1993be..c2f6d986 100644 --- a/packages/ui/src/base/button-group/button-group-text.svelte +++ b/packages/ui/src/base/button-group/button-group-text.svelte @@ -15,7 +15,7 @@ const mergedProps = $derived({ ...restProps, class: cn( - "ui:bg-muted ui:shadow-xs ui:flex ui:items-center ui:gap-2 ui:rounded-md ui:border ui:px-4 ui:text-sm ui:font-medium ui:[&_svg:not([class*='size-'])]:size-4 ui:[&_svg]:pointer-events-none", + "ui:bg-muted ui:shadow-xs ui:flex ui:items-center ui:gap-2 ui:rounded-md ui:border ui:px-4 ui:text-sm ui:font-medium ui:[&_svg:not([class*='ui:size-'])]:size-4 ui:[&_svg]:pointer-events-none", className ) }); diff --git a/packages/ui/src/base/button-group/button-group.svelte b/packages/ui/src/base/button-group/button-group.svelte index d934ac66..9516d3cd 100644 --- a/packages/ui/src/base/button-group/button-group.svelte +++ b/packages/ui/src/base/button-group/button-group.svelte @@ -2,7 +2,7 @@ import { tv, type VariantProps } from 'tailwind-variants'; export const buttonGroupVariants = tv({ - base: "ui:flex ui:w-fit ui:items-stretch ui:has-[>[data-slot=button-group]]:gap-2 ui:[&>*]:focus-visible:relative ui:[&>*]:focus-visible:z-10 ui:has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-e-md ui:[&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit ui:[&>input]:flex-1", + base: "ui:flex ui:w-fit ui:items-stretch ui:has-[>[data-slot=button-group]]:gap-2 ui:[&>*]:focus-visible:relative ui:[&>*]:focus-visible:z-10 ui:has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-e-md ui:[&>[data-slot=select-trigger]:not([class*='ui:w-'])]:w-fit ui:[&>input]:flex-1", variants: { orientation: { horizontal: diff --git a/packages/ui/src/base/button/button.svelte b/packages/ui/src/base/button/button.svelte index 8fd12a4b..ca6d24ca 100644 --- a/packages/ui/src/base/button/button.svelte +++ b/packages/ui/src/base/button/button.svelte @@ -4,16 +4,21 @@ import { type VariantProps, tv } from 'tailwind-variants'; export const buttonVariants = tv({ - base: "ui:aria-invalid:ring-destructive/20 ui:dark:aria-invalid:ring-destructive/40 ui:aria-invalid:border-destructive ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:relative ui:inline-flex ui:shrink-0 ui:items-center ui:justify-center ui:gap-2 ui:overflow-hidden ui:rounded-md ui:text-sm ui:font-medium ui:whitespace-nowrap ui:outline-hidden ui:transition-all ui:select-none ui:focus-visible:ring-[3px] ui:disabled:pointer-events-none ui:disabled:opacity-50 ui:[&_svg]:pointer-events-none ui:[&_svg]:shrink-0 ui:[&_svg:not([class*='size-'])]:size-4 ui:cursor-pointer", + base: "ui:aria-invalid:ring-destructive/20 ui:dark:aria-invalid:ring-destructive/40 ui:aria-invalid:border-destructive ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:relative ui:inline-flex ui:shrink-0 ui:items-center ui:justify-center ui:gap-2 ui:overflow-hidden ui:rounded-md ui:text-sm ui:font-medium ui:whitespace-nowrap ui:outline-hidden ui:transition-all ui:select-none ui:focus-visible:ring-[3px] ui:disabled:pointer-events-none ui:disabled:cursor-not-allowed ui:disabled:opacity-50 ui:[&_svg]:pointer-events-none ui:[&_svg]:shrink-0 ui:[&_svg:not([class*='ui:size-'])]:size-4 ui:cursor-pointer", variants: { variant: { default: 'ui:bg-primary ui:text-primary-foreground ui:[a&]:hover:bg-primary/90 ui:shadow-2xs', + 'light-default': 'ui:bg-primary/20 ui:text-primary ui:[a&]:hover:bg-primary/30 ui:shadow-2xs', destructive: 'ui:bg-destructive ui:[a&]:hover:bg-destructive/90 ui:focus-visible:ring-destructive/20 ui:dark:bg-destructive/60 ui:dark:focus-visible:ring-destructive/40 ui:text-white ui:shadow-2xs', outline: 'ui:bg-background ui:[a&]:hover:bg-accent ui:[a&]:hover:text-accent-foreground ui:dark:border-input ui:dark:bg-input/30 ui:dark:hover:bg-input/50 ui:border ui:shadow-2xs', secondary: 'ui:bg-secondary ui:text-secondary-foreground ui:[a&]:hover:bg-secondary/80 ui:shadow-2xs', ghost: 'ui:[a&]:hover:bg-accent ui:[a&]:hover:text-accent-foreground ui:dark:[a&]:hover:bg-accent/50', + 'ghost-default': + 'ui:text-primary ui:[a&]:hover:bg-accent ui:[a&]:hover:text-primary ui:dark:[a&]:hover:bg-accent/50', + 'ghost-outline': + 'ui:bg-background ui:[a&]:hover:bg-accent ui:[a&]:hover:text-accent-foreground ui:dark:hover:border-input ui:dark:bg-input/30 ui:dark:hover:bg-input/50 ui:hover:border ui:hover:shadow-2xs', link: 'ui:text-primary ui:underline-offset-4 ui:[a&]:hover:underline' }, size: { @@ -21,6 +26,8 @@ sm: 'ui:h-8 ui:gap-1.5 ui:rounded-md ui:px-3 ui:has-[>svg]:px-2.5', lg: 'ui:h-10 ui:rounded-md ui:px-6 ui:has-[>svg]:px-4', icon: 'ui:size-9', + 'icon-2xs': 'ui:size-4', + 'icon-xs': `ui:size-6`, 'icon-sm': 'ui:size-8', 'icon-lg': 'ui:size-10' } @@ -65,7 +72,7 @@ + +{#if items.length > 0} +
+ {#each items as item (item.label)} +
+ + + {item.label} + {#if showValue && item.value !== undefined} + ({item.value}) + {/if} + +
+ {/each} +
+{/if} diff --git a/packages/ui/src/base/chart/chart-style.svelte b/packages/ui/src/base/chart/chart-style.svelte index 41e6ecb2..a2b8a76f 100644 --- a/packages/ui/src/base/chart/chart-style.svelte +++ b/packages/ui/src/base/chart/chart-style.svelte @@ -1,5 +1,5 @@ {#if type === 'file'} diff --git a/packages/ui/src/base/item/index.ts b/packages/ui/src/base/item/index.ts index eb2d4ce7..7cd3d65d 100644 --- a/packages/ui/src/base/item/index.ts +++ b/packages/ui/src/base/item/index.ts @@ -1,13 +1,14 @@ -import Root from './item.svelte'; -import Group from './item-group.svelte'; -import Separator from './item-separator.svelte'; -import Header from './item-header.svelte'; -import Footer from './item-footer.svelte'; -import Content from './item-content.svelte'; -import Title from './item-title.svelte'; -import Description from './item-description.svelte'; import Actions from './item-actions.svelte'; +import Content from './item-content.svelte'; +import Description from './item-description.svelte'; +import Footer from './item-footer.svelte'; +import Group from './item-group.svelte'; +import Header from './item-header.svelte'; import Media from './item-media.svelte'; +import Root from './item.svelte'; +import Separator from './item-separator.svelte'; +import SubDescription from './item-sub-description.svelte'; +import Title from './item-title.svelte'; export { Root, @@ -18,6 +19,7 @@ export { Content, Title, Description, + SubDescription, Actions, Media, // diff --git a/packages/ui/src/base/item/item-media.svelte b/packages/ui/src/base/item/item-media.svelte index 009bcea8..5d460337 100644 --- a/packages/ui/src/base/item/item-media.svelte +++ b/packages/ui/src/base/item/item-media.svelte @@ -6,7 +6,7 @@ variants: { variant: { default: 'ui:bg-transparent', - icon: "ui:bg-muted ui:size-8 ui:rounded-sm ui:border ui:[&_svg:not([class*='size-'])]:size-4", + icon: "ui:bg-muted ui:size-8 ui:rounded-sm ui:border ui:[&_svg:not([class*='ui:size-'])]:size-4", image: 'ui:size-10 ui:overflow-hidden ui:rounded-sm ui:[&_img]:size-full ui:[&_img]:object-cover' } }, diff --git a/packages/ui/src/base/item/item-sub-description.svelte b/packages/ui/src/base/item/item-sub-description.svelte new file mode 100644 index 00000000..fd81706e --- /dev/null +++ b/packages/ui/src/base/item/item-sub-description.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/packages/ui/src/base/navigation-menu/navigation-menu-content.svelte b/packages/ui/src/base/navigation-menu/navigation-menu-content.svelte index 0952daea..11b5c047 100644 --- a/packages/ui/src/base/navigation-menu/navigation-menu-content.svelte +++ b/packages/ui/src/base/navigation-menu/navigation-menu-content.svelte @@ -9,7 +9,7 @@ bind:ref data-slot="navigation-menu-content" class={cn( - 'ui:data-[motion^=from-]:animate-in ui:data-[motion^=to-]:animate-out ui:data-[motion^=from-]:fade-in ui:data-[motion^=to-]:fade-out ui:data-[motion=from-end]:slide-in-from-end-52 ui:data-[motion=from-start]:slide-in-from-start-52 ui:data-[motion=to-end]:slide-out-to-end-52 ui:data-[motion=to-start]:slide-out-to-start-52 ui:start-0 ui:top-0 ui:w-full md:ui:absolute md:ui:w-auto', + 'ui:data-[motion^=from-]:animate-in ui:data-[motion^=to-]:animate-out ui:data-[motion^=from-]:fade-in ui:data-[motion^=to-]:fade-out ui:data-[motion=from-end]:slide-in-from-end-52 ui:data-[motion=from-start]:slide-in-from-start-52 ui:data-[motion=to-end]:slide-out-to-end-52 ui:data-[motion=to-start]:slide-out-to-start-52 ui:start-0 ui:top-0 ui:w-full ui:md:ui:absolute ui:md:ui:w-auto', 'ui:group-data-[viewport=false]/navigation-menu:bg-popover ui:group-data-[viewport=false]/navigation-menu:text-popover-foreground ui:group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in ui:group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out ui:group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 ui:group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 ui:group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 ui:group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 ui:**:data-[slot=navigation-menu-link]:focus:ring-0 ui:**:data-[slot=navigation-menu-link]:focus:outline-none ui:group-data-[viewport=false]/navigation-menu:top-full ui:group-data-[viewport=false]/navigation-menu:mt-1.5 ui:group-data-[viewport=false]/navigation-menu:overflow-hidden ui:group-data-[viewport=false]/navigation-menu:rounded-md ui:group-data-[viewport=false]/navigation-menu:border ui:group-data-[viewport=false]/navigation-menu:shadow ui:group-data-[viewport=false]/navigation-menu:duration-200', className )} diff --git a/packages/ui/src/base/navigation-menu/navigation-menu-indicator.svelte b/packages/ui/src/base/navigation-menu/navigation-menu-indicator.svelte index 4e35f9d2..d4875f0b 100644 --- a/packages/ui/src/base/navigation-menu/navigation-menu-indicator.svelte +++ b/packages/ui/src/base/navigation-menu/navigation-menu-indicator.svelte @@ -9,7 +9,7 @@ bind:ref data-slot="navigation-menu-indicator" class={cn( - 'ui:data-[state=visible]:animate-in ui:data-[state=hidden]:animate-out ui:data-[state=hidden]:fade-out ui:data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden', + 'ui:data-[state=visible]:animate-in ui:data-[state=hidden]:animate-out ui:data-[state=hidden]:fade-out ui:data-[state=visible]:fade-in ui:top-full ui:z-[1] ui:flex ui:h-1.5 ui:items-end ui:justify-center ui:overflow-hidden', className )} {...restProps} diff --git a/packages/ui/src/base/navigation-menu/navigation-menu-viewport.svelte b/packages/ui/src/base/navigation-menu/navigation-menu-viewport.svelte index 1ee07e74..a1d1aada 100644 --- a/packages/ui/src/base/navigation-menu/navigation-menu-viewport.svelte +++ b/packages/ui/src/base/navigation-menu/navigation-menu-viewport.svelte @@ -10,7 +10,7 @@ bind:ref data-slot="navigation-menu-viewport" class={cn( - 'ui:origin-top-center ui:bg-popover ui:text-popover-foreground ui:data-[state=open]:animate-in ui:data-[state=closed]:animate-out ui:data-[state=closed]:zoom-out-95 ui:data-[state=open]:zoom-in-90 ui:relative ui:mt-1.5 ui:h-(--bits-navigation-menu-viewport-height) ui:w-full ui:overflow-hidden ui:rounded-md ui:border ui:shadow md:ui:w-[var(--bits-navigation-menu-viewport-width)]', + 'ui:origin-top-center ui:bg-popover ui:text-popover-foreground ui:data-[state=open]:animate-in ui:data-[state=closed]:animate-out ui:data-[state=closed]:zoom-out-95 ui:data-[state=open]:zoom-in-90 ui:relative ui:mt-1.5 ui:h-(--bits-navigation-menu-viewport-height) ui:w-full ui:overflow-hidden ui:rounded-md ui:border ui:shadow ui:md:ui:w-[var(--bits-navigation-menu-viewport-width)]', className )} {...restProps} diff --git a/packages/ui/src/base/page/page-header.svelte b/packages/ui/src/base/page/page-header.svelte index 24a20cda..3039e047 100644 --- a/packages/ui/src/base/page/page-header.svelte +++ b/packages/ui/src/base/page/page-header.svelte @@ -5,12 +5,20 @@ let { ref = $bindable(null), class: className, + isSticky = false, children, ...restProps - }: WithElementRef> = $props(); + }: WithElementRef> & { + isSticky?: boolean; + } = $props(); -
+
{@render children?.()}
diff --git a/packages/ui/src/base/password/password-input.svelte b/packages/ui/src/base/password/password-input.svelte index 6632c5ea..105944e5 100644 --- a/packages/ui/src/base/password/password-input.svelte +++ b/packages/ui/src/base/password/password-input.svelte @@ -24,7 +24,7 @@ const mergedProps = $derived(mergeProps(rest, state.props)); -
+
diff --git a/packages/ui/src/base/sheet/sheet-content.svelte b/packages/ui/src/base/sheet/sheet-content.svelte index f9944f36..ea6e8815 100644 --- a/packages/ui/src/base/sheet/sheet-content.svelte +++ b/packages/ui/src/base/sheet/sheet-content.svelte @@ -13,7 +13,7 @@ } }, defaultVariants: { - side: 'right' + side: 'ui:right' } }); @@ -22,7 +22,7 @@ - + diff --git a/packages/ui/src/base/toggle-group/index.ts b/packages/ui/src/base/toggle-group/index.ts new file mode 100644 index 00000000..b764668e --- /dev/null +++ b/packages/ui/src/base/toggle-group/index.ts @@ -0,0 +1,17 @@ +import Root from './toggle-group.svelte'; +import Item from './toggle-group-item.svelte'; +export { type ToggleGroupSpacing } from './toggle-group.svelte'; +export { + toggleGroupItemVariants, + type ToggleGroupItemSize, + type ToggleGroupItemVariant, + type ToggleGroupItemVariants +} from './toggle-group-item.svelte'; + +export { + Root, + Item, + // + Root as ToggleGroup, + Item as ToggleGroupItem +}; diff --git a/packages/ui/src/base/toggle-group/toggle-group-context.ts b/packages/ui/src/base/toggle-group/toggle-group-context.ts new file mode 100644 index 00000000..3f758978 --- /dev/null +++ b/packages/ui/src/base/toggle-group/toggle-group-context.ts @@ -0,0 +1,8 @@ +import type { ToggleGroupItemSize, ToggleGroupItemVariant } from './toggle-group-item.svelte'; + +export interface ToggleGroupContextValue { + getVariant: () => ToggleGroupItemVariant; + getSize: () => ToggleGroupItemSize; +} + +export const TOGGLE_GROUP_CONTEXT = Symbol('toggle-group-context'); diff --git a/packages/ui/src/base/toggle-group/toggle-group-item.svelte b/packages/ui/src/base/toggle-group/toggle-group-item.svelte new file mode 100644 index 00000000..f256fb42 --- /dev/null +++ b/packages/ui/src/base/toggle-group/toggle-group-item.svelte @@ -0,0 +1,59 @@ + + + + + diff --git a/packages/ui/src/base/toggle-group/toggle-group.svelte b/packages/ui/src/base/toggle-group/toggle-group.svelte new file mode 100644 index 00000000..16336525 --- /dev/null +++ b/packages/ui/src/base/toggle-group/toggle-group.svelte @@ -0,0 +1,54 @@ + + + + + diff --git a/packages/ui/src/base/toggle/toggle.svelte b/packages/ui/src/base/toggle/toggle.svelte index 871e0d39..6711cf7c 100644 --- a/packages/ui/src/base/toggle/toggle.svelte +++ b/packages/ui/src/base/toggle/toggle.svelte @@ -2,7 +2,7 @@ import { type VariantProps, tv } from 'tailwind-variants'; export const toggleVariants = tv({ - base: "ui:hover:bg-muted ui:hover:text-muted-foreground ui:data-[state=on]:bg-accent ui:data-[state=on]:text-accent-foreground ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:aria-invalid:ring-destructive/20 ui:dark:aria-invalid:ring-destructive/40 ui:aria-invalid:border-destructive ui:inline-flex ui:items-center ui:justify-center ui:gap-2 ui:rounded-md ui:text-sm ui:font-medium ui:whitespace-nowrap ui:transition-[color,box-shadow] ui:outline-none ui:focus-visible:ring-[3px] ui:disabled:pointer-events-none ui:disabled:opacity-50 ui:[&_svg]:pointer-events-none ui:[&_svg]:shrink-0 ui:[&_svg:not([class*='size-'])]:size-4", + base: "ui:hover:bg-muted ui:hover:text-muted-foreground ui:data-[state=on]:bg-accent ui:data-[state=on]:text-accent-foreground ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:aria-invalid:ring-destructive/20 ui:dark:aria-invalid:ring-destructive/40 ui:aria-invalid:border-destructive ui:inline-flex ui:items-center ui:justify-center ui:gap-2 ui:rounded-md ui:text-sm ui:font-medium ui:whitespace-nowrap ui:transition-[color,box-shadow] ui:outline-none ui:focus-visible:ring-[3px] ui:disabled:pointer-events-none ui:disabled:opacity-50 ui:[&_svg]:pointer-events-none ui:[&_svg]:shrink-0 ui:[&_svg:not([class*='ui:size-'])]:size-4", variants: { variant: { default: 'ui:bg-transparent', diff --git a/packages/ui/src/custom/checkbox-field/checkbox-field.svelte b/packages/ui/src/custom/checkbox-field/checkbox-field.svelte index 6c0c47f2..237ce14c 100644 --- a/packages/ui/src/custom/checkbox-field/checkbox-field.svelte +++ b/packages/ui/src/custom/checkbox-field/checkbox-field.svelte @@ -28,14 +28,20 @@ }: Props = $props(); -
+
{#if isEditable} -
- +
+
{:else} - {label} + {label} {/if} {@render children?.()} diff --git a/packages/ui/src/custom/chip/chip.svelte b/packages/ui/src/custom/chip/chip.svelte index efc344c2..c9377d67 100644 --- a/packages/ui/src/custom/chip/chip.svelte +++ b/packages/ui/src/custom/chip/chip.svelte @@ -10,6 +10,6 @@ let { value = 0, className = '' }: Props = $props(); - + {value} diff --git a/packages/ui/src/custom/code/code-block.svelte b/packages/ui/src/custom/code/code-block.svelte new file mode 100644 index 00000000..bea45de0 --- /dev/null +++ b/packages/ui/src/custom/code/code-block.svelte @@ -0,0 +1,33 @@ + + +
+ + +
diff --git a/packages/ui/src/custom/code/code-copy-button.svelte b/packages/ui/src/custom/code/code-copy-button.svelte new file mode 100644 index 00000000..6f701118 --- /dev/null +++ b/packages/ui/src/custom/code/code-copy-button.svelte @@ -0,0 +1,24 @@ + + + diff --git a/packages/ui/src/custom/code/code-file-header.svelte b/packages/ui/src/custom/code/code-file-header.svelte new file mode 100644 index 00000000..d9d70f73 --- /dev/null +++ b/packages/ui/src/custom/code/code-file-header.svelte @@ -0,0 +1,43 @@ + + + diff --git a/packages/ui/src/custom/code/code-overflow.svelte b/packages/ui/src/custom/code/code-overflow.svelte new file mode 100644 index 00000000..e4c4d918 --- /dev/null +++ b/packages/ui/src/custom/code/code-overflow.svelte @@ -0,0 +1,40 @@ + + +
+ {@render children?.()} + {#if collapsed} +
+ {/if} + {#if collapsed} + + {/if} +
diff --git a/packages/ui/src/custom/code/code-utils.svelte.ts b/packages/ui/src/custom/code/code-utils.svelte.ts new file mode 100644 index 00000000..08c61da1 --- /dev/null +++ b/packages/ui/src/custom/code/code-utils.svelte.ts @@ -0,0 +1,125 @@ +import { Context } from 'runed'; +import type { ReadableBoxedValues, WritableBoxedValues } from 'svelte-toolbelt'; +import type { CodeRootProps } from './types'; +import { highlighter } from './shiki'; +import DOMPurify from 'isomorphic-dompurify'; +import type { HighlighterCore } from 'shiki'; + +type CodeOverflowStateProps = WritableBoxedValues<{ + collapsed: boolean; +}>; + +class CodeOverflowState { + constructor(readonly opts: CodeOverflowStateProps) { + this.toggleCollapsed = this.toggleCollapsed.bind(this); + } + + toggleCollapsed() { + this.opts.collapsed.current = !this.opts.collapsed.current; + } + + get collapsed() { + return this.opts.collapsed.current; + } +} + +type CodeRootStateProps = ReadableBoxedValues<{ + code: string; + lang: NonNullable; + hideLines: boolean; + highlight: CodeRootProps['highlight']; +}>; + +class CodeRootState { + highlighter: HighlighterCore | null = $state(null); + + constructor( + readonly opts: CodeRootStateProps, + readonly overflow?: CodeOverflowState + ) { + highlighter.then((hl) => (this.highlighter = hl)); + } + + highlight(code: string) { + return this.highlighter?.codeToHtml(code, { + lang: this.opts.lang.current, + themes: { + light: 'github-light-default', + dark: 'github-dark-default' + }, + transformers: [ + { + pre: (el) => { + el.properties.style = ''; + + if (!this.opts.hideLines.current) { + el.properties.class += ' line-numbers'; + } + + return el; + }, + line: (node, line) => { + if (within(line, this.opts.highlight.current)) { + node.properties.class = node.properties.class + ' line--highlighted'; + } + + return node; + } + } + ] + }); + } + + get code() { + return this.opts.code.current; + } + + highlighted = $derived(DOMPurify.sanitize(this.highlight(this.code) ?? '')); +} + +function within(num: number, range: CodeRootProps['highlight']) { + if (!range) return false; + + let within = false; + + for (const r of range) { + if (typeof r === 'number') { + if (num === r) { + within = true; + break; + } + continue; + } + + if (r[0] <= num && num <= r[1]) { + within = true; + break; + } + } + + return within; +} + +class CodeCopyButtonState { + constructor(readonly root: CodeRootState) {} + + get code() { + return this.root.opts.code.current; + } +} + +const overflowCtx = new Context('code-overflow-state'); + +const ctx = new Context('code-root-state'); + +export function useCodeOverflow(props: CodeOverflowStateProps) { + return overflowCtx.set(new CodeOverflowState(props)); +} + +export function useCode(props: CodeRootStateProps) { + return ctx.set(new CodeRootState(props, overflowCtx.getOr(undefined))); +} + +export function useCodeCopyButton() { + return new CodeCopyButtonState(ctx.get()); +} diff --git a/packages/ui/src/custom/code/code.svelte b/packages/ui/src/custom/code/code.svelte new file mode 100644 index 00000000..b0185eae --- /dev/null +++ b/packages/ui/src/custom/code/code.svelte @@ -0,0 +1,101 @@ + + +
+ {@html codeState.highlighted} + {@render children?.()} +
+ + diff --git a/packages/ui/src/custom/code/index.ts b/packages/ui/src/custom/code/index.ts new file mode 100644 index 00000000..c227e31d --- /dev/null +++ b/packages/ui/src/custom/code/index.ts @@ -0,0 +1,32 @@ +import { tv, type VariantProps } from 'tailwind-variants'; +import Root from './code.svelte'; +import Overflow from './code-overflow.svelte'; +import CopyButton from './code-copy-button.svelte'; +import FileName from './code-file-header.svelte'; +import Block from './code-block.svelte'; +import type { CodeCopyButtonProps, CodeOverflowProps, CodeRootProps } from './types'; + +export { langFromFilename } from './lang-from-filename'; + +export const codeVariants = tv({ + base: 'ui:not-prose ui:relative ui:h-full ui:overflow-auto ui:rounded-lg ui:border', + variants: { + variant: { + default: 'ui:border-border ui:bg-card', + secondary: 'ui:bg-secondary/50 ui:border-transparent' + } + } +}); + +export type CodeVariant = VariantProps['variant']; + +export { + Root, + CopyButton, + Overflow, + FileName, + Block, + type CodeRootProps as RootProps, + type CodeCopyButtonProps as CopyButtonProps, + type CodeOverflowProps as OverflowProps +}; diff --git a/packages/ui/src/custom/code/lang-from-filename.ts b/packages/ui/src/custom/code/lang-from-filename.ts new file mode 100644 index 00000000..702d063a --- /dev/null +++ b/packages/ui/src/custom/code/lang-from-filename.ts @@ -0,0 +1,21 @@ +import type { SupportedLanguage } from './shiki'; + +const extToLang: Record = { + '.ts': 'typescript', + '.tsx': 'typescript', + '.js': 'javascript', + '.jsx': 'javascript', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.svelte': 'svelte', + '.json': 'json', + '.bash': 'bash', + '.sh': 'bash', + '.diff': 'diff' +}; + +/** Derive Shiki/Code language from a filename. Defaults to `typescript` if unknown. */ +export function langFromFilename(filename: string): SupportedLanguage { + const ext = filename.includes('.') ? filename.slice(filename.lastIndexOf('.')) : ''; + return extToLang[ext] ?? 'typescript'; +} diff --git a/packages/ui/src/custom/code/shiki.ts b/packages/ui/src/custom/code/shiki.ts new file mode 100644 index 00000000..401ccd6d --- /dev/null +++ b/packages/ui/src/custom/code/shiki.ts @@ -0,0 +1,22 @@ +// Follows the best practices established in https://shiki.matsu.io/guide/best-performance +import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'; +import { createHighlighterCore } from 'shiki/core'; + +const bundledLanguages = { + bash: () => import('@shikijs/langs/bash'), + diff: () => import('@shikijs/langs/diff'), + javascript: () => import('@shikijs/langs/javascript'), + json: () => import('@shikijs/langs/json'), + svelte: () => import('@shikijs/langs/svelte'), + typescript: () => import('@shikijs/langs/typescript') +}; + +/** The languages configured for the highlighter */ +export type SupportedLanguage = keyof typeof bundledLanguages; + +/** A preloaded highlighter instance. */ +export const highlighter = createHighlighterCore({ + themes: [import('@shikijs/themes/github-light-default'), import('@shikijs/themes/github-dark-default')], + langs: Object.entries(bundledLanguages).map(([_, lang]) => lang), + engine: createJavaScriptRegexEngine() +}); diff --git a/packages/ui/src/custom/code/types.ts b/packages/ui/src/custom/code/types.ts new file mode 100644 index 00000000..601da444 --- /dev/null +++ b/packages/ui/src/custom/code/types.ts @@ -0,0 +1,27 @@ +import type { WithChildren, WithoutChildren } from 'bits-ui'; +import type { CodeVariant } from './'; +import type { SupportedLanguage } from './shiki'; +import type { HTMLAttributes } from 'svelte/elements'; +import type { CopyButtonPropsWithoutHTML } from '../../base/copy-button/types'; + +export type CodeRootPropsWithoutHTML = WithChildren<{ + ref?: HTMLDivElement | null; + variant?: CodeVariant; + lang?: SupportedLanguage; + code: string; + class?: string; + hideLines?: boolean; + highlight?: (number | [number, number])[]; +}>; + +export type CodeRootProps = CodeRootPropsWithoutHTML & WithoutChildren>; + +export type CodeCopyButtonPropsWithoutHTML = Omit; + +export type CodeCopyButtonProps = CodeCopyButtonPropsWithoutHTML & WithoutChildren>; + +export type CodeOverflowPropsWithoutHTML = WithChildren<{ + collapsed?: boolean; +}>; + +export type CodeOverflowProps = CodeOverflowPropsWithoutHTML & WithoutChildren>; diff --git a/packages/ui/src/custom/document-card/document-card.svelte b/packages/ui/src/custom/document-card/document-card.svelte new file mode 100644 index 00000000..9c8dba0d --- /dev/null +++ b/packages/ui/src/custom/document-card/document-card.svelte @@ -0,0 +1,85 @@ + + + +
+ {#if header} + {@render header()} + {/if} + + + + + + + + + + {title} + + {#if subtitle} +

{subtitle}

+ {/if} + + {#if actions || fileUrl} +
+ {#if actions} + {@render actions()} + {:else if fileUrl} + + {viewLabel} + + + + {downloadLabel} + + {/if} +
+ {/if} +
+
+
diff --git a/packages/ui/src/custom/document-card/index.ts b/packages/ui/src/custom/document-card/index.ts new file mode 100644 index 00000000..b4e631b9 --- /dev/null +++ b/packages/ui/src/custom/document-card/index.ts @@ -0,0 +1 @@ +export { default as DocumentCard } from './document-card.svelte'; diff --git a/packages/ui/src/custom/domain-input/domain-input.svelte b/packages/ui/src/custom/domain-input/domain-input.svelte index 70a259d5..f932fe9a 100644 --- a/packages/ui/src/custom/domain-input/domain-input.svelte +++ b/packages/ui/src/custom/domain-input/domain-input.svelte @@ -23,7 +23,7 @@ }: Props & Omit, 'class' | 'children'> = $props(); - + {prefix} diff --git a/packages/ui/src/custom/editor/editor.css b/packages/ui/src/custom/editor/editor.css index e0ede8f6..617f2aeb 100644 --- a/packages/ui/src/custom/editor/editor.css +++ b/packages/ui/src/custom/editor/editor.css @@ -6,14 +6,18 @@ /* For Placeholder */ -.tiptap .is-empty:not(blockquote.is-empty)::before { +.tiptap > p.is-empty:only-child::before { pointer-events: none; float: left; height: 0; - color: var(--color-muted-foreground); + color: var(--muted-foreground); content: attr(data-placeholder); } +.tiptap.ProseMirror { + outline: none; +} + /* Heading Styles */ .tiptap h1, .tiptap h2, @@ -456,38 +460,38 @@ input[type='checkbox'] { /* Mathematics extension styles */ .tiptap .tiptap-mathematics-render { - padding: 0 0.25rem; - border-radius: 0.25rem; + padding: 0 0.25rem; + border-radius: 0.25rem; } /* Editable math block */ .tiptap .tiptap-mathematics-render--editable { - cursor: pointer; - transition: background 0.2s; + cursor: pointer; + transition: background 0.2s; } .tiptap .tiptap-mathematics-render--editable:hover { - background: var(--code-bg); + background: var(--code-bg); } /* Inline math */ .tiptap .tiptap-mathematics-render[data-type='inline-math'] { - display: inline-block; + display: inline-block; } /* Block math */ .tiptap .tiptap-mathematics-render[data-type='block-math'] { - display: block; - margin: 1rem 0; - padding: 0.5rem; - text-align: center; - font-size: 1.25rem; + display: block; + margin: 1rem 0; + padding: 0.5rem; + text-align: center; + font-size: 1.25rem; } /* Error styles */ .tiptap .tiptap-mathematics-render.inline-math-error, .tiptap .tiptap-mathematics-render.block-math-error { - background: var(--code-bg); - color: var(--color-destructive); - border: 1px solid darkred; + background: var(--code-bg); + color: var(--color-destructive); + border: 1px solid darkred; } diff --git a/packages/ui/src/custom/editor/editor.svelte b/packages/ui/src/custom/editor/editor.svelte index 66126a35..b10c4dc8 100644 --- a/packages/ui/src/custom/editor/editor.svelte +++ b/packages/ui/src/custom/editor/editor.svelte @@ -41,8 +41,7 @@ editorClass = '', placeholder, onContentChange, - onEditorReady, - onEditorDestroy + onEditorReady }: Props = $props(); let editor = $state(); @@ -86,9 +85,11 @@ } }); + let isEditorReady = $state(false); // Handle editor ready $effect(() => { - if (editor) { + if (editor && !isEditorReady) { + isEditorReady = true; onEditorReady?.(editor); } }); @@ -105,7 +106,7 @@ {#if browser}
@@ -120,10 +121,12 @@ {/if} - + {#if editable} + + {/if} {/if} { const newCalculation = prompt('Enter new calculation:', node.attrs.latex); if (newCalculation) { - editor - .chain() - .setNodeSelection(pos) - .updateBlockMath({ latex: newCalculation }) - .focus() - .run(); + editor.chain().setNodeSelection(pos).updateBlockMath({ latex: newCalculation }).focus().run(); } } }, @@ -103,12 +98,7 @@ export default ( onClick: (node, pos) => { const newCalculation = prompt('Enter new calculation:', node.attrs.latex); if (newCalculation) { - editor - .chain() - .setNodeSelection(pos) - .updateInlineMath({ latex: newCalculation }) - .focus() - .run(); + editor.chain().setNodeSelection(pos).updateInlineMath({ latex: newCalculation }).focus().run(); } } } diff --git a/packages/ui/src/custom/editor/extensions/InlineMathReplacer.ts b/packages/ui/src/custom/editor/extensions/InlineMathReplacer.ts index f4a08d06..ac13f256 100644 --- a/packages/ui/src/custom/editor/extensions/InlineMathReplacer.ts +++ b/packages/ui/src/custom/editor/extensions/InlineMathReplacer.ts @@ -2,20 +2,20 @@ import { textInputRule } from '@tiptap/core'; import { InlineMath } from '@tiptap/extension-mathematics'; export const InlineMathReplacer = InlineMath.extend({ - name: 'inlineMathReplacer', - addInputRules() { - return [ - textInputRule({ - find: /\$\$([^$]+)\$\$/, - replace: ({ match, commands }) => { - const latex = match[1]; - // Insert the inline math node with the LaTeX content - commands.insertInlineMath({ - latex - }); - return ''; - } - }) - ]; - } + name: 'inlineMathReplacer', + addInputRules() { + return [ + textInputRule({ + find: /\$\$([^$]+)\$\$/, + replace: ({ match, commands }) => { + const latex = match[1]; + // Insert the inline math node with the LaTeX content + commands.insertInlineMath({ + latex + }); + return ''; + } + }) + ]; + } }); diff --git a/packages/ui/src/custom/editor/extensions/drag-handle/index.ts b/packages/ui/src/custom/editor/extensions/drag-handle/index.ts index f9a74168..414a828c 100644 --- a/packages/ui/src/custom/editor/extensions/drag-handle/index.ts +++ b/packages/ui/src/custom/editor/extensions/drag-handle/index.ts @@ -43,7 +43,7 @@ function absoluteRect(node: Element) { const data = node.getBoundingClientRect(); const modal = node.closest('[role="dialog"]'); - if (modal && window.getComputedStyle(modal).transform !== 'none') { + if (modal) { const modalRect = modal.getBoundingClientRect(); return { diff --git a/packages/ui/src/custom/editor/ui/components/CodeBlock.svelte b/packages/ui/src/custom/editor/ui/components/CodeBlock.svelte index b34e1cf0..a06b8c60 100644 --- a/packages/ui/src/custom/editor/ui/components/CodeBlock.svelte +++ b/packages/ui/src/custom/editor/ui/components/CodeBlock.svelte @@ -12,14 +12,17 @@ let isCopying = $state(false); - const languages: string[] = extension.options.lowlight.listLanguages().sort(); + const languages = $derived.by(() => extension.options.lowlight.listLanguages().sort()); - let defaultLanguage = $state(node.attrs.language); + let defaultLanguage = $derived(node.attrs.language); function copyCode() { if (!preRef) return; + isCopying = true; + navigator.clipboard.writeText(preRef.innerText); + setTimeout(() => { isCopying = false; }, 1000); diff --git a/packages/ui/src/custom/editor/ui/components/MediaExtended.svelte b/packages/ui/src/custom/editor/ui/components/MediaExtended.svelte index 43c13c06..566bddc1 100644 --- a/packages/ui/src/custom/editor/ui/components/MediaExtended.svelte +++ b/packages/ui/src/custom/editor/ui/components/MediaExtended.svelte @@ -167,7 +167,7 @@ }} >
@@ -185,7 +185,7 @@ }} >
- import { onDestroy, onMount } from 'svelte'; + import { onDestroy, onMount, untrack } from 'svelte'; import type { EdraEditorProps } from '../types'; import initEditor from '../editor'; import { focusEditor } from '../utils'; @@ -71,17 +71,15 @@ AudioExtended(AudioExtendedComp), IFramePlaceholder(IFramePlaceHolderComp), IFrameExtended(IFrameExtendedComp), - slashcommand(SlashCommandList), - Placeholder.configure({ - placeholder - }) + slashcommand(SlashCommandList) ], { onUpdate, - onTransaction(props) { - editor = undefined; - editor = props.editor; - }, + // onTransaction(props) { + // console.log('on transaction', props); + // editor = undefined; + // editor = props.editor; + // }, editable, autofocus }, diff --git a/packages/ui/src/custom/editor/ui/menus/Menu.svelte b/packages/ui/src/custom/editor/ui/menus/Menu.svelte index dc69ea16..67573261 100644 --- a/packages/ui/src/custom/editor/ui/menus/Menu.svelte +++ b/packages/ui/src/custom/editor/ui/menus/Menu.svelte @@ -22,17 +22,28 @@ let isDragging = $state(false); - editor.view.dom.addEventListener('dragstart', () => { + const handleDragStart = () => { isDragging = true; - }); - - editor.view.dom.addEventListener('drop', () => { + }; + const handleDrop = () => { isDragging = true; // Allow some time for the drop action to complete before re-enabling setTimeout(() => { isDragging = false; }, 100); // Adjust delay if needed + }; + + $effect(() => { + const editorInstance = editor; + + editorInstance.view.dom.addEventListener('dragstart', handleDragStart); + editorInstance.view.dom.addEventListener('drop', handleDrop); + + return () => { + editorInstance.view.dom.removeEventListener('dragstart', handleDragStart); + editorInstance.view.dom.removeEventListener('drop', handleDrop); + }; }); function shouldShow(props: ShouldShowProps) { diff --git a/packages/ui/src/custom/empty/empty.svelte b/packages/ui/src/custom/empty/empty.svelte index bf6c55eb..2e6b6c29 100644 --- a/packages/ui/src/custom/empty/empty.svelte +++ b/packages/ui/src/custom/empty/empty.svelte @@ -11,7 +11,7 @@ variants: { variant: { default: '', - page: 'ui:h-full ui:border ui:border-dashed ui:max-w-3xl ui:mx-auto' + page: 'ui:h-full ui:border ui:border-dashed ui:max-w-3xl ui:mx-auto' } } }); @@ -22,6 +22,7 @@ title?: string; description?: string; icon?: Component; + iconClass?: string; class?: string; children?: Snippet; variant?: EmptyVariant; @@ -33,6 +34,7 @@ title, description, icon: IconComponent, + iconClass = '', class: className = '', children, variant = 'default', @@ -55,7 +57,7 @@ {#if IconComponent} {@const Icon = IconComponent} - + {/if} {#if title} diff --git a/packages/ui/src/custom/exercise-question/index.ts b/packages/ui/src/custom/exercise-question/index.ts new file mode 100644 index 00000000..e6f30468 --- /dev/null +++ b/packages/ui/src/custom/exercise-question/index.ts @@ -0,0 +1,56 @@ +export { default as QuestionFormRoot } from './question-form-root.svelte'; +export { default as QuestionNavigation } from './question-navigation.svelte'; +export { default as QuestionRenderer } from './question-renderer.svelte'; +export { default as QuestionList } from './question-list.svelte'; +export { default as QuestionSurface } from './question-surface.svelte'; +export { default as QuestionTitle } from './question-title.svelte'; +export * from './renderer-contract'; + +export { default as TrueFalseEdit } from './renderers/true-false/edit.svelte'; +export { default as ShortAnswerEdit } from './renderers/short-answer/edit.svelte'; +export { default as NumericEdit } from './renderers/numeric/edit.svelte'; +export { default as FillBlankEdit } from './renderers/fill-blank/edit.svelte'; +export { default as FileUploadEdit } from './renderers/file-upload/edit.svelte'; +export { default as RadioEdit } from './renderers/radio/edit.svelte'; +export { default as CheckboxEdit } from './renderers/checkbox/edit.svelte'; +export { default as TextareaEdit } from './renderers/textarea/edit.svelte'; +export { default as MatchingEdit } from './renderers/matching/edit.svelte'; +export { default as OrderingEdit } from './renderers/ordering/edit.svelte'; +export { default as HotspotEdit } from './renderers/hotspot/edit.svelte'; + +export { default as TrueFalseTake } from './renderers/true-false/take.svelte'; +export { default as ShortAnswerTake } from './renderers/short-answer/take.svelte'; +export { default as NumericTake } from './renderers/numeric/take.svelte'; +export { default as FillBlankTake } from './renderers/fill-blank/take.svelte'; +export { default as FileUploadTake } from './renderers/file-upload/take.svelte'; +export { default as RadioTake } from './renderers/radio/take.svelte'; +export { default as CheckboxTake } from './renderers/checkbox/take.svelte'; +export { default as TextareaTake } from './renderers/textarea/take.svelte'; +export { default as MatchingTake } from './renderers/matching/take.svelte'; +export { default as OrderingTake } from './renderers/ordering/take.svelte'; +export { default as HotspotTake } from './renderers/hotspot/take.svelte'; + +export { default as TrueFalsePreview } from './renderers/true-false/preview.svelte'; +export { default as ShortAnswerPreview } from './renderers/short-answer/preview.svelte'; +export { default as NumericPreview } from './renderers/numeric/preview.svelte'; +export { default as FillBlankPreview } from './renderers/fill-blank/preview.svelte'; +export { default as FileUploadPreview } from './renderers/file-upload/preview.svelte'; +export { default as RadioPreview } from './renderers/radio/preview.svelte'; +export { default as CheckboxPreview } from './renderers/checkbox/preview.svelte'; +export { default as TextareaPreview } from './renderers/textarea/preview.svelte'; +export { default as MatchingPreview } from './renderers/matching/preview.svelte'; +export { default as OrderingPreview } from './renderers/ordering/preview.svelte'; +export { default as HotspotPreview } from './renderers/hotspot/preview.svelte'; + +export { default as TrueFalseSubmission } from './renderers/true-false/submission.svelte'; +export { default as ShortAnswerSubmission } from './renderers/short-answer/submission.svelte'; +export { default as NumericSubmission } from './renderers/numeric/submission.svelte'; +export { default as FillBlankSubmission } from './renderers/fill-blank/submission.svelte'; +export { default as FileUploadSubmission } from './renderers/file-upload/submission.svelte'; +export { default as RadioSubmission } from './renderers/radio/submission.svelte'; +export { default as CheckboxSubmission } from './renderers/checkbox/submission.svelte'; +export { default as TextareaSubmission } from './renderers/textarea/submission.svelte'; +export { default as MatchingSubmission } from './renderers/matching/submission.svelte'; +export { default as OrderingSubmission } from './renderers/ordering/submission.svelte'; +export { default as HotspotSubmission } from './renderers/hotspot/submission.svelte'; +export { default as LinkSubmission } from './renderers/link/submission.svelte'; diff --git a/packages/ui/src/custom/exercise-question/question-form-root.svelte b/packages/ui/src/custom/exercise-question/question-form-root.svelte new file mode 100644 index 00000000..4a16e1b3 --- /dev/null +++ b/packages/ui/src/custom/exercise-question/question-form-root.svelte @@ -0,0 +1,26 @@ + + +
+ {#if title} +
+

{title}

+ {#if description} +

{description}

+ {/if} +
+ {/if} + +
+ {@render children?.()} +
+
diff --git a/packages/ui/src/custom/exercise-question/question-list.svelte b/packages/ui/src/custom/exercise-question/question-list.svelte new file mode 100644 index 00000000..2681b276 --- /dev/null +++ b/packages/ui/src/custom/exercise-question/question-list.svelte @@ -0,0 +1,43 @@ + + +{#each contract.questions as question, index (getExerciseQuestionContractKey(question, index))} + {@const questionKey = getExerciseQuestionContractKey(question, index)} +
+ onAnswerChange(question, answer)} + onQuestionChange={(updatedQuestion) => onQuestionChange(question, updatedQuestion)} + {showContainer} + /> +
+{/each} diff --git a/packages/ui/src/custom/exercise-question/question-navigation.svelte b/packages/ui/src/custom/exercise-question/question-navigation.svelte new file mode 100644 index 00000000..d5c05c8b --- /dev/null +++ b/packages/ui/src/custom/exercise-question/question-navigation.svelte @@ -0,0 +1,42 @@ + + +
+ + +
diff --git a/packages/ui/src/custom/exercise-question/question-renderer.svelte b/packages/ui/src/custom/exercise-question/question-renderer.svelte new file mode 100644 index 00000000..c878745e --- /dev/null +++ b/packages/ui/src/custom/exercise-question/question-renderer.svelte @@ -0,0 +1,477 @@ + + +{#snippet content()} + {#if contract.mode === 'edit'} +
+
+
+

{label('question.edit.title_label')}

+ patchQuestion({ title: event.currentTarget.value })} + /> +
+ + + + {#snippet child({ props })} + + + {label('question.edit.media_menu_sr')} + + {/snippet} + + + + {hasDescription ? label('question.edit.remove_description') : label('question.edit.add_description')} + + + {label('question.edit.add_image')} + + + {label('question.edit.add_video')} + + + + {#if questionTypeSelect} + {@render questionTypeSelect()} + {/if} +
+ + {#if hasDescription} +
+

{label('question.edit.description_label')}

+ +{/if} diff --git a/packages/ui/src/custom/file-drop-zone/file-drop-zone-trigger.svelte b/packages/ui/src/custom/file-drop-zone/file-drop-zone-trigger.svelte new file mode 100644 index 00000000..18ed876c --- /dev/null +++ b/packages/ui/src/custom/file-drop-zone/file-drop-zone-trigger.svelte @@ -0,0 +1,64 @@ + + + diff --git a/packages/ui/src/custom/file-drop-zone/file-drop-zone-util.svelte.ts b/packages/ui/src/custom/file-drop-zone/file-drop-zone-util.svelte.ts new file mode 100644 index 00000000..e20debbb --- /dev/null +++ b/packages/ui/src/custom/file-drop-zone/file-drop-zone-util.svelte.ts @@ -0,0 +1,220 @@ +import type { ReadableBoxedValues } from 'svelte-toolbelt'; +import type { FileRejectedReason } from './types'; +import { Context } from 'runed'; +import type { HTMLAttributes } from 'svelte/elements'; + +type FileDropZoneStateOptions = ReadableBoxedValues<{ + id: string; + disabled: boolean; + onUpload: (files: File[]) => Promise; + maxFiles: number | undefined; + fileCount: number | undefined; + maxFileSize: number | undefined; + onFileRejected: ((opts: { reason: FileRejectedReason; file: File }) => void) | undefined; + accept: string | undefined; +}>; + +class FileDropZoneState { + uploading = $state(false); + + constructor(readonly opts: FileDropZoneStateOptions) { + if (this.opts.maxFiles !== undefined && this.opts.fileCount === undefined) { + console.warn('Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt'); + } + + this.onchange = this.onchange.bind(this); + this.ondrop = this.ondrop.bind(this); + } + + async ondrop( + e: DragEvent & { + currentTarget: EventTarget; + } + ) { + if (this.opts.disabled.current || !this.canUploadFiles) return; + + e.preventDefault(); + + const droppedFiles = Array.from(e.dataTransfer?.files ?? []); + + await this.upload(droppedFiles); + } + + async onchange( + e: Event & { + currentTarget: EventTarget & HTMLInputElement; + } + ) { + if (this.opts.disabled.current) return; + + const selectedFiles = e.currentTarget.files; + + if (!selectedFiles) return; + + await this.upload(Array.from(selectedFiles)); + + // this if a file fails and we upload the same file again we still get feedback + (e.target as HTMLInputElement).value = ''; + } + + shouldAcceptFile(file: File, fileNumber: number): FileRejectedReason | undefined { + if (this.opts.maxFileSize.current !== undefined && file.size > this.opts.maxFileSize.current) + return 'Maximum file size exceeded'; + + if (this.opts.maxFiles.current !== undefined && fileNumber > this.opts.maxFiles.current) + return 'Maximum files uploaded'; + + if (!this.opts.accept.current) return undefined; + + const acceptedTypes = this.opts.accept.current.split(',').map((a) => a.trim().toLowerCase()); + const fileType = file.type.toLowerCase(); + const fileName = file.name.toLowerCase(); + + const isAcceptable = acceptedTypes.some((pattern) => { + // check extension like .mp4 + if (fileType === '' || pattern.startsWith('.')) { + return fileName.endsWith(pattern); + } + + // if pattern has wild card like video/* + if (pattern.endsWith('/*')) { + const baseType = pattern.slice(0, pattern.indexOf('/*')); + return fileType.startsWith(baseType + '/'); + } + + // otherwise it must be a specific type like video/mp4 + return fileType === pattern; + }); + + if (!isAcceptable) return 'File type not allowed'; + + return undefined; + } + + upload = async (uploadFiles: File[]) => { + this.uploading = true; + + const validFiles: File[] = []; + + for (let i = 0; i < uploadFiles.length; i++) { + const file = uploadFiles[i]; + + const rejectedReason = this.shouldAcceptFile(file, (this.opts.fileCount?.current ?? 0) + i + 1); + + if (rejectedReason) { + this.opts.onFileRejected.current?.({ file, reason: rejectedReason }); + continue; + } + + validFiles.push(file); + } + + await this.opts.onUpload.current?.(validFiles); + + this.uploading = false; + }; + + canUploadFiles = $derived.by(() => { + if (this.opts.disabled.current) return false; + if (this.uploading) return false; + if ( + this.opts.maxFiles.current !== undefined && + this.opts.fileCount.current !== undefined && + this.opts.fileCount.current >= this.opts.maxFiles.current + ) + return false; + return true; + }); + + props = $derived.by(() => ({ + disabled: !this.canUploadFiles, + id: this.opts.id.current, + accept: this.opts.accept.current, + multiple: + this.opts.maxFiles.current === undefined || this.opts.maxFiles.current - (this.opts.fileCount.current ?? 0) > 1, + type: 'file', + onchange: this.onchange + })); +} + +class FileDropZoneTrigger { + constructor(readonly rootState: FileDropZoneState) {} + + ondragover(e: DragEvent) { + e.preventDefault(); + } + + ondrop( + e: DragEvent & { + currentTarget: EventTarget & HTMLLabelElement; + } + ) { + this.rootState.ondrop(e); + } + + props = $derived.by(() => ({ + ondragover: this.ondragover.bind(this), + ondrop: this.ondrop.bind(this), + for: this.rootState.opts.id.current, + 'aria-disabled': !this.rootState.canUploadFiles + })); +} + +type FileDropZoneTextareaOptions = ReadableBoxedValues<{ + ondragover: HTMLAttributes['ondragover']; + ondrop: HTMLAttributes['ondrop']; + onpaste: HTMLAttributes['onpaste']; +}>; + +class FileDropZoneTextareaState { + constructor( + readonly opts: FileDropZoneTextareaOptions, + readonly rootState: FileDropZoneState + ) {} + + ondragover(e: Parameters['ondragover']>>[0]) { + e.preventDefault(); + this.opts.ondragover.current?.(e); + } + + ondrop(e: Parameters['ondrop']>>[0]) { + this.rootState.ondrop(e); + this.opts.ondrop.current?.(e); + } + + onpaste(e: Parameters['onpaste']>>[0]) { + const clipboardData = e.clipboardData; + if (!clipboardData) { + this.opts.onpaste.current?.(e); + return; + } + + const files = Array.from(clipboardData.items) + .map((item) => item.getAsFile()) + .filter((file): file is File => file !== null); + + this.rootState.upload(files); + + this.opts.onpaste.current?.(e); + } + + props = $derived.by(() => ({ + ondragover: this.ondragover.bind(this), + ondrop: this.ondrop.bind(this), + onpaste: this.onpaste.bind(this) + })); +} + +const ctx = new Context('file-drop-zone-state'); + +export function useFileDropZone(opts: FileDropZoneStateOptions) { + return ctx.set(new FileDropZoneState(opts)); +} + +export function useFileDropZoneTrigger() { + return new FileDropZoneTrigger(ctx.get()); +} + +export function useFileDropZoneTextarea(opts: FileDropZoneTextareaOptions) { + return new FileDropZoneTextareaState(opts, ctx.get()); +} diff --git a/packages/ui/src/custom/file-drop-zone/file-drop-zone.svelte b/packages/ui/src/custom/file-drop-zone/file-drop-zone.svelte new file mode 100644 index 00000000..3ffeb41a --- /dev/null +++ b/packages/ui/src/custom/file-drop-zone/file-drop-zone.svelte @@ -0,0 +1,34 @@ + + + + +{@render children?.()} diff --git a/packages/ui/src/custom/file-drop-zone/index.ts b/packages/ui/src/custom/file-drop-zone/index.ts new file mode 100644 index 00000000..d4622c70 --- /dev/null +++ b/packages/ui/src/custom/file-drop-zone/index.ts @@ -0,0 +1,35 @@ +import Root from './file-drop-zone.svelte'; +import Trigger from './file-drop-zone-trigger.svelte'; +import Textarea from './file-drop-zone-textarea.svelte'; +import type { FileDropZoneRootProps, FileDropZoneTriggerTextProps, FileRejectedReason } from './types'; + +export function displaySize(bytes: number): string { + if (bytes < KILOBYTE) return `${bytes.toFixed(0)} B`; + + if (bytes < MEGABYTE) return `${(bytes / KILOBYTE).toFixed(0)} KB`; + + if (bytes < GIGABYTE) return `${(bytes / MEGABYTE).toFixed(0)} MB`; + + return `${(bytes / GIGABYTE).toFixed(0)} GB`; +} + +// Utilities for working with file sizes +export const BYTE = 1; +export const KILOBYTE = 1000; +export const MEGABYTE = 1000 * KILOBYTE; +export const GIGABYTE = 1000 * MEGABYTE; + +// utilities for limiting accepted files +export const ACCEPT_IMAGE = 'image/*'; +export const ACCEPT_VIDEO = 'video/*'; +export const ACCEPT_AUDIO = 'audio/*'; + +export { + Root, + Trigger, + Textarea, + type FileDropZoneRootProps, + type FileDropZoneTriggerTextProps, + type FileRejectedReason +}; +export { Root as FileDropZone, Trigger as FileDropZoneTrigger, Textarea as FileDropZoneTextarea }; diff --git a/packages/ui/src/custom/file-drop-zone/types.ts b/packages/ui/src/custom/file-drop-zone/types.ts new file mode 100644 index 00000000..73104ff7 --- /dev/null +++ b/packages/ui/src/custom/file-drop-zone/types.ts @@ -0,0 +1,63 @@ +import type { WithChildren } from 'bits-ui'; +import type { WithElementRef } from 'svelte-toolbelt'; +import type { HTMLInputAttributes, HTMLLabelAttributes } from 'svelte/elements'; + +export type FileRejectedReason = 'Maximum file size exceeded' | 'File type not allowed' | 'Maximum files uploaded'; + +export type FileDropZoneRootPropsWithoutHTML = WithChildren<{ + ref?: HTMLInputElement | null; + id?: string; + /** Called with the uploaded files when the user drops or clicks and selects their files. + * + * @param files + */ + onUpload: (files: File[]) => Promise; + /** The maximum amount files allowed to be uploaded */ + maxFiles?: number; + fileCount?: number; + /** The maximum size of a file in bytes */ + maxFileSize?: number; + /** Called when a file does not meet the upload criteria (size, or type) */ + onFileRejected?: (opts: { reason: FileRejectedReason; file: File }) => void; + + // just for extra documentation + /** Takes a comma separated list of one or more file types. + * + * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept) + * + * ### Usage + * ```svelte + * + * ``` + * + * ### Common Values + * ```svelte + * + * + * + * ``` + */ + accept?: string; +}>; + +export type FileDropZoneRootProps = FileDropZoneRootPropsWithoutHTML & + Omit; + +/** Text props for the default trigger UI. Pass translated strings from the app (e.g. via `$t()`). */ +export type FileDropZoneTriggerTextProps = { + /** Main instruction (e.g. "Drag 'n' drop files here, or click to select files") */ + label?: string; + /** Format "You can upload N files" – receive translated string for the given count */ + formatMaxFiles?: (count: number) => string; + /** Format "(up to SIZE each)" when both maxFiles and maxFileSize apply – SIZE is already formatted (e.g. "5 MB") */ + formatMaxFilesAndSize?: (size: string) => string; + /** Format "Maximum size SIZE" when only maxFileSize applies – SIZE is already formatted */ + formatMaxSize?: (size: string) => string; +}; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export type FileDropZoneTriggerPropsWithoutHTML = WithChildren>; + +export type FileDropZoneTriggerProps = FileDropZoneTriggerPropsWithoutHTML & Omit; diff --git a/packages/ui/src/custom/icon-button/icon-button.svelte b/packages/ui/src/custom/icon-button/icon-button.svelte index de17b589..436b365c 100644 --- a/packages/ui/src/custom/icon-button/icon-button.svelte +++ b/packages/ui/src/custom/icon-button/icon-button.svelte @@ -1,20 +1,20 @@ {#if tooltip} - + diff --git a/packages/ui/src/custom/image-cropper/image-cropper-dialog.svelte b/packages/ui/src/custom/image-cropper/image-cropper-dialog.svelte index 7bbd517f..c948fb3c 100644 --- a/packages/ui/src/custom/image-cropper/image-cropper-dialog.svelte +++ b/packages/ui/src/custom/image-cropper/image-cropper-dialog.svelte @@ -14,7 +14,7 @@ {...rest} showCloseButton class={cn( - 'ui:min-h-96 ui:max-w-[60%] ui:rounded-none ui:border-x-0 sm:ui:max-w-lg sm:ui:rounded-lg sm:ui:border-x', + 'ui:min-h-96 ui:max-w-[60%] ui:rounded-none ui:border-x-0 ui:sm:ui:max-w-lg ui:sm:ui:rounded-lg ui:sm:ui:border-x', className )} > diff --git a/packages/ui/src/custom/input-field/input-field.svelte b/packages/ui/src/custom/input-field/input-field.svelte index 47df7426..42f3f69c 100644 --- a/packages/ui/src/custom/input-field/input-field.svelte +++ b/packages/ui/src/custom/input-field/input-field.svelte @@ -1,7 +1,9 @@ @@ -69,12 +73,13 @@ {label} {#if isRequired} - * + * {/if} {/if} + import type { MediaPlayerOptions, VideoSource } from './types'; + import PlyrPlayer from './players/plyr-player.svelte'; + import MusePlayer from './players/muse-player.svelte'; + + interface Props { + source: VideoSource; + options?: MediaPlayerOptions; + class?: string; + } + + let { source, options = {}, class: className = '' }: Props = $props(); + + const isMuse = $derived.by(() => source.type === 'muse' && source.metadata?.svid); + const poster = $derived(source.type === 'upload' ? source.metadata?.thumbnailUrl : undefined); + + +
+ {#if isMuse} + + {:else} + + {/if} +
diff --git a/packages/ui/src/custom/media-player/players/constants.ts b/packages/ui/src/custom/media-player/players/constants.ts new file mode 100644 index 00000000..4d90dc4a --- /dev/null +++ b/packages/ui/src/custom/media-player/players/constants.ts @@ -0,0 +1,13 @@ +export const PLYR_DEFAULT_CONTROLS: string[] = [ + 'play-large', + 'play', + 'progress', + 'current-time', + 'mute', + 'volume', + 'captions', + 'settings', + 'pip', + 'airplay', + 'fullscreen' +]; diff --git a/packages/ui/src/custom/media-player/players/muse-player.svelte b/packages/ui/src/custom/media-player/players/muse-player.svelte new file mode 100644 index 00000000..48681e39 --- /dev/null +++ b/packages/ui/src/custom/media-player/players/muse-player.svelte @@ -0,0 +1,28 @@ + + +{#if embedUrl} +
+ +
+{/if} diff --git a/packages/ui/src/custom/media-player/players/plyr-player.svelte b/packages/ui/src/custom/media-player/players/plyr-player.svelte new file mode 100644 index 00000000..b3401014 --- /dev/null +++ b/packages/ui/src/custom/media-player/players/plyr-player.svelte @@ -0,0 +1,90 @@ + + +
+ {#if isYouTube && youtubeVideoId} +
+ {:else} + + + {/if} +
diff --git a/packages/ui/src/custom/media-player/types.ts b/packages/ui/src/custom/media-player/types.ts new file mode 100644 index 00000000..029947cf --- /dev/null +++ b/packages/ui/src/custom/media-player/types.ts @@ -0,0 +1,21 @@ +export type VideoType = 'youtube' | 'generic' | 'upload' | 'muse'; + +export interface VideoSource { + type: VideoType; + url: string; + metadata?: { + svid?: string; + thumbnailUrl?: string; + }; +} + +export interface MediaPlayerOptions { + autoplay?: boolean; + controls?: boolean; + maxHeight?: number | string; + minHeight?: number | string; + width?: number | string; + height?: number | string; + class?: string; + playsinline?: boolean; +} diff --git a/packages/ui/src/custom/media-player/utils.ts b/packages/ui/src/custom/media-player/utils.ts new file mode 100644 index 00000000..de48fd99 --- /dev/null +++ b/packages/ui/src/custom/media-player/utils.ts @@ -0,0 +1,43 @@ +/** + * Extracts a YouTube video ID from standard YouTube URL variants. + */ +export function getYoutubeVideoId(url: string): string | null { + const trimmedUrl = String(url ?? '').trim(); + if (!trimmedUrl) return null; + + try { + const parsedUrl = new URL(trimmedUrl); + const host = parsedUrl.hostname.replace(/^www\./, '').toLowerCase(); + + if (host === 'youtu.be') { + return parsedUrl.pathname.split('/').filter(Boolean)[0] ?? null; + } + + if (host !== 'youtube.com' && host !== 'm.youtube.com') { + return null; + } + + const pathParts = parsedUrl.pathname.split('/').filter(Boolean); + + if (pathParts[0] === 'watch') { + return parsedUrl.searchParams.get('v'); + } + + if (pathParts[0] === 'embed' || pathParts[0] === 'shorts' || pathParts[0] === 'live') { + return pathParts[1] ?? null; + } + + return null; + } catch { + return null; + } +} + +export function formatYoutubeEmbedUrl(url: string): string { + const videoId = getYoutubeVideoId(url); + return videoId ? `https://www.youtube.com/embed/${videoId}` : url; +} + +export function isYoutubeUrl(url: string): boolean { + return getYoutubeVideoId(url) !== null; +} diff --git a/packages/ui/src/custom/moving-icons/analytics.svelte b/packages/ui/src/custom/moving-icons/analytics.svelte new file mode 100644 index 00000000..3cb819f3 --- /dev/null +++ b/packages/ui/src/custom/moving-icons/analytics.svelte @@ -0,0 +1,76 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/arrow-top-right.svelte b/packages/ui/src/custom/moving-icons/arrow-top-right.svelte new file mode 100644 index 00000000..cc817758 --- /dev/null +++ b/packages/ui/src/custom/moving-icons/arrow-top-right.svelte @@ -0,0 +1,50 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/attachment.svelte b/packages/ui/src/custom/moving-icons/attachment.svelte new file mode 100644 index 00000000..620f91d4 --- /dev/null +++ b/packages/ui/src/custom/moving-icons/attachment.svelte @@ -0,0 +1,80 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/attendance.svelte b/packages/ui/src/custom/moving-icons/attendance.svelte new file mode 100644 index 00000000..122009f8 --- /dev/null +++ b/packages/ui/src/custom/moving-icons/attendance.svelte @@ -0,0 +1,75 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/bell.svelte b/packages/ui/src/custom/moving-icons/bell.svelte new file mode 100644 index 00000000..9f7d354d --- /dev/null +++ b/packages/ui/src/custom/moving-icons/bell.svelte @@ -0,0 +1,111 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/certificate.svelte b/packages/ui/src/custom/moving-icons/certificate.svelte new file mode 100644 index 00000000..007e60ea --- /dev/null +++ b/packages/ui/src/custom/moving-icons/certificate.svelte @@ -0,0 +1,68 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/close.svelte b/packages/ui/src/custom/moving-icons/close.svelte new file mode 100644 index 00000000..c87f60a7 --- /dev/null +++ b/packages/ui/src/custom/moving-icons/close.svelte @@ -0,0 +1,80 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/community.svelte b/packages/ui/src/custom/moving-icons/community.svelte new file mode 100644 index 00000000..89f92b0a --- /dev/null +++ b/packages/ui/src/custom/moving-icons/community.svelte @@ -0,0 +1,101 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/content.svelte b/packages/ui/src/custom/moving-icons/content.svelte new file mode 100644 index 00000000..a54f089d --- /dev/null +++ b/packages/ui/src/custom/moving-icons/content.svelte @@ -0,0 +1,77 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/course.svelte b/packages/ui/src/custom/moving-icons/course.svelte new file mode 100644 index 00000000..9f5dae7b --- /dev/null +++ b/packages/ui/src/custom/moving-icons/course.svelte @@ -0,0 +1,106 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/dashboard.svelte b/packages/ui/src/custom/moving-icons/dashboard.svelte new file mode 100644 index 00000000..b6a036cd --- /dev/null +++ b/packages/ui/src/custom/moving-icons/dashboard.svelte @@ -0,0 +1,135 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/exercise.svelte b/packages/ui/src/custom/moving-icons/exercise.svelte new file mode 100644 index 00000000..f479b2ac --- /dev/null +++ b/packages/ui/src/custom/moving-icons/exercise.svelte @@ -0,0 +1,79 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/explore.svelte b/packages/ui/src/custom/moving-icons/explore.svelte new file mode 100644 index 00000000..dd6423f1 --- /dev/null +++ b/packages/ui/src/custom/moving-icons/explore.svelte @@ -0,0 +1,68 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/goal.svelte b/packages/ui/src/custom/moving-icons/goal.svelte new file mode 100644 index 00000000..f65184e2 --- /dev/null +++ b/packages/ui/src/custom/moving-icons/goal.svelte @@ -0,0 +1,79 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/header.svelte b/packages/ui/src/custom/moving-icons/header.svelte new file mode 100644 index 00000000..c0062909 --- /dev/null +++ b/packages/ui/src/custom/moving-icons/header.svelte @@ -0,0 +1,71 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/home.svelte b/packages/ui/src/custom/moving-icons/home.svelte new file mode 100644 index 00000000..d40d7ec1 --- /dev/null +++ b/packages/ui/src/custom/moving-icons/home.svelte @@ -0,0 +1,77 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/hoverable-item.svelte b/packages/ui/src/custom/moving-icons/hoverable-item.svelte new file mode 100644 index 00000000..f6f86aa4 --- /dev/null +++ b/packages/ui/src/custom/moving-icons/hoverable-item.svelte @@ -0,0 +1,21 @@ + + + diff --git a/packages/ui/src/custom/moving-icons/index.ts b/packages/ui/src/custom/moving-icons/index.ts new file mode 100644 index 00000000..df99ad1d --- /dev/null +++ b/packages/ui/src/custom/moving-icons/index.ts @@ -0,0 +1,29 @@ +export { default as AnalyticsIcon } from './analytics.svelte'; +export { default as AttendanceIcon } from './attendance.svelte'; +export { default as AttachmentIcon } from './attachment.svelte'; +export { default as CertificateIcon } from './certificate.svelte'; +export { default as CloseIcon } from './close.svelte'; +export { default as CommunityIcon } from './community.svelte'; +export { default as ContentIcon } from './content.svelte'; +export { default as CourseIcon } from './course.svelte'; +export { default as DashboardIcon } from './dashboard.svelte'; +export { default as ExerciseIcon } from './exercise.svelte'; +export { default as ExploreIcon } from './explore.svelte'; +export { default as GoalIcon } from './goal.svelte'; +export { default as HeaderIcon } from './header.svelte'; +export { default as HomeIcon } from './home.svelte'; +export { default as HoverableItem } from './hoverable-item.svelte'; +export { default as LandingPageIcon } from './landingpage.svelte'; +export { default as LessonIcon } from './lesson.svelte'; +export { default as MarksIcon } from './marks.svelte'; +export { default as MoneyIcon } from './money.svelte'; +export { default as NewsFeedIcon } from './news-feed.svelte'; +export { default as PeopleIcon } from './people.svelte'; +export { default as PersonIcon } from './person.svelte'; +export { default as PremiumIcon } from './premium.svelte'; +export { default as PreviewIcon } from './preview.svelte'; +export { default as ReviewIcon } from './review.svelte'; +export { default as SettingsIcon } from './settings.svelte'; +export { default as SetupIcon } from './setup.svelte'; +export { default as SubmissionIcon } from './submission.svelte'; +export { default as UploadIcon } from './upload.svelte'; diff --git a/packages/ui/src/custom/moving-icons/landingpage.svelte b/packages/ui/src/custom/moving-icons/landingpage.svelte new file mode 100644 index 00000000..11039152 --- /dev/null +++ b/packages/ui/src/custom/moving-icons/landingpage.svelte @@ -0,0 +1,109 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/lesson.svelte b/packages/ui/src/custom/moving-icons/lesson.svelte new file mode 100644 index 00000000..7bf36df7 --- /dev/null +++ b/packages/ui/src/custom/moving-icons/lesson.svelte @@ -0,0 +1,152 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/marks.svelte b/packages/ui/src/custom/moving-icons/marks.svelte new file mode 100644 index 00000000..69e60020 --- /dev/null +++ b/packages/ui/src/custom/moving-icons/marks.svelte @@ -0,0 +1,95 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/money.svelte b/packages/ui/src/custom/moving-icons/money.svelte new file mode 100644 index 00000000..aed17fc5 --- /dev/null +++ b/packages/ui/src/custom/moving-icons/money.svelte @@ -0,0 +1,82 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/news-feed.svelte b/packages/ui/src/custom/moving-icons/news-feed.svelte new file mode 100644 index 00000000..af818c5d --- /dev/null +++ b/packages/ui/src/custom/moving-icons/news-feed.svelte @@ -0,0 +1,101 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/people.svelte b/packages/ui/src/custom/moving-icons/people.svelte new file mode 100644 index 00000000..bb5b68fe --- /dev/null +++ b/packages/ui/src/custom/moving-icons/people.svelte @@ -0,0 +1,70 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/person.svelte b/packages/ui/src/custom/moving-icons/person.svelte new file mode 100644 index 00000000..d14028dd --- /dev/null +++ b/packages/ui/src/custom/moving-icons/person.svelte @@ -0,0 +1,89 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/premium.svelte b/packages/ui/src/custom/moving-icons/premium.svelte new file mode 100644 index 00000000..2975e84a --- /dev/null +++ b/packages/ui/src/custom/moving-icons/premium.svelte @@ -0,0 +1,147 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/preview.svelte b/packages/ui/src/custom/moving-icons/preview.svelte new file mode 100644 index 00000000..bfb74242 --- /dev/null +++ b/packages/ui/src/custom/moving-icons/preview.svelte @@ -0,0 +1,81 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/review.svelte b/packages/ui/src/custom/moving-icons/review.svelte new file mode 100644 index 00000000..18dd117b --- /dev/null +++ b/packages/ui/src/custom/moving-icons/review.svelte @@ -0,0 +1,71 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/settings.svelte b/packages/ui/src/custom/moving-icons/settings.svelte new file mode 100644 index 00000000..8714dfc3 --- /dev/null +++ b/packages/ui/src/custom/moving-icons/settings.svelte @@ -0,0 +1,65 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/setup.svelte b/packages/ui/src/custom/moving-icons/setup.svelte new file mode 100644 index 00000000..650d603b --- /dev/null +++ b/packages/ui/src/custom/moving-icons/setup.svelte @@ -0,0 +1,136 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/submission.svelte b/packages/ui/src/custom/moving-icons/submission.svelte new file mode 100644 index 00000000..d76455a3 --- /dev/null +++ b/packages/ui/src/custom/moving-icons/submission.svelte @@ -0,0 +1,89 @@ + + + + + diff --git a/packages/ui/src/custom/moving-icons/upload.svelte b/packages/ui/src/custom/moving-icons/upload.svelte new file mode 100644 index 00000000..86f97fa7 --- /dev/null +++ b/packages/ui/src/custom/moving-icons/upload.svelte @@ -0,0 +1,65 @@ + + + + + diff --git a/packages/ui/src/custom/password/password.svelte b/packages/ui/src/custom/password/password.svelte index 32332d78..57718d3f 100644 --- a/packages/ui/src/custom/password/password.svelte +++ b/packages/ui/src/custom/password/password.svelte @@ -40,7 +40,7 @@ const ariaLabel = $derived(showPassword ? hidePasswordAriaLabel : showPasswordAriaLabel); - + diff --git a/packages/ui/src/custom/pricing-card/index.ts b/packages/ui/src/custom/pricing-card/index.ts new file mode 100644 index 00000000..874fd92b --- /dev/null +++ b/packages/ui/src/custom/pricing-card/index.ts @@ -0,0 +1,6 @@ +import PricingCard from './pricing-card.svelte'; + +export { PricingCard }; +export { PricingCard as default }; + +export type { PlanData } from './types'; diff --git a/packages/ui/src/custom/pricing-card/pricing-card.svelte b/packages/ui/src/custom/pricing-card/pricing-card.svelte new file mode 100644 index 00000000..73038cce --- /dev/null +++ b/packages/ui/src/custom/pricing-card/pricing-card.svelte @@ -0,0 +1,108 @@ + + +
+ {#if isPopular} + + {popularLabel} + + {/if} + + +
+

+ {plan.NAME} +

+
+ + {plan.PRICE.CURRENCY}{isYearlyPlan ? plan.PRICE.YEARLY : plan.PRICE.MONTHLY} + +
+

{perOrgLabel}

+
+ + +
+ + {#snippet children(isHovered: boolean)} + + {/snippet} + +
+ + +
+

What's included:

+
    + {#each plan.FEATURES as feature} +
  • +
    + +
    + + {feature} + +
  • + {/each} +
+
+
diff --git a/packages/ui/src/custom/pricing-card/types.ts b/packages/ui/src/custom/pricing-card/types.ts new file mode 100644 index 00000000..386caf01 --- /dev/null +++ b/packages/ui/src/custom/pricing-card/types.ts @@ -0,0 +1,20 @@ +export interface PlanData { + NAME: string; + DESCRIPTION?: string; + PRICE: { + CURRENCY: string; + MONTHLY: string; + YEARLY: string; + IS_PREMIUM: boolean; + }; + FEATURES: string[]; + CTA: { + LABEL?: string; + LINK?: string; + DASHBOARD_LABEL: string; + DASHBOARD_LINK?: string; + IS_DISABLED: boolean; + PRODUCT_ID?: string; + PRODUCT_ID_YEARLY?: string; + }; +} diff --git a/packages/ui/src/custom/pricing-toggle/index.ts b/packages/ui/src/custom/pricing-toggle/index.ts new file mode 100644 index 00000000..7180ff2b --- /dev/null +++ b/packages/ui/src/custom/pricing-toggle/index.ts @@ -0,0 +1,4 @@ +import PricingToggle from './pricing-toggle.svelte'; + +export { PricingToggle }; +export { PricingToggle as default }; diff --git a/packages/ui/src/custom/pricing-toggle/pricing-toggle.svelte b/packages/ui/src/custom/pricing-toggle/pricing-toggle.svelte new file mode 100644 index 00000000..ab195af5 --- /dev/null +++ b/packages/ui/src/custom/pricing-toggle/pricing-toggle.svelte @@ -0,0 +1,59 @@ + + +{#snippet period_button(isActive: boolean, isMonth: boolean)} + +{/snippet} + +
+ {@render period_button(!isYearly, true)} + + {@render period_button(isYearly, false)} + + {#if saveLabel} + + {#snippet children(isHovered)} + + + {saveLabel} + + {/snippet} + + {/if} +
diff --git a/packages/ui/src/custom/radio-item/radio-item.svelte b/packages/ui/src/custom/radio-item/radio-item.svelte index 59f4e548..2d94c942 100644 --- a/packages/ui/src/custom/radio-item/radio-item.svelte +++ b/packages/ui/src/custom/radio-item/radio-item.svelte @@ -28,14 +28,20 @@ }: Props = $props(); -
+
{#if isEditable} -
- +
+
{:else} - {label} + {label} {/if} {@render children?.()} diff --git a/packages/ui/src/custom/radio-option-card/index.ts b/packages/ui/src/custom/radio-option-card/index.ts new file mode 100644 index 00000000..5b6fa36b --- /dev/null +++ b/packages/ui/src/custom/radio-option-card/index.ts @@ -0,0 +1,6 @@ +import RadioOptionCard from './radio-option-card.svelte'; +import RadioOptionCardGroup from './radio-option-card-group.svelte'; + +export { RadioOptionCard, RadioOptionCardGroup }; +export type { RadioOptionCardOption } from './radio-option-card-group.svelte'; +export { RadioOptionCard as default }; diff --git a/packages/ui/src/custom/radio-option-card/radio-option-card-group.svelte b/packages/ui/src/custom/radio-option-card/radio-option-card-group.svelte new file mode 100644 index 00000000..f77ffed5 --- /dev/null +++ b/packages/ui/src/custom/radio-option-card/radio-option-card-group.svelte @@ -0,0 +1,41 @@ + + + + {#each options as option (option.id)} + + {#snippet titleSuffix()} + {@render parentTitleSuffix?.(option)} + {/snippet} + + {/each} + diff --git a/packages/ui/src/custom/radio-option-card/radio-option-card.svelte b/packages/ui/src/custom/radio-option-card/radio-option-card.svelte new file mode 100644 index 00000000..69d1e570 --- /dev/null +++ b/packages/ui/src/custom/radio-option-card/radio-option-card.svelte @@ -0,0 +1,43 @@ + + + + + + + {title} + {#if titleSuffix} + {@render titleSuffix()} + {/if} + + {description} + + + + diff --git a/packages/ui/src/custom/search/search.svelte b/packages/ui/src/custom/search/search.svelte index a18d2eb3..0cf24b28 100644 --- a/packages/ui/src/custom/search/search.svelte +++ b/packages/ui/src/custom/search/search.svelte @@ -18,7 +18,7 @@ }: Props & Omit, 'class' | 'children'> = $props(); const mergedProps = $derived({ - class: cn('w-fit max-w-[200px]', className), + class: cn('ui:w-fit ui:max-w-[200px]', className), 'data-slot': 'search', ...restProps }); diff --git a/packages/ui/src/custom/simple-logo-nav/simple-logo-nav.svelte b/packages/ui/src/custom/simple-logo-nav/simple-logo-nav.svelte index 690b01a2..9dd1a7fd 100644 --- a/packages/ui/src/custom/simple-logo-nav/simple-logo-nav.svelte +++ b/packages/ui/src/custom/simple-logo-nav/simple-logo-nav.svelte @@ -11,5 +11,7 @@ ClassroomIO -

The Course Platform That’s Actually Easy To Use

+

+ Finally, A Course Platform That’s Beautiful & Easy To Use +

diff --git a/packages/ui/src/custom/textarea-field/textarea-field.svelte b/packages/ui/src/custom/textarea-field/textarea-field.svelte index fb9db346..08075eb1 100644 --- a/packages/ui/src/custom/textarea-field/textarea-field.svelte +++ b/packages/ui/src/custom/textarea-field/textarea-field.svelte @@ -37,7 +37,7 @@ {label} {#if isRequired} - * + * {/if} {/if} diff --git a/packages/ui/src/custom/underline-tabs/underline-tabs-trigger.svelte b/packages/ui/src/custom/underline-tabs/underline-tabs-trigger.svelte index a71413ea..283f9f3c 100644 --- a/packages/ui/src/custom/underline-tabs/underline-tabs-trigger.svelte +++ b/packages/ui/src/custom/underline-tabs/underline-tabs-trigger.svelte @@ -30,7 +30,7 @@ bind:ref data-slot="underline-tabs-trigger" class={cn( - "dark:data-[state=active]:text-foreground data-[state=active]:text-foreground ui:text-foreground ui:z-10 ui:relative ui:inline-flex ui:h-[calc(100%-3px)] ui:flex-1 ui:items-center ui:justify-center ui:gap-1.5 ui:whitespace-nowrap ui:px-3 ui:py-1 ui:text-sm ui:transition-colors ui:focus:outline-none ui:disabled:pointer-events-none ui:disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", + "dark:data-[state=active]:text-foreground data-[state=active]:text-foreground ui:text-foreground ui:z-10 ui:relative ui:inline-flex ui:h-[calc(100%-3px)] ui:flex-1 ui:items-center ui:justify-center ui:gap-1.5 ui:whitespace-nowrap ui:px-3 ui:py-1 ui:text-sm ui:transition-colors ui:focus:outline-none ui:disabled:pointer-events-none ui:disabled:opacity-50 ui:cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", state.rootState.isHovered && state.rootState.hoveredTab === value && 'data-[state=inactive]:text-primary-foreground ui:text-primary', @@ -44,7 +44,7 @@ {#if state.rootState.hoveredTab === value}
+ import { Button } from '../../base/button'; + import { Input } from '../../base/input'; + import { Label } from '../../base/label'; + import { cn } from '../../tools'; + import { normalizeYoutubeLink, splitYoutubeLinks, toCanonicalYoutubeLink } from './youtube-link-utils'; + + interface Props { + inputLabel: string; + inputPlaceholder?: string; + addButtonLabel: string; + invalidYoutubeMessage: string; + disabled?: boolean; + class?: string; + onSubmit?: (links: string[]) => Promise | void; + onInputChange?: (value: string) => void; + } + + let { + inputLabel, + inputPlaceholder = '', + addButtonLabel, + invalidYoutubeMessage, + disabled = false, + class: className = '', + onSubmit = () => {}, + onInputChange = () => {} + }: Props = $props(); + + let rawInput = $state(''); + let validationError = $state(''); + let isSubmitting = $state(false); + + function handleInputChange(event: Event) { + rawInput = (event.currentTarget as HTMLInputElement).value; + validationError = ''; + onInputChange(rawInput); + } + + async function addYoutubeLink() { + if (disabled || isSubmitting) return; + + const links = splitYoutubeLinks(rawInput) + .map(normalizeYoutubeLink) + .map(toCanonicalYoutubeLink) + .filter((entry): entry is string => Boolean(entry)); + + const dedupedLinks = Array.from(new Set(links)); + + if (dedupedLinks.length === 0) { + validationError = invalidYoutubeMessage; + return; + } + + validationError = ''; + isSubmitting = true; + + try { + await onSubmit(dedupedLinks); + rawInput = ''; + onInputChange(''); + } finally { + isSubmitting = false; + } + } + + +
{ + event.preventDefault(); + void addYoutubeLink(); + }} +> +
+ + + {#if validationError} +

{validationError}

+ {/if} +
+ + +
diff --git a/packages/ui/src/custom/youtube-link-form/youtube-link-utils.ts b/packages/ui/src/custom/youtube-link-form/youtube-link-utils.ts new file mode 100644 index 00000000..477d1c4a --- /dev/null +++ b/packages/ui/src/custom/youtube-link-form/youtube-link-utils.ts @@ -0,0 +1,72 @@ +const YOUTUBE_VIDEO_ID_PATTERN = /^[a-zA-Z0-9_-]{11}$/; + +export function splitYoutubeLinks(rawInput = ''): string[] { + return rawInput + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); +} + +export function normalizeYoutubeLink(rawLink = ''): string { + const trimmedLink = rawLink.trim(); + if (!trimmedLink) return ''; + + if (trimmedLink.startsWith('http://') || trimmedLink.startsWith('https://')) { + return trimmedLink; + } + + return `https://${trimmedLink}`; +} + +export function getYoutubeVideoId(link = ''): string | null { + const normalizedLink = normalizeYoutubeLink(link); + if (!normalizedLink) return null; + + try { + const parsedUrl = new URL(normalizedLink); + const host = parsedUrl.hostname.replace(/^www\./, '').toLowerCase(); + + if (host === 'youtu.be') { + const shortPath = parsedUrl.pathname.split('/').filter(Boolean)[0] ?? ''; + return YOUTUBE_VIDEO_ID_PATTERN.test(shortPath) ? shortPath : null; + } + + if (host !== 'youtube.com' && host !== 'm.youtube.com') { + return null; + } + + const directPath = parsedUrl.pathname.split('/').filter(Boolean); + + if (directPath[0] === 'watch') { + const watchId = parsedUrl.searchParams.get('v') ?? ''; + return YOUTUBE_VIDEO_ID_PATTERN.test(watchId) ? watchId : null; + } + + if (directPath[0] === 'embed' || directPath[0] === 'shorts' || directPath[0] === 'live') { + const pathId = directPath[1] ?? ''; + return YOUTUBE_VIDEO_ID_PATTERN.test(pathId) ? pathId : null; + } + + return null; + } catch { + return null; + } +} + +export function isValidYoutubeLink(link = ''): boolean { + return getYoutubeVideoId(link) !== null; +} + +export function toCanonicalYoutubeLink(link = ''): string | null { + const videoId = getYoutubeVideoId(link); + if (!videoId) return null; + + return `https://www.youtube.com/watch?v=${videoId}`; +} + +export function toYoutubeEmbedUrl(link = ''): string | null { + const videoId = getYoutubeVideoId(link); + if (!videoId) return null; + + return `https://www.youtube.com/embed/${videoId}`; +} diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index b2abf609..e29b3782 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -1,6 +1,7 @@ @import 'tailwindcss' prefix(ui); @import 'tw-animate-css'; +@source './**/*.{html,js,ts,svelte}'; @source '../node_modules/layerchart/dist'; @custom-variant dark (&:where(.dark, .dark *)); @@ -139,3 +140,49 @@ --normal-border: var(--color-border); } } + +@property --border-angle { + inherits: false; + initial-value: 0deg; + syntax: ''; +} + +@keyframes border { + to { + --border-angle: 360deg; + } +} + +.animate-gradient-border { + border: 1px solid transparent; + background: + linear-gradient(45deg, white, white) padding-box, + conic-gradient( + from var(--border-angle), + rgb(148 163 184 / 0.3) 75%, + var(--primary, #1d4ee2) 82%, + var(--primary-foreground, white) 88%, + var(--primary, #1d4ee2) 92%, + rgb(148 163 184 / 0.3) 100% + ) + border-box; + transition: --border-angle 3s ease-in-out; +} + +.animate-gradient-border:hover { + animation: border 4s ease-in-out infinite; +} + +.dark.animate-gradient-border { + background: + linear-gradient(45deg, rgb(0 0 0), rgb(0 0 0)) padding-box, + conic-gradient( + from var(--border-angle), + rgb(71 85 105 / 0.48) 75%, + var(--primary, #1d4ee2) 82%, + var(--primary-foreground, black) 88%, + var(--primary, #1d4ee2) 92%, + rgb(71 85 105 / 0.48) 100% + ) + border-box; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 856e2ecd..1af0ae84 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,6 +1,7 @@ import './index.css'; export * as Accordion from './base/accordion'; +export * as Alert from './base/alert'; export * as Avatar from './base/avatar'; export * as Badge from './base/badge'; export * as Breadcrumb from './base/breadcrumb'; @@ -9,6 +10,7 @@ export * as ButtonGroup from './base/button-group'; export * as Card from './base/card'; export * as Chart from './base/chart'; export * as Checkbox from './base/checkbox'; +export * as Code from './custom/code'; export * as Collapsible from './base/collapsible'; export * as Command from './base/command'; export * as CopyButton from './base/copy-button'; @@ -42,6 +44,7 @@ export * as Table from './base/table'; export * as Tabs from './base/tabs'; export * as Textarea from './base/textarea'; export * as Toggle from './base/toggle'; +export * as ToggleGroup from './base/toggle-group'; export * as Tooltip from './base/tooltip'; export * as NavigationMenu from './base/navigation-menu'; @@ -51,6 +54,7 @@ export { BarChart, LineChart, AreaChart, PieChart, scaleBand } from './base/char export * from './tools'; export * as UnderlineTabs from './custom/underline-tabs'; +export * as FileDropZone from './custom/file-drop-zone'; export * from './custom/editor/ui'; export * as ImageCropper from './custom/image-cropper'; export { Chip } from './custom/chip'; @@ -58,3 +62,8 @@ export { IconButton } from './custom/icon-button'; export { TextareaField } from './custom/textarea-field'; export { CheckboxField } from './custom/checkbox-field'; export { RadioItem } from './custom/radio-item'; +export { RadioOptionCard, RadioOptionCardGroup, type RadioOptionCardOption } from './custom/radio-option-card'; +export { DocumentCard } from './custom/document-card'; +export * as ExerciseQuestion from './custom/exercise-question'; +export * as MediaPlayer from './custom/media-player'; +export * as YoutubeLinkForm from './custom/youtube-link-form'; diff --git a/packages/ui/src/output.css b/packages/ui/src/output.css index d708e4d8..b53883f9 100644 --- a/packages/ui/src/output.css +++ b/packages/ui/src/output.css @@ -1,2 +1,2 @@ -/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:"";--tw-outline-style:solid;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--ui-font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--ui-font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--ui-color-red-500:oklch(63.7% .237 25.331);--ui-color-yellow-500:oklch(79.5% .184 86.047);--ui-color-green-500:oklch(72.3% .219 149.579);--ui-color-black:#000;--ui-color-white:#fff;--ui-spacing:.25rem;--ui-container-sm:24rem;--ui-container-lg:32rem;--ui-container-3xl:48rem;--ui-text-xs:.75rem;--ui-text-xs--line-height:calc(1/.75);--ui-text-sm:.875rem;--ui-text-sm--line-height:calc(1.25/.875);--ui-text-base:1rem;--ui-text-base--line-height:calc(1.5/1);--ui-text-lg:1.125rem;--ui-text-lg--line-height:calc(1.75/1.125);--ui-text-2xl:1.5rem;--ui-text-2xl--line-height:calc(2/1.5);--ui-font-weight-normal:400;--ui-font-weight-medium:500;--ui-font-weight-semibold:600;--ui-tracking-tight:-.025em;--ui-tracking-widest:.1em;--ui-leading-snug:1.375;--ui-leading-normal:1.5;--ui-leading-relaxed:1.625;--ui-radius-xs:.125rem;--ui-ease-in-out:cubic-bezier(.4,0,.2,1);--ui-animate-spin:spin 1s linear infinite;--ui-animate-pulse:pulse 2s cubic-bezier(.4,0,.6,1)infinite;--ui-blur-sm:8px;--ui-default-transition-duration:.15s;--ui-default-transition-timing-function:cubic-bezier(.4,0,.2,1);--ui-default-mono-font-family:var(--ui-font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--font-cio,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--ui-default-font-feature-settings,normal);font-variation-settings:var(--ui-default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--ui-default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--ui-default-mono-font-feature-settings,normal);font-variation-settings:var(--ui-default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:var(--border);outline-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){*{outline-color:color-mix(in oklab,var(--ring)50%,transparent)}}body{background-color:var(--background);font-family:var(--font-sans);color:var(--foreground)}button,input,select,optgroup,textarea{font-family:var(--font-sans)}::file-selector-button{font-family:var(--font-sans)}.toaster{--normal-bg:var(--color-popover);--normal-text:var(--color-popover-foreground);--normal-border:var(--color-border)}}@layer components;@layer utilities{.ui\:\@container\/field-group{container:field-group/inline-size}.ui\:pointer-events-none{pointer-events:none}.ui\:sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.ui\:absolute{position:absolute}.ui\:fixed{position:fixed}.ui\:relative{position:relative}.ui\:inset-0{inset:calc(var(--ui-spacing)*0)}.ui\:inset-x-0{inset-inline:calc(var(--ui-spacing)*0)}.ui\:inset-y-0{inset-block:calc(var(--ui-spacing)*0)}.ui\:start-0{inset-inline-start:calc(var(--ui-spacing)*0)}.ui\:end-4{inset-inline-end:calc(var(--ui-spacing)*4)}.ui\:-top-2{top:calc(var(--ui-spacing)*-2)}.ui\:top-0{top:calc(var(--ui-spacing)*0)}.ui\:top-1\.5{top:calc(var(--ui-spacing)*1.5)}.ui\:top-1\/2{top:50%}.ui\:top-2{top:calc(var(--ui-spacing)*2)}.ui\:top-3\.5{top:calc(var(--ui-spacing)*3.5)}.ui\:top-4{top:calc(var(--ui-spacing)*4)}.ui\:top-\[50\%\]{top:50%}.ui\:top-\[60\%\]{top:60%}.ui\:top-full{top:100%}.ui\:top-px{top:1px}.ui\:right-0{right:calc(var(--ui-spacing)*0)}.ui\:right-1{right:calc(var(--ui-spacing)*1)}.ui\:right-2{right:calc(var(--ui-spacing)*2)}.ui\:right-3{right:calc(var(--ui-spacing)*3)}.ui\:right-4{right:calc(var(--ui-spacing)*4)}.ui\:right-9{right:calc(var(--ui-spacing)*9)}.ui\:-bottom-px{bottom:-1px}.ui\:bottom-0{bottom:calc(var(--ui-spacing)*0)}.ui\:left-0{left:calc(var(--ui-spacing)*0)}.ui\:left-1\/2{left:50%}.ui\:left-2{left:calc(var(--ui-spacing)*2)}.ui\:left-\[50\%\]{left:50%}.ui\:left-\[calc\(50\%-3rem\)\]{left:calc(50% - 3rem)}.ui\:isolate{isolation:isolate}.ui\:z-1{z-index:1}.ui\:z-10{z-index:10}.ui\:z-20{z-index:20}.ui\:z-50{z-index:50}.ui\:z-999{z-index:999}.ui\:order-first{order:-9999}.ui\:order-last{order:9999}.ui\:col-start-2{grid-column-start:2}.ui\:row-span-2{grid-row:span 2/span 2}.ui\:row-start-1{grid-row-start:1}.ui\:m-0{margin:calc(var(--ui-spacing)*0)}.ui\:m-0\!{margin:calc(var(--ui-spacing)*0)!important}.ui\:-mx-1{margin-inline:calc(var(--ui-spacing)*-1)}.ui\:mx-3\.5{margin-inline:calc(var(--ui-spacing)*3.5)}.ui\:mx-auto{margin-inline:auto}.ui\:-my-2{margin-block:calc(var(--ui-spacing)*-2)}.ui\:my-0{margin-block:calc(var(--ui-spacing)*0)}.ui\:my-0\.5{margin-block:calc(var(--ui-spacing)*.5)}.ui\:my-1{margin-block:calc(var(--ui-spacing)*1)}.ui\:my-2{margin-block:calc(var(--ui-spacing)*2)}.ui\:my-4{margin-block:calc(var(--ui-spacing)*4)}.ui\:ms-1{margin-inline-start:calc(var(--ui-spacing)*1)}.ui\:ms-4{margin-inline-start:calc(var(--ui-spacing)*4)}.ui\:mt-1{margin-top:calc(var(--ui-spacing)*1)}.ui\:mt-1\.5{margin-top:calc(var(--ui-spacing)*1.5)}.ui\:mt-3{margin-top:calc(var(--ui-spacing)*3)}.ui\:mt-4{margin-top:calc(var(--ui-spacing)*4)}.ui\:mt-auto{margin-top:auto}.ui\:mr-1{margin-right:calc(var(--ui-spacing)*1)}.ui\:mb-2{margin-bottom:calc(var(--ui-spacing)*2)}.ui\:mb-3{margin-bottom:calc(var(--ui-spacing)*3)}.ui\:mb-4{margin-bottom:calc(var(--ui-spacing)*4)}.ui\:ml-auto{margin-left:auto}.ui\:line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.ui\:block{display:block}.ui\:flex{display:flex}.ui\:grid{display:grid}.ui\:hidden{display:none}.ui\:inline-flex{display:inline-flex}.ui\:field-sizing-content{field-sizing:content}.ui\:size-2{width:calc(var(--ui-spacing)*2);height:calc(var(--ui-spacing)*2)}.ui\:size-2\.5{width:calc(var(--ui-spacing)*2.5);height:calc(var(--ui-spacing)*2.5)}.ui\:size-3{width:calc(var(--ui-spacing)*3);height:calc(var(--ui-spacing)*3)}.ui\:size-3\.5{width:calc(var(--ui-spacing)*3.5);height:calc(var(--ui-spacing)*3.5)}.ui\:size-4{width:calc(var(--ui-spacing)*4);height:calc(var(--ui-spacing)*4)}.ui\:size-6{width:calc(var(--ui-spacing)*6);height:calc(var(--ui-spacing)*6)}.ui\:size-7{width:calc(var(--ui-spacing)*7);height:calc(var(--ui-spacing)*7)}.ui\:size-8{width:calc(var(--ui-spacing)*8);height:calc(var(--ui-spacing)*8)}.ui\:size-9{width:calc(var(--ui-spacing)*9);height:calc(var(--ui-spacing)*9)}.ui\:size-10{width:calc(var(--ui-spacing)*10);height:calc(var(--ui-spacing)*10)}.ui\:size-20{width:calc(var(--ui-spacing)*20);height:calc(var(--ui-spacing)*20)}.ui\:size-fit{width:fit-content;height:fit-content}.ui\:size-full{width:100%;height:100%}.ui\:h-\(--bits-navigation-menu-viewport-height\){height:var(--bits-navigation-menu-viewport-height)}.ui\:h-\(--bits-select-anchor-height\){height:var(--bits-select-anchor-height)}.ui\:h-0\.5{height:calc(var(--ui-spacing)*.5)}.ui\:h-2{height:calc(var(--ui-spacing)*2)}.ui\:h-4{height:calc(var(--ui-spacing)*4)}.ui\:h-5{height:calc(var(--ui-spacing)*5)}.ui\:h-6{height:calc(var(--ui-spacing)*6)}.ui\:h-7{height:calc(var(--ui-spacing)*7)}.ui\:h-8{height:calc(var(--ui-spacing)*8)}.ui\:h-9{height:calc(var(--ui-spacing)*9)}.ui\:h-10{height:calc(var(--ui-spacing)*10)}.ui\:h-12{height:calc(var(--ui-spacing)*12)}.ui\:h-96{height:calc(var(--ui-spacing)*96)}.ui\:h-128{height:calc(var(--ui-spacing)*128)}.ui\:h-\[1\.2rem\]{height:1.2rem}.ui\:h-\[1\.15rem\]{height:1.15rem}.ui\:h-\[6px\]{height:6px}.ui\:h-\[70px\]{height:70px}.ui\:h-\[calc\(100\%-1px\)\]{height:calc(100% - 1px)}.ui\:h-\[calc\(100\%-3px\)\]{height:calc(100% - 3px)}.ui\:h-auto{height:auto}.ui\:h-fit{height:fit-content}.ui\:h-full{height:100%}.ui\:h-px{height:1px}.ui\:h-screen{height:100vh}.ui\:max-h-\(--bits-dropdown-menu-content-available-height\){max-height:var(--bits-dropdown-menu-content-available-height)}.ui\:max-h-\(--bits-select-content-available-height\){max-height:var(--bits-select-content-available-height)}.ui\:max-h-80{max-height:calc(var(--ui-spacing)*80)}.ui\:max-h-90{max-height:calc(var(--ui-spacing)*90)}.ui\:max-h-96{max-height:calc(var(--ui-spacing)*96)}.ui\:max-h-\[300px\]{max-height:300px}.ui\:max-h-svh{max-height:100svh}.ui\:min-h-0{min-height:calc(var(--ui-spacing)*0)}.ui\:min-h-16{min-height:calc(var(--ui-spacing)*16)}.ui\:min-h-96{min-height:calc(var(--ui-spacing)*96)}.ui\:min-h-\[calc\(100vh-48px\)\]{min-height:calc(100vh - 48px)}.ui\:w-\(--sidebar-width\){width:var(--sidebar-width)}.ui\:w-0{width:calc(var(--ui-spacing)*0)}.ui\:w-1{width:calc(var(--ui-spacing)*1)}.ui\:w-1\/4{width:25%}.ui\:w-2{width:calc(var(--ui-spacing)*2)}.ui\:w-3\/4{width:75%}.ui\:w-4{width:calc(var(--ui-spacing)*4)}.ui\:w-5{width:calc(var(--ui-spacing)*5)}.ui\:w-7{width:calc(var(--ui-spacing)*7)}.ui\:w-8{width:calc(var(--ui-spacing)*8)}.ui\:w-40{width:calc(var(--ui-spacing)*40)}.ui\:w-64{width:calc(var(--ui-spacing)*64)}.ui\:w-72{width:calc(var(--ui-spacing)*72)}.ui\:w-\[1\.2rem\]{width:1.2rem}.ui\:w-\[25px\]{width:25px}.ui\:w-\[100px\]{width:100px}.ui\:w-fit{width:fit-content}.ui\:w-full{width:100%}.ui\:w-max{width:max-content}.ui\:w-screen{width:100vw}.ui\:max-w-\(--skeleton-width\){max-width:var(--skeleton-width)}.ui\:max-w-3xl{max-width:var(--ui-container-3xl)}.ui\:max-w-6{max-width:calc(var(--ui-spacing)*6)}.ui\:max-w-80{max-width:calc(var(--ui-spacing)*80)}.ui\:max-w-\[60\%\]{max-width:60%}.ui\:max-w-\[calc\(100\%-2rem\)\]{max-width:calc(100% - 2rem)}.ui\:max-w-full{max-width:100%}.ui\:max-w-max{max-width:max-content}.ui\:max-w-sm{max-width:var(--ui-container-sm)}.ui\:min-w-\(--bits-select-anchor-width\){min-width:var(--bits-select-anchor-width)}.ui\:min-w-0{min-width:calc(var(--ui-spacing)*0)}.ui\:min-w-5{min-width:calc(var(--ui-spacing)*5)}.ui\:min-w-8{min-width:calc(var(--ui-spacing)*8)}.ui\:min-w-9{min-width:calc(var(--ui-spacing)*9)}.ui\:min-w-10{min-width:calc(var(--ui-spacing)*10)}.ui\:min-w-32{min-width:calc(var(--ui-spacing)*32)}.ui\:min-w-48{min-width:calc(var(--ui-spacing)*48)}.ui\:min-w-\[9rem\]{min-width:9rem}.ui\:flex-1{flex:1}.ui\:shrink-0{flex-shrink:0}.ui\:basis-full{flex-basis:100%}.ui\:origin-\(--bits-dropdown-menu-content-transform-origin\){transform-origin:var(--bits-dropdown-menu-content-transform-origin)}.ui\:origin-\(--bits-menubar-content-transform-origin\){transform-origin:var(--bits-menubar-content-transform-origin)}.ui\:origin-\(--bits-popover-content-transform-origin\){transform-origin:var(--bits-popover-content-transform-origin)}.ui\:origin-\(--bits-select-content-transform-origin\){transform-origin:var(--bits-select-content-transform-origin)}.ui\:origin-\(--bits-tooltip-content-transform-origin\){transform-origin:var(--bits-tooltip-content-transform-origin)}.ui\:-translate-x-1\/2{--tw-translate-x:calc(calc(1/2*100%)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.ui\:-translate-x-px{--tw-translate-x:-1px;translate:var(--tw-translate-x)var(--tw-translate-y)}.ui\:translate-x-\[-50\%\]{--tw-translate-x:-50%;translate:var(--tw-translate-x)var(--tw-translate-y)}.ui\:translate-x-px{--tw-translate-x:1px;translate:var(--tw-translate-x)var(--tw-translate-y)}.ui\:-translate-y-1\/2{--tw-translate-y:calc(calc(1/2*100%)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.ui\:translate-y-0\.5{--tw-translate-y:calc(var(--ui-spacing)*.5);translate:var(--tw-translate-x)var(--tw-translate-y)}.ui\:translate-y-\[-50\%\]{--tw-translate-y:-50%;translate:var(--tw-translate-x)var(--tw-translate-y)}.ui\:scale-0{--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x)var(--tw-scale-y)}.ui\:scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x)var(--tw-scale-y)}.ui\:rotate-0{rotate:none}.ui\:rotate-45{rotate:45deg}.ui\:rotate-90{rotate:90deg}.ui\:animate-in{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.ui\:animate-pulse{animation:var(--ui-animate-pulse)}.ui\:animate-spin{animation:var(--ui-animate-spin)}.ui\:cursor-auto{cursor:auto}.ui\:cursor-col-resize{cursor:col-resize}.ui\:cursor-default{cursor:default}.ui\:cursor-not-allowed{cursor:not-allowed}.ui\:cursor-pointer{cursor:pointer}.ui\:cursor-text{cursor:text}.ui\:resize-none{resize:none}.ui\:scroll-my-1{scroll-margin-block:calc(var(--ui-spacing)*1)}.ui\:scroll-py-1{scroll-padding-block:calc(var(--ui-spacing)*1)}.ui\:list-disc{list-style-type:disc}.ui\:list-none{list-style-type:none}.ui\:auto-rows-min{grid-auto-rows:min-content}.ui\:grid-rows-\[auto_auto\]{grid-template-rows:auto auto}.ui\:flex-col{flex-direction:column}.ui\:flex-col-reverse{flex-direction:column-reverse}.ui\:flex-row{flex-direction:row}.ui\:flex-wrap{flex-wrap:wrap}.ui\:place-items-center{place-items:center}.ui\:items-center{align-items:center}.ui\:items-end{align-items:flex-end}.ui\:items-start{align-items:flex-start}.ui\:items-stretch{align-items:stretch}.ui\:justify-between{justify-content:space-between}.ui\:justify-center{justify-content:center}.ui\:justify-end{justify-content:flex-end}.ui\:justify-start{justify-content:flex-start}.ui\:gap-0{gap:calc(var(--ui-spacing)*0)}.ui\:gap-0\.5{gap:calc(var(--ui-spacing)*.5)}.ui\:gap-1{gap:calc(var(--ui-spacing)*1)}.ui\:gap-1\.5{gap:calc(var(--ui-spacing)*1.5)}.ui\:gap-2{gap:calc(var(--ui-spacing)*2)}.ui\:gap-2\.5{gap:calc(var(--ui-spacing)*2.5)}.ui\:gap-3{gap:calc(var(--ui-spacing)*3)}.ui\:gap-4{gap:calc(var(--ui-spacing)*4)}.ui\:gap-6{gap:calc(var(--ui-spacing)*6)}.ui\:gap-7{gap:calc(var(--ui-spacing)*7)}.ui\:self-start{align-self:flex-start}.ui\:self-stretch{align-self:stretch}.ui\:justify-self-end{justify-self:flex-end}.ui\:overflow-auto{overflow:auto}.ui\:overflow-hidden{overflow:hidden}.ui\:overflow-visible{overflow:visible}.ui\:overflow-x-auto{overflow-x:auto}.ui\:overflow-x-hidden{overflow-x:hidden}.ui\:overflow-y-auto{overflow-y:auto}.ui\:scroll-smooth{scroll-behavior:smooth}.ui\:rounded{border-radius:.25rem}.ui\:rounded-\[2px\]{border-radius:2px}.ui\:rounded-\[4px\]{border-radius:4px}.ui\:rounded-\[calc\(var\(--radius\)-5px\)\]{border-radius:calc(var(--radius) - 5px)}.ui\:rounded-full{border-radius:3.40282e38px}.ui\:rounded-lg{border-radius:var(--radius)}.ui\:rounded-md{border-radius:calc(var(--radius) - 2px)}.ui\:rounded-none{border-radius:0}.ui\:rounded-sm{border-radius:calc(var(--radius) - 4px)}.ui\:rounded-xl{border-radius:calc(var(--radius) + 4px)}.ui\:rounded-xs{border-radius:var(--ui-radius-xs)}.ui\:border{border-style:var(--tw-border-style);border-width:1px}.ui\:border-0{border-style:var(--tw-border-style);border-width:0}.ui\:border-2{border-style:var(--tw-border-style);border-width:2px}.ui\:border-\[1\.5px\]{border-style:var(--tw-border-style);border-width:1.5px}.ui\:border-x-0{border-inline-style:var(--tw-border-style);border-inline-width:0}.ui\:border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.ui\:border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.ui\:border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.ui\:border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.ui\:border-dashed{--tw-border-style:dashed;border-style:dashed}.ui\:border-\(--color-border\){border-color:var(--color-border)}.ui\:border-border,.ui\:border-border\/50{border-color:var(--border)}@supports (color:color-mix(in lab, red, red)){.ui\:border-border\/50{border-color:color-mix(in oklab,var(--border)50%,transparent)}}.ui\:border-input{border-color:var(--input)}.ui\:border-red-500{border-color:var(--ui-color-red-500)}.ui\:border-sidebar-border{border-color:var(--sidebar-border)}.ui\:border-transparent{border-color:#0000}.ui\:bg-\(--color-bg\){background-color:var(--color-bg)}.ui\:bg-accent{background-color:var(--accent)}.ui\:bg-background,.ui\:bg-background\/50{background-color:var(--background)}@supports (color:color-mix(in lab, red, red)){.ui\:bg-background\/50{background-color:color-mix(in oklab,var(--background)50%,transparent)}}.ui\:bg-black\/50{background-color:var(--ui-color-black)}@supports (color:color-mix(in lab, red, red)){.ui\:bg-black\/50{background-color:color-mix(in oklab,var(--ui-color-black)50%,transparent)}}.ui\:bg-border{background-color:var(--border)}.ui\:bg-card{background-color:var(--card)}.ui\:bg-destructive{background-color:var(--destructive)}.ui\:bg-green-500{background-color:var(--ui-color-green-500)}.ui\:bg-inherit{background-color:inherit}.ui\:bg-input{background-color:var(--input)}.ui\:bg-muted,.ui\:bg-muted\/50{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.ui\:bg-muted\/50{background-color:color-mix(in oklab,var(--muted)50%,transparent)}}.ui\:bg-popover{background-color:var(--popover)}.ui\:bg-primary{background-color:var(--primary)}.ui\:bg-primary-foreground{background-color:var(--primary-foreground)}.ui\:bg-primary\/20{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.ui\:bg-primary\/20{background-color:color-mix(in oklab,var(--primary)20%,transparent)}}.ui\:bg-red-500{background-color:var(--ui-color-red-500)}.ui\:bg-secondary,.ui\:bg-secondary\/50{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.ui\:bg-secondary\/50{background-color:color-mix(in oklab,var(--secondary)50%,transparent)}}.ui\:bg-sidebar{background-color:var(--sidebar)}.ui\:bg-sidebar-border{background-color:var(--sidebar-border)}.ui\:bg-transparent{background-color:#0000}.ui\:bg-yellow-500{background-color:var(--ui-color-yellow-500)}.ui\:bg-clip-padding{background-clip:padding-box}.ui\:fill-current{fill:currentColor}.ui\:fill-primary{fill:var(--primary)}.ui\:object-cover{object-fit:cover}.ui\:p-0{padding:calc(var(--ui-spacing)*0)}.ui\:p-0\.5{padding:calc(var(--ui-spacing)*.5)}.ui\:p-1{padding:calc(var(--ui-spacing)*1)}.ui\:p-2{padding:calc(var(--ui-spacing)*2)}.ui\:p-4{padding:calc(var(--ui-spacing)*4)}.ui\:p-6{padding:calc(var(--ui-spacing)*6)}.ui\:p-\[3px\]{padding:3px}.ui\:px-1{padding-inline:calc(var(--ui-spacing)*1)}.ui\:px-1\.5{padding-inline:calc(var(--ui-spacing)*1.5)}.ui\:px-2{padding-inline:calc(var(--ui-spacing)*2)}.ui\:px-2\.5{padding-inline:calc(var(--ui-spacing)*2.5)}.ui\:px-3{padding-inline:calc(var(--ui-spacing)*3)}.ui\:px-4{padding-inline:calc(var(--ui-spacing)*4)}.ui\:px-6{padding-inline:calc(var(--ui-spacing)*6)}.ui\:py-0\.5{padding-block:calc(var(--ui-spacing)*.5)}.ui\:py-1{padding-block:calc(var(--ui-spacing)*1)}.ui\:py-1\.5{padding-block:calc(var(--ui-spacing)*1.5)}.ui\:py-2{padding-block:calc(var(--ui-spacing)*2)}.ui\:py-3{padding-block:calc(var(--ui-spacing)*3)}.ui\:py-4{padding-block:calc(var(--ui-spacing)*4)}.ui\:py-6{padding-block:calc(var(--ui-spacing)*6)}.ui\:ps-3{padding-inline-start:calc(var(--ui-spacing)*3)}.ui\:pe-3{padding-inline-end:calc(var(--ui-spacing)*3)}.ui\:pt-0{padding-top:calc(var(--ui-spacing)*0)}.ui\:pt-1\.5{padding-top:calc(var(--ui-spacing)*1.5)}.ui\:pt-3{padding-top:calc(var(--ui-spacing)*3)}.ui\:pr-2{padding-right:calc(var(--ui-spacing)*2)}.ui\:pr-8{padding-right:calc(var(--ui-spacing)*8)}.ui\:pr-9{padding-right:calc(var(--ui-spacing)*9)}.ui\:pr-18{padding-right:calc(var(--ui-spacing)*18)}.ui\:pb-3{padding-bottom:calc(var(--ui-spacing)*3)}.ui\:pb-4{padding-bottom:calc(var(--ui-spacing)*4)}.ui\:pl-2{padding-left:calc(var(--ui-spacing)*2)}.ui\:pl-3{padding-left:calc(var(--ui-spacing)*3)}.ui\:pl-8{padding-left:calc(var(--ui-spacing)*8)}.ui\:text-center{text-align:center}.ui\:text-left{text-align:left}.ui\:font-mono{font-family:var(--ui-font-mono)}.ui\:font-sans{font-family:var(--ui-font-sans)}.ui\:text-2xl{font-size:var(--ui-text-2xl);line-height:var(--tw-leading,var(--ui-text-2xl--line-height))}.ui\:text-base{font-size:var(--ui-text-base);line-height:var(--tw-leading,var(--ui-text-base--line-height))}.ui\:text-lg{font-size:var(--ui-text-lg);line-height:var(--tw-leading,var(--ui-text-lg--line-height))}.ui\:text-sm{font-size:var(--ui-text-sm);line-height:var(--tw-leading,var(--ui-text-sm--line-height))}.ui\:text-sm\/relaxed{font-size:var(--ui-text-sm);line-height:var(--ui-leading-relaxed)}.ui\:text-xs{font-size:var(--ui-text-xs);line-height:var(--tw-leading,var(--ui-text-xs--line-height))}.ui\:leading-none{--tw-leading:1;line-height:1}.ui\:leading-normal{--tw-leading:var(--ui-leading-normal);line-height:var(--ui-leading-normal)}.ui\:leading-snug{--tw-leading:var(--ui-leading-snug);line-height:var(--ui-leading-snug)}.ui\:font-medium{--tw-font-weight:var(--ui-font-weight-medium);font-weight:var(--ui-font-weight-medium)}.ui\:font-normal{--tw-font-weight:var(--ui-font-weight-normal);font-weight:var(--ui-font-weight-normal)}.ui\:font-semibold{--tw-font-weight:var(--ui-font-weight-semibold);font-weight:var(--ui-font-weight-semibold)}.ui\:tracking-tight{--tw-tracking:var(--ui-tracking-tight);letter-spacing:var(--ui-tracking-tight)}.ui\:tracking-widest{--tw-tracking:var(--ui-tracking-widest);letter-spacing:var(--ui-tracking-widest)}.ui\:text-balance{text-wrap:balance}.ui\:break-words{overflow-wrap:break-word}.ui\:text-ellipsis{text-overflow:ellipsis}.ui\:whitespace-nowrap{white-space:nowrap}.ui\:text-card-foreground{color:var(--card-foreground)}.ui\:text-current{color:currentColor}.ui\:text-destructive{color:var(--destructive)}.ui\:text-foreground{color:var(--foreground)}.ui\:text-green-500{color:var(--ui-color-green-500)}.ui\:text-muted-foreground{color:var(--muted-foreground)}.ui\:text-popover-foreground{color:var(--popover-foreground)}.ui\:text-primary{color:var(--primary)}.ui\:text-primary-foreground{color:var(--primary-foreground)}.ui\:text-secondary-foreground{color:var(--secondary-foreground)}.ui\:text-sidebar-foreground,.ui\:text-sidebar-foreground\/70{color:var(--sidebar-foreground)}@supports (color:color-mix(in lab, red, red)){.ui\:text-sidebar-foreground\/70{color:color-mix(in oklab,var(--sidebar-foreground)70%,transparent)}}.ui\:text-white{color:var(--ui-color-white)}.ui\:capitalize{text-transform:capitalize}.ui\:underline-offset-4{text-underline-offset:4px}.ui\:opacity-0{opacity:0}.ui\:opacity-50{opacity:.5}.ui\:opacity-70{opacity:.7}.ui\:opacity-100{opacity:1}.ui\:shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ui\:shadow-2xs{--tw-shadow:0 1px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ui\:shadow-\[0_0_0_1px_var\(--sidebar-border\)\]{--tw-shadow:0 0 0 1px var(--tw-shadow-color,var(--sidebar-border));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ui\:shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ui\:shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ui\:shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ui\:shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ui\:shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a),0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ui\:shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ui\:ring-0{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(0px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ui\:ring-2{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ui\:ring-3{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(3px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ui\:ring-accent{--tw-ring-color:var(--accent)}.ui\:ring-background{--tw-ring-color:var(--background)}.ui\:ring-sidebar-ring{--tw-ring-color:var(--sidebar-ring)}.ui\:ring-offset-2{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.ui\:ring-offset-background{--tw-ring-offset-color:var(--background)}.ui\:outline-hidden{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.ui\:outline-hidden{outline-offset:2px;outline:2px solid #0000}}.ui\:backdrop-blur-sm{--tw-backdrop-blur:blur(var(--ui-blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.ui\:transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--ui-default-transition-timing-function));transition-duration:var(--tw-duration,var(--ui-default-transition-duration))}.ui\:transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--ui-default-transition-timing-function));transition-duration:var(--tw-duration,var(--ui-default-transition-duration))}.ui\:transition-\[left\,right\,width\]{transition-property:left,right,width;transition-timing-function:var(--tw-ease,var(--ui-default-transition-timing-function));transition-duration:var(--tw-duration,var(--ui-default-transition-duration))}.ui\:transition-\[margin\,opacity\]{transition-property:margin,opacity;transition-timing-function:var(--tw-ease,var(--ui-default-transition-timing-function));transition-duration:var(--tw-duration,var(--ui-default-transition-duration))}.ui\:transition-\[width\,height\,padding\]{transition-property:width,height,padding;transition-timing-function:var(--tw-ease,var(--ui-default-transition-timing-function));transition-duration:var(--tw-duration,var(--ui-default-transition-duration))}.ui\:transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--ui-default-transition-timing-function));transition-duration:var(--tw-duration,var(--ui-default-transition-duration))}.ui\:transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--ui-default-transition-timing-function));transition-duration:var(--tw-duration,var(--ui-default-transition-duration))}.ui\:transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--ui-default-transition-timing-function));transition-duration:var(--tw-duration,var(--ui-default-transition-duration))}.ui\:transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--ui-default-transition-timing-function));transition-duration:var(--tw-duration,var(--ui-default-transition-duration))}.ui\:transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--ui-default-transition-timing-function));transition-duration:var(--tw-duration,var(--ui-default-transition-duration))}.ui\:transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--ui-default-transition-timing-function));transition-duration:var(--tw-duration,var(--ui-default-transition-duration))}.ui\:transition-none{transition-property:none}.ui\:duration-100{--tw-duration:.1s;transition-duration:.1s}.ui\:duration-200{--tw-duration:.2s;transition-duration:.2s}.ui\:duration-300{--tw-duration:.3s;transition-duration:.3s}.ui\:duration-500{--tw-duration:.5s;transition-duration:.5s}.ui\:ease-in-out{--tw-ease:var(--ui-ease-in-out);transition-timing-function:var(--ui-ease-in-out)}.ui\:ease-linear{--tw-ease:linear;transition-timing-function:linear}.ui\:fade-in-0{--tw-enter-opacity:0}.ui\:outline-none{--tw-outline-style:none;outline-style:none}.ui\:select-none{-webkit-user-select:none;user-select:none}.ui\:zoom-in-95{--tw-enter-scale:.95}:is(.ui\:\*\:w-full>*){width:100%}.ui\:group-has-data-\[orientation\=horizontal\]\/field\:text-balance:is(:where(.ui\:group\/field):has([data-orientation=horizontal]) *){text-wrap:balance}.ui\:group-has-data-\[sidebar\=menu-action\]\/menu-item\:pr-8:is(:where(.ui\:group\/menu-item):has([data-sidebar=menu-action]) *){padding-right:calc(var(--ui-spacing)*8)}.ui\:group-has-\[\[data-slot\=item-description\]\]\/item\:translate-y-0\.5:is(:where(.ui\:group\/item):has([data-slot=item-description]) *){--tw-translate-y:calc(var(--ui-spacing)*.5);translate:var(--tw-translate-x)var(--tw-translate-y)}.ui\:group-has-\[\[data-slot\=item-description\]\]\/item\:self-start:is(:where(.ui\:group\/item):has([data-slot=item-description]) *){align-self:flex-start}.ui\:group-has-\[\>input\]\/input-group\:pt-2\.5:is(:where(.ui\:group\/input-group):has(>input) *){padding-top:calc(var(--ui-spacing)*2.5)}.ui\:group-has-\[\>input\]\/input-group\:pb-2\.5:is(:where(.ui\:group\/input-group):has(>input) *){padding-bottom:calc(var(--ui-spacing)*2.5)}.ui\:group-data-\[collapsible\=icon\]\:-mt-8:is(:where(.ui\:group)[data-collapsible=icon] *){margin-top:calc(var(--ui-spacing)*-8)}.ui\:group-data-\[collapsible\=icon\]\:hidden:is(:where(.ui\:group)[data-collapsible=icon] *){display:none}.ui\:group-data-\[collapsible\=icon\]\:size-8\!:is(:where(.ui\:group)[data-collapsible=icon] *){width:calc(var(--ui-spacing)*8)!important;height:calc(var(--ui-spacing)*8)!important}.ui\:group-data-\[collapsible\=icon\]\:w-\(--sidebar-width-icon\):is(:where(.ui\:group)[data-collapsible=icon] *){width:var(--sidebar-width-icon)}.ui\:group-data-\[collapsible\=icon\]\:w-\[calc\(var\(--sidebar-width-icon\)\+\(--spacing\(4\)\)\+2px\)\]:is(:where(.ui\:group)[data-collapsible=icon] *){width:calc(var(--sidebar-width-icon) + (calc(var(--ui-spacing)*4)) + 2px)}.ui\:group-data-\[collapsible\=icon\]\:overflow-hidden:is(:where(.ui\:group)[data-collapsible=icon] *){overflow:hidden}.ui\:group-data-\[collapsible\=icon\]\:p-0\!:is(:where(.ui\:group)[data-collapsible=icon] *){padding:calc(var(--ui-spacing)*0)!important}.ui\:group-data-\[collapsible\=icon\]\:p-2\!:is(:where(.ui\:group)[data-collapsible=icon] *){padding:calc(var(--ui-spacing)*2)!important}.ui\:group-data-\[collapsible\=icon\]\:opacity-0:is(:where(.ui\:group)[data-collapsible=icon] *){opacity:0}.ui\:group-data-\[collapsible\=offcanvas\]\:right-\[calc\(var\(--sidebar-width\)\*-1\)\]:is(:where(.ui\:group)[data-collapsible=offcanvas] *){right:calc(var(--sidebar-width)*-1)}.ui\:group-data-\[collapsible\=offcanvas\]\:left-\[calc\(var\(--sidebar-width\)\*-1\)\]:is(:where(.ui\:group)[data-collapsible=offcanvas] *){left:calc(var(--sidebar-width)*-1)}.ui\:group-data-\[collapsible\=offcanvas\]\:w-0:is(:where(.ui\:group)[data-collapsible=offcanvas] *){width:calc(var(--ui-spacing)*0)}.ui\:group-data-\[collapsible\=offcanvas\]\:translate-x-0:is(:where(.ui\:group)[data-collapsible=offcanvas] *){--tw-translate-x:calc(var(--ui-spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.ui\:group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.ui\:group)[data-disabled=true] *){pointer-events:none}.ui\:group-data-\[disabled\=true\]\:opacity-50:is(:where(.ui\:group)[data-disabled=true] *),.ui\:group-data-\[disabled\=true\]\/field\:opacity-50:is(:where(.ui\:group\/field)[data-disabled=true] *),.ui\:group-data-\[disabled\=true\]\/input-group\:opacity-50:is(:where(.ui\:group\/input-group)[data-disabled=true] *){opacity:.5}.ui\:group-data-\[side\=left\]\:-right-4:is(:where(.ui\:group)[data-side=left] *){right:calc(var(--ui-spacing)*-4)}.ui\:group-data-\[side\=left\]\:border-r:is(:where(.ui\:group)[data-side=left] *){border-right-style:var(--tw-border-style);border-right-width:1px}.ui\:group-data-\[side\=right\]\:left-0:is(:where(.ui\:group)[data-side=right] *){left:calc(var(--ui-spacing)*0)}.ui\:group-data-\[side\=right\]\:rotate-180:is(:where(.ui\:group)[data-side=right] *){rotate:180deg}.ui\:group-data-\[side\=right\]\:border-l:is(:where(.ui\:group)[data-side=right] *){border-left-style:var(--tw-border-style);border-left-width:1px}.ui\:group-data-\[variant\=floating\]\:rounded-lg:is(:where(.ui\:group)[data-variant=floating] *){border-radius:var(--radius)}.ui\:group-data-\[variant\=floating\]\:border:is(:where(.ui\:group)[data-variant=floating] *){border-style:var(--tw-border-style);border-width:1px}.ui\:group-data-\[variant\=floating\]\:border-sidebar-border:is(:where(.ui\:group)[data-variant=floating] *){border-color:var(--sidebar-border)}.ui\:group-data-\[variant\=floating\]\:shadow-sm:is(:where(.ui\:group)[data-variant=floating] *){--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ui\:group-data-\[variant\=outline\]\/field-group\:-mb-2:is(:where(.ui\:group\/field-group)[data-variant=outline] *){margin-bottom:calc(var(--ui-spacing)*-2)}.ui\:group-data-\[vaul-drawer-direction\=bottom\]\/drawer-content\:block:is(:where(.ui\:group\/drawer-content)[data-vaul-drawer-direction=bottom] *){display:block}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:top-full:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *){top:100%}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:mt-1\.5:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *){margin-top:calc(var(--ui-spacing)*1.5)}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:overflow-hidden:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *){overflow:hidden}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:rounded-md:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *){border-radius:calc(var(--radius) - 2px)}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:border:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *){border-style:var(--tw-border-style);border-width:1px}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:bg-popover:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *){background-color:var(--popover)}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:text-popover-foreground:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *){color:var(--popover-foreground)}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:shadow:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *){--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:duration-200:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *){--tw-duration:.2s;transition-duration:.2s}@media (hover:hover){.ui\:peer-hover\/menu-button\:text-sidebar-accent-foreground:is(:where(.ui\:peer\/menu-button):hover~*){color:var(--sidebar-accent-foreground)}}.ui\:peer-disabled\:cursor-not-allowed:is(:where(.ui\:peer):disabled~*){cursor:not-allowed}.ui\:peer-disabled\:opacity-50:is(:where(.ui\:peer):disabled~*){opacity:.5}.ui\:peer-data-\[active\=true\]\/menu-button\:text-sidebar-accent-foreground:is(:where(.ui\:peer\/menu-button)[data-active=true]~*){color:var(--sidebar-accent-foreground)}.ui\:peer-data-\[size\=default\]\/menu-button\:top-1\.5:is(:where(.ui\:peer\/menu-button)[data-size=default]~*){top:calc(var(--ui-spacing)*1.5)}.ui\:peer-data-\[size\=lg\]\/menu-button\:top-2\.5:is(:where(.ui\:peer\/menu-button)[data-size=lg]~*){top:calc(var(--ui-spacing)*2.5)}.ui\:peer-data-\[size\=sm\]\/menu-button\:top-1:is(:where(.ui\:peer\/menu-button)[data-size=sm]~*){top:calc(var(--ui-spacing)*1)}.ui\:selection\:bg-primary ::selection{background-color:var(--primary)}.ui\:selection\:bg-primary::selection{background-color:var(--primary)}.ui\:selection\:text-primary-foreground ::selection{color:var(--primary-foreground)}.ui\:selection\:text-primary-foreground::selection{color:var(--primary-foreground)}.ui\:placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}.ui\:after\:absolute:after{content:var(--tw-content);position:absolute}.ui\:after\:-inset-2:after{content:var(--tw-content);inset:calc(var(--ui-spacing)*-2)}.ui\:after\:inset-y-0:after{content:var(--tw-content);inset-block:calc(var(--ui-spacing)*0)}.ui\:after\:left-\[calc\(1\/2\*100\%-1px\)\]:after{content:var(--tw-content);left:calc(50% - 1px)}.ui\:after\:w-\[2px\]:after{content:var(--tw-content);width:2px}.ui\:group-data-\[collapsible\=offcanvas\]\:after\:left-full:is(:where(.ui\:group)[data-collapsible=offcanvas] *):after{content:var(--tw-content);left:100%}.ui\:last\:mt-0:last-child{margin-top:calc(var(--ui-spacing)*0)}.ui\:last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media (hover:hover){.ui\:hover\:bg-accent:hover{background-color:var(--accent)}.ui\:hover\:bg-muted:hover{background-color:var(--muted)}.ui\:hover\:bg-sidebar-accent:hover{background-color:var(--sidebar-accent)}.ui\:hover\:bg-transparent\!:hover{background-color:#0000!important}.ui\:hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.ui\:hover\:text-foreground:hover{color:var(--foreground)}.ui\:hover\:text-muted-foreground:hover{color:var(--muted-foreground)}.ui\:hover\:text-sidebar-accent-foreground:hover{color:var(--sidebar-accent-foreground)}.ui\:hover\:underline:hover{text-decoration-line:underline}.ui\:hover\:opacity-100:hover{opacity:1}.ui\:hover\:shadow-\[0_0_0_1px_var\(--sidebar-accent\)\]:hover{--tw-shadow:0 0 0 1px var(--tw-shadow-color,var(--sidebar-accent));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ui\:hover\:group-data-\[collapsible\=offcanvas\]\:bg-sidebar:hover:is(:where(.ui\:group)[data-collapsible=offcanvas] *){background-color:var(--sidebar)}.ui\:hover\:after\:bg-sidebar-border:hover:after{content:var(--tw-content);background-color:var(--sidebar-border)}}.ui\:focus\:bg-accent:focus{background-color:var(--accent)}.ui\:focus\:text-accent-foreground:focus{color:var(--accent-foreground)}.ui\:focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ui\:focus\:ring-ring:focus{--tw-ring-color:var(--ring)}.ui\:focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.ui\:focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.ui\:focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.ui\:focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.ui\:focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.ui\:focus-visible\:ring-0:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(0px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ui\:focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ui\:focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(3px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ui\:focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.ui\:focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.ui\:focus-visible\:ring-ring:focus-visible,.ui\:focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.ui\:focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab,var(--ring)50%,transparent)}}.ui\:focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.ui\:focus-visible\:outline-hidden:focus-visible{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.ui\:focus-visible\:outline-hidden:focus-visible{outline-offset:2px;outline:2px solid #0000}}.ui\:focus-visible\:outline-1:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.ui\:focus-visible\:outline-ring:focus-visible{outline-color:var(--ring)}.ui\:active\:bg-sidebar-accent:active{background-color:var(--sidebar-accent)}.ui\:active\:text-sidebar-accent-foreground:active{color:var(--sidebar-accent-foreground)}.ui\:disabled\:pointer-events-none:disabled{pointer-events:none}.ui\:disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.ui\:disabled\:opacity-50:disabled{opacity:.5}:where([data-side=left]) .ui\:in-data-\[side\=left\]\:cursor-w-resize{cursor:w-resize}:where([data-side=right]) .ui\:in-data-\[side\=right\]\:cursor-e-resize{cursor:e-resize}:where([data-slot=tooltip-content]) .ui\:in-data-\[slot\=tooltip-content\]\:bg-background\/20{background-color:var(--background)}@supports (color:color-mix(in lab, red, red)){:where([data-slot=tooltip-content]) .ui\:in-data-\[slot\=tooltip-content\]\:bg-background\/20{background-color:color-mix(in oklab,var(--background)20%,transparent)}}:where([data-slot=tooltip-content]) .ui\:in-data-\[slot\=tooltip-content\]\:text-background{color:var(--background)}.ui\:has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.ui\:has-data-\[state\=checked\]\:border-primary:has([data-state=checked]){border-color:var(--primary)}.ui\:has-data-\[state\=checked\]\:bg-primary\/5:has([data-state=checked]){background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.ui\:has-data-\[state\=checked\]\:bg-primary\/5:has([data-state=checked]){background-color:color-mix(in oklab,var(--primary)5%,transparent)}}.ui\:has-data-\[variant\=inset\]\:bg-sidebar:has([data-variant=inset]){background-color:var(--sidebar)}.ui\:has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:border-ring:has([data-slot=input-group-control]:focus-visible){border-color:var(--ring)}.ui\:has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:ring-\[3px\]:has([data-slot=input-group-control]:focus-visible){--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(3px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ui\:has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:ring-ring\/50:has([data-slot=input-group-control]:focus-visible){--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.ui\:has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:ring-ring\/50:has([data-slot=input-group-control]:focus-visible){--tw-ring-color:color-mix(in oklab,var(--ring)50%,transparent)}}.ui\:has-\[\[data-slot\]\[aria-invalid\=true\]\]\:border-destructive:has([data-slot][aria-invalid=true]){border-color:var(--destructive)}.ui\:has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/20:has([data-slot][aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.ui\:has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/20:has([data-slot][aria-invalid=true]){--tw-ring-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.ui\:has-\[\>\[data-align\=block-end\]\]\:h-auto:has(>[data-align=block-end]){height:auto}.ui\:has-\[\>\[data-align\=block-end\]\]\:flex-col:has(>[data-align=block-end]){flex-direction:column}.ui\:has-\[\>\[data-align\=block-start\]\]\:h-auto:has(>[data-align=block-start]){height:auto}.ui\:has-\[\>\[data-align\=block-start\]\]\:flex-col:has(>[data-align=block-start]){flex-direction:column}.ui\:has-\[\>\[data-slot\=button-group\]\]\:gap-2:has(>[data-slot=button-group]){gap:calc(var(--ui-spacing)*2)}.ui\:has-\[\>\[data-slot\=checkbox-group\]\]\:gap-3:has(>[data-slot=checkbox-group]){gap:calc(var(--ui-spacing)*3)}.ui\:has-\[\>\[data-slot\=field-content\]\]\:items-start:has(>[data-slot=field-content]){align-items:flex-start}.ui\:has-\[\>\[data-slot\=field\]\]\:w-full:has(>[data-slot=field]){width:100%}.ui\:has-\[\>\[data-slot\=field\]\]\:flex-col:has(>[data-slot=field]){flex-direction:column}.ui\:has-\[\>\[data-slot\=field\]\]\:rounded-md:has(>[data-slot=field]){border-radius:calc(var(--radius) - 2px)}.ui\:has-\[\>\[data-slot\=field\]\]\:border:has(>[data-slot=field]){border-style:var(--tw-border-style);border-width:1px}.ui\:has-\[\>\[data-slot\=radio-group\]\]\:gap-3:has(>[data-slot=radio-group]){gap:calc(var(--ui-spacing)*3)}.ui\:has-\[\>button\]\:ms-\[-0\.45rem\]:has(>button){margin-inline-start:-.45rem}.ui\:has-\[\>button\]\:me-\[-0\.45rem\]:has(>button){margin-inline-end:-.45rem}.ui\:has-\[\>kbd\]\:ms-\[-0\.35rem\]:has(>kbd){margin-inline-start:-.35rem}.ui\:has-\[\>kbd\]\:me-\[-0\.35rem\]:has(>kbd){margin-inline-end:-.35rem}.ui\:has-\[\>svg\]\:p-0:has(>svg){padding:calc(var(--ui-spacing)*0)}.ui\:has-\[\>svg\]\:px-2:has(>svg){padding-inline:calc(var(--ui-spacing)*2)}.ui\:has-\[\>svg\]\:px-2\.5:has(>svg){padding-inline:calc(var(--ui-spacing)*2.5)}.ui\:has-\[\>svg\]\:px-3:has(>svg){padding-inline:calc(var(--ui-spacing)*3)}.ui\:has-\[\>svg\]\:px-4:has(>svg){padding-inline:calc(var(--ui-spacing)*4)}.ui\:has-\[\>textarea\]\:h-auto:has(>textarea){height:auto}.ui\:aria-disabled\:pointer-events-none[aria-disabled=true]{pointer-events:none}.ui\:aria-disabled\:opacity-50[aria-disabled=true]{opacity:.5}.ui\:aria-invalid\:border-destructive[aria-invalid=true]{border-color:var(--destructive)}.ui\:aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.ui\:aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.ui\:aria-selected\:bg-accent[aria-selected=true]{background-color:var(--accent)}.ui\:aria-selected\:text-accent-foreground[aria-selected=true]{color:var(--accent-foreground)}:is(.ui\:\*\*\:data-command-group\:px-2 *)[data-command-group]{padding-inline:calc(var(--ui-spacing)*2)}:is(.ui\:\*\*\:data-command-input\:h-12 *)[data-command-input]{height:calc(var(--ui-spacing)*12)}:is(.ui\:\*\*\:data-command-item\:px-2 *)[data-command-item]{padding-inline:calc(var(--ui-spacing)*2)}:is(.ui\:\*\*\:data-command-item\:py-3 *)[data-command-item]{padding-block:calc(var(--ui-spacing)*3)}.ui\:data-disabled\:pointer-events-none[data-disabled]{pointer-events:none}.ui\:data-disabled\:cursor-not-allowed[data-disabled]{cursor:not-allowed}.ui\:data-disabled\:opacity-50[data-disabled]{opacity:.5}.ui\:data-highlighted\:bg-accent[data-highlighted]{background-color:var(--accent)}.ui\:data-highlighted\:text-accent-foreground[data-highlighted]{color:var(--accent-foreground)}.ui\:data-inset\:pl-8[data-inset]{padding-left:calc(var(--ui-spacing)*8)}.ui\:data-placeholder\:text-muted-foreground[data-placeholder]{color:var(--muted-foreground)}.ui\:data-\[active\=true\]\:bg-accent\/50[data-active=true]{background-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.ui\:data-\[active\=true\]\:bg-accent\/50[data-active=true]{background-color:color-mix(in oklab,var(--accent)50%,transparent)}}.ui\:data-\[active\=true\]\:bg-sidebar-accent[data-active=true]{background-color:var(--sidebar-accent)}.ui\:data-\[active\=true\]\:font-medium[data-active=true]{--tw-font-weight:var(--ui-font-weight-medium);font-weight:var(--ui-font-weight-medium)}.ui\:data-\[active\=true\]\:text-accent-foreground[data-active=true]{color:var(--accent-foreground)}.ui\:data-\[active\=true\]\:text-sidebar-accent-foreground[data-active=true]{color:var(--sidebar-accent-foreground)}@media (hover:hover){.ui\:data-\[active\=true\]\:hover\:bg-accent[data-active=true]:hover{background-color:var(--accent)}}.ui\:data-\[active\=true\]\:focus\:bg-accent[data-active=true]:focus{background-color:var(--accent)}.ui\:data-\[current\=true\]\:bg-muted[data-current=true]{background-color:var(--muted)}.ui\:data-\[disabled\=true\]\:pointer-events-none[data-disabled=true]{pointer-events:none}.ui\:data-\[disabled\=true\]\:opacity-50[data-disabled=true]{opacity:.5}.ui\:data-\[inset\]\:pl-8[data-inset]{padding-left:calc(var(--ui-spacing)*8)}.ui\:data-\[invalid\=true\]\:text-destructive[data-invalid=true]{color:var(--destructive)}.ui\:data-\[motion\=from-end\]\:slide-in-from-end-52[data-motion=from-end]:where(:dir(ltr),[dir=ltr]){--tw-enter-translate-x:calc(52*var(--spacing))}.ui\:data-\[motion\=from-end\]\:slide-in-from-end-52[data-motion=from-end]:where(:dir(rtl),[dir=rtl]),.ui\:data-\[motion\=from-start\]\:slide-in-from-start-52[data-motion=from-start]:where(:dir(ltr),[dir=ltr]){--tw-enter-translate-x:calc(52*var(--spacing)*-1)}.ui\:data-\[motion\=from-start\]\:slide-in-from-start-52[data-motion=from-start]:where(:dir(rtl),[dir=rtl]){--tw-enter-translate-x:calc(52*var(--spacing))}.ui\:data-\[motion\=to-end\]\:slide-out-to-end-52[data-motion=to-end]:where(:dir(ltr),[dir=ltr]){--tw-exit-translate-x:calc(52*var(--spacing))}.ui\:data-\[motion\=to-end\]\:slide-out-to-end-52[data-motion=to-end]:where(:dir(rtl),[dir=rtl]),.ui\:data-\[motion\=to-start\]\:slide-out-to-start-52[data-motion=to-start]:where(:dir(ltr),[dir=ltr]){--tw-exit-translate-x:calc(52*var(--spacing)*-1)}.ui\:data-\[motion\=to-start\]\:slide-out-to-start-52[data-motion=to-start]:where(:dir(rtl),[dir=rtl]){--tw-exit-translate-x:calc(52*var(--spacing))}.ui\:data-\[motion\^\=from-\]\:animate-in[data-motion^=from-]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.ui\:data-\[motion\^\=from-\]\:fade-in[data-motion^=from-]{--tw-enter-opacity:0}.ui\:data-\[motion\^\=to-\]\:animate-out[data-motion^=to-]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.ui\:data-\[motion\^\=to-\]\:fade-out[data-motion^=to-]{--tw-exit-opacity:0}.ui\:data-\[orientation\=horizontal\]\:h-px[data-orientation=horizontal]{height:1px}.ui\:data-\[orientation\=horizontal\]\:w-full[data-orientation=horizontal]{width:100%}.ui\:data-\[orientation\=vertical\]\:h-auto[data-orientation=vertical]{height:auto}.ui\:data-\[orientation\=vertical\]\:h-full[data-orientation=vertical]{height:100%}.ui\:data-\[orientation\=vertical\]\:w-px[data-orientation=vertical]{width:1px}.ui\:data-\[side\=bottom\]\:-translate-x-1\/2[data-side=bottom]{--tw-translate-x:calc(calc(1/2*100%)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.ui\:data-\[side\=bottom\]\:-translate-y-\[calc\(-50\%\+1px\)\][data-side=bottom]{--tw-translate-y:calc(calc(-50% + 1px)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.ui\:data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y:calc(var(--ui-spacing)*1);translate:var(--tw-translate-x)var(--tw-translate-y)}.ui\:data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y:calc(2*var(--spacing)*-1)}.ui\:data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x:calc(var(--ui-spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.ui\:data-\[side\=left\]\:-translate-y-\[calc\(50\%-3px\)\][data-side=left]{--tw-translate-y:calc(calc(50% - 3px)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.ui\:data-\[side\=left\]\:slide-in-from-end-2[data-side=left]:where(:dir(ltr),[dir=ltr]){--tw-enter-translate-x:calc(2*var(--spacing))}.ui\:data-\[side\=left\]\:slide-in-from-end-2[data-side=left]:where(:dir(rtl),[dir=rtl]){--tw-enter-translate-x:calc(2*var(--spacing)*-1)}.ui\:data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x:calc(2*var(--spacing))}.ui\:data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x:calc(var(--ui-spacing)*1);translate:var(--tw-translate-x)var(--tw-translate-y)}.ui\:data-\[side\=right\]\:translate-x-\[calc\(50\%\+2px\)\][data-side=right]{--tw-translate-x:calc(50% + 2px);translate:var(--tw-translate-x)var(--tw-translate-y)}.ui\:data-\[side\=right\]\:translate-y-1\/2[data-side=right]{--tw-translate-y:calc(1/2*100%);translate:var(--tw-translate-x)var(--tw-translate-y)}.ui\:data-\[side\=right\]\:slide-in-from-start-2[data-side=right]:where(:dir(ltr),[dir=ltr]){--tw-enter-translate-x:calc(2*var(--spacing)*-1)}.ui\:data-\[side\=right\]\:slide-in-from-start-2[data-side=right]:where(:dir(rtl),[dir=rtl]){--tw-enter-translate-x:calc(2*var(--spacing))}.ui\:data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x:calc(2*var(--spacing)*-1)}.ui\:data-\[side\=top\]\:translate-x-1\/2[data-side=top]{--tw-translate-x:calc(1/2*100%);translate:var(--tw-translate-x)var(--tw-translate-y)}.ui\:data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y:calc(var(--ui-spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.ui\:data-\[side\=top\]\:translate-y-\[calc\(-50\%\+2px\)\][data-side=top]{--tw-translate-y:calc(-50% + 2px);translate:var(--tw-translate-x)var(--tw-translate-y)}.ui\:data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y:calc(2*var(--spacing))}.ui\:data-\[size\=default\]\:h-9[data-size=default]{height:calc(var(--ui-spacing)*9)}.ui\:data-\[size\=sm\]\:h-8[data-size=sm]{height:calc(var(--ui-spacing)*8)}.ui\:data-\[slot\=checkbox-group\]\:gap-3[data-slot=checkbox-group]{gap:calc(var(--ui-spacing)*3)}:is(.ui\:\*\:data-\[slot\=field\]\:p-4>*)[data-slot=field]{padding:calc(var(--ui-spacing)*4)}:is(.ui\:\*\:data-\[slot\=field-group\]\:gap-4>*)[data-slot=field-group]{gap:calc(var(--ui-spacing)*4)}:is(.ui\:\*\*\:data-\[slot\=navigation-menu-link\]\:focus\:ring-0 *)[data-slot=navigation-menu-link]:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(0px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}:is(.ui\:\*\*\:data-\[slot\=navigation-menu-link\]\:focus\:outline-none *)[data-slot=navigation-menu-link]:focus{--tw-outline-style:none;outline-style:none}:is(.ui\:\*\*\:data-\[slot\=select-value\]\:line-clamp-1 *)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.ui\:\*\*\:data-\[slot\=select-value\]\:flex *)[data-slot=select-value]{display:flex}:is(.ui\:\*\*\:data-\[slot\=select-value\]\:items-center *)[data-slot=select-value]{align-items:center}:is(.ui\:\*\*\:data-\[slot\=select-value\]\:gap-2 *)[data-slot=select-value]{gap:calc(var(--ui-spacing)*2)}.ui\:data-\[state\=active\]\:bg-background[data-state=active]{background-color:var(--background)}.ui\:data-\[state\=active\]\:shadow-sm[data-state=active]{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ui\:data-\[state\=checked\]\:translate-x-\[calc\(100\%-2px\)\][data-state=checked]{--tw-translate-x:calc(100% - 2px);translate:var(--tw-translate-x)var(--tw-translate-y)}.ui\:data-\[state\=checked\]\:border-primary[data-state=checked]{border-color:var(--primary)}.ui\:data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:var(--primary)}.ui\:data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:var(--primary-foreground)}.ui\:data-\[state\=closed\]\:animate-accordion-up[data-state=closed]{animation:accordion-up var(--tw-animation-duration,var(--tw-duration,.2s))var(--tw-ease,ease-out)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.ui\:data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.ui\:data-\[state\=closed\]\:duration-300[data-state=closed]{--tw-duration:.3s;transition-duration:.3s}.ui\:data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity:0}.ui\:data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale:.95}.ui\:data-\[state\=closed\]\:slide-out-to-bottom[data-state=closed]{--tw-exit-translate-y:100%}.ui\:data-\[state\=closed\]\:slide-out-to-left[data-state=closed]{--tw-exit-translate-x:-100%}.ui\:data-\[state\=closed\]\:slide-out-to-right[data-state=closed]{--tw-exit-translate-x:100%}.ui\:data-\[state\=closed\]\:slide-out-to-top[data-state=closed]{--tw-exit-translate-y:-100%}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:data-\[state\=closed\]\:animate-out:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *)[data-state=closed]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:data-\[state\=closed\]\:fade-out-0:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *)[data-state=closed]{--tw-exit-opacity:0}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:data-\[state\=closed\]\:zoom-out-95:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *)[data-state=closed]{--tw-exit-scale:.95}.ui\:data-\[state\=hidden\]\:animate-out[data-state=hidden]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.ui\:data-\[state\=hidden\]\:fade-out[data-state=hidden]{--tw-exit-opacity:0}.ui\:data-\[state\=off\]\:text-muted-foreground[data-state=off]{color:var(--muted-foreground)}@media (hover:hover){.ui\:hover\:data-\[state\=off\]\:text-accent-foreground:hover[data-state=off]{color:var(--accent-foreground)}}.ui\:data-\[state\=on\]\:bg-accent[data-state=on]{background-color:var(--accent)}.ui\:data-\[state\=on\]\:bg-transparent[data-state=on]{background-color:#0000}.ui\:data-\[state\=on\]\:text-accent-foreground[data-state=on]{color:var(--accent-foreground)}.ui\:data-\[state\=on\]\:text-muted-foreground[data-state=on]{color:var(--muted-foreground)}@media (hover:hover){.ui\:hover\:data-\[state\=on\]\:text-accent-foreground:hover[data-state=on]{color:var(--accent-foreground)}}.ui\:data-\[state\=open\]\:animate-accordion-down[data-state=open]{animation:accordion-down var(--tw-animation-duration,var(--tw-duration,.2s))var(--tw-ease,ease-out)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.ui\:data-\[state\=open\]\:animate-in[data-state=open]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.ui\:data-\[state\=open\]\:bg-accent[data-state=open]{background-color:var(--accent)}.ui\:data-\[state\=open\]\:text-accent-foreground[data-state=open]{color:var(--accent-foreground)}.ui\:data-\[state\=open\]\:opacity-100[data-state=open]{opacity:1}.ui\:data-\[state\=open\]\:duration-500[data-state=open]{--tw-duration:.5s;transition-duration:.5s}.ui\:data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity:0}.ui\:data-\[state\=open\]\:zoom-in-90[data-state=open]{--tw-enter-scale:.9}.ui\:data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale:.95}.ui\:data-\[state\=open\]\:slide-in-from-bottom[data-state=open]{--tw-enter-translate-y:100%}.ui\:data-\[state\=open\]\:slide-in-from-left[data-state=open]{--tw-enter-translate-x:-100%}.ui\:data-\[state\=open\]\:slide-in-from-right[data-state=open]{--tw-enter-translate-x:100%}.ui\:data-\[state\=open\]\:slide-in-from-top[data-state=open]{--tw-enter-translate-y:-100%}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:data-\[state\=open\]\:animate-in:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *)[data-state=open]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:data-\[state\=open\]\:fade-in-0:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *)[data-state=open]{--tw-enter-opacity:0}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:data-\[state\=open\]\:zoom-in-95:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *)[data-state=open]{--tw-enter-scale:.95}@media (hover:hover){.ui\:data-\[state\=open\]\:hover\:bg-sidebar-accent[data-state=open]:hover{background-color:var(--sidebar-accent)}.ui\:data-\[state\=open\]\:hover\:text-sidebar-accent-foreground[data-state=open]:hover{color:var(--sidebar-accent-foreground)}}.ui\:data-\[state\=selected\]\:bg-muted[data-state=selected]{background-color:var(--muted)}.ui\:data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x:calc(var(--ui-spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.ui\:data-\[state\=unchecked\]\:bg-input[data-state=unchecked]{background-color:var(--input)}.ui\:data-\[state\=visible\]\:animate-in[data-state=visible]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.ui\:data-\[state\=visible\]\:fade-in[data-state=visible]{--tw-enter-opacity:0}.ui\:data-\[variant\=destructive\]\:text-destructive[data-variant=destructive]{color:var(--destructive)}.ui\:data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.ui\:data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:color-mix(in oklab,var(--destructive)10%,transparent)}}.ui\:data-\[variant\=destructive\]\:focus\:text-destructive[data-variant=destructive]:focus{color:var(--destructive)}.ui\:data-\[variant\=destructive\]\:data-highlighted\:bg-destructive\/10[data-variant=destructive][data-highlighted]{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.ui\:data-\[variant\=destructive\]\:data-highlighted\:bg-destructive\/10[data-variant=destructive][data-highlighted]{background-color:color-mix(in oklab,var(--destructive)10%,transparent)}}.ui\:data-\[variant\=destructive\]\:data-highlighted\:text-destructive[data-variant=destructive][data-highlighted]{color:var(--destructive)}.ui\:data-\[variant\=label\]\:text-sm[data-variant=label]{font-size:var(--ui-text-sm);line-height:var(--tw-leading,var(--ui-text-sm--line-height))}.ui\:data-\[variant\=legend\]\:text-base[data-variant=legend]{font-size:var(--ui-text-base);line-height:var(--tw-leading,var(--ui-text-base--line-height))}.ui\:data-\[vaul-drawer-direction\=bottom\]\:inset-x-0[data-vaul-drawer-direction=bottom]{inset-inline:calc(var(--ui-spacing)*0)}.ui\:data-\[vaul-drawer-direction\=bottom\]\:bottom-0[data-vaul-drawer-direction=bottom]{bottom:calc(var(--ui-spacing)*0)}.ui\:data-\[vaul-drawer-direction\=bottom\]\:mt-24[data-vaul-drawer-direction=bottom]{margin-top:calc(var(--ui-spacing)*24)}.ui\:data-\[vaul-drawer-direction\=bottom\]\:max-h-\[80vh\][data-vaul-drawer-direction=bottom]{max-height:80vh}.ui\:data-\[vaul-drawer-direction\=bottom\]\:rounded-t-lg[data-vaul-drawer-direction=bottom]{border-top-left-radius:var(--radius);border-top-right-radius:var(--radius)}.ui\:data-\[vaul-drawer-direction\=bottom\]\:border-t[data-vaul-drawer-direction=bottom]{border-top-style:var(--tw-border-style);border-top-width:1px}.ui\:data-\[vaul-drawer-direction\=left\]\:inset-y-0[data-vaul-drawer-direction=left]{inset-block:calc(var(--ui-spacing)*0)}.ui\:data-\[vaul-drawer-direction\=left\]\:start-0[data-vaul-drawer-direction=left]{inset-inline-start:calc(var(--ui-spacing)*0)}.ui\:data-\[vaul-drawer-direction\=left\]\:w-3\/4[data-vaul-drawer-direction=left]{width:75%}.ui\:data-\[vaul-drawer-direction\=left\]\:border-e[data-vaul-drawer-direction=left]{border-inline-end-style:var(--tw-border-style);border-inline-end-width:1px}.ui\:data-\[vaul-drawer-direction\=right\]\:inset-y-0[data-vaul-drawer-direction=right]{inset-block:calc(var(--ui-spacing)*0)}.ui\:data-\[vaul-drawer-direction\=right\]\:end-0[data-vaul-drawer-direction=right]{inset-inline-end:calc(var(--ui-spacing)*0)}.ui\:data-\[vaul-drawer-direction\=right\]\:w-3\/4[data-vaul-drawer-direction=right]{width:75%}.ui\:data-\[vaul-drawer-direction\=right\]\:border-s[data-vaul-drawer-direction=right]{border-inline-start-style:var(--tw-border-style);border-inline-start-width:1px}.ui\:data-\[vaul-drawer-direction\=top\]\:inset-x-0[data-vaul-drawer-direction=top]{inset-inline:calc(var(--ui-spacing)*0)}.ui\:data-\[vaul-drawer-direction\=top\]\:top-0[data-vaul-drawer-direction=top]{top:calc(var(--ui-spacing)*0)}.ui\:data-\[vaul-drawer-direction\=top\]\:mb-24[data-vaul-drawer-direction=top]{margin-bottom:calc(var(--ui-spacing)*24)}.ui\:data-\[vaul-drawer-direction\=top\]\:max-h-\[80vh\][data-vaul-drawer-direction=top]{max-height:80vh}.ui\:data-\[vaul-drawer-direction\=top\]\:rounded-b-lg[data-vaul-drawer-direction=top]{border-bottom-right-radius:var(--radius);border-bottom-left-radius:var(--radius)}.ui\:data-\[vaul-drawer-direction\=top\]\:border-b[data-vaul-drawer-direction=top]{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.ui\:nth-last-2\:-mt-1:nth-last-child(2){margin-top:calc(var(--ui-spacing)*-1)}@media (min-width:40rem){.ui\:sm\:flex{display:flex}.ui\:sm\:max-w-lg{max-width:var(--ui-container-lg)}.ui\:sm\:max-w-sm{max-width:var(--ui-container-sm)}.ui\:sm\:flex-row{flex-direction:row}.ui\:sm\:justify-end{justify-content:flex-end}.ui\:sm\:gap-2\.5{gap:calc(var(--ui-spacing)*2.5)}.ui\:sm\:pr-2\.5{padding-right:calc(var(--ui-spacing)*2.5)}.ui\:sm\:pl-2\.5{padding-left:calc(var(--ui-spacing)*2.5)}.ui\:data-\[vaul-drawer-direction\=left\]\:sm\:max-w-sm[data-vaul-drawer-direction=left],.ui\:data-\[vaul-drawer-direction\=right\]\:sm\:max-w-sm[data-vaul-drawer-direction=right]{max-width:var(--ui-container-sm)}}@media (min-width:48rem){.ui\:md\:block{display:block}.ui\:md\:flex{display:flex}.ui\:md\:p-12{padding:calc(var(--ui-spacing)*12)}.ui\:md\:text-sm{font-size:var(--ui-text-sm);line-height:var(--tw-leading,var(--ui-text-sm--line-height))}.ui\:md\:opacity-0{opacity:0}.ui\:md\:peer-data-\[variant\=inset\]\:m-2:is(:where(.ui\:peer)[data-variant=inset]~*){margin:calc(var(--ui-spacing)*2)}.ui\:md\:peer-data-\[variant\=inset\]\:ml-0:is(:where(.ui\:peer)[data-variant=inset]~*){margin-left:calc(var(--ui-spacing)*0)}.ui\:md\:peer-data-\[variant\=inset\]\:rounded-xl:is(:where(.ui\:peer)[data-variant=inset]~*){border-radius:calc(var(--radius) + 4px)}.ui\:md\:peer-data-\[variant\=inset\]\:shadow-sm:is(:where(.ui\:peer)[data-variant=inset]~*){--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ui\:md\:peer-data-\[variant\=inset\]\:peer-data-\[state\=collapsed\]\:ml-2:is(:where(.ui\:peer)[data-variant=inset]~*):is(:where(.ui\:peer)[data-state=collapsed]~*){margin-left:calc(var(--ui-spacing)*2)}.ui\:md\:after\:hidden:after{content:var(--tw-content);display:none}}.ui\:dark\:scale-0:where(.dark,.dark *){--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x)var(--tw-scale-y)}.ui\:dark\:scale-100:where(.dark,.dark *){--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x)var(--tw-scale-y)}.ui\:dark\:-rotate-90:where(.dark,.dark *){rotate:-90deg}.ui\:dark\:rotate-0:where(.dark,.dark *){rotate:none}.ui\:dark\:border-input:where(.dark,.dark *){border-color:var(--input)}.ui\:dark\:bg-destructive\/60:where(.dark,.dark *){background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.ui\:dark\:bg-destructive\/60:where(.dark,.dark *){background-color:color-mix(in oklab,var(--destructive)60%,transparent)}}.ui\:dark\:bg-destructive\/70:where(.dark,.dark *){background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.ui\:dark\:bg-destructive\/70:where(.dark,.dark *){background-color:color-mix(in oklab,var(--destructive)70%,transparent)}}.ui\:dark\:bg-input\/30:where(.dark,.dark *){background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.ui\:dark\:bg-input\/30:where(.dark,.dark *){background-color:color-mix(in oklab,var(--input)30%,transparent)}}.ui\:dark\:bg-transparent:where(.dark,.dark *){background-color:#0000}.ui\:dark\:text-muted-foreground:where(.dark,.dark *){color:var(--muted-foreground)}@media (hover:hover){.ui\:dark\:hover\:bg-input\/50:where(.dark,.dark *):hover{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.ui\:dark\:hover\:bg-input\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab,var(--input)50%,transparent)}}}.ui\:dark\:focus-visible\:ring-destructive\/40:where(.dark,.dark *):focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.ui\:dark\:focus-visible\:ring-destructive\/40:where(.dark,.dark *):focus-visible{--tw-ring-color:color-mix(in oklab,var(--destructive)40%,transparent)}}:where([data-slot=tooltip-content]) .ui\:dark\:in-data-\[slot\=tooltip-content\]\:bg-background\/10:where(.dark,.dark *){background-color:var(--background)}@supports (color:color-mix(in lab, red, red)){:where([data-slot=tooltip-content]) .ui\:dark\:in-data-\[slot\=tooltip-content\]\:bg-background\/10:where(.dark,.dark *){background-color:color-mix(in oklab,var(--background)10%,transparent)}}.ui\:dark\:has-data-\[state\=checked\]\:bg-primary\/10:where(.dark,.dark *):has([data-state=checked]){background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.ui\:dark\:has-data-\[state\=checked\]\:bg-primary\/10:where(.dark,.dark *):has([data-state=checked]){background-color:color-mix(in oklab,var(--primary)10%,transparent)}}.ui\:dark\:has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/40:where(.dark,.dark *):has([data-slot][aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.ui\:dark\:has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/40:where(.dark,.dark *):has([data-slot][aria-invalid=true]){--tw-ring-color:color-mix(in oklab,var(--destructive)40%,transparent)}}.ui\:dark\:aria-invalid\:ring-destructive\/40:where(.dark,.dark *)[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.ui\:dark\:aria-invalid\:ring-destructive\/40:where(.dark,.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab,var(--destructive)40%,transparent)}}.ui\:dark\:data-\[state\=active\]\:border-input:where(.dark,.dark *)[data-state=active]{border-color:var(--input)}.ui\:dark\:data-\[state\=active\]\:bg-input\/30:where(.dark,.dark *)[data-state=active]{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.ui\:dark\:data-\[state\=active\]\:bg-input\/30:where(.dark,.dark *)[data-state=active]{background-color:color-mix(in oklab,var(--input)30%,transparent)}}.ui\:dark\:data-\[state\=active\]\:text-foreground:where(.dark,.dark *)[data-state=active]{color:var(--foreground)}.ui\:dark\:data-\[state\=checked\]\:bg-primary:where(.dark,.dark *)[data-state=checked]{background-color:var(--primary)}.ui\:dark\:data-\[state\=checked\]\:bg-primary-foreground:where(.dark,.dark *)[data-state=checked]{background-color:var(--primary-foreground)}.ui\:dark\:data-\[state\=unchecked\]\:bg-foreground:where(.dark,.dark *)[data-state=unchecked]{background-color:var(--foreground)}.ui\:dark\:data-\[state\=unchecked\]\:bg-input\/80:where(.dark,.dark *)[data-state=unchecked]{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.ui\:dark\:data-\[state\=unchecked\]\:bg-input\/80:where(.dark,.dark *)[data-state=unchecked]{background-color:color-mix(in oklab,var(--input)80%,transparent)}}.ui\:\[\&_\[data-command-group\]\:not\(\[hidden\]\)_\~\[data-command-group\]\]\:pt-0 [data-command-group]:not([hidden])~[data-command-group]{padding-top:calc(var(--ui-spacing)*0)}.ui\:\[\&_\[data-command-input-wrapper\]_svg\]\:h-5 [data-command-input-wrapper] svg{height:calc(var(--ui-spacing)*5)}.ui\:\[\&_\[data-command-input-wrapper\]_svg\]\:w-5 [data-command-input-wrapper] svg{width:calc(var(--ui-spacing)*5)}.ui\:\[\&_\[data-command-item\]_svg\]\:h-5 [data-command-item] svg{height:calc(var(--ui-spacing)*5)}.ui\:\[\&_\[data-command-item\]_svg\]\:w-5 [data-command-item] svg{width:calc(var(--ui-spacing)*5)}.ui\:\[\&_img\]\:size-full img{width:100%;height:100%}.ui\:\[\&_img\]\:object-cover img{object-fit:cover}.ui\:\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.ui\:\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.ui\:\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3 svg:not([class*=size-]){width:calc(var(--ui-spacing)*3);height:calc(var(--ui-spacing)*3)}.ui\:\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--ui-spacing)*4);height:calc(var(--ui-spacing)*4)}.ui\:\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-6 svg:not([class*=size-]){width:calc(var(--ui-spacing)*6);height:calc(var(--ui-spacing)*6)}.ui\:\[\&_svg\:not\(\[class\*\=\'text-\'\]\)\]\:text-muted-foreground svg:not([class*=text-]){color:var(--muted-foreground)}.ui\:\[\&_tr\]\:border-b tr{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.ui\:\[\&_tr\:last-child\]\:border-0 tr:last-child{border-style:var(--tw-border-style);border-width:0}.ui\:\[\&\+\[data-slot\=item-content\]\]\:flex-none+[data-slot=item-content]{flex:none}.ui\:\[\&\:has\(\[role\=checkbox\]\)\]\:pr-0:has([role=checkbox]){padding-right:calc(var(--ui-spacing)*0)}.ui\:\[\.border-b\]\:pb-3.border-b{padding-bottom:calc(var(--ui-spacing)*3)}.ui\:\[\.border-b\]\:pb-6.border-b{padding-bottom:calc(var(--ui-spacing)*6)}.ui\:\[\.border-t\]\:pt-3.border-t{padding-top:calc(var(--ui-spacing)*3)}.ui\:\[\.border-t\]\:pt-6.border-t{padding-top:calc(var(--ui-spacing)*6)}.ui\:\[a\]\:transition-colors:is(a){transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--ui-default-transition-timing-function));transition-duration:var(--tw-duration,var(--ui-default-transition-duration))}@media (hover:hover){.ui\:\[a\]\:hover\:bg-accent\/50:is(a):hover{background-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.ui\:\[a\]\:hover\:bg-accent\/50:is(a):hover{background-color:color-mix(in oklab,var(--accent)50%,transparent)}}}:is(.ui\:\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.ui\:\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.ui\:\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--ui-spacing)*2)}:is(.ui\:data-\[variant\=destructive\]\:\*\:\[svg\]\:text-destructive\![data-variant=destructive]>*):is(svg){color:var(--destructive)!important}.ui\:\[\&\>\*\]\:focus-visible\:relative>:focus-visible{position:relative}.ui\:\[\&\>\*\]\:focus-visible\:z-10>:focus-visible{z-index:10}.ui\:\[\&\>\*\:not\(\:first-child\)\]\:rounded-s-none>:not(:first-child){border-start-start-radius:0;border-end-start-radius:0}.ui\:\[\&\>\*\:not\(\:first-child\)\]\:rounded-t-none>:not(:first-child){border-top-left-radius:0;border-top-right-radius:0}.ui\:\[\&\>\*\:not\(\:first-child\)\]\:border-s-0>:not(:first-child){border-inline-start-style:var(--tw-border-style);border-inline-start-width:0}.ui\:\[\&\>\*\:not\(\:first-child\)\]\:border-t-0>:not(:first-child){border-top-style:var(--tw-border-style);border-top-width:0}.ui\:\[\&\>\*\:not\(\:last-child\)\]\:rounded-e-none>:not(:last-child){border-start-end-radius:0;border-end-end-radius:0}.ui\:\[\&\>\*\:not\(\:last-child\)\]\:rounded-b-none>:not(:last-child){border-bottom-right-radius:0;border-bottom-left-radius:0}.ui\:has-\[select\[aria-hidden\=true\]\:last-child\]\:\[\&\>\[data-slot\=select-trigger\]\:last-of-type\]\:rounded-e-md:has(:is(select[aria-hidden=true]:last-child))>[data-slot=select-trigger]:last-of-type{border-start-end-radius:calc(var(--radius) - 2px);border-end-end-radius:calc(var(--radius) - 2px)}.ui\:\[\&\>\[data-slot\=select-trigger\]\:not\(\[class\*\=\'w-\'\]\)\]\:w-fit>[data-slot=select-trigger]:not([class*=w-]){width:fit-content}.ui\:has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content])>[role=checkbox],.ui\:has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content]) [role=radio]{margin-top:1px}.ui\:\[\&\>a\]\:underline>a{text-decoration-line:underline}.ui\:\[\&\>a\]\:underline-offset-4>a{text-underline-offset:4px}.ui\:\[\&\>a\:hover\]\:text-primary>a:hover{color:var(--primary)}.ui\:\[\&\>button\]\:hidden>button{display:none}.ui\:\[\&\>input\]\:flex-1>input{flex:1}.ui\:has-\[\>\[data-align\=block-end\]\]\:\[\&\>input\]\:pt-3:has(>[data-align=block-end])>input{padding-top:calc(var(--ui-spacing)*3)}.ui\:has-\[\>\[data-align\=block-start\]\]\:\[\&\>input\]\:pb-3:has(>[data-align=block-start])>input{padding-bottom:calc(var(--ui-spacing)*3)}.ui\:has-\[\>\[data-align\=inline-end\]\]\:\[\&\>input\]\:pe-2:has(>[data-align=inline-end])>input{padding-inline-end:calc(var(--ui-spacing)*2)}.ui\:has-\[\>\[data-align\=inline-start\]\]\:\[\&\>input\]\:ps-2:has(>[data-align=inline-start])>input{padding-inline-start:calc(var(--ui-spacing)*2)}.ui\:\[\&\>kbd\]\:rounded-\[calc\(var\(--radius\)-5px\)\]>kbd{border-radius:calc(var(--radius) - 5px)}.ui\:\[\&\>span\:last-child\]\:truncate>span:last-child{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.ui\:\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.ui\:\[\&\>svg\]\:size-2\.5>svg{width:calc(var(--ui-spacing)*2.5);height:calc(var(--ui-spacing)*2.5)}.ui\:\[\&\>svg\]\:size-3>svg{width:calc(var(--ui-spacing)*3);height:calc(var(--ui-spacing)*3)}.ui\:\[\&\>svg\]\:size-3\.5>svg{width:calc(var(--ui-spacing)*3.5);height:calc(var(--ui-spacing)*3.5)}.ui\:\[\&\>svg\]\:size-4>svg{width:calc(var(--ui-spacing)*4);height:calc(var(--ui-spacing)*4)}.ui\:\[\&\>svg\]\:shrink-0>svg{flex-shrink:0}.ui\:\[\&\>svg\]\:text-muted-foreground>svg{color:var(--muted-foreground)}.ui\:\[\&\>svg\]\:text-sidebar-accent-foreground>svg{color:var(--sidebar-accent-foreground)}.ui\:\[\&\>svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3\.5>svg:not([class*=size-]){width:calc(var(--ui-spacing)*3.5);height:calc(var(--ui-spacing)*3.5)}.ui\:\[\&\>svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4>svg:not([class*=size-]){width:calc(var(--ui-spacing)*4);height:calc(var(--ui-spacing)*4)}@media (hover:hover){:is(.ui\:hover\:\[\&\,\&\>svelte-css-wrapper\]\:\[\&\>th\,td\]\:bg-muted\/50:hover,.ui\:hover\:\[\&\,\&\>svelte-css-wrapper\]\:\[\&\>th\,td\]\:bg-muted\/50:hover>svelte-css-wrapper)>th,:is(.ui\:hover\:\[\&\,\&\>svelte-css-wrapper\]\:\[\&\>th\,td\]\:bg-muted\/50:hover,.ui\:hover\:\[\&\,\&\>svelte-css-wrapper\]\:\[\&\>th\,td\]\:bg-muted\/50:hover>svelte-css-wrapper) td{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){:is(.ui\:hover\:\[\&\,\&\>svelte-css-wrapper\]\:\[\&\>th\,td\]\:bg-muted\/50:hover,.ui\:hover\:\[\&\,\&\>svelte-css-wrapper\]\:\[\&\>th\,td\]\:bg-muted\/50:hover>svelte-css-wrapper)>th,:is(.ui\:hover\:\[\&\,\&\>svelte-css-wrapper\]\:\[\&\>th\,td\]\:bg-muted\/50:hover,.ui\:hover\:\[\&\,\&\>svelte-css-wrapper\]\:\[\&\>th\,td\]\:bg-muted\/50:hover>svelte-css-wrapper) td{background-color:color-mix(in oklab,var(--muted)50%,transparent)}}}.ui\:\[\&\>tr\]\:last\:border-b-0>tr:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.ui\:\[\&\[data-state\=open\]\>svg\]\:rotate-180[data-state=open]>svg{rotate:180deg}[data-side=left][data-collapsible=offcanvas] .ui\:\[\[data-side\=left\]\[data-collapsible\=offcanvas\]_\&\]\:-right-2{right:calc(var(--ui-spacing)*-2)}[data-side=left][data-state=collapsed] .ui\:\[\[data-side\=left\]\[data-state\=collapsed\]_\&\]\:cursor-e-resize{cursor:e-resize}[data-side=right][data-collapsible=offcanvas] .ui\:\[\[data-side\=right\]\[data-collapsible\=offcanvas\]_\&\]\:-left-2{left:calc(var(--ui-spacing)*-2)}[data-side=right][data-state=collapsed] .ui\:\[\[data-side\=right\]\[data-state\=collapsed\]_\&\]\:cursor-w-resize{cursor:w-resize}[data-variant=legend]+.ui\:\[\[data-variant\=legend\]\+\&\]\:-mt-1\.5{margin-top:calc(var(--ui-spacing)*-1.5)}@media (hover:hover){a.ui\:\[a\&\]\:hover\:bg-accent:hover{background-color:var(--accent)}a.ui\:\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){a.ui\:\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive)90%,transparent)}}a.ui\:\[a\&\]\:hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){a.ui\:\[a\&\]\:hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary)90%,transparent)}}a.ui\:\[a\&\]\:hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){a.ui\:\[a\&\]\:hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--secondary)80%,transparent)}}a.ui\:\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){a.ui\:\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:color-mix(in oklab,var(--secondary)90%,transparent)}}a.ui\:\[a\&\]\:hover\:text-accent-foreground:hover{color:var(--accent-foreground)}a.ui\:\[a\&\]\:hover\:underline:hover{text-decoration-line:underline}a.ui\:dark\:\[a\&\]\:hover\:bg-accent\/50:where(.dark,.dark *):hover{background-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){a.ui\:dark\:\[a\&\]\:hover\:bg-accent\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab,var(--accent)50%,transparent)}}}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:none}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}:root{--radius:.625rem;--background:oklch(100% 0 0);--foreground:oklch(14.7% .004 49.25);--card:oklch(100% 0 0);--card-foreground:oklch(14.7% .004 49.25);--popover:oklch(100% 0 0);--popover-foreground:oklch(14.7% .004 49.25);--primary:oklch(21.6% .006 56.043);--primary-foreground:oklch(98.5% .001 106.423);--secondary:oklch(97% .001 106.424);--secondary-foreground:oklch(21.6% .006 56.043);--muted:oklch(97% .001 106.424);--muted-foreground:oklch(55.3% .013 58.071);--accent:oklch(96.7% .001 286.375);--accent-foreground:oklch(21.6% .006 56.043);--destructive:oklch(57.7% .245 27.325);--border:oklch(92.3% .003 48.717);--input:oklch(92.3% .003 48.717);--ring:oklch(70.9% .01 56.259);--chart-1:oklch(64.6% .222 41.116);--chart-2:oklch(60% .118 184.704);--chart-3:oklch(39.8% .07 227.392);--chart-4:oklch(82.8% .189 84.429);--chart-5:oklch(76.9% .188 70.08);--sidebar:oklch(100% 0 0);--sidebar-foreground:oklch(14.7% .004 49.25);--sidebar-primary:oklch(21.6% .006 56.043);--sidebar-primary-foreground:oklch(98.5% .001 106.423);--sidebar-accent:oklch(97% .001 106.424);--sidebar-accent-foreground:oklch(21.6% .006 56.043);--sidebar-border:oklch(92.3% .003 48.717);--sidebar-ring:oklch(70.9% .01 56.259)}.dark{--background:oklch(14.7% .004 49.25);--foreground:oklch(98.5% .001 106.423);--card:oklch(21.6% .006 56.043);--card-foreground:oklch(98.5% .001 106.423);--popover:oklch(21.6% .006 56.043);--popover-foreground:oklch(98.5% .001 106.423);--primary:oklch(92.3% .003 48.717);--primary-foreground:oklch(21.6% .006 56.043);--secondary:oklch(26.8% .007 34.298);--secondary-foreground:oklch(98.5% .001 106.423);--muted:oklch(26.8% .007 34.298);--muted-foreground:oklch(70.9% .01 56.259);--accent:oklch(26.8% .007 34.298);--accent-foreground:oklch(98.5% .001 106.423);--destructive:oklch(70.4% .191 22.216);--border:oklch(100% 0 0/.1);--input:oklch(100% 0 0/.15);--ring:oklch(55.3% .013 58.071);--chart-1:oklch(48.8% .243 264.376);--chart-2:oklch(69.6% .17 162.48);--chart-3:oklch(76.9% .188 70.08);--chart-4:oklch(62.7% .265 303.9);--chart-5:oklch(64.5% .246 16.439);--sidebar:oklch(0% 0 0);--sidebar-foreground:oklch(98.5% .001 106.423);--sidebar-primary:oklch(48.8% .243 264.376);--sidebar-primary-foreground:oklch(98.5% .001 106.423);--sidebar-accent:oklch(26.8% .007 34.298);--sidebar-accent-foreground:oklch(98.5% .001 106.423);--sidebar-border:oklch(100% 0 0/.1);--sidebar-ring:oklch(55.3% .013 58.071)}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0)scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1))rotate(var(--tw-enter-rotate,0));filter:blur(var(--tw-enter-blur,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0)scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1))rotate(var(--tw-exit-rotate,0));filter:blur(var(--tw-exit-blur,0))}}@keyframes accordion-down{0%{height:0}to{height:var(--radix-accordion-content-height,var(--bits-accordion-content-height,var(--reka-accordion-content-height,var(--kb-accordion-content-height,var(--ngp-accordion-content-height,auto)))))}}@keyframes accordion-up{0%{height:var(--radix-accordion-content-height,var(--bits-accordion-content-height,var(--reka-accordion-content-height,var(--kb-accordion-content-height,var(--ngp-accordion-content-height,auto)))))}to{height:0}} \ No newline at end of file +/*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:"";--tw-outline-style:solid;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0;--border-angle:0deg}}}@layer theme{:root,:host{--ui-font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--ui-font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--ui-color-red-500:oklch(63.7% .237 25.331);--ui-color-red-700:oklch(50.5% .213 27.518);--ui-color-orange-500:oklch(70.5% .213 47.604);--ui-color-amber-50:oklch(98.7% .022 95.277);--ui-color-amber-200:oklch(92.4% .12 95.746);--ui-color-amber-400:oklch(82.8% .189 84.429);--ui-color-amber-500:oklch(76.9% .188 70.08);--ui-color-amber-600:oklch(66.6% .179 58.318);--ui-color-amber-900:oklch(41.4% .112 45.904);--ui-color-amber-950:oklch(27.9% .077 45.635);--ui-color-yellow-500:oklch(79.5% .184 86.047);--ui-color-green-500:oklch(72.3% .219 149.579);--ui-color-green-600:oklch(62.7% .194 149.214);--ui-color-emerald-50:oklch(97.9% .021 166.113);--ui-color-emerald-400:oklch(76.5% .177 163.223);--ui-color-emerald-500:oklch(69.6% .17 162.48);--ui-color-emerald-600:oklch(59.6% .145 163.225);--ui-color-emerald-950:oklch(26.2% .051 172.552);--ui-color-sky-50:oklch(97.7% .013 236.62);--ui-color-sky-200:oklch(90.1% .058 230.902);--ui-color-sky-900:oklch(39.1% .09 240.876);--ui-color-sky-950:oklch(29.3% .066 243.157);--ui-color-pink-500:oklch(65.6% .241 354.308);--ui-color-slate-800:oklch(27.9% .041 260.031);--ui-color-slate-950:oklch(12.9% .042 264.695);--ui-color-gray-200:oklch(92.8% .006 264.531);--ui-color-black:#000;--ui-color-white:#fff;--ui-spacing:.25rem;--ui-container-sm:24rem;--ui-container-lg:32rem;--ui-container-xl:36rem;--ui-container-3xl:48rem;--ui-text-xs:.75rem;--ui-text-xs--line-height:calc(1 / .75);--ui-text-sm:.875rem;--ui-text-sm--line-height:calc(1.25 / .875);--ui-text-base:1rem;--ui-text-base--line-height:calc(1.5 / 1);--ui-text-lg:1.125rem;--ui-text-lg--line-height:calc(1.75 / 1.125);--ui-text-xl:1.25rem;--ui-text-xl--line-height:calc(1.75 / 1.25);--ui-text-2xl:1.5rem;--ui-text-2xl--line-height:calc(2 / 1.5);--ui-font-weight-normal:400;--ui-font-weight-medium:500;--ui-font-weight-semibold:600;--ui-font-weight-bold:700;--ui-tracking-tight:-.025em;--ui-tracking-widest:.1em;--ui-leading-snug:1.375;--ui-leading-normal:1.5;--ui-leading-relaxed:1.625;--ui-radius-xs:.125rem;--ui-ease-in-out:cubic-bezier(.4, 0, .2, 1);--ui-animate-spin:spin 1s linear infinite;--ui-animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--ui-blur-sm:8px;--ui-default-transition-duration:.15s;--ui-default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--ui-default-mono-font-family:var(--ui-font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--font-cio,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--ui-default-font-feature-settings,normal);font-variation-settings:var(--ui-default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--ui-default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--ui-default-mono-font-feature-settings,normal);font-variation-settings:var(--ui-default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:var(--border);outline-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){*{outline-color:color-mix(in oklab, var(--ring) 50%, transparent)}}body{background-color:var(--background);font-family:var(--font-sans);color:var(--foreground)}button,input,select,optgroup,textarea{font-family:var(--font-sans)}::file-selector-button{font-family:var(--font-sans)}.toaster{--normal-bg:var(--color-popover);--normal-text:var(--color-popover-foreground);--normal-border:var(--color-border)}}@layer components;@layer utilities{.ui\:\@container\/field-group{container:field-group/inline-size}.ui\:pointer-events-none{pointer-events:none}.ui\:sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.ui\:absolute{position:absolute}.ui\:fixed{position:fixed}.ui\:relative{position:relative}.ui\:sticky{position:sticky}.ui\:inset-0{inset:calc(var(--ui-spacing) * 0)}.ui\:inset-x-0{inset-inline:calc(var(--ui-spacing) * 0)}.ui\:inset-y-0{inset-block:calc(var(--ui-spacing) * 0)}.ui\:start-0{inset-inline-start:calc(var(--ui-spacing) * 0)}.ui\:end-4{inset-inline-end:calc(var(--ui-spacing) * 4)}.ui\:-top-2{top:calc(var(--ui-spacing) * -2)}.ui\:-top-3{top:calc(var(--ui-spacing) * -3)}.ui\:top-0{top:calc(var(--ui-spacing) * 0)}.ui\:top-1\.5{top:calc(var(--ui-spacing) * 1.5)}.ui\:top-1\/2{top:50%}.ui\:top-2{top:calc(var(--ui-spacing) * 2)}.ui\:top-3\.5{top:calc(var(--ui-spacing) * 3.5)}.ui\:top-4{top:calc(var(--ui-spacing) * 4)}.ui\:top-13{top:calc(var(--ui-spacing) * 13)}.ui\:top-\[-12px\]{top:-12px}.ui\:top-\[50\%\]{top:50%}.ui\:top-\[60\%\]{top:60%}.ui\:top-full{top:100%}.ui\:top-px{top:1px}.ui\:-right-4{right:calc(var(--ui-spacing) * -4)}.ui\:right-0{right:calc(var(--ui-spacing) * 0)}.ui\:right-1{right:calc(var(--ui-spacing) * 1)}.ui\:right-2{right:calc(var(--ui-spacing) * 2)}.ui\:right-3{right:calc(var(--ui-spacing) * 3)}.ui\:right-4{right:calc(var(--ui-spacing) * 4)}.ui\:right-9{right:calc(var(--ui-spacing) * 9)}.ui\:right-\[-12px\]{right:-12px}.ui\:-bottom-px{bottom:-1px}.ui\:bottom-0{bottom:calc(var(--ui-spacing) * 0)}.ui\:bottom-2{bottom:calc(var(--ui-spacing) * 2)}.ui\:left-0{left:calc(var(--ui-spacing) * 0)}.ui\:left-1\/2{left:50%}.ui\:left-2{left:calc(var(--ui-spacing) * 2)}.ui\:left-\[50\%\]{left:50%}.ui\:left-\[calc\(50\%-3rem\)\]{left:calc(50% - 3rem)}.ui\:isolate{isolation:isolate}.ui\:z-1{z-index:1}.ui\:z-10{z-index:10}.ui\:z-20{z-index:20}.ui\:z-50{z-index:50}.ui\:z-200{z-index:200}.ui\:z-999{z-index:999}.ui\:z-\[1\]{z-index:1}.ui\:order-first{order:-9999}.ui\:order-last{order:9999}.ui\:col-start-2{grid-column-start:2}.ui\:row-span-2{grid-row:span 2/span 2}.ui\:row-start-1{grid-row-start:1}.ui\:m-0{margin:calc(var(--ui-spacing) * 0)}.ui\:m-0\!{margin:calc(var(--ui-spacing) * 0)!important}.ui\:-mx-1{margin-inline:calc(var(--ui-spacing) * -1)}.ui\:mx-auto{margin-inline:auto}.ui\:-my-2{margin-block:calc(var(--ui-spacing) * -2)}.ui\:my-0{margin-block:calc(var(--ui-spacing) * 0)}.ui\:my-0\.5{margin-block:calc(var(--ui-spacing) * .5)}.ui\:my-1{margin-block:calc(var(--ui-spacing) * 1)}.ui\:my-2{margin-block:calc(var(--ui-spacing) * 2)}.ui\:my-4{margin-block:calc(var(--ui-spacing) * 4)}.ui\:ms-1{margin-inline-start:calc(var(--ui-spacing) * 1)}.ui\:ms-4{margin-inline-start:calc(var(--ui-spacing) * 4)}.ui\:mt-0\.5{margin-top:calc(var(--ui-spacing) * .5)}.ui\:mt-1{margin-top:calc(var(--ui-spacing) * 1)}.ui\:mt-1\.5{margin-top:calc(var(--ui-spacing) * 1.5)}.ui\:mt-3{margin-top:calc(var(--ui-spacing) * 3)}.ui\:mt-4{margin-top:calc(var(--ui-spacing) * 4)}.ui\:mt-auto{margin-top:auto}.ui\:mr-1{margin-right:calc(var(--ui-spacing) * 1)}.ui\:mr-2{margin-right:calc(var(--ui-spacing) * 2)}.ui\:mb-1{margin-bottom:calc(var(--ui-spacing) * 1)}.ui\:mb-2{margin-bottom:calc(var(--ui-spacing) * 2)}.ui\:mb-3{margin-bottom:calc(var(--ui-spacing) * 3)}.ui\:mb-4{margin-bottom:calc(var(--ui-spacing) * 4)}.ui\:ml-1{margin-left:calc(var(--ui-spacing) * 1)}.ui\:ml-2{margin-left:calc(var(--ui-spacing) * 2)}.ui\:ml-2\.5{margin-left:calc(var(--ui-spacing) * 2.5)}.ui\:ml-auto{margin-left:auto}.ui\:line-clamp-1{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.ui\:line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.ui\:line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.ui\:block{display:block}.ui\:flex{display:flex}.ui\:grid{display:grid}.ui\:hidden{display:none}.ui\:inline-block{display:inline-block}.ui\:inline-flex{display:inline-flex}.ui\:field-sizing-content{field-sizing:content}.ui\:size-2{width:calc(var(--ui-spacing) * 2);height:calc(var(--ui-spacing) * 2)}.ui\:size-2\.5{width:calc(var(--ui-spacing) * 2.5);height:calc(var(--ui-spacing) * 2.5)}.ui\:size-3{width:calc(var(--ui-spacing) * 3);height:calc(var(--ui-spacing) * 3)}.ui\:size-3\.5{width:calc(var(--ui-spacing) * 3.5);height:calc(var(--ui-spacing) * 3.5)}.ui\:size-4{width:calc(var(--ui-spacing) * 4);height:calc(var(--ui-spacing) * 4)}.ui\:size-5{width:calc(var(--ui-spacing) * 5);height:calc(var(--ui-spacing) * 5)}.ui\:size-6{width:calc(var(--ui-spacing) * 6);height:calc(var(--ui-spacing) * 6)}.ui\:size-7{width:calc(var(--ui-spacing) * 7);height:calc(var(--ui-spacing) * 7)}.ui\:size-8{width:calc(var(--ui-spacing) * 8);height:calc(var(--ui-spacing) * 8)}.ui\:size-9{width:calc(var(--ui-spacing) * 9);height:calc(var(--ui-spacing) * 9)}.ui\:size-10{width:calc(var(--ui-spacing) * 10);height:calc(var(--ui-spacing) * 10)}.ui\:size-14{width:calc(var(--ui-spacing) * 14);height:calc(var(--ui-spacing) * 14)}.ui\:size-14\!{width:calc(var(--ui-spacing) * 14)!important;height:calc(var(--ui-spacing) * 14)!important}.ui\:size-20{width:calc(var(--ui-spacing) * 20);height:calc(var(--ui-spacing) * 20)}.ui\:size-fit{width:fit-content;height:fit-content}.ui\:size-full{width:100%;height:100%}.ui\:h-\(--bits-navigation-menu-viewport-height\){height:var(--bits-navigation-menu-viewport-height)}.ui\:h-\(--bits-select-anchor-height\){height:var(--bits-select-anchor-height)}.ui\:h-0\.5{height:calc(var(--ui-spacing) * .5)}.ui\:h-1\.5{height:calc(var(--ui-spacing) * 1.5)}.ui\:h-2{height:calc(var(--ui-spacing) * 2)}.ui\:h-4{height:calc(var(--ui-spacing) * 4)}.ui\:h-5{height:calc(var(--ui-spacing) * 5)}.ui\:h-6{height:calc(var(--ui-spacing) * 6)}.ui\:h-7{height:calc(var(--ui-spacing) * 7)}.ui\:h-8{height:calc(var(--ui-spacing) * 8)}.ui\:h-9{height:calc(var(--ui-spacing) * 9)}.ui\:h-10{height:calc(var(--ui-spacing) * 10)}.ui\:h-12{height:calc(var(--ui-spacing) * 12)}.ui\:h-24{height:calc(var(--ui-spacing) * 24)}.ui\:h-28{height:calc(var(--ui-spacing) * 28)}.ui\:h-40{height:calc(var(--ui-spacing) * 40)}.ui\:h-48{height:calc(var(--ui-spacing) * 48)}.ui\:h-96{height:calc(var(--ui-spacing) * 96)}.ui\:h-128{height:calc(var(--ui-spacing) * 128)}.ui\:h-\[1\.2rem\]{height:1.2rem}.ui\:h-\[1\.15rem\]{height:1.15rem}.ui\:h-\[6px\]{height:6px}.ui\:h-\[70px\]{height:70px}.ui\:h-\[280px\]{height:280px}.ui\:h-\[calc\(100\%-1px\)\]{height:calc(100% - 1px)}.ui\:h-\[calc\(100\%-3px\)\]{height:calc(100% - 3px)}.ui\:h-auto{height:auto}.ui\:h-fit{height:fit-content}.ui\:h-full{height:100%}.ui\:h-px{height:1px}.ui\:h-screen{height:100vh}.ui\:max-h-\(--bits-dropdown-menu-content-available-height\){max-height:var(--bits-dropdown-menu-content-available-height)}.ui\:max-h-\(--bits-select-content-available-height\){max-height:var(--bits-select-content-available-height)}.ui\:max-h-64{max-height:calc(var(--ui-spacing) * 64)}.ui\:max-h-72{max-height:calc(var(--ui-spacing) * 72)}.ui\:max-h-80{max-height:calc(var(--ui-spacing) * 80)}.ui\:max-h-90{max-height:calc(var(--ui-spacing) * 90)}.ui\:max-h-96{max-height:calc(var(--ui-spacing) * 96)}.ui\:max-h-\[300px\]{max-height:300px}.ui\:max-h-svh{max-height:100svh}.ui\:min-h-0{min-height:calc(var(--ui-spacing) * 0)}.ui\:min-h-4{min-height:calc(var(--ui-spacing) * 4)}.ui\:min-h-16{min-height:calc(var(--ui-spacing) * 16)}.ui\:min-h-96{min-height:calc(var(--ui-spacing) * 96)}.ui\:min-h-\[44px\]{min-height:44px}.ui\:min-h-\[calc\(100vh-48px\)\]{min-height:calc(100vh - 48px)}.ui\:w-\(--sidebar-width\){width:var(--sidebar-width)}.ui\:w-0{width:calc(var(--ui-spacing) * 0)}.ui\:w-1{width:calc(var(--ui-spacing) * 1)}.ui\:w-1\/4{width:25%}.ui\:w-2{width:calc(var(--ui-spacing) * 2)}.ui\:w-2\/4{width:50%}.ui\:w-3\/4{width:75%}.ui\:w-4{width:calc(var(--ui-spacing) * 4)}.ui\:w-5{width:calc(var(--ui-spacing) * 5)}.ui\:w-6{width:calc(var(--ui-spacing) * 6)}.ui\:w-7{width:calc(var(--ui-spacing) * 7)}.ui\:w-8{width:calc(var(--ui-spacing) * 8)}.ui\:w-24{width:calc(var(--ui-spacing) * 24)}.ui\:w-28{width:calc(var(--ui-spacing) * 28)}.ui\:w-40{width:calc(var(--ui-spacing) * 40)}.ui\:w-64{width:calc(var(--ui-spacing) * 64)}.ui\:w-72{width:calc(var(--ui-spacing) * 72)}.ui\:w-88{width:calc(var(--ui-spacing) * 88)}.ui\:w-\[1\.2rem\]{width:1.2rem}.ui\:w-\[25px\]{width:25px}.ui\:w-\[100px\]{width:100px}.ui\:w-fit{width:fit-content}.ui\:w-fit\!{width:fit-content!important}.ui\:w-full{width:100%}.ui\:w-full\!{width:100%!important}.ui\:w-max{width:max-content}.ui\:w-screen{width:100vw}.ui\:max-w-\(--skeleton-width\){max-width:var(--skeleton-width)}.ui\:max-w-3xl{max-width:var(--ui-container-3xl)}.ui\:max-w-6{max-width:calc(var(--ui-spacing) * 6)}.ui\:max-w-80{max-width:calc(var(--ui-spacing) * 80)}.ui\:max-w-\[60\%\]{max-width:60%}.ui\:max-w-\[200px\]{max-width:200px}.ui\:max-w-\[300px\]{max-width:300px}.ui\:max-w-\[calc\(100\%-2rem\)\]{max-width:calc(100% - 2rem)}.ui\:max-w-full{max-width:100%}.ui\:max-w-max{max-width:max-content}.ui\:max-w-sm{max-width:var(--ui-container-sm)}.ui\:max-w-xl{max-width:var(--ui-container-xl)}.ui\:min-w-\(--bits-select-anchor-width\){min-width:var(--bits-select-anchor-width)}.ui\:min-w-0{min-width:calc(var(--ui-spacing) * 0)}.ui\:min-w-5{min-width:calc(var(--ui-spacing) * 5)}.ui\:min-w-8{min-width:calc(var(--ui-spacing) * 8)}.ui\:min-w-9{min-width:calc(var(--ui-spacing) * 9)}.ui\:min-w-10{min-width:calc(var(--ui-spacing) * 10)}.ui\:min-w-32{min-width:calc(var(--ui-spacing) * 32)}.ui\:min-w-48{min-width:calc(var(--ui-spacing) * 48)}.ui\:min-w-\[9rem\]{min-width:9rem}.ui\:min-w-full{min-width:100%}.ui\:flex-1{flex:1}.ui\:shrink{flex-shrink:1}.ui\:shrink-0{flex-shrink:0}.ui\:basis-full{flex-basis:100%}.ui\:origin-\(--bits-dropdown-menu-content-transform-origin\){transform-origin:var(--bits-dropdown-menu-content-transform-origin)}.ui\:origin-\(--bits-menubar-content-transform-origin\){transform-origin:var(--bits-menubar-content-transform-origin)}.ui\:origin-\(--bits-popover-content-transform-origin\){transform-origin:var(--bits-popover-content-transform-origin)}.ui\:origin-\(--bits-select-content-transform-origin\){transform-origin:var(--bits-select-content-transform-origin)}.ui\:origin-\(--bits-tooltip-content-transform-origin\){transform-origin:var(--bits-tooltip-content-transform-origin)}.ui\:-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.ui\:-translate-x-px{--tw-translate-x:-1px;translate:var(--tw-translate-x) var(--tw-translate-y)}.ui\:translate-x-\[-50\%\]{--tw-translate-x:-50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.ui\:translate-x-px{--tw-translate-x:1px;translate:var(--tw-translate-x) var(--tw-translate-y)}.ui\:-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.ui\:translate-y-0\.5{--tw-translate-y:calc(var(--ui-spacing) * .5);translate:var(--tw-translate-x) var(--tw-translate-y)}.ui\:translate-y-\[-50\%\]{--tw-translate-y:-50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.ui\:scale-0{--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.ui\:scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.ui\:rotate-0{rotate:none}.ui\:rotate-5{rotate:5deg}.ui\:rotate-45{rotate:45deg}.ui\:rotate-90{rotate:90deg}.ui\:animate-in{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.ui\:animate-pulse{animation:var(--ui-animate-pulse)}.ui\:animate-spin{animation:var(--ui-animate-spin)}.ui\:cursor-auto{cursor:auto}.ui\:cursor-col-resize{cursor:col-resize}.ui\:cursor-default{cursor:default}.ui\:cursor-default\!{cursor:default!important}.ui\:cursor-grab{cursor:grab}.ui\:cursor-not-allowed{cursor:not-allowed}.ui\:cursor-pointer{cursor:pointer}.ui\:cursor-text{cursor:text}.ui\:resize-none{resize:none}.ui\:scroll-my-1{scroll-margin-block:calc(var(--ui-spacing) * 1)}.ui\:scroll-py-1{scroll-padding-block:calc(var(--ui-spacing) * 1)}.ui\:list-disc{list-style-type:disc}.ui\:list-none{list-style-type:none}.ui\:auto-rows-min{grid-auto-rows:min-content}.ui\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.ui\:grid-cols-\[0_1fr\]{grid-template-columns:0 1fr}.ui\:grid-rows-\[auto_auto\]{grid-template-rows:auto auto}.ui\:flex-col{flex-direction:column}.ui\:flex-col-reverse{flex-direction:column-reverse}.ui\:flex-row{flex-direction:row}.ui\:flex-wrap{flex-wrap:wrap}.ui\:place-items-center{place-items:center}.ui\:items-baseline{align-items:baseline}.ui\:items-center{align-items:center}.ui\:items-end{align-items:flex-end}.ui\:items-start{align-items:flex-start}.ui\:items-stretch{align-items:stretch}.ui\:justify-between{justify-content:space-between}.ui\:justify-center{justify-content:center}.ui\:justify-end{justify-content:flex-end}.ui\:justify-start{justify-content:flex-start}.ui\:justify-items-start{justify-items:start}.ui\:gap-0{gap:calc(var(--ui-spacing) * 0)}.ui\:gap-0\.5{gap:calc(var(--ui-spacing) * .5)}.ui\:gap-1{gap:calc(var(--ui-spacing) * 1)}.ui\:gap-1\.5{gap:calc(var(--ui-spacing) * 1.5)}.ui\:gap-2{gap:calc(var(--ui-spacing) * 2)}.ui\:gap-2\.5{gap:calc(var(--ui-spacing) * 2.5)}.ui\:gap-3{gap:calc(var(--ui-spacing) * 3)}.ui\:gap-4{gap:calc(var(--ui-spacing) * 4)}.ui\:gap-6{gap:calc(var(--ui-spacing) * 6)}.ui\:gap-7{gap:calc(var(--ui-spacing) * 7)}:where(.ui\:space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--ui-spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--ui-spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.ui\:space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--ui-spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--ui-spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.ui\:space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--ui-spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--ui-spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.ui\:space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--ui-spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--ui-spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.ui\:gap-x-6{column-gap:calc(var(--ui-spacing) * 6)}.ui\:gap-y-0\.5{row-gap:calc(var(--ui-spacing) * .5)}.ui\:gap-y-2{row-gap:calc(var(--ui-spacing) * 2)}.ui\:self-start{align-self:flex-start}.ui\:self-stretch{align-self:stretch}.ui\:justify-self-end{justify-self:flex-end}.ui\:truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.ui\:overflow-auto{overflow:auto}.ui\:overflow-hidden{overflow:hidden}.ui\:overflow-visible{overflow:visible}.ui\:overflow-x-auto{overflow-x:auto}.ui\:overflow-x-hidden{overflow-x:hidden}.ui\:overflow-y-auto{overflow-y:auto}.ui\:overflow-y-hidden{overflow-y:hidden}.ui\:scroll-smooth{scroll-behavior:smooth}.ui\:rounded{border-radius:.25rem}.ui\:rounded-\[2px\]{border-radius:2px}.ui\:rounded-\[4px\]{border-radius:4px}.ui\:rounded-\[calc\(var\(--radius\)-5px\)\]{border-radius:calc(var(--radius) - 5px)}.ui\:rounded-full{border-radius:3.40282e38px}.ui\:rounded-lg{border-radius:var(--radius)}.ui\:rounded-md{border-radius:calc(var(--radius) - 2px)}.ui\:rounded-none{border-radius:0}.ui\:rounded-sm{border-radius:calc(var(--radius) - 4px)}.ui\:rounded-xl{border-radius:calc(var(--radius) + 4px)}.ui\:rounded-xs{border-radius:var(--ui-radius-xs)}.ui\:border{border-style:var(--tw-border-style);border-width:1px}.ui\:border-0{border-style:var(--tw-border-style);border-width:0}.ui\:border-2{border-style:var(--tw-border-style);border-width:2px}.ui\:border-\[1\.5px\]{border-style:var(--tw-border-style);border-width:1.5px}.ui\:border-x-0{border-inline-style:var(--tw-border-style);border-inline-width:0}.ui\:border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.ui\:border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.ui\:border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.ui\:border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.ui\:border-dashed{--tw-border-style:dashed;border-style:dashed}.ui\:border-none{--tw-border-style:none;border-style:none}.ui\:border-\(--color-border\){border-color:var(--color-border)}.ui\:border-amber-200{border-color:var(--ui-color-amber-200)}.ui\:border-amber-400{border-color:var(--ui-color-amber-400)}.ui\:border-border,.ui\:border-border\/50{border-color:var(--border)}@supports (color:color-mix(in lab, red, red)){.ui\:border-border\/50{border-color:color-mix(in oklab, var(--border) 50%, transparent)}}.ui\:border-emerald-400{border-color:var(--ui-color-emerald-400)}.ui\:border-input{border-color:var(--input)}.ui\:border-muted{border-color:var(--muted)}.ui\:border-primary\/30{border-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.ui\:border-primary\/30{border-color:color-mix(in oklab, var(--primary) 30%, transparent)}}.ui\:border-red-500{border-color:var(--ui-color-red-500)}.ui\:border-sidebar-border{border-color:var(--sidebar-border)}.ui\:border-sky-200{border-color:var(--ui-color-sky-200)}.ui\:border-transparent{border-color:#0000}.ui\:bg-\(--color-bg\){background-color:var(--color-bg)}.ui\:bg-accent{background-color:var(--accent)}.ui\:bg-amber-50,.ui\:bg-amber-50\/30{background-color:var(--ui-color-amber-50)}@supports (color:color-mix(in lab, red, red)){.ui\:bg-amber-50\/30{background-color:color-mix(in oklab, var(--ui-color-amber-50) 30%, transparent)}}.ui\:bg-amber-600{background-color:var(--ui-color-amber-600)}.ui\:bg-background,.ui\:bg-background\/50{background-color:var(--background)}@supports (color:color-mix(in lab, red, red)){.ui\:bg-background\/50{background-color:color-mix(in oklab, var(--background) 50%, transparent)}}.ui\:bg-black\/50{background-color:var(--ui-color-black)}@supports (color:color-mix(in lab, red, red)){.ui\:bg-black\/50{background-color:color-mix(in oklab, var(--ui-color-black) 50%, transparent)}}.ui\:bg-border{background-color:var(--border)}.ui\:bg-card{background-color:var(--card)}.ui\:bg-destructive{background-color:var(--destructive)}.ui\:bg-emerald-50\/30{background-color:var(--ui-color-emerald-50)}@supports (color:color-mix(in lab, red, red)){.ui\:bg-emerald-50\/30{background-color:color-mix(in oklab, var(--ui-color-emerald-50) 30%, transparent)}}.ui\:bg-emerald-600{background-color:var(--ui-color-emerald-600)}.ui\:bg-gray-200{background-color:var(--ui-color-gray-200)}.ui\:bg-green-500{background-color:var(--ui-color-green-500)}.ui\:bg-inherit{background-color:inherit}.ui\:bg-input{background-color:var(--input)}.ui\:bg-muted,.ui\:bg-muted\/50{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.ui\:bg-muted\/50{background-color:color-mix(in oklab, var(--muted) 50%, transparent)}}.ui\:bg-popover{background-color:var(--popover)}.ui\:bg-primary{background-color:var(--primary)}.ui\:bg-primary-foreground{background-color:var(--primary-foreground)}.ui\:bg-primary\/10{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.ui\:bg-primary\/10{background-color:color-mix(in oklab, var(--primary) 10%, transparent)}}.ui\:bg-primary\/20{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.ui\:bg-primary\/20{background-color:color-mix(in oklab, var(--primary) 20%, transparent)}}.ui\:bg-red-500{background-color:var(--ui-color-red-500)}.ui\:bg-secondary,.ui\:bg-secondary\/50{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.ui\:bg-secondary\/50{background-color:color-mix(in oklab, var(--secondary) 50%, transparent)}}.ui\:bg-sidebar{background-color:var(--sidebar)}.ui\:bg-sidebar-border{background-color:var(--sidebar-border)}.ui\:bg-sky-50{background-color:var(--ui-color-sky-50)}.ui\:bg-transparent{background-color:#0000}.ui\:bg-white{background-color:var(--ui-color-white)}.ui\:bg-yellow-500{background-color:var(--ui-color-yellow-500)}.ui\:bg-linear-to-r{--tw-gradient-position:to right}@supports (background-image:linear-gradient(in lab, red, red)){.ui\:bg-linear-to-r{--tw-gradient-position:to right in oklab}}.ui\:bg-linear-to-r{background-image:linear-gradient(var(--tw-gradient-stops))}.ui\:bg-linear-to-t{--tw-gradient-position:to top}@supports (background-image:linear-gradient(in lab, red, red)){.ui\:bg-linear-to-t{--tw-gradient-position:to top in oklab}}.ui\:bg-linear-to-t{background-image:linear-gradient(var(--tw-gradient-stops))}.ui\:from-background{--tw-gradient-from:var(--background);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.ui\:from-pink-500{--tw-gradient-from:var(--ui-color-pink-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.ui\:to-orange-500{--tw-gradient-to:var(--ui-color-orange-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.ui\:to-transparent{--tw-gradient-to:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.ui\:bg-clip-padding{background-clip:padding-box}.ui\:fill-current{fill:currentColor}.ui\:fill-primary{fill:var(--primary)}.ui\:object-cover{object-fit:cover}.ui\:p-0{padding:calc(var(--ui-spacing) * 0)}.ui\:p-0\.5{padding:calc(var(--ui-spacing) * .5)}.ui\:p-1{padding:calc(var(--ui-spacing) * 1)}.ui\:p-2{padding:calc(var(--ui-spacing) * 2)}.ui\:p-3{padding:calc(var(--ui-spacing) * 3)}.ui\:p-3\!{padding:calc(var(--ui-spacing) * 3)!important}.ui\:p-4{padding:calc(var(--ui-spacing) * 4)}.ui\:p-6{padding:calc(var(--ui-spacing) * 6)}.ui\:p-8{padding:calc(var(--ui-spacing) * 8)}.ui\:p-\[3px\]{padding:3px}.ui\:px-1{padding-inline:calc(var(--ui-spacing) * 1)}.ui\:px-1\.5{padding-inline:calc(var(--ui-spacing) * 1.5)}.ui\:px-2{padding-inline:calc(var(--ui-spacing) * 2)}.ui\:px-2\.5{padding-inline:calc(var(--ui-spacing) * 2.5)}.ui\:px-3{padding-inline:calc(var(--ui-spacing) * 3)}.ui\:px-4{padding-inline:calc(var(--ui-spacing) * 4)}.ui\:px-6{padding-inline:calc(var(--ui-spacing) * 6)}.ui\:py-0\.5{padding-block:calc(var(--ui-spacing) * .5)}.ui\:py-1{padding-block:calc(var(--ui-spacing) * 1)}.ui\:py-1\.5{padding-block:calc(var(--ui-spacing) * 1.5)}.ui\:py-2{padding-block:calc(var(--ui-spacing) * 2)}.ui\:py-3{padding-block:calc(var(--ui-spacing) * 3)}.ui\:py-4{padding-block:calc(var(--ui-spacing) * 4)}.ui\:py-6{padding-block:calc(var(--ui-spacing) * 6)}.ui\:ps-3{padding-inline-start:calc(var(--ui-spacing) * 3)}.ui\:pe-3{padding-inline-end:calc(var(--ui-spacing) * 3)}.ui\:pt-0{padding-top:calc(var(--ui-spacing) * 0)}.ui\:pt-1\.5{padding-top:calc(var(--ui-spacing) * 1.5)}.ui\:pt-3{padding-top:calc(var(--ui-spacing) * 3)}.ui\:pr-1{padding-right:calc(var(--ui-spacing) * 1)}.ui\:pr-2{padding-right:calc(var(--ui-spacing) * 2)}.ui\:pr-8{padding-right:calc(var(--ui-spacing) * 8)}.ui\:pr-9{padding-right:calc(var(--ui-spacing) * 9)}.ui\:pr-18{padding-right:calc(var(--ui-spacing) * 18)}.ui\:pb-3{padding-bottom:calc(var(--ui-spacing) * 3)}.ui\:pb-4{padding-bottom:calc(var(--ui-spacing) * 4)}.ui\:pl-2{padding-left:calc(var(--ui-spacing) * 2)}.ui\:pl-3{padding-left:calc(var(--ui-spacing) * 3)}.ui\:pl-8{padding-left:calc(var(--ui-spacing) * 8)}.ui\:text-center{text-align:center}.ui\:text-left{text-align:left}.ui\:font-mono{font-family:var(--ui-font-mono)}.ui\:font-sans{font-family:var(--ui-font-sans)}.ui\:text-2xl{font-size:var(--ui-text-2xl);line-height:var(--tw-leading,var(--ui-text-2xl--line-height))}.ui\:text-base{font-size:var(--ui-text-base);line-height:var(--tw-leading,var(--ui-text-base--line-height))}.ui\:text-base\!{font-size:var(--ui-text-base)!important;line-height:var(--tw-leading,var(--ui-text-base--line-height))!important}.ui\:text-lg{font-size:var(--ui-text-lg);line-height:var(--tw-leading,var(--ui-text-lg--line-height))}.ui\:text-sm{font-size:var(--ui-text-sm);line-height:var(--tw-leading,var(--ui-text-sm--line-height))}.ui\:text-sm\/relaxed{font-size:var(--ui-text-sm);line-height:var(--ui-leading-relaxed)}.ui\:text-xl{font-size:var(--ui-text-xl);line-height:var(--tw-leading,var(--ui-text-xl--line-height))}.ui\:text-xs{font-size:var(--ui-text-xs);line-height:var(--tw-leading,var(--ui-text-xs--line-height))}.ui\:text-\[10px\]{font-size:10px}.ui\:leading-none{--tw-leading:1;line-height:1}.ui\:leading-normal{--tw-leading:var(--ui-leading-normal);line-height:var(--ui-leading-normal)}.ui\:leading-relaxed{--tw-leading:var(--ui-leading-relaxed);line-height:var(--ui-leading-relaxed)}.ui\:leading-snug{--tw-leading:var(--ui-leading-snug);line-height:var(--ui-leading-snug)}.ui\:font-bold{--tw-font-weight:var(--ui-font-weight-bold);font-weight:var(--ui-font-weight-bold)}.ui\:font-medium{--tw-font-weight:var(--ui-font-weight-medium);font-weight:var(--ui-font-weight-medium)}.ui\:font-normal{--tw-font-weight:var(--ui-font-weight-normal);font-weight:var(--ui-font-weight-normal)}.ui\:font-semibold{--tw-font-weight:var(--ui-font-weight-semibold);font-weight:var(--ui-font-weight-semibold)}.ui\:tracking-tight{--tw-tracking:var(--ui-tracking-tight);letter-spacing:var(--ui-tracking-tight)}.ui\:tracking-widest{--tw-tracking:var(--ui-tracking-widest);letter-spacing:var(--ui-tracking-widest)}.ui\:text-balance{text-wrap:balance}.ui\:break-words{overflow-wrap:break-word}.ui\:break-all{word-break:break-all}.ui\:text-ellipsis{text-overflow:ellipsis}.ui\:whitespace-nowrap{white-space:nowrap}.ui\:text-amber-600{color:var(--ui-color-amber-600)}.ui\:text-amber-900{color:var(--ui-color-amber-900)}.ui\:text-card-foreground{color:var(--card-foreground)}.ui\:text-current{color:currentColor}.ui\:text-destructive{color:var(--destructive)}.ui\:text-emerald-600{color:var(--ui-color-emerald-600)}.ui\:text-foreground,.ui\:text-foreground\/70{color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.ui\:text-foreground\/70{color:color-mix(in oklab, var(--foreground) 70%, transparent)}}.ui\:text-green-500{color:var(--ui-color-green-500)}.ui\:text-green-600{color:var(--ui-color-green-600)}.ui\:text-muted-foreground,.ui\:text-muted-foreground\/75{color:var(--muted-foreground)}@supports (color:color-mix(in lab, red, red)){.ui\:text-muted-foreground\/75{color:color-mix(in oklab, var(--muted-foreground) 75%, transparent)}}.ui\:text-popover-foreground{color:var(--popover-foreground)}.ui\:text-primary{color:var(--primary)}.ui\:text-primary-foreground{color:var(--primary-foreground)}.ui\:text-red-700{color:var(--ui-color-red-700)}.ui\:text-secondary-foreground{color:var(--secondary-foreground)}.ui\:text-sidebar-foreground,.ui\:text-sidebar-foreground\/70{color:var(--sidebar-foreground)}@supports (color:color-mix(in lab, red, red)){.ui\:text-sidebar-foreground\/70{color:color-mix(in oklab, var(--sidebar-foreground) 70%, transparent)}}.ui\:text-sky-900{color:var(--ui-color-sky-900)}.ui\:text-white{color:var(--ui-color-white)}.ui\:text-white\!{color:var(--ui-color-white)!important}.ui\:capitalize{text-transform:capitalize}.ui\:underline{text-decoration-line:underline}.ui\:underline-offset-4{text-underline-offset:4px}.ui\:opacity-0{opacity:0}.ui\:opacity-50{opacity:.5}.ui\:opacity-70{opacity:.7}.ui\:opacity-100{opacity:1}.ui\:shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:shadow-2xs{--tw-shadow:0 1px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:shadow-\[0_0_0_1px_var\(--sidebar-border\)\]{--tw-shadow:0 0 0 1px var(--tw-shadow-color,var(--sidebar-border));box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:ring-0{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:ring-3{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:ring-accent{--tw-ring-color:var(--accent)}.ui\:ring-background{--tw-ring-color:var(--background)}.ui\:ring-border\/50{--tw-ring-color:var(--border)}@supports (color:color-mix(in lab, red, red)){.ui\:ring-border\/50{--tw-ring-color:color-mix(in oklab, var(--border) 50%, transparent)}}.ui\:ring-sidebar-ring{--tw-ring-color:var(--sidebar-ring)}.ui\:ring-offset-2{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.ui\:ring-offset-background{--tw-ring-offset-color:var(--background)}.ui\:outline-hidden{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.ui\:outline-hidden{outline-offset:2px;outline:2px solid #0000}}.ui\:backdrop-blur-sm{--tw-backdrop-blur:blur(var(--ui-blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.ui\:transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--ui-default-transition-timing-function));transition-duration:var(--tw-duration,var(--ui-default-transition-duration))}.ui\:transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--ui-default-transition-timing-function));transition-duration:var(--tw-duration,var(--ui-default-transition-duration))}.ui\:transition-\[left\,right\,width\]{transition-property:left,right,width;transition-timing-function:var(--tw-ease,var(--ui-default-transition-timing-function));transition-duration:var(--tw-duration,var(--ui-default-transition-duration))}.ui\:transition-\[margin\,opacity\]{transition-property:margin,opacity;transition-timing-function:var(--tw-ease,var(--ui-default-transition-timing-function));transition-duration:var(--tw-duration,var(--ui-default-transition-duration))}.ui\:transition-\[width\,height\,padding\]{transition-property:width,height,padding;transition-timing-function:var(--tw-ease,var(--ui-default-transition-timing-function));transition-duration:var(--tw-duration,var(--ui-default-transition-duration))}.ui\:transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--ui-default-transition-timing-function));transition-duration:var(--tw-duration,var(--ui-default-transition-duration))}.ui\:transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--ui-default-transition-timing-function));transition-duration:var(--tw-duration,var(--ui-default-transition-duration))}.ui\:transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--ui-default-transition-timing-function));transition-duration:var(--tw-duration,var(--ui-default-transition-duration))}.ui\:transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--ui-default-transition-timing-function));transition-duration:var(--tw-duration,var(--ui-default-transition-duration))}.ui\:transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--ui-default-transition-timing-function));transition-duration:var(--tw-duration,var(--ui-default-transition-duration))}.ui\:transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--ui-default-transition-timing-function));transition-duration:var(--tw-duration,var(--ui-default-transition-duration))}.ui\:transition-none{transition-property:none}.ui\:duration-100{--tw-duration:.1s;transition-duration:.1s}.ui\:duration-200{--tw-duration:.2s;transition-duration:.2s}.ui\:duration-300{--tw-duration:.3s;transition-duration:.3s}.ui\:duration-500{--tw-duration:.5s;transition-duration:.5s}.ui\:ease-in-out{--tw-ease:var(--ui-ease-in-out);transition-timing-function:var(--ui-ease-in-out)}.ui\:ease-linear{--tw-ease:linear;transition-timing-function:linear}.ui\:fade-in-0{--tw-enter-opacity:0}.ui\:outline-none{--tw-outline-style:none;outline-style:none}.ui\:select-none{-webkit-user-select:none;user-select:none}.ui\:zoom-in-95{--tw-enter-scale:.95}.ui\:ring-inset{--tw-ring-inset:inset}:is(.ui\:\*\:w-full>*){width:100%}.ui\:group-focus-within\/menu-item\:opacity-100:is(:where(.ui\:group\/menu-item):focus-within *){opacity:1}@media (hover:hover){.ui\:group-hover\:opacity-100:is(:where(.ui\:group):hover *),.ui\:group-hover\/menu-item\:opacity-100:is(:where(.ui\:group\/menu-item):hover *){opacity:1}}.ui\:group-has-data-\[orientation\=horizontal\]\/field\:text-balance:is(:where(.ui\:group\/field):has([data-orientation=horizontal]) *){text-wrap:balance}.ui\:group-has-data-\[sidebar\=menu-action\]\/menu-item\:pr-8:is(:where(.ui\:group\/menu-item):has([data-sidebar=menu-action]) *){padding-right:calc(var(--ui-spacing) * 8)}.ui\:group-has-\[\[data-slot\=item-description\]\]\/item\:translate-y-0\.5:is(:where(.ui\:group\/item):has([data-slot=item-description]) *){--tw-translate-y:calc(var(--ui-spacing) * .5);translate:var(--tw-translate-x) var(--tw-translate-y)}.ui\:group-has-\[\[data-slot\=item-description\]\]\/item\:self-start:is(:where(.ui\:group\/item):has([data-slot=item-description]) *){align-self:flex-start}.ui\:group-has-\[\>input\]\/input-group\:pt-2\.5:is(:where(.ui\:group\/input-group):has(>input) *){padding-top:calc(var(--ui-spacing) * 2.5)}.ui\:group-has-\[\>input\]\/input-group\:pb-2\.5:is(:where(.ui\:group\/input-group):has(>input) *){padding-bottom:calc(var(--ui-spacing) * 2.5)}.ui\:group-data-\[collapsible\=icon\]\:-mt-8:is(:where(.ui\:group)[data-collapsible=icon] *){margin-top:calc(var(--ui-spacing) * -8)}.ui\:group-data-\[collapsible\=icon\]\:hidden:is(:where(.ui\:group)[data-collapsible=icon] *){display:none}.ui\:group-data-\[collapsible\=icon\]\:size-8\!:is(:where(.ui\:group)[data-collapsible=icon] *){width:calc(var(--ui-spacing) * 8)!important;height:calc(var(--ui-spacing) * 8)!important}.ui\:group-data-\[collapsible\=icon\]\:w-\(--sidebar-width-icon\):is(:where(.ui\:group)[data-collapsible=icon] *){width:var(--sidebar-width-icon)}.ui\:group-data-\[collapsible\=icon\]\:w-\[calc\(var\(--sidebar-width-icon\)\+\(--spacing\(4\)\)\+2px\)\]:is(:where(.ui\:group)[data-collapsible=icon] *){width:calc(var(--sidebar-width-icon) + (calc(var(--ui-spacing) * 4)) + 2px)}.ui\:group-data-\[collapsible\=icon\]\:overflow-hidden:is(:where(.ui\:group)[data-collapsible=icon] *){overflow:hidden}.ui\:group-data-\[collapsible\=icon\]\:p-0\!:is(:where(.ui\:group)[data-collapsible=icon] *){padding:calc(var(--ui-spacing) * 0)!important}.ui\:group-data-\[collapsible\=icon\]\:p-2\!:is(:where(.ui\:group)[data-collapsible=icon] *){padding:calc(var(--ui-spacing) * 2)!important}.ui\:group-data-\[collapsible\=icon\]\:opacity-0:is(:where(.ui\:group)[data-collapsible=icon] *){opacity:0}.ui\:group-data-\[collapsible\=offcanvas\]\:right-\[calc\(var\(--sidebar-width\)\*-1\)\]:is(:where(.ui\:group)[data-collapsible=offcanvas] *){right:calc(var(--sidebar-width) * -1)}.ui\:group-data-\[collapsible\=offcanvas\]\:left-\[calc\(var\(--sidebar-width\)\*-1\)\]:is(:where(.ui\:group)[data-collapsible=offcanvas] *){left:calc(var(--sidebar-width) * -1)}.ui\:group-data-\[collapsible\=offcanvas\]\:w-0:is(:where(.ui\:group)[data-collapsible=offcanvas] *){width:calc(var(--ui-spacing) * 0)}.ui\:group-data-\[collapsible\=offcanvas\]\:translate-x-0:is(:where(.ui\:group)[data-collapsible=offcanvas] *){--tw-translate-x:calc(var(--ui-spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.ui\:group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.ui\:group)[data-disabled=true] *){pointer-events:none}.ui\:group-data-\[disabled\=true\]\:opacity-50:is(:where(.ui\:group)[data-disabled=true] *),.ui\:group-data-\[disabled\=true\]\/field\:opacity-50:is(:where(.ui\:group\/field)[data-disabled=true] *),.ui\:group-data-\[disabled\=true\]\/input-group\:opacity-50:is(:where(.ui\:group\/input-group)[data-disabled=true] *){opacity:.5}.ui\:group-data-\[side\=left\]\:-right-4:is(:where(.ui\:group)[data-side=left] *){right:calc(var(--ui-spacing) * -4)}.ui\:group-data-\[side\=left\]\:border-r:is(:where(.ui\:group)[data-side=left] *){border-right-style:var(--tw-border-style);border-right-width:1px}.ui\:group-data-\[side\=right\]\:left-0:is(:where(.ui\:group)[data-side=right] *){left:calc(var(--ui-spacing) * 0)}.ui\:group-data-\[side\=right\]\:rotate-180:is(:where(.ui\:group)[data-side=right] *){rotate:180deg}.ui\:group-data-\[side\=right\]\:border-l:is(:where(.ui\:group)[data-side=right] *){border-left-style:var(--tw-border-style);border-left-width:1px}.ui\:group-data-\[variant\=floating\]\:rounded-lg:is(:where(.ui\:group)[data-variant=floating] *){border-radius:var(--radius)}.ui\:group-data-\[variant\=floating\]\:border:is(:where(.ui\:group)[data-variant=floating] *){border-style:var(--tw-border-style);border-width:1px}.ui\:group-data-\[variant\=floating\]\:border-sidebar-border:is(:where(.ui\:group)[data-variant=floating] *){border-color:var(--sidebar-border)}.ui\:group-data-\[variant\=floating\]\:shadow-sm:is(:where(.ui\:group)[data-variant=floating] *){--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:group-data-\[variant\=outline\]\/field-group\:-mb-2:is(:where(.ui\:group\/field-group)[data-variant=outline] *){margin-bottom:calc(var(--ui-spacing) * -2)}.ui\:group-data-\[vaul-drawer-direction\=bottom\]\/drawer-content\:block:is(:where(.ui\:group\/drawer-content)[data-vaul-drawer-direction=bottom] *){display:block}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:top-full:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *){top:100%}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:mt-1\.5:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *){margin-top:calc(var(--ui-spacing) * 1.5)}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:overflow-hidden:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *){overflow:hidden}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:rounded-md:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *){border-radius:calc(var(--radius) - 2px)}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:border:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *){border-style:var(--tw-border-style);border-width:1px}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:bg-popover:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *){background-color:var(--popover)}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:text-popover-foreground:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *){color:var(--popover-foreground)}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:shadow:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *){--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:duration-200:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *){--tw-duration:.2s;transition-duration:.2s}@media (hover:hover){.ui\:peer-hover\/menu-button\:text-sidebar-accent-foreground:is(:where(.ui\:peer\/menu-button):hover~*){color:var(--sidebar-accent-foreground)}}.ui\:peer-disabled\:cursor-not-allowed:is(:where(.ui\:peer):disabled~*){cursor:not-allowed}.ui\:peer-disabled\:opacity-50:is(:where(.ui\:peer):disabled~*){opacity:.5}.ui\:peer-data-\[active\=true\]\/menu-button\:text-sidebar-accent-foreground:is(:where(.ui\:peer\/menu-button)[data-active=true]~*){color:var(--sidebar-accent-foreground)}.ui\:peer-data-\[size\=default\]\/menu-button\:top-1\.5:is(:where(.ui\:peer\/menu-button)[data-size=default]~*){top:calc(var(--ui-spacing) * 1.5)}.ui\:peer-data-\[size\=lg\]\/menu-button\:top-2\.5:is(:where(.ui\:peer\/menu-button)[data-size=lg]~*){top:calc(var(--ui-spacing) * 2.5)}.ui\:peer-data-\[size\=sm\]\/menu-button\:top-1:is(:where(.ui\:peer\/menu-button)[data-size=sm]~*){top:calc(var(--ui-spacing) * 1)}.ui\:selection\:bg-primary ::selection{background-color:var(--primary)}.ui\:selection\:bg-primary::selection{background-color:var(--primary)}.ui\:selection\:text-primary-foreground ::selection{color:var(--primary-foreground)}.ui\:selection\:text-primary-foreground::selection{color:var(--primary-foreground)}.ui\:placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}.ui\:after\:absolute:after{content:var(--tw-content);position:absolute}.ui\:after\:-inset-2:after{content:var(--tw-content);inset:calc(var(--ui-spacing) * -2)}.ui\:after\:inset-y-0:after{content:var(--tw-content);inset-block:calc(var(--ui-spacing) * 0)}.ui\:after\:left-\[calc\(1\/2\*100\%-1px\)\]:after{content:var(--tw-content);left:calc(50% - 1px)}.ui\:after\:w-\[2px\]:after{content:var(--tw-content);width:2px}.ui\:group-data-\[collapsible\=offcanvas\]\:after\:left-full:is(:where(.ui\:group)[data-collapsible=offcanvas] *):after{content:var(--tw-content);left:100%}.ui\:last\:mt-0:last-child{margin-top:calc(var(--ui-spacing) * 0)}.ui\:last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media (hover:hover){.ui\:hover\:cursor-pointer:hover{cursor:pointer}.ui\:hover\:border:hover{border-style:var(--tw-border-style);border-width:1px}.ui\:hover\:bg-accent:hover,.ui\:hover\:bg-accent\/25:hover{background-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.ui\:hover\:bg-accent\/25:hover{background-color:color-mix(in oklab, var(--accent) 25%, transparent)}}.ui\:hover\:bg-muted:hover{background-color:var(--muted)}.ui\:hover\:bg-sidebar-accent:hover{background-color:var(--sidebar-accent)}.ui\:hover\:bg-transparent\!:hover{background-color:#0000!important}.ui\:hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.ui\:hover\:text-foreground:hover{color:var(--foreground)}.ui\:hover\:text-muted-foreground:hover{color:var(--muted-foreground)}.ui\:hover\:text-sidebar-accent-foreground:hover{color:var(--sidebar-accent-foreground)}.ui\:hover\:underline:hover{text-decoration-line:underline}.ui\:hover\:opacity-100:hover{opacity:1}.ui\:hover\:shadow-2xs:hover{--tw-shadow:0 1px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:hover\:shadow-\[0_0_0_1px_var\(--sidebar-accent\)\]:hover{--tw-shadow:0 0 0 1px var(--tw-shadow-color,var(--sidebar-accent));box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:hover\:group-data-\[collapsible\=offcanvas\]\:bg-sidebar:hover:is(:where(.ui\:group)[data-collapsible=offcanvas] *){background-color:var(--sidebar)}.ui\:hover\:after\:bg-sidebar-border:hover:after{content:var(--tw-content);background-color:var(--sidebar-border)}}.ui\:focus\:bg-accent:focus{background-color:var(--accent)}.ui\:focus\:text-accent-foreground:focus{color:var(--accent-foreground)}.ui\:focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:focus\:ring-ring:focus{--tw-ring-color:var(--ring)}.ui\:focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.ui\:focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.ui\:focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.ui\:focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.ui\:focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.ui\:focus-visible\:ring-0:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.ui\:focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.ui\:focus-visible\:ring-ring:focus-visible,.ui\:focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.ui\:focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab, var(--ring) 50%, transparent)}}.ui\:focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.ui\:focus-visible\:outline-hidden:focus-visible{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.ui\:focus-visible\:outline-hidden:focus-visible{outline-offset:2px;outline:2px solid #0000}}.ui\:focus-visible\:outline-1:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.ui\:focus-visible\:outline-ring:focus-visible{outline-color:var(--ring)}.ui\:active\:bg-sidebar-accent:active{background-color:var(--sidebar-accent)}.ui\:active\:text-sidebar-accent-foreground:active{color:var(--sidebar-accent-foreground)}.ui\:disabled\:pointer-events-none:disabled{pointer-events:none}.ui\:disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.ui\:disabled\:opacity-50:disabled{opacity:.5}:where([data-side=left]) .ui\:in-data-\[side\=left\]\:cursor-w-resize{cursor:w-resize}:where([data-side=right]) .ui\:in-data-\[side\=right\]\:cursor-e-resize{cursor:e-resize}:where([data-slot=tooltip-content]) .ui\:in-data-\[slot\=tooltip-content\]\:bg-background\/20{background-color:var(--background)}@supports (color:color-mix(in lab, red, red)){:where([data-slot=tooltip-content]) .ui\:in-data-\[slot\=tooltip-content\]\:bg-background\/20{background-color:color-mix(in oklab, var(--background) 20%, transparent)}}:where([data-slot=tooltip-content]) .ui\:in-data-\[slot\=tooltip-content\]\:text-background{color:var(--background)}.ui\:has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.ui\:has-data-\[state\=checked\]\:border-primary:has([data-state=checked]){border-color:var(--primary)}.ui\:has-data-\[state\=checked\]\:bg-primary\/5:has([data-state=checked]){background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.ui\:has-data-\[state\=checked\]\:bg-primary\/5:has([data-state=checked]){background-color:color-mix(in oklab, var(--primary) 5%, transparent)}}.ui\:has-data-\[variant\=inset\]\:bg-sidebar:has([data-variant=inset]){background-color:var(--sidebar)}.ui\:has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:border-ring:has([data-slot=input-group-control]:focus-visible){border-color:var(--ring)}.ui\:has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:ring-\[3px\]:has([data-slot=input-group-control]:focus-visible){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:ring-ring\/50:has([data-slot=input-group-control]:focus-visible){--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.ui\:has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:ring-ring\/50:has([data-slot=input-group-control]:focus-visible){--tw-ring-color:color-mix(in oklab, var(--ring) 50%, transparent)}}.ui\:has-\[\[data-slot\]\[aria-invalid\=true\]\]\:border-destructive:has([data-slot][aria-invalid=true]){border-color:var(--destructive)}.ui\:has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/20:has([data-slot][aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.ui\:has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/20:has([data-slot][aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.ui\:has-\[\>\[data-align\=block-end\]\]\:h-auto:has(>[data-align=block-end]){height:auto}.ui\:has-\[\>\[data-align\=block-end\]\]\:flex-col:has(>[data-align=block-end]){flex-direction:column}.ui\:has-\[\>\[data-align\=block-start\]\]\:h-auto:has(>[data-align=block-start]){height:auto}.ui\:has-\[\>\[data-align\=block-start\]\]\:flex-col:has(>[data-align=block-start]){flex-direction:column}.ui\:has-\[\>\[data-slot\=button-group\]\]\:gap-2:has(>[data-slot=button-group]){gap:calc(var(--ui-spacing) * 2)}.ui\:has-\[\>\[data-slot\=checkbox-group\]\]\:gap-3:has(>[data-slot=checkbox-group]){gap:calc(var(--ui-spacing) * 3)}.ui\:has-\[\>\[data-slot\=field-content\]\]\:items-start:has(>[data-slot=field-content]){align-items:flex-start}.ui\:has-\[\>\[data-slot\=field\]\]\:w-full:has(>[data-slot=field]){width:100%}.ui\:has-\[\>\[data-slot\=field\]\]\:flex-col:has(>[data-slot=field]){flex-direction:column}.ui\:has-\[\>\[data-slot\=field\]\]\:rounded-md:has(>[data-slot=field]){border-radius:calc(var(--radius) - 2px)}.ui\:has-\[\>\[data-slot\=field\]\]\:border:has(>[data-slot=field]){border-style:var(--tw-border-style);border-width:1px}.ui\:has-\[\>\[data-slot\=radio-group\]\]\:gap-3:has(>[data-slot=radio-group]){gap:calc(var(--ui-spacing) * 3)}.ui\:has-\[\>button\]\:ms-\[-0\.45rem\]:has(>button){margin-inline-start:-.45rem}.ui\:has-\[\>button\]\:me-\[-0\.45rem\]:has(>button){margin-inline-end:-.45rem}.ui\:has-\[\>kbd\]\:ms-\[-0\.35rem\]:has(>kbd){margin-inline-start:-.35rem}.ui\:has-\[\>kbd\]\:me-\[-0\.35rem\]:has(>kbd){margin-inline-end:-.35rem}.ui\:has-\[\>svg\]\:grid-cols-\[calc\(var\(--spacing\)\*4\)_1fr\]:has(>svg){grid-template-columns:calc(var(--spacing) * 4) 1fr}.ui\:has-\[\>svg\]\:gap-x-3:has(>svg){column-gap:calc(var(--ui-spacing) * 3)}.ui\:has-\[\>svg\]\:p-0:has(>svg){padding:calc(var(--ui-spacing) * 0)}.ui\:has-\[\>svg\]\:px-2:has(>svg){padding-inline:calc(var(--ui-spacing) * 2)}.ui\:has-\[\>svg\]\:px-2\.5:has(>svg){padding-inline:calc(var(--ui-spacing) * 2.5)}.ui\:has-\[\>svg\]\:px-3:has(>svg){padding-inline:calc(var(--ui-spacing) * 3)}.ui\:has-\[\>svg\]\:px-4:has(>svg){padding-inline:calc(var(--ui-spacing) * 4)}.ui\:has-\[\>textarea\]\:h-auto:has(>textarea){height:auto}.ui\:aria-disabled\:pointer-events-none[aria-disabled=true]{pointer-events:none}.ui\:aria-disabled\:opacity-50[aria-disabled=true]{opacity:.5}.ui\:aria-invalid\:border-destructive[aria-invalid=true]{border-color:var(--destructive)}.ui\:aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.ui\:aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.ui\:aria-selected\:bg-accent[aria-selected=true]{background-color:var(--accent)}.ui\:aria-selected\:text-accent-foreground[aria-selected=true]{color:var(--accent-foreground)}:is(.ui\:\*\*\:data-command-group\:px-2 *)[data-command-group]{padding-inline:calc(var(--ui-spacing) * 2)}:is(.ui\:\*\*\:data-command-input\:h-12 *)[data-command-input]{height:calc(var(--ui-spacing) * 12)}:is(.ui\:\*\*\:data-command-item\:px-2 *)[data-command-item]{padding-inline:calc(var(--ui-spacing) * 2)}:is(.ui\:\*\*\:data-command-item\:py-3 *)[data-command-item]{padding-block:calc(var(--ui-spacing) * 3)}.ui\:data-disabled\:pointer-events-none[data-disabled]{pointer-events:none}.ui\:data-disabled\:cursor-not-allowed[data-disabled]{cursor:not-allowed}.ui\:data-disabled\:opacity-50[data-disabled]{opacity:.5}.ui\:data-highlighted\:bg-accent[data-highlighted]{background-color:var(--accent)}.ui\:data-highlighted\:text-accent-foreground[data-highlighted]{color:var(--accent-foreground)}.ui\:data-inset\:pl-8[data-inset]{padding-left:calc(var(--ui-spacing) * 8)}.ui\:data-placeholder\:text-muted-foreground[data-placeholder]{color:var(--muted-foreground)}.ui\:data-\[active\=true\]\:bg-accent\/50[data-active=true]{background-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.ui\:data-\[active\=true\]\:bg-accent\/50[data-active=true]{background-color:color-mix(in oklab, var(--accent) 50%, transparent)}}.ui\:data-\[active\=true\]\:bg-sidebar-accent[data-active=true]{background-color:var(--sidebar-accent)}.ui\:data-\[active\=true\]\:font-medium[data-active=true]{--tw-font-weight:var(--ui-font-weight-medium);font-weight:var(--ui-font-weight-medium)}.ui\:data-\[active\=true\]\:text-accent-foreground[data-active=true]{color:var(--accent-foreground)}.ui\:data-\[active\=true\]\:text-sidebar-accent-foreground[data-active=true]{color:var(--sidebar-accent-foreground)}@media (hover:hover){.ui\:data-\[active\=true\]\:hover\:bg-accent[data-active=true]:hover{background-color:var(--accent)}}.ui\:data-\[active\=true\]\:focus\:bg-accent[data-active=true]:focus{background-color:var(--accent)}.ui\:data-\[current\=true\]\:bg-muted[data-current=true]{background-color:var(--muted)}.ui\:data-\[disabled\=true\]\:pointer-events-none[data-disabled=true]{pointer-events:none}.ui\:data-\[disabled\=true\]\:opacity-50[data-disabled=true]{opacity:.5}.ui\:data-\[inset\]\:pl-8[data-inset]{padding-left:calc(var(--ui-spacing) * 8)}.ui\:data-\[invalid\=true\]\:text-destructive[data-invalid=true]{color:var(--destructive)}.ui\:data-\[motion\=from-end\]\:slide-in-from-end-52[data-motion=from-end]:where(:dir(ltr),[dir=ltr]){--tw-enter-translate-x:calc(52*var(--spacing))}.ui\:data-\[motion\=from-end\]\:slide-in-from-end-52[data-motion=from-end]:where(:dir(rtl),[dir=rtl]),.ui\:data-\[motion\=from-start\]\:slide-in-from-start-52[data-motion=from-start]:where(:dir(ltr),[dir=ltr]){--tw-enter-translate-x:calc(52*var(--spacing)*-1)}.ui\:data-\[motion\=from-start\]\:slide-in-from-start-52[data-motion=from-start]:where(:dir(rtl),[dir=rtl]){--tw-enter-translate-x:calc(52*var(--spacing))}.ui\:data-\[motion\=to-end\]\:slide-out-to-end-52[data-motion=to-end]:where(:dir(ltr),[dir=ltr]){--tw-exit-translate-x:calc(52*var(--spacing))}.ui\:data-\[motion\=to-end\]\:slide-out-to-end-52[data-motion=to-end]:where(:dir(rtl),[dir=rtl]),.ui\:data-\[motion\=to-start\]\:slide-out-to-start-52[data-motion=to-start]:where(:dir(ltr),[dir=ltr]){--tw-exit-translate-x:calc(52*var(--spacing)*-1)}.ui\:data-\[motion\=to-start\]\:slide-out-to-start-52[data-motion=to-start]:where(:dir(rtl),[dir=rtl]){--tw-exit-translate-x:calc(52*var(--spacing))}.ui\:data-\[motion\^\=from-\]\:animate-in[data-motion^=from-]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.ui\:data-\[motion\^\=from-\]\:fade-in[data-motion^=from-]{--tw-enter-opacity:0}.ui\:data-\[motion\^\=to-\]\:animate-out[data-motion^=to-]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.ui\:data-\[motion\^\=to-\]\:fade-out[data-motion^=to-]{--tw-exit-opacity:0}.ui\:data-\[orientation\=horizontal\]\:h-px[data-orientation=horizontal]{height:1px}.ui\:data-\[orientation\=horizontal\]\:w-full[data-orientation=horizontal]{width:100%}.ui\:data-\[orientation\=vertical\]\:h-auto[data-orientation=vertical]{height:auto}.ui\:data-\[orientation\=vertical\]\:h-full[data-orientation=vertical]{height:100%}.ui\:data-\[orientation\=vertical\]\:w-px[data-orientation=vertical]{width:1px}.ui\:data-\[side\=bottom\]\:-translate-x-1\/2[data-side=bottom]{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.ui\:data-\[side\=bottom\]\:-translate-y-\[calc\(-50\%\+1px\)\][data-side=bottom]{--tw-translate-y:calc(calc(-50% + 1px) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.ui\:data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y:calc(var(--ui-spacing) * 1);translate:var(--tw-translate-x) var(--tw-translate-y)}.ui\:data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y:calc(2*var(--spacing)*-1)}.ui\:data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x:calc(var(--ui-spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.ui\:data-\[side\=left\]\:-translate-y-\[calc\(50\%-3px\)\][data-side=left]{--tw-translate-y:calc(calc(50% - 3px) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.ui\:data-\[side\=left\]\:slide-in-from-end-2[data-side=left]:where(:dir(ltr),[dir=ltr]){--tw-enter-translate-x:calc(2*var(--spacing))}.ui\:data-\[side\=left\]\:slide-in-from-end-2[data-side=left]:where(:dir(rtl),[dir=rtl]){--tw-enter-translate-x:calc(2*var(--spacing)*-1)}.ui\:data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x:calc(2*var(--spacing))}.ui\:data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x:calc(var(--ui-spacing) * 1);translate:var(--tw-translate-x) var(--tw-translate-y)}.ui\:data-\[side\=right\]\:translate-x-\[calc\(50\%\+2px\)\][data-side=right]{--tw-translate-x:calc(50% + 2px);translate:var(--tw-translate-x) var(--tw-translate-y)}.ui\:data-\[side\=right\]\:translate-y-1\/2[data-side=right]{--tw-translate-y:calc(1 / 2 * 100%);translate:var(--tw-translate-x) var(--tw-translate-y)}.ui\:data-\[side\=right\]\:slide-in-from-start-2[data-side=right]:where(:dir(ltr),[dir=ltr]){--tw-enter-translate-x:calc(2*var(--spacing)*-1)}.ui\:data-\[side\=right\]\:slide-in-from-start-2[data-side=right]:where(:dir(rtl),[dir=rtl]){--tw-enter-translate-x:calc(2*var(--spacing))}.ui\:data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x:calc(2*var(--spacing)*-1)}.ui\:data-\[side\=top\]\:translate-x-1\/2[data-side=top]{--tw-translate-x:calc(1 / 2 * 100%);translate:var(--tw-translate-x) var(--tw-translate-y)}.ui\:data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y:calc(var(--ui-spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.ui\:data-\[side\=top\]\:translate-y-\[calc\(-50\%\+2px\)\][data-side=top]{--tw-translate-y:calc(-50% + 2px);translate:var(--tw-translate-x) var(--tw-translate-y)}.ui\:data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y:calc(2*var(--spacing))}.ui\:data-\[size\=default\]\:h-9[data-size=default]{height:calc(var(--ui-spacing) * 9)}.ui\:data-\[size\=sm\]\:h-8[data-size=sm]{height:calc(var(--ui-spacing) * 8)}.ui\:data-\[slot\=checkbox-group\]\:gap-3[data-slot=checkbox-group]{gap:calc(var(--ui-spacing) * 3)}:is(.ui\:\*\:data-\[slot\=field\]\:p-4>*)[data-slot=field]{padding:calc(var(--ui-spacing) * 4)}:is(.ui\:\*\:data-\[slot\=field-group\]\:gap-4>*)[data-slot=field-group]{gap:calc(var(--ui-spacing) * 4)}:is(.ui\:\*\*\:data-\[slot\=navigation-menu-link\]\:focus\:ring-0 *)[data-slot=navigation-menu-link]:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}:is(.ui\:\*\*\:data-\[slot\=navigation-menu-link\]\:focus\:outline-none *)[data-slot=navigation-menu-link]:focus{--tw-outline-style:none;outline-style:none}:is(.ui\:\*\*\:data-\[slot\=select-value\]\:line-clamp-1 *)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.ui\:\*\*\:data-\[slot\=select-value\]\:flex *)[data-slot=select-value]{display:flex}:is(.ui\:\*\*\:data-\[slot\=select-value\]\:items-center *)[data-slot=select-value]{align-items:center}:is(.ui\:\*\*\:data-\[slot\=select-value\]\:gap-2 *)[data-slot=select-value]{gap:calc(var(--ui-spacing) * 2)}.ui\:data-\[state\=active\]\:bg-background[data-state=active]{background-color:var(--background)}.ui\:data-\[state\=active\]\:shadow-sm[data-state=active]{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:data-\[state\=checked\]\:translate-x-\[calc\(100\%-2px\)\][data-state=checked]{--tw-translate-x:calc(100% - 2px);translate:var(--tw-translate-x) var(--tw-translate-y)}.ui\:data-\[state\=checked\]\:border-primary[data-state=checked]{border-color:var(--primary)}.ui\:data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:var(--primary)}.ui\:data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:var(--primary-foreground)}.ui\:data-\[state\=closed\]\:animate-accordion-up[data-state=closed]{animation:accordion-up var(--tw-animation-duration,var(--tw-duration,.2s))var(--tw-ease,ease-out)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.ui\:data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.ui\:data-\[state\=closed\]\:duration-300[data-state=closed]{--tw-duration:.3s;transition-duration:.3s}.ui\:data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity:0}.ui\:data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale:.95}.ui\:data-\[state\=closed\]\:slide-out-to-bottom[data-state=closed]{--tw-exit-translate-y:100%}.ui\:data-\[state\=closed\]\:slide-out-to-left[data-state=closed]{--tw-exit-translate-x:-100%}.ui\:data-\[state\=closed\]\:slide-out-to-right[data-state=closed]{--tw-exit-translate-x:100%}.ui\:data-\[state\=closed\]\:slide-out-to-top[data-state=closed]{--tw-exit-translate-y:-100%}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:data-\[state\=closed\]\:animate-out:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *)[data-state=closed]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:data-\[state\=closed\]\:fade-out-0:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *)[data-state=closed]{--tw-exit-opacity:0}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:data-\[state\=closed\]\:zoom-out-95:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *)[data-state=closed]{--tw-exit-scale:.95}.ui\:data-\[state\=hidden\]\:animate-out[data-state=hidden]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.ui\:data-\[state\=hidden\]\:fade-out[data-state=hidden]{--tw-exit-opacity:0}.ui\:data-\[state\=off\]\:text-muted-foreground[data-state=off]{color:var(--muted-foreground)}@media (hover:hover){.ui\:hover\:data-\[state\=off\]\:text-accent-foreground:hover[data-state=off]{color:var(--accent-foreground)}}.ui\:data-\[state\=on\]\:bg-accent[data-state=on]{background-color:var(--accent)}.ui\:data-\[state\=on\]\:bg-transparent[data-state=on]{background-color:#0000}.ui\:data-\[state\=on\]\:text-accent-foreground[data-state=on]{color:var(--accent-foreground)}.ui\:data-\[state\=on\]\:text-muted-foreground[data-state=on]{color:var(--muted-foreground)}@media (hover:hover){.ui\:hover\:data-\[state\=on\]\:text-accent-foreground:hover[data-state=on]{color:var(--accent-foreground)}}.ui\:data-\[state\=open\]\:animate-accordion-down[data-state=open]{animation:accordion-down var(--tw-animation-duration,var(--tw-duration,.2s))var(--tw-ease,ease-out)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.ui\:data-\[state\=open\]\:animate-in[data-state=open]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.ui\:data-\[state\=open\]\:bg-accent[data-state=open]{background-color:var(--accent)}.ui\:data-\[state\=open\]\:text-accent-foreground[data-state=open]{color:var(--accent-foreground)}.ui\:data-\[state\=open\]\:opacity-100[data-state=open]{opacity:1}.ui\:data-\[state\=open\]\:duration-500[data-state=open]{--tw-duration:.5s;transition-duration:.5s}.ui\:data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity:0}.ui\:data-\[state\=open\]\:zoom-in-90[data-state=open]{--tw-enter-scale:.9}.ui\:data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale:.95}.ui\:data-\[state\=open\]\:slide-in-from-bottom[data-state=open]{--tw-enter-translate-y:100%}.ui\:data-\[state\=open\]\:slide-in-from-left[data-state=open]{--tw-enter-translate-x:-100%}.ui\:data-\[state\=open\]\:slide-in-from-right[data-state=open]{--tw-enter-translate-x:100%}.ui\:data-\[state\=open\]\:slide-in-from-top[data-state=open]{--tw-enter-translate-y:-100%}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:data-\[state\=open\]\:animate-in:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *)[data-state=open]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:data-\[state\=open\]\:fade-in-0:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *)[data-state=open]{--tw-enter-opacity:0}.ui\:group-data-\[viewport\=false\]\/navigation-menu\:data-\[state\=open\]\:zoom-in-95:is(:where(.ui\:group\/navigation-menu)[data-viewport=false] *)[data-state=open]{--tw-enter-scale:.95}@media (hover:hover){.ui\:data-\[state\=open\]\:hover\:bg-sidebar-accent[data-state=open]:hover{background-color:var(--sidebar-accent)}.ui\:data-\[state\=open\]\:hover\:text-sidebar-accent-foreground[data-state=open]:hover{color:var(--sidebar-accent-foreground)}}.ui\:data-\[state\=selected\]\:bg-muted[data-state=selected]{background-color:var(--muted)}.ui\:data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x:calc(var(--ui-spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.ui\:data-\[state\=unchecked\]\:bg-input[data-state=unchecked]{background-color:var(--input)}.ui\:data-\[state\=visible\]\:animate-in[data-state=visible]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.ui\:data-\[state\=visible\]\:fade-in[data-state=visible]{--tw-enter-opacity:0}.ui\:data-\[variant\=destructive\]\:text-destructive[data-variant=destructive]{color:var(--destructive)}.ui\:data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.ui\:data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:color-mix(in oklab, var(--destructive) 10%, transparent)}}.ui\:data-\[variant\=destructive\]\:focus\:text-destructive[data-variant=destructive]:focus{color:var(--destructive)}.ui\:data-\[variant\=destructive\]\:data-highlighted\:bg-destructive\/10[data-variant=destructive][data-highlighted]{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.ui\:data-\[variant\=destructive\]\:data-highlighted\:bg-destructive\/10[data-variant=destructive][data-highlighted]{background-color:color-mix(in oklab, var(--destructive) 10%, transparent)}}.ui\:data-\[variant\=destructive\]\:data-highlighted\:text-destructive[data-variant=destructive][data-highlighted]{color:var(--destructive)}.ui\:data-\[variant\=label\]\:text-sm[data-variant=label]{font-size:var(--ui-text-sm);line-height:var(--tw-leading,var(--ui-text-sm--line-height))}.ui\:data-\[variant\=legend\]\:text-base[data-variant=legend]{font-size:var(--ui-text-base);line-height:var(--tw-leading,var(--ui-text-base--line-height))}.ui\:data-\[vaul-drawer-direction\=bottom\]\:inset-x-0[data-vaul-drawer-direction=bottom]{inset-inline:calc(var(--ui-spacing) * 0)}.ui\:data-\[vaul-drawer-direction\=bottom\]\:bottom-0[data-vaul-drawer-direction=bottom]{bottom:calc(var(--ui-spacing) * 0)}.ui\:data-\[vaul-drawer-direction\=bottom\]\:mt-24[data-vaul-drawer-direction=bottom]{margin-top:calc(var(--ui-spacing) * 24)}.ui\:data-\[vaul-drawer-direction\=bottom\]\:max-h-\[80vh\][data-vaul-drawer-direction=bottom]{max-height:80vh}.ui\:data-\[vaul-drawer-direction\=bottom\]\:rounded-t-lg[data-vaul-drawer-direction=bottom]{border-top-left-radius:var(--radius);border-top-right-radius:var(--radius)}.ui\:data-\[vaul-drawer-direction\=bottom\]\:border-t[data-vaul-drawer-direction=bottom]{border-top-style:var(--tw-border-style);border-top-width:1px}.ui\:data-\[vaul-drawer-direction\=left\]\:inset-y-0[data-vaul-drawer-direction=left]{inset-block:calc(var(--ui-spacing) * 0)}.ui\:data-\[vaul-drawer-direction\=left\]\:start-0[data-vaul-drawer-direction=left]{inset-inline-start:calc(var(--ui-spacing) * 0)}.ui\:data-\[vaul-drawer-direction\=left\]\:w-3\/4[data-vaul-drawer-direction=left]{width:75%}.ui\:data-\[vaul-drawer-direction\=left\]\:border-e[data-vaul-drawer-direction=left]{border-inline-end-style:var(--tw-border-style);border-inline-end-width:1px}.ui\:data-\[vaul-drawer-direction\=right\]\:inset-y-0[data-vaul-drawer-direction=right]{inset-block:calc(var(--ui-spacing) * 0)}.ui\:data-\[vaul-drawer-direction\=right\]\:end-0[data-vaul-drawer-direction=right]{inset-inline-end:calc(var(--ui-spacing) * 0)}.ui\:data-\[vaul-drawer-direction\=right\]\:w-3\/4[data-vaul-drawer-direction=right]{width:75%}.ui\:data-\[vaul-drawer-direction\=right\]\:border-s[data-vaul-drawer-direction=right]{border-inline-start-style:var(--tw-border-style);border-inline-start-width:1px}.ui\:data-\[vaul-drawer-direction\=top\]\:inset-x-0[data-vaul-drawer-direction=top]{inset-inline:calc(var(--ui-spacing) * 0)}.ui\:data-\[vaul-drawer-direction\=top\]\:top-0[data-vaul-drawer-direction=top]{top:calc(var(--ui-spacing) * 0)}.ui\:data-\[vaul-drawer-direction\=top\]\:mb-24[data-vaul-drawer-direction=top]{margin-bottom:calc(var(--ui-spacing) * 24)}.ui\:data-\[vaul-drawer-direction\=top\]\:max-h-\[80vh\][data-vaul-drawer-direction=top]{max-height:80vh}.ui\:data-\[vaul-drawer-direction\=top\]\:rounded-b-lg[data-vaul-drawer-direction=top]{border-bottom-right-radius:var(--radius);border-bottom-left-radius:var(--radius)}.ui\:data-\[vaul-drawer-direction\=top\]\:border-b[data-vaul-drawer-direction=top]{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.ui\:nth-last-2\:-mt-1:nth-last-child(2){margin-top:calc(var(--ui-spacing) * -1)}@media (min-width:40rem){.ui\:sm\:flex{display:flex}.ui\:sm\:max-w-lg{max-width:var(--ui-container-lg)}.ui\:sm\:max-w-sm{max-width:var(--ui-container-sm)}.ui\:sm\:flex-row{flex-direction:row}.ui\:sm\:justify-end{justify-content:flex-end}.ui\:sm\:gap-2\.5{gap:calc(var(--ui-spacing) * 2.5)}.ui\:sm\:pr-2\.5{padding-right:calc(var(--ui-spacing) * 2.5)}.ui\:sm\:pl-2\.5{padding-left:calc(var(--ui-spacing) * 2.5)}.ui\:data-\[vaul-drawer-direction\=left\]\:sm\:max-w-sm[data-vaul-drawer-direction=left],.ui\:data-\[vaul-drawer-direction\=right\]\:sm\:max-w-sm[data-vaul-drawer-direction=right]{max-width:var(--ui-container-sm)}}@media (min-width:48rem){.ui\:md\:block{display:block}.ui\:md\:flex{display:flex}.ui\:md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.ui\:md\:p-12{padding:calc(var(--ui-spacing) * 12)}.ui\:md\:text-sm{font-size:var(--ui-text-sm);line-height:var(--tw-leading,var(--ui-text-sm--line-height))}.ui\:md\:opacity-0{opacity:0}.ui\:md\:peer-data-\[variant\=inset\]\:m-2:is(:where(.ui\:peer)[data-variant=inset]~*){margin:calc(var(--ui-spacing) * 2)}.ui\:md\:peer-data-\[variant\=inset\]\:ml-0:is(:where(.ui\:peer)[data-variant=inset]~*){margin-left:calc(var(--ui-spacing) * 0)}.ui\:md\:peer-data-\[variant\=inset\]\:rounded-xl:is(:where(.ui\:peer)[data-variant=inset]~*){border-radius:calc(var(--radius) + 4px)}.ui\:md\:peer-data-\[variant\=inset\]\:shadow-sm:is(:where(.ui\:peer)[data-variant=inset]~*){--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ui\:md\:peer-data-\[variant\=inset\]\:peer-data-\[state\=collapsed\]\:ml-2:is(:where(.ui\:peer)[data-variant=inset]~*):is(:where(.ui\:peer)[data-state=collapsed]~*){margin-left:calc(var(--ui-spacing) * 2)}.ui\:md\:after\:hidden:after{content:var(--tw-content);display:none}}.ui\:dark\:scale-0:where(.dark,.dark *){--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.ui\:dark\:scale-100:where(.dark,.dark *){--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.ui\:dark\:-rotate-90:where(.dark,.dark *){rotate:-90deg}.ui\:dark\:rotate-0:where(.dark,.dark *){rotate:none}.ui\:dark\:border-amber-900:where(.dark,.dark *){border-color:var(--ui-color-amber-900)}.ui\:dark\:border-input:where(.dark,.dark *){border-color:var(--input)}.ui\:dark\:border-sky-900:where(.dark,.dark *){border-color:var(--ui-color-sky-900)}.ui\:dark\:border-slate-800:where(.dark,.dark *){border-color:var(--ui-color-slate-800)}.ui\:dark\:bg-amber-500:where(.dark,.dark *){background-color:var(--ui-color-amber-500)}.ui\:dark\:bg-amber-950:where(.dark,.dark *){background-color:var(--ui-color-amber-950)}.ui\:dark\:bg-destructive\/60:where(.dark,.dark *){background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.ui\:dark\:bg-destructive\/60:where(.dark,.dark *){background-color:color-mix(in oklab, var(--destructive) 60%, transparent)}}.ui\:dark\:bg-destructive\/70:where(.dark,.dark *){background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.ui\:dark\:bg-destructive\/70:where(.dark,.dark *){background-color:color-mix(in oklab, var(--destructive) 70%, transparent)}}.ui\:dark\:bg-emerald-500:where(.dark,.dark *){background-color:var(--ui-color-emerald-500)}.ui\:dark\:bg-input\/30:where(.dark,.dark *){background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.ui\:dark\:bg-input\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--input) 30%, transparent)}}.ui\:dark\:bg-sky-950:where(.dark,.dark *){background-color:var(--ui-color-sky-950)}.ui\:dark\:bg-slate-950:where(.dark,.dark *){background-color:var(--ui-color-slate-950)}.ui\:dark\:bg-transparent:where(.dark,.dark *){background-color:#0000}.ui\:dark\:text-amber-50:where(.dark,.dark *){color:var(--ui-color-amber-50)}.ui\:dark\:text-amber-950:where(.dark,.dark *){color:var(--ui-color-amber-950)}.ui\:dark\:text-emerald-950:where(.dark,.dark *){color:var(--ui-color-emerald-950)}.ui\:dark\:text-muted-foreground:where(.dark,.dark *){color:var(--muted-foreground)}.ui\:dark\:text-sky-50:where(.dark,.dark *){color:var(--ui-color-sky-50)}.ui\:dark\:text-white:where(.dark,.dark *){color:var(--ui-color-white)}@media (hover:hover){.ui\:dark\:hover\:border-input:where(.dark,.dark *):hover{border-color:var(--input)}.ui\:dark\:hover\:bg-input\/50:where(.dark,.dark *):hover{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.ui\:dark\:hover\:bg-input\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--input) 50%, transparent)}}}.ui\:dark\:focus-visible\:ring-destructive\/40:where(.dark,.dark *):focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.ui\:dark\:focus-visible\:ring-destructive\/40:where(.dark,.dark *):focus-visible{--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}:where([data-slot=tooltip-content]) .ui\:dark\:in-data-\[slot\=tooltip-content\]\:bg-background\/10:where(.dark,.dark *){background-color:var(--background)}@supports (color:color-mix(in lab, red, red)){:where([data-slot=tooltip-content]) .ui\:dark\:in-data-\[slot\=tooltip-content\]\:bg-background\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--background) 10%, transparent)}}.ui\:dark\:has-data-\[state\=checked\]\:bg-primary\/10:where(.dark,.dark *):has([data-state=checked]){background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.ui\:dark\:has-data-\[state\=checked\]\:bg-primary\/10:where(.dark,.dark *):has([data-state=checked]){background-color:color-mix(in oklab, var(--primary) 10%, transparent)}}.ui\:dark\:has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/40:where(.dark,.dark *):has([data-slot][aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.ui\:dark\:has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/40:where(.dark,.dark *):has([data-slot][aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.ui\:dark\:aria-invalid\:ring-destructive\/40:where(.dark,.dark *)[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.ui\:dark\:aria-invalid\:ring-destructive\/40:where(.dark,.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.ui\:dark\:data-\[state\=active\]\:border-input:where(.dark,.dark *)[data-state=active]{border-color:var(--input)}.ui\:dark\:data-\[state\=active\]\:bg-input\/30:where(.dark,.dark *)[data-state=active]{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.ui\:dark\:data-\[state\=active\]\:bg-input\/30:where(.dark,.dark *)[data-state=active]{background-color:color-mix(in oklab, var(--input) 30%, transparent)}}.ui\:dark\:data-\[state\=active\]\:text-foreground:where(.dark,.dark *)[data-state=active]{color:var(--foreground)}.ui\:dark\:data-\[state\=checked\]\:bg-primary:where(.dark,.dark *)[data-state=checked]{background-color:var(--primary)}.ui\:dark\:data-\[state\=checked\]\:bg-primary-foreground:where(.dark,.dark *)[data-state=checked]{background-color:var(--primary-foreground)}.ui\:dark\:data-\[state\=unchecked\]\:bg-foreground:where(.dark,.dark *)[data-state=unchecked]{background-color:var(--foreground)}.ui\:dark\:data-\[state\=unchecked\]\:bg-input\/80:where(.dark,.dark *)[data-state=unchecked]{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.ui\:dark\:data-\[state\=unchecked\]\:bg-input\/80:where(.dark,.dark *)[data-state=unchecked]{background-color:color-mix(in oklab, var(--input) 80%, transparent)}}.ui\:\[\&_\[data-command-group\]\:not\(\[hidden\]\)_\~\[data-command-group\]\]\:pt-0 [data-command-group]:not([hidden])~[data-command-group]{padding-top:calc(var(--ui-spacing) * 0)}.ui\:\[\&_\[data-command-input-wrapper\]_svg\]\:h-5 [data-command-input-wrapper] svg{height:calc(var(--ui-spacing) * 5)}.ui\:\[\&_\[data-command-input-wrapper\]_svg\]\:w-5 [data-command-input-wrapper] svg{width:calc(var(--ui-spacing) * 5)}.ui\:\[\&_\[data-command-item\]_svg\]\:h-5 [data-command-item] svg{height:calc(var(--ui-spacing) * 5)}.ui\:\[\&_\[data-command-item\]_svg\]\:w-5 [data-command-item] svg{width:calc(var(--ui-spacing) * 5)}.ui\:\[\&_img\]\:size-full img{width:100%;height:100%}.ui\:\[\&_img\]\:object-cover img{object-fit:cover}.ui\:\[\&_p\]\:leading-relaxed p{--tw-leading:var(--ui-leading-relaxed);line-height:var(--ui-leading-relaxed)}.ui\:\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.ui\:\[\&_svg\]\:size-8\! svg{width:calc(var(--ui-spacing) * 8)!important;height:calc(var(--ui-spacing) * 8)!important}.ui\:\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.ui\:\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3 svg:not([class*=size-]){width:calc(var(--ui-spacing) * 3);height:calc(var(--ui-spacing) * 3)}.ui\:\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--ui-spacing) * 4);height:calc(var(--ui-spacing) * 4)}.ui\:\[\&_svg\:not\(\[class\*\=\'text-\'\]\)\]\:text-muted-foreground svg:not([class*=text-]){color:var(--muted-foreground)}.ui\:\[\&_svg\:not\(\[class\*\=\'ui\:size-\'\]\)\]\:size-4 svg:not([class*=ui\:size-]){width:calc(var(--ui-spacing) * 4);height:calc(var(--ui-spacing) * 4)}.ui\:\[\&_svg\:not\(\[class\*\=\'ui\:size-\'\]\)\]\:size-6 svg:not([class*=ui\:size-]){width:calc(var(--ui-spacing) * 6);height:calc(var(--ui-spacing) * 6)}.ui\:\[\&_tr\]\:border-b tr{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.ui\:\[\&_tr\:last-child\]\:border-0 tr:last-child{border-style:var(--tw-border-style);border-width:0}.ui\:\[\&\+\[data-slot\=item-content\]\]\:flex-none+[data-slot=item-content]{flex:none}.ui\:\[\&\:has\(\[role\=checkbox\]\)\]\:pr-0:has([role=checkbox]){padding-right:calc(var(--ui-spacing) * 0)}.ui\:\[\.border-b\]\:pb-3.border-b{padding-bottom:calc(var(--ui-spacing) * 3)}.ui\:\[\.border-b\]\:pb-6.border-b{padding-bottom:calc(var(--ui-spacing) * 6)}.ui\:\[\.border-t\]\:pt-3.border-t{padding-top:calc(var(--ui-spacing) * 3)}.ui\:\[\.border-t\]\:pt-6.border-t{padding-top:calc(var(--ui-spacing) * 6)}.ui\:\[a\]\:transition-colors:is(a){transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--ui-default-transition-timing-function));transition-duration:var(--tw-duration,var(--ui-default-transition-duration))}@media (hover:hover){.ui\:\[a\]\:hover\:bg-accent\/50:is(a):hover{background-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.ui\:\[a\]\:hover\:bg-accent\/50:is(a):hover{background-color:color-mix(in oklab, var(--accent) 50%, transparent)}}}:is(.ui\:\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.ui\:\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.ui\:\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--ui-spacing) * 2)}:is(.ui\:data-\[variant\=destructive\]\:\*\:\[svg\]\:text-destructive\![data-variant=destructive]>*):is(svg){color:var(--destructive)!important}.ui\:\[\&\>\*\]\:focus-visible\:relative>:focus-visible{position:relative}.ui\:\[\&\>\*\]\:focus-visible\:z-10>:focus-visible{z-index:10}.ui\:\[\&\>\*\:not\(\:first-child\)\]\:rounded-s-none>:not(:first-child){border-start-start-radius:0;border-end-start-radius:0}.ui\:\[\&\>\*\:not\(\:first-child\)\]\:rounded-t-none>:not(:first-child){border-top-left-radius:0;border-top-right-radius:0}.ui\:\[\&\>\*\:not\(\:first-child\)\]\:border-s-0>:not(:first-child){border-inline-start-style:var(--tw-border-style);border-inline-start-width:0}.ui\:\[\&\>\*\:not\(\:first-child\)\]\:border-t-0>:not(:first-child){border-top-style:var(--tw-border-style);border-top-width:0}.ui\:\[\&\>\*\:not\(\:last-child\)\]\:rounded-e-none>:not(:last-child){border-start-end-radius:0;border-end-end-radius:0}.ui\:\[\&\>\*\:not\(\:last-child\)\]\:rounded-b-none>:not(:last-child){border-bottom-right-radius:0;border-bottom-left-radius:0}.ui\:\[\&\>\[data-slot\=field-label\]\]\:flex-auto>[data-slot=field-label]{flex:auto}.ui\:has-\[select\[aria-hidden\=true\]\:last-child\]\:\[\&\>\[data-slot\=select-trigger\]\:last-of-type\]\:rounded-e-md:has(:is(select[aria-hidden=true]:last-child))>[data-slot=select-trigger]:last-of-type{border-start-end-radius:calc(var(--radius) - 2px);border-end-end-radius:calc(var(--radius) - 2px)}.ui\:\[\&\>\[data-slot\=select-trigger\]\:not\(\[class\*\=\'ui\:w-\'\]\)\]\:w-fit>[data-slot=select-trigger]:not([class*=ui\:w-]){width:fit-content}.ui\:has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content])>[role=checkbox],.ui\:has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content]) [role=radio]{margin-top:1px}.ui\:\[\&\>a\]\:underline>a{text-decoration-line:underline}.ui\:\[\&\>a\]\:underline-offset-4>a{text-underline-offset:4px}.ui\:\[\&\>a\:hover\]\:text-primary>a:hover{color:var(--primary)}.ui\:\[\&\>button\]\:hidden>button{display:none}.ui\:\[\&\>input\]\:flex-1>input{flex:1}.ui\:has-\[\>\[data-align\=block-end\]\]\:\[\&\>input\]\:pt-3:has(>[data-align=block-end])>input{padding-top:calc(var(--ui-spacing) * 3)}.ui\:has-\[\>\[data-align\=block-start\]\]\:\[\&\>input\]\:pb-3:has(>[data-align=block-start])>input{padding-bottom:calc(var(--ui-spacing) * 3)}.ui\:has-\[\>\[data-align\=inline-end\]\]\:\[\&\>input\]\:pe-2:has(>[data-align=inline-end])>input{padding-inline-end:calc(var(--ui-spacing) * 2)}.ui\:has-\[\>\[data-align\=inline-start\]\]\:\[\&\>input\]\:ps-2:has(>[data-align=inline-start])>input{padding-inline-start:calc(var(--ui-spacing) * 2)}.ui\:\[\&\>kbd\]\:rounded-\[calc\(var\(--radius\)-5px\)\]>kbd{border-radius:calc(var(--radius) - 5px)}.ui\:\[\&\>span\:last-child\]\:truncate>span:last-child{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.ui\:\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.ui\:\[\&\>svg\]\:size-2\.5>svg{width:calc(var(--ui-spacing) * 2.5);height:calc(var(--ui-spacing) * 2.5)}.ui\:\[\&\>svg\]\:size-3>svg{width:calc(var(--ui-spacing) * 3);height:calc(var(--ui-spacing) * 3)}.ui\:\[\&\>svg\]\:size-3\.5>svg{width:calc(var(--ui-spacing) * 3.5);height:calc(var(--ui-spacing) * 3.5)}.ui\:\[\&\>svg\]\:size-4>svg{width:calc(var(--ui-spacing) * 4);height:calc(var(--ui-spacing) * 4)}.ui\:\[\&\>svg\]\:shrink-0>svg{flex-shrink:0}.ui\:\[\&\>svg\]\:translate-y-0\.5>svg{--tw-translate-y:calc(var(--ui-spacing) * .5);translate:var(--tw-translate-x) var(--tw-translate-y)}.ui\:\[\&\>svg\]\:text-current>svg{color:currentColor}.ui\:\[\&\>svg\]\:text-muted-foreground>svg{color:var(--muted-foreground)}.ui\:\[\&\>svg\]\:text-sidebar-accent-foreground>svg{color:var(--sidebar-accent-foreground)}.ui\:\[\&\>svg\:not\(\[class\*\=\'ui\:size-\'\]\)\]\:size-3\.5>svg:not([class*=ui\:size-]){width:calc(var(--ui-spacing) * 3.5);height:calc(var(--ui-spacing) * 3.5)}.ui\:\[\&\>svg\:not\(\[class\*\=\'ui\:size-\'\]\)\]\:size-4>svg:not([class*=ui\:size-]){width:calc(var(--ui-spacing) * 4);height:calc(var(--ui-spacing) * 4)}@media (hover:hover){:is(.ui\:hover\:\[\&\,\&\>svelte-css-wrapper\]\:\[\&\>th\,td\]\:bg-muted\/50:hover,.ui\:hover\:\[\&\,\&\>svelte-css-wrapper\]\:\[\&\>th\,td\]\:bg-muted\/50:hover>svelte-css-wrapper)>th,:is(.ui\:hover\:\[\&\,\&\>svelte-css-wrapper\]\:\[\&\>th\,td\]\:bg-muted\/50:hover,.ui\:hover\:\[\&\,\&\>svelte-css-wrapper\]\:\[\&\>th\,td\]\:bg-muted\/50:hover>svelte-css-wrapper) td{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){:is(.ui\:hover\:\[\&\,\&\>svelte-css-wrapper\]\:\[\&\>th\,td\]\:bg-muted\/50:hover,.ui\:hover\:\[\&\,\&\>svelte-css-wrapper\]\:\[\&\>th\,td\]\:bg-muted\/50:hover>svelte-css-wrapper)>th,:is(.ui\:hover\:\[\&\,\&\>svelte-css-wrapper\]\:\[\&\>th\,td\]\:bg-muted\/50:hover,.ui\:hover\:\[\&\,\&\>svelte-css-wrapper\]\:\[\&\>th\,td\]\:bg-muted\/50:hover>svelte-css-wrapper) td{background-color:color-mix(in oklab, var(--muted) 50%, transparent)}}}.ui\:\[\&\>tr\]\:last\:border-b-0>tr:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.ui\:\[\&\[data-state\=open\]\>svg\]\:rotate-180[data-state=open]>svg{rotate:180deg}[data-side=left][data-collapsible=offcanvas] .ui\:\[\[data-side\=left\]\[data-collapsible\=offcanvas\]_\&\]\:-right-2{right:calc(var(--ui-spacing) * -2)}[data-side=left][data-state=collapsed] .ui\:\[\[data-side\=left\]\[data-state\=collapsed\]_\&\]\:cursor-e-resize{cursor:e-resize}[data-side=right][data-collapsible=offcanvas] .ui\:\[\[data-side\=right\]\[data-collapsible\=offcanvas\]_\&\]\:-left-2{left:calc(var(--ui-spacing) * -2)}[data-side=right][data-state=collapsed] .ui\:\[\[data-side\=right\]\[data-state\=collapsed\]_\&\]\:cursor-w-resize{cursor:w-resize}[data-variant=legend]+.ui\:\[\[data-variant\=legend\]\+\&\]\:-mt-1\.5{margin-top:calc(var(--ui-spacing) * -1.5)}@media (hover:hover){a.ui\:\[a\&\]\:hover\:bg-accent:hover{background-color:var(--accent)}a.ui\:\[a\&\]\:hover\:bg-amber-600\/90:hover{background-color:var(--ui-color-amber-600)}@supports (color:color-mix(in lab, red, red)){a.ui\:\[a\&\]\:hover\:bg-amber-600\/90:hover{background-color:color-mix(in oklab, var(--ui-color-amber-600) 90%, transparent)}}a.ui\:\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){a.ui\:\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab, var(--destructive) 90%, transparent)}}a.ui\:\[a\&\]\:hover\:bg-emerald-600\/90:hover{background-color:var(--ui-color-emerald-600)}@supports (color:color-mix(in lab, red, red)){a.ui\:\[a\&\]\:hover\:bg-emerald-600\/90:hover{background-color:color-mix(in oklab, var(--ui-color-emerald-600) 90%, transparent)}}a.ui\:\[a\&\]\:hover\:bg-primary\/30:hover{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){a.ui\:\[a\&\]\:hover\:bg-primary\/30:hover{background-color:color-mix(in oklab, var(--primary) 30%, transparent)}}a.ui\:\[a\&\]\:hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){a.ui\:\[a\&\]\:hover\:bg-primary\/90:hover{background-color:color-mix(in oklab, var(--primary) 90%, transparent)}}a.ui\:\[a\&\]\:hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){a.ui\:\[a\&\]\:hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab, var(--secondary) 80%, transparent)}}a.ui\:\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){a.ui\:\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:color-mix(in oklab, var(--secondary) 90%, transparent)}}a.ui\:\[a\&\]\:hover\:text-accent-foreground:hover{color:var(--accent-foreground)}a.ui\:\[a\&\]\:hover\:text-primary:hover{color:var(--primary)}a.ui\:\[a\&\]\:hover\:underline:hover{text-decoration-line:underline}a.ui\:dark\:\[a\&\]\:hover\:bg-accent\/50:where(.dark,.dark *):hover{background-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){a.ui\:dark\:\[a\&\]\:hover\:bg-accent\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--accent) 50%, transparent)}}}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:none}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}:root{--radius:.625rem;--background:oklch(100% 0 0);--foreground:oklch(14.7% .004 49.25);--card:oklch(100% 0 0);--card-foreground:oklch(14.7% .004 49.25);--popover:oklch(100% 0 0);--popover-foreground:oklch(14.7% .004 49.25);--primary:oklch(21.6% .006 56.043);--primary-foreground:oklch(98.5% .001 106.423);--secondary:oklch(97% .001 106.424);--secondary-foreground:oklch(21.6% .006 56.043);--muted:oklch(97% .001 106.424);--muted-foreground:oklch(55.3% .013 58.071);--accent:oklch(96.7% .001 286.375);--accent-foreground:oklch(21.6% .006 56.043);--destructive:oklch(57.7% .245 27.325);--border:oklch(92.3% .003 48.717);--input:oklch(92.3% .003 48.717);--ring:oklch(70.9% .01 56.259);--chart-1:oklch(64.6% .222 41.116);--chart-2:oklch(60% .118 184.704);--chart-3:oklch(39.8% .07 227.392);--chart-4:oklch(82.8% .189 84.429);--chart-5:oklch(76.9% .188 70.08);--sidebar:oklch(100% 0 0);--sidebar-foreground:oklch(14.7% .004 49.25);--sidebar-primary:oklch(21.6% .006 56.043);--sidebar-primary-foreground:oklch(98.5% .001 106.423);--sidebar-accent:oklch(97% .001 106.424);--sidebar-accent-foreground:oklch(21.6% .006 56.043);--sidebar-border:oklch(92.3% .003 48.717);--sidebar-ring:oklch(70.9% .01 56.259)}.dark{--background:oklch(14.7% .004 49.25);--foreground:oklch(98.5% .001 106.423);--card:oklch(21.6% .006 56.043);--card-foreground:oklch(98.5% .001 106.423);--popover:oklch(21.6% .006 56.043);--popover-foreground:oklch(98.5% .001 106.423);--primary:oklch(92.3% .003 48.717);--primary-foreground:oklch(21.6% .006 56.043);--secondary:oklch(26.8% .007 34.298);--secondary-foreground:oklch(98.5% .001 106.423);--muted:oklch(26.8% .007 34.298);--muted-foreground:oklch(70.9% .01 56.259);--accent:oklch(26.8% .007 34.298);--accent-foreground:oklch(98.5% .001 106.423);--destructive:oklch(70.4% .191 22.216);--border:oklch(100% 0 0/.1);--input:oklch(100% 0 0/.15);--ring:oklch(55.3% .013 58.071);--chart-1:oklch(48.8% .243 264.376);--chart-2:oklch(69.6% .17 162.48);--chart-3:oklch(76.9% .188 70.08);--chart-4:oklch(62.7% .265 303.9);--chart-5:oklch(64.5% .246 16.439);--sidebar:oklch(0% 0 0);--sidebar-foreground:oklch(98.5% .001 106.423);--sidebar-primary:oklch(48.8% .243 264.376);--sidebar-primary-foreground:oklch(98.5% .001 106.423);--sidebar-accent:oklch(26.8% .007 34.298);--sidebar-accent-foreground:oklch(98.5% .001 106.423);--sidebar-border:oklch(100% 0 0/.1);--sidebar-ring:oklch(55.3% .013 58.071)}@property --border-angle{syntax:"";inherits:false;initial-value:0deg}@keyframes border{to{--border-angle:360deg}}.animate-gradient-border{background:linear-gradient(45deg, white, white) padding-box, conic-gradient(from var(--border-angle), #94a3b84d 75%, var(--primary,#1d4ee2) 82%, var(--primary-foreground,white) 88%, var(--primary,#1d4ee2) 92%, #94a3b84d 100%) border-box;border:1px solid #0000;transition:--border-angle 3s ease-in-out}.animate-gradient-border:hover{animation:4s ease-in-out infinite border}.dark.animate-gradient-border{background:linear-gradient(45deg, #000, #000) padding-box, conic-gradient(from var(--border-angle), #4755697a 75%, var(--primary,#1d4ee2) 82%, var(--primary-foreground,black) 88%, var(--primary,#1d4ee2) 92%, #4755697a 100%) border-box}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0)scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1))rotate(var(--tw-enter-rotate,0));filter:blur(var(--tw-enter-blur,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0)scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1))rotate(var(--tw-exit-rotate,0));filter:blur(var(--tw-exit-blur,0))}}@keyframes accordion-down{0%{height:0}to{height:var(--radix-accordion-content-height,var(--bits-accordion-content-height,var(--reka-accordion-content-height,var(--kb-accordion-content-height,var(--ngp-accordion-content-height,auto)))))}}@keyframes accordion-up{0%{height:var(--radix-accordion-content-height,var(--bits-accordion-content-height,var(--reka-accordion-content-height,var(--kb-accordion-content-height,var(--ngp-accordion-content-height,auto)))))}to{height:0}} \ No newline at end of file diff --git a/packages/ui/src/use-clipboard.svelte.ts b/packages/ui/src/use-clipboard.svelte.ts new file mode 100644 index 00000000..592647ed --- /dev/null +++ b/packages/ui/src/use-clipboard.svelte.ts @@ -0,0 +1,99 @@ +type Options = { + /** The time before the copied status is reset. */ + delay: number; +}; + +/** Use this hook to copy text to the clipboard and show a copied state. + * + * ## Usage + * ```svelte + * + * + * + * ``` + * + */ +export class UseClipboard { + #copiedStatus = $state<'success' | 'failure'>(); + private delay: number; + private timeout: ReturnType | undefined = undefined; + + constructor({ delay = 500 }: Partial = {}) { + this.delay = delay; + } + + /** Copies the given text to the users clipboard. + * + * ## Usage + * ```ts + * clipboard.copy('Hello, World!'); + * ``` + * + * @param text + * @returns + */ + async copy(text: string) { + if (this.timeout) { + this.#copiedStatus = undefined; + clearTimeout(this.timeout); + } + + this.#copiedStatus = await copyText(text); + + this.timeout = setTimeout(() => { + this.#copiedStatus = undefined; + }, this.delay); + + return this.#copiedStatus; + } + + /** true when the user has just copied to the clipboard. */ + get copied() { + return this.#copiedStatus === 'success'; + } + + /** Indicates whether a copy has occurred + * and gives a status of either `success` or `failure`. */ + get status() { + return this.#copiedStatus; + } +} + +export async function copyText(text: string): Promise<'success' | 'failure'> { + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return 'success'; + } + + // when navigator.clipboard is unavailable we fallback to this for wider browser compatibility + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.top = '0'; + textArea.style.left = '0'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + const successful = document.execCommand('copy'); + + document.body.removeChild(textArea); + + return successful ? 'success' : 'failure'; + } catch { + return 'failure'; + } +} diff --git a/packages/ui/src/utils.ts b/packages/ui/src/utils.ts new file mode 100644 index 00000000..d204561d --- /dev/null +++ b/packages/ui/src/utils.ts @@ -0,0 +1,13 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChild = T extends { child?: any } ? Omit : T; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChildren = T extends { children?: any } ? Omit : T; +export type WithoutChildrenOrChild = WithoutChildren>; +export type WithElementRef = T & { ref?: U | null }; diff --git a/packages/utils/package.json b/packages/utils/package.json index 9e80acf9..702a28a4 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -2,48 +2,52 @@ "name": "@cio/utils", "version": "1.0.0", "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { ".": { - "types": "./src/index.ts", - "default": "./src/index.ts" + "types": "./dist/index.d.ts", + "default": "./dist/index.js" }, "./constants": { - "types": "./src/constants/index.ts", - "default": "./src/constants/index.ts" + "types": "./dist/constants/index.d.ts", + "default": "./dist/constants/index.js" }, "./constants/*": { - "types": "./src/constants/*/index.ts", - "default": "./src/constants/*/index.ts" + "types": "./dist/constants/*.d.ts", + "default": "./dist/constants/*.js" }, "./plans": { - "types": "./src/plans/index.ts", - "default": "./src/plans/index.ts" + "types": "./dist/plans/index.d.ts", + "default": "./dist/plans/index.js" }, "./senja": { - "types": "./src/senja/index.ts", - "default": "./src/senja/index.ts" + "types": "./dist/senja/index.d.ts", + "default": "./dist/senja/index.js" }, "./validation": { - "types": "./src/validation/index.ts", - "default": "./src/validation/index.ts" + "types": "./dist/validation/index.d.ts", + "default": "./dist/validation/index.js" + }, + "./validation/constants": { + "types": "./dist/validation/constants.d.ts", + "default": "./dist/validation/constants.js" }, "./validation/*": { - "types": "./src/validation/*/index.ts", - "default": "./src/validation/*/index.ts" + "types": "./dist/validation/*/index.d.ts", + "default": "./dist/validation/*/index.js" }, "./functions": { - "types": "./src/functions/index.ts", - "default": "./src/functions/index.ts" + "types": "./dist/functions/index.d.ts", + "default": "./dist/functions/index.js" }, "./functions/*": { - "types": "./src/functions/*/index.ts", - "default": "./src/functions/*/index.ts" + "types": "./dist/functions/*.d.ts", + "default": "./dist/functions/*.js" } }, "scripts": { - "build": "tsc", + "build": "tsc && tsc-alias", "dev": "tsc --watch", "format": "prettier --write ." }, @@ -51,6 +55,7 @@ "zod": "^4.1.8" }, "dependencies": { + "@cio/question-types": "workspace:*", "@cio/tsconfig": "workspace:*" }, "devDependencies": { diff --git a/packages/utils/src/constants/content.ts b/packages/utils/src/constants/content.ts new file mode 100644 index 00000000..203920f6 --- /dev/null +++ b/packages/utils/src/constants/content.ts @@ -0,0 +1,5 @@ +export enum ContentType { + Section = 'SECTION', + Lesson = 'LESSON', + Exercise = 'EXERCISE' +} diff --git a/packages/utils/src/constants/index.ts b/packages/utils/src/constants/index.ts index 927ef162..1a725d28 100644 --- a/packages/utils/src/constants/index.ts +++ b/packages/utils/src/constants/index.ts @@ -1,2 +1,3 @@ export * from './roles'; export * from './org'; +export * from './content'; diff --git a/packages/utils/src/functions/array.ts b/packages/utils/src/functions/array.ts new file mode 100644 index 00000000..789ce613 --- /dev/null +++ b/packages/utils/src/functions/array.ts @@ -0,0 +1,12 @@ +/** + * Safely normalizes a value to an array for processing + * Useful for processing jsonb fields from database or API responses + * @param value Value that might be an array, null, or undefined + * @returns Array (empty if value is not an array) + */ +export function normalizeToArray(value: unknown): unknown[] { + if (Array.isArray(value)) { + return value; + } + return []; +} diff --git a/packages/utils/src/functions/fileValidation.ts b/packages/utils/src/functions/fileValidation.ts index 658c5750..843bd338 100644 --- a/packages/utils/src/functions/fileValidation.ts +++ b/packages/utils/src/functions/fileValidation.ts @@ -2,7 +2,7 @@ * Server-side file type validation utilities for preventing malicious uploads * Specifically designed to prevent SVG XSS attacks and other security issues */ -export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'] as const; +import { ALLOWED_IMAGE_TYPES } from '../validation/constants'; export const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] as const; diff --git a/packages/utils/src/functions/index.ts b/packages/utils/src/functions/index.ts index 6232789a..4b39c330 100644 --- a/packages/utils/src/functions/index.ts +++ b/packages/utils/src/functions/index.ts @@ -1 +1,2 @@ export * from './fileValidation'; +export * from './array'; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index f7398d24..84363ffd 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,5 +1,6 @@ // Main entry point for @cio/utils package -export * from './plans'; +export * from './license'; export * from './senja'; export * from './validation'; export * from './constants'; +export * from './functions'; diff --git a/packages/utils/src/license/constants.ts b/packages/utils/src/license/constants.ts new file mode 100644 index 00000000..81266b45 --- /dev/null +++ b/packages/utils/src/license/constants.ts @@ -0,0 +1,18 @@ +/** + * License-gated feature identifiers. + * Add new features here to make them require a license key. + * + * Expected from external license API response: { valid: boolean, features: string[] } + */ +export const LICENSE_FEATURE = { + SSO: 'sso', + TOKEN_AUTH: 'token-auth', + NO_TRACKING: 'no-tracking', + CUSTOM_DOMAIN: 'custom-domain', + CUSTOM_BRANDING: 'custom-branding' +} as const; + +export type LicenseFeatureId = (typeof LICENSE_FEATURE)[keyof typeof LICENSE_FEATURE]; + +/** All known license feature IDs for validation */ +export const LICENSE_FEATURE_IDS = Object.values(LICENSE_FEATURE) as readonly string[]; diff --git a/packages/utils/src/license/index.ts b/packages/utils/src/license/index.ts new file mode 100644 index 00000000..c94f80f8 --- /dev/null +++ b/packages/utils/src/license/index.ts @@ -0,0 +1 @@ +export * from './constants'; diff --git a/packages/utils/src/validation/account/index.ts b/packages/utils/src/validation/account/index.ts index 362a768e..4a7ea0e4 100644 --- a/packages/utils/src/validation/account/index.ts +++ b/packages/utils/src/validation/account/index.ts @@ -1 +1,2 @@ export * from './account'; +export * from './profile'; diff --git a/packages/utils/src/validation/account/profile.ts b/packages/utils/src/validation/account/profile.ts new file mode 100644 index 00000000..6aa37a1e --- /dev/null +++ b/packages/utils/src/validation/account/profile.ts @@ -0,0 +1,7 @@ +import * as z from 'zod'; + +export const ZGetProfileByEmail = z.object({ + email: z.email() +}); + +export type TGetProfileByEmail = z.infer; diff --git a/packages/utils/src/validation/assets/assets.ts b/packages/utils/src/validation/assets/assets.ts new file mode 100644 index 00000000..6c9d2b31 --- /dev/null +++ b/packages/utils/src/validation/assets/assets.ts @@ -0,0 +1,121 @@ +import * as z from 'zod'; + +const AssetKind = z.enum(['video', 'document', 'image', 'audio', 'other']); +const AssetProvider = z.enum(['upload', 'youtube', 'generic', 'external_url']); +const AssetStatus = z.enum(['active', 'archived']); +const AssetTargetType = z.enum(['lesson', 'exercise', 'question']); + +export const ZAssetListQuery = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), + kind: AssetKind.optional(), + status: AssetStatus.optional(), + search: z.string().trim().optional(), + includeExternal: z.coerce.boolean().optional().default(true) +}); +export type TAssetListQuery = z.infer; + +export const ZAssetCreateUpload = z + .object({ + kind: AssetKind.default('video'), + provider: AssetProvider.default('upload'), + storageProvider: z.string().default('s3'), + storageKey: z.string().min(1).optional(), + sourceUrl: z.string().url().optional(), + mimeType: z.string().optional(), + byteSize: z.coerce.number().int().min(0).optional(), + checksum: z.string().optional(), + title: z.string().optional(), + description: z.string().optional(), + thumbnailUrl: z.string().url().optional(), + durationSeconds: z.coerce.number().int().min(0).optional(), + aspectRatio: z.string().optional(), + isExternal: z.boolean().optional().default(false), + metadata: z.record(z.string(), z.unknown()).optional() + }) + .superRefine((value, ctx) => { + if (value.provider === 'upload' && !value.storageKey) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'storageKey is required for upload assets', + path: ['storageKey'] + }); + } + + if (!value.isExternal && value.provider === 'upload' && value.byteSize == null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'byteSize is required for uploaded internal assets', + path: ['byteSize'] + }); + } + }); +export type TAssetCreateUpload = z.infer; + +export const ZAssetGetParam = z.object({ + assetId: z.string().min(1) +}); +export type TAssetGetParam = z.infer; + +export const ZAssetUpdate = z.object({ + title: z.string().optional(), + description: z.string().optional(), + thumbnailUrl: z.string().url().optional(), + durationSeconds: z.coerce.number().int().min(0).optional(), + aspectRatio: z.string().optional(), + status: AssetStatus.optional(), + metadata: z.record(z.string(), z.unknown()).optional() +}); +export type TAssetUpdate = z.infer; + +export const ZAssetAttach = z.object({ + targetType: AssetTargetType, + targetId: z.string().min(1), + slotType: z.string().min(1), + slotKey: z.string().optional(), + position: z.coerce.number().int().min(0).optional() +}); +export type TAssetAttach = z.infer; + +export const ZAssetDetach = z + .object({ + usageId: z.string().uuid().optional(), + targetType: AssetTargetType.optional(), + targetId: z.string().optional(), + slotType: z.string().optional(), + slotKey: z.string().optional(), + position: z.coerce.number().int().min(0).optional() + }) + .superRefine((value, ctx) => { + if (!value.usageId) { + if (!value.targetType || !value.targetId || !value.slotType) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'usageId or targetType/targetId/slotType is required' + }); + } + } + }); +export type TAssetDetach = z.infer; + +export const ZAssetUsageParams = z.object({ + assetId: z.string().min(1) +}); +export type TAssetUsageParams = z.infer; + +export const ZAssetExportQuery = z.object({ + includeArchived: z.coerce.boolean().optional().default(false), + includeExternal: z.coerce.boolean().optional().default(true) +}); +export type TAssetExportQuery = z.infer; + +export const ZAssetStorageQuery = z.object({ + includeArchived: z.coerce.boolean().optional().default(false), + includeExternal: z.coerce.boolean().optional().default(true) +}); +export type TAssetStorageQuery = z.infer; + +export const ZYouTubeMetadataQuery = z.object({ + url: z.string().url() +}); +export type TYouTubeMetadataQuery = z.infer; diff --git a/packages/utils/src/validation/assets/index.ts b/packages/utils/src/validation/assets/index.ts new file mode 100644 index 00000000..df644664 --- /dev/null +++ b/packages/utils/src/validation/assets/index.ts @@ -0,0 +1 @@ +export * from './assets'; diff --git a/packages/utils/src/validation/attendance/attendance.ts b/packages/utils/src/validation/attendance/attendance.ts new file mode 100644 index 00000000..9c29462a --- /dev/null +++ b/packages/utils/src/validation/attendance/attendance.ts @@ -0,0 +1,15 @@ +import * as z from 'zod'; + +export const ZAttendanceUpsert = z.object({ + courseId: z.string().min(1), + lessonId: z.string().min(1), + studentId: z.string().min(1), + isPresent: z.boolean() +}); +export type TAttendanceUpsert = z.infer; + +export const ZAttendanceListQuery = z.object({ + courseId: z.string().min(1), + lessonId: z.string().optional() +}); +export type TAttendanceListQuery = z.infer; diff --git a/packages/utils/src/validation/attendance/index.ts b/packages/utils/src/validation/attendance/index.ts new file mode 100644 index 00000000..22118df8 --- /dev/null +++ b/packages/utils/src/validation/attendance/index.ts @@ -0,0 +1 @@ +export * from './attendance'; diff --git a/packages/utils/src/validation/constants.ts b/packages/utils/src/validation/constants.ts index afdbd448..1a38ce66 100644 --- a/packages/utils/src/validation/constants.ts +++ b/packages/utils/src/validation/constants.ts @@ -1,3 +1,5 @@ +import { QUESTION_TYPE_IDS } from '@cio/question-types'; + /** * Validation Constants * @@ -14,3 +16,9 @@ export const ALLOWED_DOCUMENT_TYPES = [ ] as const; export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'] as const; + +/** + * Question Type Constants + * These match canonical question type ids. + */ +export const QUESTION_TYPE = QUESTION_TYPE_IDS; diff --git a/packages/utils/src/validation/course/course.ts b/packages/utils/src/validation/course/course.ts index fd974eb3..cb93d474 100644 --- a/packages/utils/src/validation/course/course.ts +++ b/packages/utils/src/validation/course/course.ts @@ -15,6 +15,31 @@ export const ZCourseCloneParam = z.object({ }); export type TCourseCloneParam = z.infer; +export const ZCourseGetParam = z.object({ + courseId: z.string().min(1) +}); +export type TCourseGetParam = z.infer; + +export const ZCourseGetQuery = z.object({ + slug: z.string().optional() +}); +export type TCourseGetQuery = z.infer; + +export const ZCourseGetBySlugParam = z.object({ + slug: z.string().min(1) +}); +export type TCourseGetBySlugParam = z.infer; + +export const ZCourseEnrollParam = z.object({ + courseId: z.string().min(1) +}); +export type TCourseEnrollParam = z.infer; + +export const ZCourseEnrollBody = z.object({ + inviteToken: z.string().min(1).optional() +}); +export type TCourseEnrollBody = z.infer; + export const ZCourseDownloadParam = z.object({ courseId: z.string().min(1) }); @@ -74,3 +99,141 @@ export const ZCourseDownloadPresignedUrl = z.object({ keys: z.array(z.string().min(1)).min(1) }); export type TCourseDownloadPresignedUrl = z.infer; + +export const ZCourseContentUpdateItem = z.object({ + id: z.string().min(1), + type: z.enum(['LESSON', 'EXERCISE']), + isUnlocked: z.boolean().optional(), + order: z.number().int().min(0).optional(), + sectionId: z.string().nullable().optional() +}); +export type TCourseContentUpdateItem = z.infer; + +export const ZCourseContentUpdate = z.object({ + items: z.array(ZCourseContentUpdateItem).min(1) +}); +export type TCourseContentUpdate = z.infer; + +export const ZCourseContentDeleteItem = z.object({ + id: z.string().min(1), + type: z.enum(['LESSON', 'EXERCISE']) +}); +export type TCourseContentDeleteItem = z.infer; + +export const ZCourseContentDelete = z + .object({ + sectionId: z.string().min(1).optional(), + items: z.array(ZCourseContentDeleteItem).min(1).optional() + }) + .refine((data) => Boolean(data.sectionId) !== Boolean(data.items), { + message: 'Provide either sectionId or items', + path: ['sectionId'] + }); +export type TCourseContentDelete = z.infer; + +export const ZCourseCreate = z.object({ + title: z.string().min(1), + description: z.string().min(1), + type: z.enum(['LIVE_CLASS', 'SELF_PACED']), + organizationId: z.string().min(1) +}); +export type TCourseCreate = z.infer; + +// Course metadata schema matching the database structure +export const ZCourseMetadata = z.object({ + requirements: z.string().optional(), + description: z.string().optional(), + goals: z.string().optional(), + videoUrl: z.string().optional(), + showDiscount: z.boolean().optional(), + discount: z.number().optional(), + paymentLink: z.string().optional(), + reward: z + .object({ + show: z.boolean(), + description: z.string() + }) + .optional(), + instructor: z + .object({ + name: z.string(), + role: z.string(), + coursesNo: z.number(), + description: z.string(), + imgUrl: z.string() + }) + .optional(), + certificate: z + .object({ + templateUrl: z.string() + }) + .optional(), + reviews: z + .array( + z.object({ + id: z.number(), + hide: z.boolean(), + name: z.string(), + avatar_url: z.string(), + rating: z.number(), + created_at: z.number(), + description: z.string() + }) + ) + .optional(), + lessonTabsOrder: z + .array( + z.object({ + id: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]), + name: z.string() + }) + ) + .optional(), + grading: z.boolean().optional(), + lessonDownload: z.boolean().optional(), + allowNewStudent: z.boolean(), + sectionDisplay: z.record(z.string(), z.boolean()).optional(), + isContentGroupingEnabled: z.boolean().optional() +}); +export type TCourseMetadata = z.infer; + +export const ZCourseUpdate = z.object({ + title: z.string().min(1).optional(), + description: z.string().min(1).optional(), + type: z.enum(['LIVE_CLASS', 'SELF_PACED']).optional(), + logo: z.string().optional(), + slug: z.string().optional(), + isPublished: z.boolean().optional(), + overview: z.string().optional(), + metadata: ZCourseMetadata.optional(), + isCertificateDownloadable: z.boolean().optional(), + certificateTheme: z.string().optional(), + tagIds: z.array(z.uuid()).max(100).optional() +}); +export type TCourseUpdate = z.infer; + +export const ZCourseUpdateParam = z.object({ + courseId: z.string().min(1) +}); +export type TCourseUpdateParam = z.infer; + +export const ZCourseDeleteParam = z.object({ + courseId: z.string().min(1) +}); +export type TCourseDeleteParam = z.infer; + +export const ZCourseProgressParam = z.object({ + courseId: z.string().min(1) +}); +export type TCourseProgressParam = z.infer; + +export const ZCourseProgressQuery = z.object({ + profileId: z.string().uuid() +}); +export type TCourseProgressQuery = z.infer; + +export const ZCourseUserAnalyticsParam = z.object({ + courseId: z.string().min(1), + userId: z.string().uuid() +}); +export type TCourseUserAnalyticsParam = z.infer; diff --git a/packages/utils/src/validation/course/index.ts b/packages/utils/src/validation/course/index.ts index a06b58f4..42046c78 100644 --- a/packages/utils/src/validation/course/index.ts +++ b/packages/utils/src/validation/course/index.ts @@ -6,3 +6,6 @@ */ export * from './course'; +export * from './section'; +export * from './invite'; +export { ZCourseGetParam, type TCourseGetParam } from './course'; diff --git a/packages/utils/src/validation/course/invite.ts b/packages/utils/src/validation/course/invite.ts new file mode 100644 index 00000000..5a26c2ea --- /dev/null +++ b/packages/utils/src/validation/course/invite.ts @@ -0,0 +1,53 @@ +import * as z from 'zod'; + +export const ZCourseInviteParam = z.object({ + courseId: z.string().min(1) +}); +export type TCourseInviteParam = z.infer; + +export const ZCourseInviteRevokeParam = z.object({ + courseId: z.string().min(1), + inviteId: z.string().uuid() +}); +export type TCourseInviteRevokeParam = z.infer; + +export const ZCourseInviteAuditParam = z.object({ + courseId: z.string().min(1), + inviteId: z.string().uuid() +}); +export type TCourseInviteAuditParam = z.infer; + +export const ZCourseInviteTokenParam = z.object({ + token: z.string().min(1) +}); +export type TCourseInviteTokenParam = z.infer; + +export const ZCreatePublicCourseInviteLink = z.object({ + courseId: z.uuid() +}); +export type TCreatePublicCourseInviteLink = z.infer; + +export const ZCourseInvitePreset = z.enum(['ONE_TIME_24H', 'MULTI_USE_7D', 'MULTI_USE_30D', 'CUSTOM']); +export type TCourseInvitePreset = z.infer; + +export const ZCreateCourseInvite = z + .object({ + preset: ZCourseInvitePreset.default('MULTI_USE_30D'), + expiresAt: z.string().min(1).optional(), + maxUses: z.number().int().min(1).max(1000).optional(), + allowedEmails: z.array(z.string().email()).max(100).optional(), + allowedDomains: z.array(z.string().min(1)).max(100).optional(), + recipientEmails: z.array(z.string().email()).max(500).optional(), + recipientCsv: z.string().max(25000).optional(), + sendEmail: z.boolean().default(false), + metadata: z.record(z.string(), z.unknown()).optional() + }) + .refine( + (data) => { + const hasEmails = (data.recipientEmails?.length ?? 0) > 0; + const hasCsv = typeof data.recipientCsv === 'string' && data.recipientCsv.trim().length > 0; + return hasEmails || hasCsv; + }, + { message: 'recipientEmails or recipientCsv is required', path: ['recipientEmails'] } + ); +export type TCreateCourseInvite = z.infer; diff --git a/packages/utils/src/validation/course/people.ts b/packages/utils/src/validation/course/people.ts new file mode 100644 index 00000000..d1bf8be9 --- /dev/null +++ b/packages/utils/src/validation/course/people.ts @@ -0,0 +1,28 @@ +import * as z from 'zod'; + +export const ZCourseMembersParam = z.object({ + courseId: z.string().min(1) +}); +export type TCourseMembersParam = z.infer; + +export const ZCourseMembersMemberParam = z.object({ + courseId: z.string().min(1), + memberId: z.string().min(1) +}); +export type TCourseMembersMemberParam = z.infer; + +export const ZAddCourseMembers = z.array( + z.object({ + profileId: z.uuid().optional(), + roleId: z.number().int().min(1), + email: z.email().optional(), + name: z.string().optional() // For email sending + }) +); +export type TAddCourseMembers = z.infer; + +export const ZUpdateCourseMember = z.object({ + roleId: z.number().int().min(1).optional(), + email: z.email().optional() +}); +export type TUpdateCourseMember = z.infer; diff --git a/packages/utils/src/validation/course/section.ts b/packages/utils/src/validation/course/section.ts new file mode 100644 index 00000000..9ea75dd2 --- /dev/null +++ b/packages/utils/src/validation/course/section.ts @@ -0,0 +1,37 @@ +import * as z from 'zod'; + +// Course Section Schemas +export const ZCourseSectionCreate = z.object({ + title: z.string().min(1), + courseId: z.string().min(1), + order: z.number().int().min(0).optional() +}); +export type TCourseSectionCreate = z.infer; + +export const ZCourseSectionUpdate = z.object({ + title: z.string().min(1).optional(), + order: z.number().int().min(0).optional() +}); +export type TCourseSectionUpdate = z.infer; + +export const ZCourseSectionPromoteUngrouped = z.object({ + title: z.string().min(1) +}); +export type TCourseSectionPromoteUngrouped = z.infer; + +export const ZCourseSectionGetParam = z.object({ + sectionId: z.string().min(1) +}); +export type TCourseSectionGetParam = z.infer; + +export const ZCourseSectionReorder = z.object({ + sections: z + .array( + z.object({ + id: z.string().min(1), + order: z.number().int().min(0) + }) + ) + .min(1) +}); +export type TCourseSectionReorder = z.infer; diff --git a/packages/utils/src/validation/exercise/exercise.ts b/packages/utils/src/validation/exercise/exercise.ts new file mode 100644 index 00000000..a06e445a --- /dev/null +++ b/packages/utils/src/validation/exercise/exercise.ts @@ -0,0 +1,177 @@ +import * as z from 'zod'; + +import { QUESTION_TYPE } from '../constants'; + +const QUESTION_VALIDATION_RULES: Record< + number, + Array<(question: { options?: Array<{ isCorrect: boolean }> }) => string | null> +> = { + [QUESTION_TYPE.RADIO]: [ + (question) => { + const options = question.options || []; + if (options.length > 0 && options.length < 2) { + return 'Options must have at least 2 items for RADIO/CHECKBOX questions'; + } + return null; + }, + (question) => { + const options = question.options || []; + if (options.length > 0 && !options.some((opt) => opt.isCorrect === true)) { + return 'Please mark an option as the correct answer'; + } + return null; + } + ], + [QUESTION_TYPE.CHECKBOX]: [ + (question) => { + const options = question.options || []; + if (options.length > 0 && options.length < 2) { + return 'Options must have at least 2 items for RADIO/CHECKBOX questions'; + } + return null; + }, + (question) => { + const options = question.options || []; + if (options.length > 0 && !options.some((opt) => opt.isCorrect === true)) { + return 'Please mark an option as the correct answer'; + } + return null; + } + ], + [QUESTION_TYPE.TRUE_FALSE]: [ + (question) => { + const options = question.options || []; + if (options.length > 0 && options.length < 2) { + return 'True/False questions need both True and False options'; + } + return null; + }, + (question) => { + const options = question.options || []; + if (options.length > 0 && !options.some((opt) => opt.isCorrect === true)) { + return 'Please mark an option as the correct answer'; + } + return null; + } + ], + [QUESTION_TYPE.TEXTAREA]: [] +}; + +// Base schema for exercise update question (without refinement) +const ZExerciseUpdateQuestionBase = z.object({ + id: z.number().int().optional(), + question: z.string().min(1), + questionTypeId: z.number().int().min(1).optional(), // Added to support question type updates + points: z.number().int().min(0).optional(), + order: z.number().int().min(0).optional(), + settings: z.record(z.string(), z.unknown()).optional(), + deletedAt: z.string().optional(), // Marks question as deleted + options: z + .array( + z.object({ + id: z.number().int().optional(), + label: z.string().min(1), + isCorrect: z.boolean(), + settings: z.record(z.string(), z.unknown()).optional(), + deletedAt: z.string().optional() // Marks option as deleted + }) + ) + .optional() +}); + +function validateQuestionOptions(question: z.infer, ctx: z.core.$RefinementCtx) { + // Skip validation for deleted questions + if (question.deletedAt) return; + + const rules = QUESTION_VALIDATION_RULES[question.questionTypeId ?? -1] ?? []; + for (const rule of rules) { + const message = rule(question); + if (!message) continue; + ctx.addIssue({ + code: 'custom', + message, + path: ['options'] + }); + } +} + +// Final schema with validation refinement +const ZExerciseUpdateQuestion = ZExerciseUpdateQuestionBase.superRefine(validateQuestionOptions); + +export const ZExerciseCreate = z.object({ + title: z.string().min(1), + description: z.string().optional(), + lessonId: z.string().optional(), + sectionId: z.string().optional(), + order: z.number().int().optional(), + courseId: z.string().min(1), + dueBy: z.string().optional(), + questions: z + .array( + z.object({ + question: z.string().min(1), + questionTypeId: z.number().int().min(1).optional(), + points: z.number().int().min(0).optional(), + settings: z.record(z.string(), z.unknown()).optional(), + options: z + .array( + z.object({ + label: z.string().min(1), + isCorrect: z.boolean(), + settings: z.record(z.string(), z.unknown()).optional() + }) + ) + .min(2) + }) + ) + .optional() +}); +export type TExerciseCreate = z.infer; + +export const ZExerciseUpdate = z.object({ + title: z.string().min(1).optional(), + description: z.string().optional(), + lessonId: z.string().optional(), + sectionId: z.string().optional(), + order: z.number().int().optional(), + isUnlocked: z.boolean().optional(), + dueBy: z.string().optional(), // Changed from iso.datetime() to string to match frontend format + questions: z.array(ZExerciseUpdateQuestion).optional() +}); +export type TExerciseUpdate = z.infer; + +export const ZExerciseGetParam = z.object({ + exerciseId: z.string().min(1) +}); +export type TExerciseGetParam = z.infer; + +export const ZExerciseListQuery = z.object({ + lessonId: z.string().optional(), + sectionId: z.string().optional() +}); +export type TExerciseListQuery = z.infer; + +// Exercise Submission Schemas +export const ZExerciseSubmissionCreate = z.object({ + exerciseId: z.string().min(1), + answers: z + .array( + z.object({ + questionId: z.number().int(), + optionId: z.number().int().optional(), + answer: z.string().optional() + }) + ) + .min(1) +}); +export type TExerciseSubmissionCreate = z.infer; + +// Exercise Template Schemas +export const ZExerciseFromTemplate = z.object({ + lessonId: z.string().optional(), + sectionId: z.string().optional(), + order: z.number().int().optional(), + templateId: z.number().int().min(1) +}); + +export type TExerciseFromTemplate = z.infer; diff --git a/packages/utils/src/validation/exercise/index.ts b/packages/utils/src/validation/exercise/index.ts new file mode 100644 index 00000000..0ccfe408 --- /dev/null +++ b/packages/utils/src/validation/exercise/index.ts @@ -0,0 +1 @@ +export * from './exercise'; diff --git a/packages/utils/src/validation/index.ts b/packages/utils/src/validation/index.ts index 64ff99a6..9e0ba8fd 100644 --- a/packages/utils/src/validation/index.ts +++ b/packages/utils/src/validation/index.ts @@ -1,9 +1,16 @@ export * from './constants'; export * from './account'; +export * from './attendance'; +export * from './assets'; export * from './course'; +export * from './exercise'; +export * from './lesson'; export * from './mail'; export * from './media'; +export * from './newsfeed'; export * from './onboarding'; export * from './organization'; +export * from './submission'; export * from './dash'; +export * from './tag'; diff --git a/packages/utils/src/validation/lesson/index.ts b/packages/utils/src/validation/lesson/index.ts new file mode 100644 index 00000000..7f5be91c --- /dev/null +++ b/packages/utils/src/validation/lesson/index.ts @@ -0,0 +1,2 @@ +export * from './lesson'; +export * from './language'; diff --git a/packages/utils/src/validation/lesson/language.ts b/packages/utils/src/validation/lesson/language.ts new file mode 100644 index 00000000..b433e7ef --- /dev/null +++ b/packages/utils/src/validation/lesson/language.ts @@ -0,0 +1,29 @@ +import * as z from 'zod'; + +export const ZLessonLanguageGetParam = z.object({ + courseId: z.uuid(), + lessonId: z.uuid() +}); + +export type TLessonLanguageGetParam = z.infer; + +export const ZLessonLanguageGetByLocaleParam = z.object({ + courseId: z.uuid(), + lessonId: z.uuid(), + locale: z.enum(['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']) +}); + +export type TLessonLanguageGetByLocaleParam = z.infer; + +export const ZLessonLanguageCreate = z.object({ + locale: z.enum(['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']), + content: z.string().optional() +}); + +export type TLessonLanguageCreate = z.infer; + +export const ZLessonLanguageUpdate = z.object({ + content: z.string().optional() +}); + +export type TLessonLanguageUpdate = z.infer; diff --git a/packages/utils/src/validation/lesson/lesson.ts b/packages/utils/src/validation/lesson/lesson.ts new file mode 100644 index 00000000..d1f2e6a3 --- /dev/null +++ b/packages/utils/src/validation/lesson/lesson.ts @@ -0,0 +1,126 @@ +import * as z from 'zod'; + +// Lesson Schemas +export const ZLessonCreate = z.object({ + title: z.string().min(1), + note: z.string().optional(), + courseId: z.string().min(1), + sectionId: z.string().optional(), + order: z.number().int().min(0).optional(), + lessonAt: z.string().optional(), + teacherId: z.string().optional(), + isUnlocked: z.boolean().optional(), + public: z.boolean().optional() +}); +export type TLessonCreate = z.infer; + +export const ZLessonUpdate = z.object({ + title: z.string().min(1).optional(), + note: z.string().optional(), + sectionId: z.string().optional(), + order: z.number().int().min(0).optional(), + callUrl: z.string().optional(), + lessonAt: z.string().optional(), + teacherId: z.string().optional(), + isUnlocked: z.boolean().optional(), + public: z.boolean().optional(), + isComplete: z.boolean().optional(), + videoUrl: z.url().optional(), + slideUrl: z.url().optional(), + videos: z + .array( + z.object({ + type: z.enum(['youtube', 'generic', 'upload']), + link: z.string(), + key: z.string().optional(), + assetId: z.string().uuid().optional(), + fileName: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional() + }) + ) + .optional(), + documents: z + .array( + z.object({ + type: z.string(), + name: z.string(), + link: z.string(), + size: z.number().optional(), + key: z.string(), + assetId: z.string().uuid().optional() + }) + ) + .optional() +}); +export type TLessonUpdate = z.infer; + +export const ZLessonGetParam = z.object({ + lessonId: z.string().min(1) +}); +export type TLessonGetParam = z.infer; + +export const ZLessonListQuery = z.object({ + sectionId: z.string().optional(), + courseId: z.string().min(1) +}); + +export const ZLessonHistoryParam = z.object({ + courseId: z.string().min(1), + lessonId: z.string().min(1) +}); +export type TLessonHistoryParam = z.infer; + +export const ZLessonHistoryQuery = z.object({ + locale: z.string().min(1), + endRange: z.string().transform(Number).pipe(z.number().int().min(0)) +}); +export type TLessonHistoryQuery = z.infer; +export type TLessonListQuery = z.infer; + +export const ZLessonReorder = z.object({ + lessons: z + .array( + z.object({ + id: z.string().min(1), + order: z.number().int().min(0), + sectionId: z.string().optional() + }) + ) + .min(1) +}); +export type TLessonReorder = z.infer; + +// Lesson Comment Schemas +export const ZLessonCommentCreate = z.object({ + lessonId: z.string().min(1), + comment: z.string().min(1) +}); +export type TLessonCommentCreate = z.infer; + +export const ZLessonCommentGetParam = z.object({ + commentId: z.string().min(1) +}); +export type TLessonCommentGetParam = z.infer; + +export const ZLessonCommentUpdate = z.object({ + comment: z.string().min(1) +}); +export type TLessonCommentUpdate = z.infer; + +export const ZLessonCommentsQuery = z.object({ + cursor: z.string().optional(), + limit: z.coerce.number().min(1).max(50).optional().default(10) +}); +export type TLessonCommentsQuery = z.infer; + +// Lesson Completion Schemas +export const ZLessonCompletionCreate = z.object({ + lessonId: z.string().min(1), + isComplete: z.boolean() +}); +export type TLessonCompletionCreate = z.infer; + +export const ZLessonCompletionUpdate = z.object({ + isComplete: z.boolean() +}); +export type TLessonCompletionUpdate = z.infer; diff --git a/packages/utils/src/validation/newsfeed/index.ts b/packages/utils/src/validation/newsfeed/index.ts new file mode 100644 index 00000000..7828e4ef --- /dev/null +++ b/packages/utils/src/validation/newsfeed/index.ts @@ -0,0 +1 @@ +export * from './newsfeed'; diff --git a/packages/utils/src/validation/newsfeed/newsfeed.ts b/packages/utils/src/validation/newsfeed/newsfeed.ts new file mode 100644 index 00000000..1a2d2b70 --- /dev/null +++ b/packages/utils/src/validation/newsfeed/newsfeed.ts @@ -0,0 +1,53 @@ +import * as z from 'zod'; + +export const ZNewsfeedCreate = z.object({ + courseId: z.string().min(1), + content: z.string().min(1), + isPinned: z.boolean().optional() +}); +export type TNewsfeedCreate = z.infer; + +export const ZNewsfeedUpdate = z.object({ + content: z.string().min(1).optional(), + isPinned: z.boolean().optional() +}); +export type TNewsfeedUpdate = z.infer; + +export const ZNewsfeedReactionUpdate = z.object({ + reaction: z.record(z.enum(['clap', 'smile', 'thumbsup', 'thumbsdown']), z.array(z.string())) +}); +export type TNewsfeedReactionUpdate = z.infer; + +export const ZNewsfeedGetParam = z.object({ + feedId: z.string().min(1) +}); +export type TNewsfeedGetParam = z.infer; + +export const ZNewsfeedListQuery = z.object({ + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(50).optional().default(10) +}); +export type TNewsfeedListQuery = z.infer; + +// Newsfeed Comment Schemas +export const ZNewsfeedCommentCreate = z.object({ + courseNewsfeedId: z.string().min(1), + content: z.string().min(1) +}); +export type TNewsfeedCommentCreate = z.infer; + +export const ZNewsfeedCommentGetParam = z.object({ + commentId: z.string().min(1) +}); +export type TNewsfeedCommentGetParam = z.infer; + +export const ZNewsfeedCommentUpdate = z.object({ + content: z.string().min(1) +}); +export type TNewsfeedCommentUpdate = z.infer; + +export const ZNewsfeedCommentsQuery = z.object({ + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(50).optional().default(5) +}); +export type TNewsfeedCommentsQuery = z.infer; diff --git a/packages/utils/src/validation/organization/exercise.ts b/packages/utils/src/validation/organization/exercise.ts new file mode 100644 index 00000000..cf3c9ce6 --- /dev/null +++ b/packages/utils/src/validation/organization/exercise.ts @@ -0,0 +1,7 @@ +import * as z from 'zod'; + +export const ZLMSExercisesParam = z.object({ + orgId: z.uuid() +}); + +export type TLMSExercisesParam = z.infer; diff --git a/packages/utils/src/validation/organization/index.ts b/packages/utils/src/validation/organization/index.ts index 5612e1c5..f5bf0249 100644 --- a/packages/utils/src/validation/organization/index.ts +++ b/packages/utils/src/validation/organization/index.ts @@ -1 +1,6 @@ export * from './organization'; +export * from './quiz'; +export * from './exercise'; +export * from './invite'; +export * from './sso'; +export * from './token-auth'; diff --git a/packages/utils/src/validation/organization/invite.ts b/packages/utils/src/validation/organization/invite.ts new file mode 100644 index 00000000..a750cf1c --- /dev/null +++ b/packages/utils/src/validation/organization/invite.ts @@ -0,0 +1,7 @@ +import * as z from 'zod'; + +export const ZOrganizationInviteTokenParam = z.object({ + token: z.string().min(10).max(512) +}); + +export type TOrganizationInviteTokenParam = z.infer; diff --git a/packages/utils/src/validation/organization/organization.ts b/packages/utils/src/validation/organization/organization.ts index d4cf7479..078235d6 100644 --- a/packages/utils/src/validation/organization/organization.ts +++ b/packages/utils/src/validation/organization/organization.ts @@ -10,30 +10,19 @@ export const ZGetOrganizations = z.object({ export type TGetOrganizations = z.infer; -export const ZGetOrganizationTeam = z.object({ - orgId: z.uuid() -}); - -export type TGetOrganizationTeam = z.infer; - -export const ZUpdateOrganizationParam = z.object({ - orgId: z.uuid() -}); - -export type TUpdateOrganizationParam = z.infer; - -export const ZGetOrganizationAudience = z.object({ - orgId: z.uuid() -}); - -export type TGetOrganizationAudience = z.infer; - export const ZGetCoursesBySiteName = z.object({ - siteName: z.string().min(1) + siteName: z.string().min(1), + tags: z.string().optional() }); export type TGetCoursesBySiteName = z.infer; +export const ZGetOrganizationCoursesQuery = z.object({ + tags: z.string().optional() +}); + +export type TGetOrganizationCoursesQuery = z.infer; + export const ZGetOrgSetup = z.object({ siteName: z.string().min(1) }); @@ -98,7 +87,21 @@ export const ZUpdateOrganization = z.object({ siteName: z.string().min(1).optional(), customDomain: z.string().nullable().optional(), isCustomDomainVerified: z.boolean().optional(), - customization: z.record(z.string(), z.unknown()).optional() + customization: z.record(z.string(), z.unknown()).optional(), + disableSignup: z.boolean().optional(), + disableSignupMessage: z.string().optional(), + disableEmailPassword: z.boolean().optional(), + disableGoogleAuth: z.boolean().optional(), + /** Nested settings (stored in organization.settings JSONB). signup.inviteOnly = true means invite-only signup. */ + settings: z + .object({ + signup: z + .object({ + inviteOnly: z.boolean().optional() + }) + .optional() + }) + .optional() }); export type TUpdateOrganization = z.infer; @@ -111,8 +114,13 @@ export const ZInviteTeamMembers = z.object({ export type TInviteTeamMembers = z.infer; export const ZRemoveTeamMember = z.object({ - orgId: z.uuid(), - memberId: z.number().int().positive() + memberId: z.coerce.number().int().positive() }); export type TRemoveTeamMember = z.infer; + +export const ZGetUserAnalytics = z.object({ + userId: z.uuid() +}); + +export type TGetUserAnalytics = z.infer; diff --git a/packages/utils/src/validation/organization/quiz.ts b/packages/utils/src/validation/organization/quiz.ts new file mode 100644 index 00000000..7bc3acd8 --- /dev/null +++ b/packages/utils/src/validation/organization/quiz.ts @@ -0,0 +1,32 @@ +import * as z from 'zod'; + +export const ZQuizGetParam = z.object({ + orgId: z.uuid(), + quizId: z.uuid() +}); + +export type TQuizGetParam = z.infer; + +export const ZQuizListParam = z.object({ + orgId: z.uuid() +}); + +export type TQuizListParam = z.infer; + +export const ZQuizCreate = z.object({ + title: z.string().min(1), + questions: z.array(z.any()).optional(), + timelimit: z.string().optional(), + theme: z.string().optional() +}); + +export type TQuizCreate = z.infer; + +export const ZQuizUpdate = z.object({ + title: z.string().min(1).optional(), + questions: z.array(z.any()).optional(), + timelimit: z.string().optional(), + theme: z.string().optional() +}); + +export type TQuizUpdate = z.infer; diff --git a/packages/utils/src/validation/organization/sso.ts b/packages/utils/src/validation/organization/sso.ts new file mode 100644 index 00000000..4ca35242 --- /dev/null +++ b/packages/utils/src/validation/organization/sso.ts @@ -0,0 +1,58 @@ +import * as z from 'zod'; + +// SSO Provider enum values +export const SSO_PROVIDERS = ['OKTA', 'GOOGLE_WORKSPACE', 'AUTH0'] as const; + +// Create SSO connection +export const ZCreateSsoConnection = z.object({ + provider: z.enum(SSO_PROVIDERS), + displayName: z.string().min(1, 'Display name is required'), + issuer: z.url('Must be a valid URL'), + domain: z + .string() + .min(1, 'Domain is required') + .regex(/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$/, 'Invalid domain format'), + clientId: z.string().min(1, 'Client ID is required'), + clientSecret: z.string().min(1, 'Client secret is required'), + scopes: z.string().default('openid profile email') +}); + +export type TCreateSsoConnection = z.infer; + +// Update SSO connection +export const ZUpdateSsoConnection = z.object({ + displayName: z.string().min(1).optional(), + issuer: z.url().optional(), + domain: z.string().optional(), + clientId: z.string().optional(), + clientSecret: z.string().optional(), + scopes: z.string().optional(), + isActive: z.boolean().optional() +}); + +export type TUpdateSsoConnection = z.infer; + +// Update SSO policy +export const ZUpdateSsoPolicy = z.object({ + forceSso: z.boolean().optional(), + autoJoinSsoDomains: z.boolean().optional(), + breakGlassEnabled: z.boolean().optional(), + defaultRoleId: z.number().int().positive().optional(), + roleMapping: z.record(z.string(), z.number()).optional() +}); + +export type TUpdateSsoPolicy = z.infer; + +// SSO Discovery query +export const ZSsoDiscoveryQuery = z.object({ + email: z.email('Valid email is required') +}); + +export type TSsoDiscoveryQuery = z.infer; + +// Get SSO config by org (param) +export const ZGetSsoConfig = z.object({ + orgId: z.uuid() +}); + +export type TGetSsoConfig = z.infer; diff --git a/packages/utils/src/validation/organization/token-auth.ts b/packages/utils/src/validation/organization/token-auth.ts new file mode 100644 index 00000000..fd4f8b6f --- /dev/null +++ b/packages/utils/src/validation/organization/token-auth.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +export const ZTokenExchangeQuery = z.object({ + token: z.string().min(1), + redirect: z.string().optional() +}); + +export type TTokenExchangeQuery = z.infer; + +export const ZTokenExchangePayload = z.object({ + sub: z.string(), + email: z.email(), + name: z.string().optional(), + avatar: z.url().optional(), + iat: z.number(), + exp: z.number() +}); + +export type TTokenExchangePayload = z.infer; diff --git a/packages/utils/src/validation/submission/index.ts b/packages/utils/src/validation/submission/index.ts new file mode 100644 index 00000000..24b2da1e --- /dev/null +++ b/packages/utils/src/validation/submission/index.ts @@ -0,0 +1 @@ +export * from './submission'; diff --git a/packages/utils/src/validation/submission/submission.ts b/packages/utils/src/validation/submission/submission.ts new file mode 100644 index 00000000..e9c45cd8 --- /dev/null +++ b/packages/utils/src/validation/submission/submission.ts @@ -0,0 +1,44 @@ +import * as z from 'zod'; + +export const ZSubmissionGradingState = z.enum(['queued', 'processing', 'awaiting_manual', 'completed', 'failed']); +export type TSubmissionGradingState = z.infer; + +export const ZSubmissionOverallStatus = z.enum(['auto_graded', 'manual_required', 'hybrid']); +export type TSubmissionOverallStatus = z.infer; + +export const ZSubmissionGetParam = z.object({ + submissionId: z.string().min(1) +}); +export type TSubmissionGetParam = z.infer; + +export const ZSubmissionUpdate = z.object({ + statusId: z.number().int().optional(), + gradingState: ZSubmissionGradingState.optional(), + total: z.number().int().min(0).optional(), + feedback: z.string().optional(), + reviewerId: z.number().int().optional() +}); +export type TSubmissionUpdate = z.infer; + +export const ZSubmissionAnswerUpdate = z.object({ + questionId: z.number().int(), + optionId: z.number().int().optional(), + answer: z.string().optional(), + points: z.number().int().min(0).optional() +}); +export type TSubmissionAnswerUpdate = z.infer; + +/** Batch update for grading: all answer points + submission total/feedback in one request */ +export const ZSubmissionGradeItem = z.object({ + questionId: z.number().int(), + points: z.number().int().min(0) +}); +export type TSubmissionGradeItem = z.infer; + +export const ZSubmissionGradesUpdate = z.object({ + answers: z.array(ZSubmissionGradeItem), + total: z.number().int().min(0), + feedback: z.string().optional(), + statusId: z.number().int().min(1).max(3).optional() +}); +export type TSubmissionGradesUpdate = z.infer; diff --git a/packages/utils/src/validation/tag/index.ts b/packages/utils/src/validation/tag/index.ts new file mode 100644 index 00000000..584cc0af --- /dev/null +++ b/packages/utils/src/validation/tag/index.ts @@ -0,0 +1 @@ +export * from './tag'; diff --git a/packages/utils/src/validation/tag/tag.ts b/packages/utils/src/validation/tag/tag.ts new file mode 100644 index 00000000..2ed35571 --- /dev/null +++ b/packages/utils/src/validation/tag/tag.ts @@ -0,0 +1,69 @@ +import * as z from 'zod'; + +export const TAG_COLOR_OPTIONS = [ + '#EF4444', + '#F97316', + '#F59E0B', + '#84CC16', + '#22C55E', + '#14B8A6', + '#06B6D4', + '#3B82F6', + '#8B5CF6', + '#EC4899' +] as const; + +export const ZTagColor = z.enum(TAG_COLOR_OPTIONS); +export type TTagColor = z.infer; + +export const ZTagGroupParam = z.object({ + groupId: z.uuid() +}); +export type TTagGroupParam = z.infer; + +export const ZTagGroupCreate = z.object({ + name: z.string().min(1).max(80), + description: z.string().max(240).optional(), + order: z.number().int().min(0).optional() +}); +export type TTagGroupCreate = z.infer; + +export const ZTagGroupUpdate = ZTagGroupCreate.partial().refine((value) => Object.keys(value).length > 0, { + message: 'At least one field is required' +}); +export type TTagGroupUpdate = z.infer; + +export const ZTagParam = z.object({ + tagId: z.uuid() +}); +export type TTagParam = z.infer; + +export const ZTagCreate = z.object({ + name: z.string().min(1).max(80), + description: z.string().max(240).optional(), + groupId: z.uuid(), + color: ZTagColor +}); +export type TTagCreate = z.infer; + +export const ZTagUpdate = z + .object({ + name: z.string().min(1).max(80).optional(), + description: z.string().max(240).optional(), + groupId: z.uuid().optional(), + color: ZTagColor.optional() + }) + .refine((value) => Object.keys(value).length > 0, { + message: 'At least one field is required' + }); +export type TTagUpdate = z.infer; + +export const ZCourseTagParam = z.object({ + courseId: z.uuid() +}); +export type TCourseTagParam = z.infer; + +export const ZCourseTagAssignment = z.object({ + tagIds: z.array(z.uuid()).max(100).default([]) +}); +export type TCourseTagAssignment = z.infer; diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json index 1f8676f5..ac05119a 100644 --- a/packages/utils/tsconfig.json +++ b/packages/utils/tsconfig.json @@ -13,5 +13,9 @@ "declarationMap": true }, "include": ["src/**/*.ts", "src/**/*.json"], - "exclude": ["node_modules", "dist", "scripts", "tsconfig.tsbuildinfo"] + "exclude": ["node_modules", "dist", "scripts", "tsconfig.tsbuildinfo"], + "tsc-alias": { + "resolveFullPaths": true, + "verbose": false + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ac3453e..fbd8182e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,8 +31,8 @@ importers: specifier: ^3.4.1 version: 3.4.1(prettier@3.6.2)(svelte@5.46.0) prettier-plugin-tailwindcss: - specifier: ^0.5.7 - version: 0.5.14(prettier-plugin-svelte@3.4.1(prettier@3.6.2)(svelte@5.46.0))(prettier@3.6.2) + specifier: ^0.7.2 + version: 0.7.2(prettier-plugin-svelte@3.4.1(prettier@3.6.2)(svelte@5.46.0))(prettier@3.6.2) standard-version: specifier: ^9.5.0 version: 9.5.0 @@ -54,21 +54,24 @@ importers: '@cio/email': specifier: workspace:* version: link:../../packages/email + '@cio/question-types': + specifier: workspace:* + version: link:../../packages/question-types '@cio/utils': specifier: workspace:* version: link:../../packages/utils '@hono/node-server': - specifier: ^1.8.2 - version: 1.19.7(hono@4.11.1) + specifier: ^1.19.7 + version: 1.19.7(hono@4.11.3) '@hono/standard-validator': specifier: ^0.1.5 - version: 0.1.5(@standard-schema/spec@1.1.0)(hono@4.11.1) + version: 0.1.5(@standard-schema/spec@1.1.0)(hono@4.11.3) '@hono/zod-validator': - specifier: ^0.7.2 - version: 0.7.6(hono@4.11.1)(zod@4.2.1) + specifier: ^0.7.6 + version: 0.7.6(hono@4.11.3)(zod@4.2.1) '@scalar/hono-api-reference': specifier: ^0.9.18 - version: 0.9.30(hono@4.11.1) + version: 0.9.30(hono@4.11.3) '@sentry/profiling-node': specifier: ^7.110.1 version: 7.120.4 @@ -79,17 +82,14 @@ importers: specifier: ^16.3.1 version: 16.6.1 hono: - specifier: ^4.9.7 - version: 4.11.1 + specifier: ^4.11.3 + version: 4.11.3 hono-openapi: specifier: ^1.0.8 - version: 1.1.2(@hono/standard-validator@0.1.5(@standard-schema/spec@1.1.0)(hono@4.11.1))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.2.1))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.2.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod-openapi@5.4.5(zod@4.2.1))(zod@4.2.1))(@types/json-schema@7.0.15)(hono@4.11.1)(openapi-types@12.1.3) + version: 1.1.2(@hono/standard-validator@0.1.5(@standard-schema/spec@1.1.0)(hono@4.11.3))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod@4.2.1))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod@4.2.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod-openapi@5.4.5(zod@4.2.1))(zod@4.2.1))(@types/json-schema@7.0.15)(hono@4.11.3)(openapi-types@12.1.3) hono-rate-limiter: specifier: ^0.4.2 - version: 0.4.2(hono@4.11.1) - ioredis: - specifier: ^5.7.0 - version: 5.8.2 + version: 0.4.2(hono@4.11.3) katex: specifier: ^0.16.10 version: 0.16.27 @@ -99,6 +99,9 @@ importers: nanoid: specifier: ^5.1.5 version: 5.1.6 + redis: + specifier: ^4.7.0 + version: 4.7.1 zod: specifier: ^4.1.8 version: 4.2.1 @@ -120,7 +123,7 @@ importers: version: 7.0.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: ^1.2.2 - version: 1.6.1(vitest@1.6.1(@types/node@20.19.27)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.30.2)(sass@1.97.1)) + version: 1.6.1(vitest@1.6.1(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1)) eslint: specifier: ^9.31.0 version: 9.39.2(jiti@2.6.1) @@ -141,7 +144,7 @@ importers: version: 5.9.3 vitest: specifier: ^1.2.2 - version: 1.6.1(@types/node@20.19.27)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.30.2)(sass@1.97.1) + version: 1.6.1(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1) wrangler: specifier: 4.21.x version: 4.21.2(bufferutil@4.1.0)(utf-8-validate@6.0.6) @@ -178,13 +181,13 @@ importers: devDependencies: '@sveltejs/adapter-auto': specifier: ^3.0.0 - version: 3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))) + version: 3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))) '@sveltejs/kit': specifier: ^2.9.0 - version: 2.49.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 2.49.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/vite-plugin-svelte': specifier: ^5.0.0 - version: 5.1.1(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.1.1(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) autoprefixer: specifier: ^10.4.20 version: 10.4.23(postcss@8.5.6) @@ -226,22 +229,25 @@ importers: version: 5.9.3 vite: specifier: ^6.0.0 - version: 6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + version: 6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^2.0.4 - version: 2.1.9(@types/node@24.10.4)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.30.2)(sass@1.97.1) + version: 2.1.9(@types/node@24.10.4)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1) apps/dashboard: dependencies: - '@carbon/charts-svelte': - specifier: ^1.13.6 - version: 1.22.18(svelte@5.46.0) + '@better-auth/sso': + specifier: 1.4.18 + version: 1.4.18(@better-auth/utils@0.3.0)(better-auth@1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3))) '@cio/api': specifier: workspace:* version: link:../api '@cio/db': specifier: workspace:* version: link:../../packages/db + '@cio/question-types': + specifier: workspace:* + version: link:../../packages/question-types '@cio/ui': specifier: workspace:* version: link:../../packages/ui @@ -253,19 +259,16 @@ importers: version: 0.545.0(svelte@5.46.0) '@polar-sh/sveltekit': specifier: ^0.2.1 - version: 0.2.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(zod@4.2.1) - '@supabase/supabase-js': - specifier: ^2.31.0 - version: 2.89.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + version: 0.2.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(zod@4.2.1) '@sveltejs/adapter-auto': specifier: ^3.0.0 - version: 3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1))) + version: 3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1))) '@sveltejs/adapter-node': specifier: ^2.0.0 - version: 2.1.2(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1))) + version: 2.1.2(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1))) '@sveltejs/adapter-vercel': specifier: ^4.0.0 - version: 4.0.5(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(encoding@0.1.13) + version: 4.0.5(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(encoding@0.1.13) '@sveltekit-i18n/base': specifier: ^1.3.7 version: 1.3.7(svelte@5.46.0) @@ -288,11 +291,11 @@ importers: specifier: ^1.8.4 version: 1.13.2 better-auth: - specifier: ^1.3.34 - version: 1.4.9(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(kysely@0.28.9)(postgres@3.4.7))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@20.19.27)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.30.2)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)) + specifier: ^1.4.18 + version: 1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)) bits-ui: specifier: ^2.14.4 - version: 2.14.4(@internationalized/date@3.10.1)(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0) + version: 2.14.4(@internationalized/date@3.10.1)(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0) body-parser: specifier: ^1.20.2 version: 1.20.4 @@ -359,6 +362,9 @@ importers: pluralize: specifier: ^8.0.0 version: 8.0.0 + plyr: + specifier: ^3.7.8 + version: 3.8.4 posthog-js: specifier: ^1.180.1 version: 1.310.1 @@ -425,10 +431,10 @@ importers: version: 6.0.3(rollup@4.54.0) '@sveltejs/kit': specifier: ^2.5.27 - version: 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)) + version: 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)) '@sveltejs/vite-plugin-svelte': specifier: ^4.0.0 - version: 4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)) + version: 4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)) '@tailwindcss/typography': specifier: ^0.5.9 version: 0.5.19(tailwindcss@4.1.18) @@ -521,7 +527,7 @@ importers: version: 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^5.4.4 - version: 5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1) + version: 5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1) apps/docs: dependencies: @@ -533,13 +539,13 @@ importers: version: 1.143.4(@tanstack/react-router@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.143.4)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10) '@tanstack/react-start': specifier: ^1.134.12 - version: 1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) fumadocs-core: specifier: 16.2.1 version: 16.2.1(@tanstack/react-router@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) fumadocs-mdx: specifier: 14.0.4 - version: 14.0.4(fumadocs-core@16.2.1(@tanstack/react-router@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 14.0.4(fumadocs-core@16.2.1(@tanstack/react-router@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) fumadocs-ui: specifier: 16.2.1 version: 16.2.1(@tanstack/react-router@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18) @@ -560,11 +566,11 @@ importers: version: 3.4.0 vite: specifier: ^7.2.0 - version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) devDependencies: '@tailwindcss/vite': specifier: ^4.1.16 - version: 4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) '@types/mdx': specifier: ^2.0.13 version: 2.0.13 @@ -579,10 +585,10 @@ importers: version: 19.2.3(@types/react@19.2.7) '@vitejs/plugin-react': specifier: ^5.1.0 - version: 5.1.2(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.1.2(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) nitro: specifier: 3.0.1-alpha.1 - version: 3.0.1-alpha.1(chokidar@4.0.3)(drizzle-orm@0.44.7(kysely@0.28.9)(postgres@3.4.7))(ioredis@5.8.2)(lru-cache@11.2.4)(rollup@4.54.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.0.1-alpha.1(chokidar@4.0.3)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(ioredis@5.8.2)(lru-cache@11.2.4)(rollup@4.54.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) tailwindcss: specifier: ^4.1.16 version: 4.1.18 @@ -591,7 +597,7 @@ importers: version: 5.9.3 vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.1.4(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) apps/website: dependencies: @@ -637,16 +643,16 @@ importers: version: link:../../packages/tsconfig '@sveltejs/adapter-auto': specifier: ^3.0.0 - version: 3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1))) + version: 3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1))) '@sveltejs/adapter-vercel': specifier: ^4.0.0 - version: 4.0.5(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1)))(encoding@0.1.13) + version: 4.0.5(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(encoding@0.1.13) '@sveltejs/kit': specifier: ^2.5.27 - version: 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1)) + version: 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)) '@sveltejs/vite-plugin-svelte': specifier: ^4.0.0 - version: 4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1)) + version: 4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)) '@tailwindcss/typography': specifier: ^0.5.9 version: 0.5.19(tailwindcss@4.1.18) @@ -670,7 +676,7 @@ importers: version: 5.9.3 vite: specifier: ^5.4.4 - version: 5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1) + version: 5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1) packages/course-app: dependencies: @@ -701,7 +707,7 @@ importers: version: 5.9.3 vite: specifier: ^5.0.3 - version: 5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1) + version: 5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1) packages/course-app/src/template: dependencies: @@ -735,19 +741,19 @@ importers: version: 1.57.0 '@sveltejs/adapter-auto': specifier: ^3.0.0 - version: 3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1))) + version: 3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1))) '@sveltejs/kit': specifier: ^2.5.27 - version: 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1)) + version: 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1)) '@sveltejs/vite-plugin-svelte': specifier: ^4.0.0 - version: 4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1)) + version: 4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1)) '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.9.1 '@testing-library/svelte': specifier: ^5.2.4 - version: 5.3.0(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1))(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.30.2)(sass@1.97.1)) + version: 5.3.0(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1))(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1)) '@testing-library/user-event': specifier: ^14.5.2 version: 14.6.1(@testing-library/dom@10.4.1) @@ -789,16 +795,22 @@ importers: version: 5.9.3 vite: specifier: ^5.4.4 - version: 5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1) + version: 5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1) vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@22.19.3)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.30.2)(sass@1.97.1) + version: 2.1.9(@types/node@22.19.3)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1) packages/db: dependencies: + '@better-auth/sso': + specifier: ^1.4.18 + version: 1.4.18(@better-auth/utils@0.3.0)(better-auth@1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@24.10.4)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3))) '@cio/email': specifier: workspace:* version: link:../email + '@cio/question-types': + specifier: workspace:* + version: link:../question-types '@cio/tsconfig': specifier: workspace:* version: link:../tsconfig @@ -809,14 +821,17 @@ importers: specifier: 6.0.0 version: 6.0.0 better-auth: - specifier: ^1.4.1 - version: 1.4.9(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(kysely@0.28.9)(postgres@3.4.7))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@24.10.4)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.30.2)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)) + specifier: ^1.4.18 + version: 1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@24.10.4)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)) drizzle-kit: - specifier: ^0.31.5 + specifier: ^0.31.8 version: 0.31.8 drizzle-orm: specifier: ^0.44.7 - version: 0.44.7(kysely@0.28.9)(postgres@3.4.7) + version: 0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7) + jose: + specifier: ^6.1.3 + version: 6.1.3 postgres: specifier: ^3.4.7 version: 3.4.7 @@ -827,9 +842,15 @@ importers: '@types/node': specifier: ^24.8.1 version: 24.10.4 + '@types/pg': + specifier: ^8.16.0 + version: 8.16.0 dotenv: specifier: ^17.2.3 version: 17.2.3 + pg: + specifier: ^8.18.0 + version: 8.18.0 tsc-alias: specifier: ^1.8.16 version: 1.8.16 @@ -877,6 +898,22 @@ importers: specifier: ^5.5.2 version: 5.9.3 + packages/question-types: + dependencies: + '@cio/tsconfig': + specifier: workspace:* + version: link:../tsconfig + devDependencies: + tsc-alias: + specifier: ^1.8.16 + version: 1.8.16 + typescript: + specifier: ^5.5.2 + version: 5.9.3 + vitest: + specifier: ^1.2.2 + version: 1.6.1(@types/node@24.10.4)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1) + packages/storybook: dependencies: '@cio/ui': @@ -900,7 +937,7 @@ importers: version: 8.6.15(react@19.2.3)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6)) '@storybook/addon-svelte-csf': specifier: ^5.0.6 - version: 5.0.10(@storybook/svelte@8.6.15(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0))(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.0.10(@storybook/svelte@8.6.15(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0))(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) '@storybook/blocks': specifier: ^8.4.2 version: 8.6.14(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6)) @@ -909,19 +946,19 @@ importers: version: 8.6.15(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0) '@storybook/svelte-vite': specifier: ^10.0.2 - version: 10.1.10(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(esbuild@0.25.12)(rollup@4.54.0)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 10.1.10(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(esbuild@0.25.12)(rollup@4.54.0)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) '@storybook/sveltekit': specifier: ^8.4.2 - version: 8.6.15(@babel/core@7.28.5)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.4)(typescript@5.9.3)))(postcss@8.5.6)(sass@1.97.1)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 8.6.15(@babel/core@7.28.5)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.4)(typescript@5.9.3)))(postcss@8.5.6)(sass@1.97.1)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) '@storybook/test': specifier: ^8.4.2 version: 8.6.14(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6)) '@sveltejs/vite-plugin-svelte': specifier: ^6.2.1 - version: 6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) '@tailwindcss/vite': specifier: ^4.1.16 - version: 4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) '@tiptap/core': specifier: ^3.7.1 version: 3.14.0(@tiptap/pm@3.14.0) @@ -954,12 +991,15 @@ importers: version: 0.3.50(svelte@5.46.0)(typescript@5.9.3) vite: specifier: ^7.1.4 - version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) packages/tsconfig: {} packages/ui: dependencies: + '@cio/question-types': + specifier: workspace:* + version: link:../question-types '@floating-ui/dom': specifier: ^1.7.4 version: 1.7.4 @@ -1032,6 +1072,15 @@ importers: lowlight: specifier: ^3.3.0 version: 3.3.0 + plyr: + specifier: ^3.7.8 + version: 3.8.4 + simple-icons: + specifier: ^16.9.0 + version: 16.9.0 + svelte-dnd-action: + specifier: ^0.9.68 + version: 0.9.68(svelte@5.46.0) svelte-tiptap: specifier: ^2.2.0 version: 2.2.0(@tiptap/core@3.14.0(@tiptap/pm@3.14.0))(@tiptap/extension-bubble-menu@3.14.0(@tiptap/core@3.14.0(@tiptap/pm@3.14.0))(@tiptap/pm@3.14.0))(@tiptap/extension-floating-menu@3.14.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.14.0(@tiptap/pm@3.14.0))(@tiptap/pm@3.14.0))(@tiptap/pm@3.14.0)(svelte@5.46.0) @@ -1046,17 +1095,26 @@ importers: specifier: ^3.8.1 version: 3.10.1 '@lucide/svelte': - specifier: ^0.544.0 - version: 0.544.0(svelte@5.46.0) + specifier: ^0.562.0 + version: 0.562.0(svelte@5.46.0) + '@shikijs/langs': + specifier: ^3.22.0 + version: 3.22.0 + '@shikijs/themes': + specifier: ^3.22.0 + version: 3.22.0 '@sveltejs/adapter-auto': specifier: ^3.0.0 - version: 3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))) + version: 3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))) '@sveltejs/kit': specifier: ^2.5.27 - version: 2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/vite-plugin-svelte': specifier: ^6.2.1 - version: 6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@tailwindcss/cli': + specifier: ^4.1.18 + version: 4.2.1 '@tailwindcss/typography': specifier: ^0.5.18 version: 0.5.19(tailwindcss@4.1.18) @@ -1073,11 +1131,17 @@ importers: specifier: ^3.0.2 version: 3.0.2 bits-ui: - specifier: ^2.14.4 - version: 2.14.4(@internationalized/date@3.10.1)(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0) + specifier: ^2.16.1 + version: 2.16.1(@internationalized/date@3.10.1)(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0) clsx: specifier: ^2.1.1 version: 2.1.1 + isomorphic-dompurify: + specifier: 2.25.0 + version: 2.25.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + jsrepo: + specifier: ^3.5.1 + version: 3.5.1(svelte@5.46.0)(vue@3.5.26(typescript@5.9.3)) layerchart: specifier: 2.0.0-next.27 version: 2.0.0-next.27(svelte@5.46.0) @@ -1085,8 +1149,11 @@ importers: specifier: ^1.1.0 version: 1.1.0(svelte@5.46.0) runed: - specifier: ^0.36.0 - version: 0.36.0(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(zod@4.2.1) + specifier: ^0.37.1 + version: 0.37.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(zod@4.3.6) + shiki: + specifier: ^3.22.0 + version: 3.22.0 svelte: specifier: ^5.39.11 version: 5.46.0 @@ -1098,7 +1165,7 @@ importers: version: 1.0.7(svelte@5.46.0) svelte-toolbelt: specifier: ^0.10.6 - version: 0.10.6(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0) + version: 0.10.6(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -1117,6 +1184,9 @@ importers: packages/utils: dependencies: + '@cio/question-types': + specifier: workspace:* + version: link:../question-types '@cio/tsconfig': specifier: workspace:* version: link:../tsconfig @@ -1147,6 +1217,10 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@authenio/xml-encryption@2.0.2': + resolution: {integrity: sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==} + engines: {node: '>=12'} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -1944,20 +2018,26 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} - '@better-auth/core@1.4.9': - resolution: {integrity: sha512-JT2q4NDkQzN22KclUEoZ7qU6tl9HUTfK1ctg2oWlT87SEagkwJcnrUwS9VznL+u9ziOIfY27P0f7/jSnmvLcoQ==} + '@better-auth/core@1.4.18': + resolution: {integrity: sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg==} peerDependencies: '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 - better-call: 1.1.7 + better-call: 1.1.8 jose: ^6.1.0 kysely: ^0.28.5 nanostores: ^1.0.1 - '@better-auth/telemetry@1.4.9': - resolution: {integrity: sha512-Tthy1/Gmx+pYlbvRQPBTKfVei8+pJwvH1NZp+5SbhwA6K2EXIaoonx/K6N/AXYs2aKUpyR4/gzqDesDjL7zd6A==} + '@better-auth/sso@1.4.18': + resolution: {integrity: sha512-jwTxZUBp71W6YVOavy50DBZ4OFi0a9MGvTTAi+mxMuINFRcz2RyGZv0gb3jy8AJT97TwKU60qRYIWYYyJj1uhA==} peerDependencies: - '@better-auth/core': 1.4.9 + '@better-auth/utils': 0.3.0 + better-auth: 1.4.18 + + '@better-auth/telemetry@1.4.18': + resolution: {integrity: sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ==} + peerDependencies: + '@better-auth/core': 1.4.18 '@better-auth/utils@0.3.0': resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} @@ -1965,20 +2045,6 @@ packages: '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} - '@carbon/charts-svelte@1.22.18': - resolution: {integrity: sha512-nm5eixRK5TrKsFIII922lqHxGFWYcQHFIB/RsbF4YGMkZ3xOKTRQoVnPDgaYpam/jn1rwiFWLC9mmglnfcOfCA==} - peerDependencies: - svelte: ^3.31.0 || ^4.0.0 || ^5.20.0 - - '@carbon/charts@1.22.18': - resolution: {integrity: sha512-OjQTv3c8KGeoHCIJYpNnaa3FHEU3mdW15Yx1eG6i7CRiH5A+yr3GzFOXvpqhQNFd+vmhKHQEIwioS+HbrBhjZA==} - - '@carbon/colors@11.43.0': - resolution: {integrity: sha512-Remz2fS6lreIi9qc6iQajaZCsbz68++bP7qNH3RyKmgm1R7xktGX7/WL2GpGi9kCOMe5+YxNLGygsqkzc28Vww==} - - '@carbon/utils-position@1.3.0': - resolution: {integrity: sha512-bfar2dV+fQ15syIrH3n9ujY4PXd1Q+AF2VcTLJIC04IDe2f80zOnJlLNPc/RktHcWTZ7WSQm80cQo3abGcsfTA==} - '@cloudflare/kv-asset-handler@0.4.0': resolution: {integrity: sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==} engines: {node: '>=18.0.0'} @@ -3095,10 +3161,6 @@ packages: resolution: {integrity: sha512-H9XAx3hc0BQHY6l+IFSWHDySypcXsvsuLhgYLUGywmJ5pswRVQJUHpOsobnLYp2ZUaUlKiKDrgWWhosOwAEM8Q==} engines: {node: '>=6.9.0'} - '@ibm/telemetry-js@1.10.2': - resolution: {integrity: sha512-F8+/NNUwtm8BuFz18O9KPvIFTFDo8GUSoyhPxPjEpk7nEyEzWGfhIiEPhL00B2NdHRLDSljh3AiCfSnL/tutiQ==} - hasBin: true - '@img/sharp-darwin-arm64@0.33.5': resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3371,13 +3433,13 @@ packages: '@layerstack/utils@2.0.0-next.12': resolution: {integrity: sha512-fhGZUlSr3N+D44BYm37WKMGSEFyZBW+dwIqtGU8Cl54mR4TLQ/UwyGhdpgIHyH/x/8q1abE0fP0Dn6ZsrDE3BA==} - '@lucide/svelte@0.544.0': - resolution: {integrity: sha512-9f9O6uxng2pLB01sxNySHduJN3HTl5p0HDu4H26VR51vhZfiMzyOMe9Mhof3XAk4l813eTtl+/DYRvGyoRR+yw==} + '@lucide/svelte@0.545.0': + resolution: {integrity: sha512-RlAtWefx9MdpXaOMbx3Qv3/NqpeZKOIPxN2D0RBN2+op0opKly8VgYEEWZTT6Ow/zf7UwyTg6/0ExJlsVLK+8g==} peerDependencies: svelte: ^5 - '@lucide/svelte@0.545.0': - resolution: {integrity: sha512-RlAtWefx9MdpXaOMbx3Qv3/NqpeZKOIPxN2D0RBN2+op0opKly8VgYEEWZTT6Ow/zf7UwyTg6/0ExJlsVLK+8g==} + '@lucide/svelte@0.562.0': + resolution: {integrity: sha512-wDMULwtTFN2Sc/TFBm6gfuVCNb4Y5P9LDrwxNnUbV52+IEU7NXZmvxwXoz+vrrpad6Xupq+Hw5eUlqIHEGhouw==} peerDependencies: svelte: ^5 @@ -3402,6 +3464,9 @@ packages: '@napi-rs/wasm-runtime@1.1.0': resolution: {integrity: sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==} + '@napi-rs/wasm-runtime@1.1.1': + resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@noble/ciphers@2.1.1': resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} engines: {node: '>= 20.19.0'} @@ -3531,6 +3596,128 @@ packages: cpu: [x64] os: [win32] + '@oxc-parser/binding-android-arm-eabi@0.112.0': + resolution: {integrity: sha512-retxBzJ39Da7Lh/eZTn9+HJgTeDUxZIpuI0urOsmcFsBKXAth3lc1jIvwseQ9qbAI/VrsoFOXiGIzgclARbAHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.112.0': + resolution: {integrity: sha512-pRkbBRbuIIsufUWpOJ+JHWfJFNupkidy4sbjfcm37e6xwYrn9LSKMLubPHvNaL1Zf92ZRhGiwaYkEcmaFg2VcA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.112.0': + resolution: {integrity: sha512-fh6/KQL/cbH5DukT3VkdCqnULLuvVnszVKySD5IgSE0WZb32YZo/cPsPdEv052kk6w3N4agu+NTiMnZjcvhUIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.112.0': + resolution: {integrity: sha512-vUBOOY1E30vlu/DoTGDoT1UbLlwu5Yv9tqeBabAwRzwNDz8Skho16VKhsBDUiyqddtpsR3//v6vNk38w4c+6IA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.112.0': + resolution: {integrity: sha512-hnEtO/9AVnYWzrgnp6L+oPs/6UqlFeteUL6n7magkd2tttgmx1C01hyNNh6nTpZfLzEVJSNJ0S+4NTsK2q2CxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.112.0': + resolution: {integrity: sha512-WxJrUz3pcIc2hp4lvJbvt/sTL33oX9NPvkD3vDDybE6tc0V++rS+hNOJxwXdD2FDIFPkHs/IEn5asEZFVH+VKw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.112.0': + resolution: {integrity: sha512-jj8A8WWySaJQqM9XKAIG8U2Q3qxhFQKrXPWv98d1oC35at+L1h+C+V4M3l8BAKhpHKCu3dYlloaAbHd5q1Hw6A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.112.0': + resolution: {integrity: sha512-G2F8H6FcAExVK5vvhpSh61tqWx5QoaXXUnSsj5FyuDiFT/K7AMMVSQVqnZREDc+YxhrjB0vnKjCcuobXK63kIw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-arm64-musl@0.112.0': + resolution: {integrity: sha512-3R0iqjM3xYOZCnwgcxOQXH7hrz64/USDIuLbNTM1kZqQzRqaR4w7SwoWKU934zABo8d0op2oSwOp+CV3hZnM7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-ppc64-gnu@0.112.0': + resolution: {integrity: sha512-lAQf8PQxfgy7h0bmcfSVE3hg3qMueshPYULFsCrHM+8KefGZ9W+ZMvRyU33gLrB4w1O3Fz1orR0hmKMCRxXNrQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-gnu@0.112.0': + resolution: {integrity: sha512-2QlvQBUhHuAE3ezD4X3CAEKMXdfgInggQ5Bj/7gb5NcYP3GyfLTj7c+mMu+BRwfC9B3AXBNyqHWbqEuuUvZyRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-musl@0.112.0': + resolution: {integrity: sha512-v06iu0osHszgqJ1dLQRb6leWFU1sjG/UQk4MoVBtE6ZPewgfTkby6G9II1SpEAf2onnAuQceVYxQH9iuU3NJqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-s390x-gnu@0.112.0': + resolution: {integrity: sha512-+5HhNHtxsdcd7+ljXFnn9FOoCNXJX3UPgIfIE6vdwS1HqdGNH6eAcVobuqGOp54l8pvcxDQA6F4cPswCgLrQfQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxc-parser/binding-linux-x64-gnu@0.112.0': + resolution: {integrity: sha512-jKwO7ZLNkjxwg7FoCLw+fJszooL9yXRZsDN0AQ1AQUTWq1l8GH/2e44k68N3fcP19jl8O8jGpqLAZcQTYk6skA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-linux-x64-musl@0.112.0': + resolution: {integrity: sha512-TYqnuKV/p3eOc+N61E0961nA7DC+gaCeJ3+V2LcjJdTwFMdikqWL6uVk1jlrpUCBrozHDATVUKDZYH7r4FQYjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-openharmony-arm64@0.112.0': + resolution: {integrity: sha512-ZhrVmWFifVEFQX4XPwLoVFDHw9tAWH9p9vHsHFH+5uCKdfVR+jje4WxVo6YrokWCboGckoOzHq5KKMOcPZfkRg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-wasm32-wasi@0.112.0': + resolution: {integrity: sha512-Gr8X2PUU3hX1g3F5oLWIZB8DhzDmjr5TfOrmn5tlBOo9l8ojPGdKjnIBfObM7X15928vza8QRKW25RTR7jfivg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.112.0': + resolution: {integrity: sha512-t5CDLbU70Ea88bGRhvU/dLJTc/Wcrtf2Jp534E8P3cgjAvHDjdKsfDDqBZrhybJ8Jv9v9vW5ngE40EK51BluDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.112.0': + resolution: {integrity: sha512-rZH0JynCCwnhe2HfRoyNOl/Kfd9pudoWxgpC5OZhj7j77pMK0UOAa35hYDfrtSOUk2HLzrikV5dPUOY2DpSBSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.112.0': + resolution: {integrity: sha512-oGHluohzmVFAuQrkEnl1OXAxMz2aYmimxUqIgKXpBgbr7PvFv0doELB273sX+5V3fKeggohKg1A2Qq21W9Z9cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.112.0': + resolution: {integrity: sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ==} + '@oxc-transform/binding-android-arm64@0.96.0': resolution: {integrity: sha512-wOm+ZsqFvyZ7B9RefUMsj0zcXw77Z2pXA51nbSQyPXqr+g0/pDGxriZWP8Sdpz/e4AEaKPA9DvrwyOZxu7GRDQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3727,6 +3914,9 @@ packages: '@posthog/core@1.9.0': resolution: {integrity: sha512-j7KSWxJTUtNyKynLt/p0hfip/3I46dWU2dk+pt7dKRoz2l5CYueHuHK4EO7Wlgno5yo1HO4sc4s30MXMTICHJw==} + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -4092,6 +4282,35 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@redis/bloom@1.2.0': + resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/client@1.6.1': + resolution: {integrity: sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==} + engines: {node: '>=14'} + + '@redis/graph@1.1.1': + resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/json@1.0.7': + resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/search@1.2.0': + resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/time-series@1.1.0': + resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==} + peerDependencies: + '@redis/client': ^1.0.0 + '@remirror/core-constants@3.0.0': resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} @@ -4289,24 +4508,36 @@ packages: '@shikijs/core@3.20.0': resolution: {integrity: sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g==} + '@shikijs/core@3.22.0': + resolution: {integrity: sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==} + '@shikijs/engine-javascript@1.29.2': resolution: {integrity: sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A==} '@shikijs/engine-javascript@3.20.0': resolution: {integrity: sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg==} + '@shikijs/engine-javascript@3.22.0': + resolution: {integrity: sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw==} + '@shikijs/engine-oniguruma@1.29.2': resolution: {integrity: sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA==} '@shikijs/engine-oniguruma@3.20.0': resolution: {integrity: sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ==} + '@shikijs/engine-oniguruma@3.22.0': + resolution: {integrity: sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA==} + '@shikijs/langs@1.29.2': resolution: {integrity: sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ==} '@shikijs/langs@3.20.0': resolution: {integrity: sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA==} + '@shikijs/langs@3.22.0': + resolution: {integrity: sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA==} + '@shikijs/rehype@3.20.0': resolution: {integrity: sha512-/sqob3V/lJK0m2mZ64nkcWPN88im0D9atkI3S3PUBvtJZTHnJXVwZhHQFRDyObgEIa37IpHYHR3CuFtXB5bT2g==} @@ -4316,6 +4547,9 @@ packages: '@shikijs/themes@3.20.0': resolution: {integrity: sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ==} + '@shikijs/themes@3.22.0': + resolution: {integrity: sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g==} + '@shikijs/transformers@3.20.0': resolution: {integrity: sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g==} @@ -4325,6 +4559,9 @@ packages: '@shikijs/types@3.20.0': resolution: {integrity: sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw==} + '@shikijs/types@3.22.0': + resolution: {integrity: sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==} + '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} @@ -4858,30 +5095,6 @@ packages: peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - '@supabase/auth-js@2.89.0': - resolution: {integrity: sha512-wiWZdz8WMad8LQdJMWYDZ2SJtZP5MwMqzQq3ehtW2ngiI3UTgbKiFrvMUUS3KADiVlk4LiGfODB2mrYx7w2f8w==} - engines: {node: '>=20.0.0'} - - '@supabase/functions-js@2.89.0': - resolution: {integrity: sha512-XEueaC5gMe5NufNYfBh9kPwJlP5M2f+Ogr8rvhmRDAZNHgY6mI35RCkYDijd92pMcNM7g8pUUJov93UGUnqfyw==} - engines: {node: '>=20.0.0'} - - '@supabase/postgrest-js@2.89.0': - resolution: {integrity: sha512-/b0fKrxV9i7RNOEXMno/I1862RsYhuUo+Q6m6z3ar1f4ulTMXnDfv0y4YYxK2POcgrOXQOgKYQx1eArybyNvtg==} - engines: {node: '>=20.0.0'} - - '@supabase/realtime-js@2.89.0': - resolution: {integrity: sha512-aMOvfDb2a52u6PX6jrrjvACHXGV3zsOlWRzZsTIOAJa0hOVvRp01AwC1+nLTGUzxzezejrYeCX+KnnM1xHdl+w==} - engines: {node: '>=20.0.0'} - - '@supabase/storage-js@2.89.0': - resolution: {integrity: sha512-6zKcXofk/M/4Eato7iqpRh+B+vnxeiTumCIP+Tz26xEqIiywzD9JxHq+udRrDuv6hXE+pmetvJd8n5wcf4MFRQ==} - engines: {node: '>=20.0.0'} - - '@supabase/supabase-js@2.89.0': - resolution: {integrity: sha512-KlaRwSfFA0fD73PYVMHj5/iXFtQGCcX7PSx0FdQwYEEw9b2wqM7GxadY+5YwcmuEhalmjFB/YvqaoNVF+sWUlg==} - engines: {node: '>=20.0.0'} - '@sveltejs/acorn-typescript@1.0.8': resolution: {integrity: sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==} peerDependencies: @@ -4974,6 +5187,10 @@ packages: '@swc/helpers@0.5.17': resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} + '@tailwindcss/cli@4.2.1': + resolution: {integrity: sha512-b7MGn51IA80oSG+7fuAgzfQ+7pZBgjzbqwmiv6NO7/+a1sev32cGqnwhscT7h0EcAvMa9r7gjRylqOH8Xhc4DA==} + hasBin: true + '@tailwindcss/container-queries@0.1.1': resolution: {integrity: sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==} peerDependencies: @@ -4987,60 +5204,117 @@ packages: '@tailwindcss/node@4.1.18': resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} + '@tailwindcss/node@4.2.1': + resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} + '@tailwindcss/oxide-android-arm64@4.1.18': resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} engines: {node: '>= 10'} cpu: [arm64] os: [android] + '@tailwindcss/oxide-android-arm64@4.2.1': + resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + '@tailwindcss/oxide-darwin-arm64@4.1.18': resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] + '@tailwindcss/oxide-darwin-arm64@4.2.1': + resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + '@tailwindcss/oxide-darwin-x64@4.1.18': resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] + '@tailwindcss/oxide-darwin-x64@4.2.1': + resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + '@tailwindcss/oxide-freebsd-x64@4.1.18': resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} engines: {node: '>= 10'} cpu: [x64] os: [freebsd] + '@tailwindcss/oxide-freebsd-x64@4.2.1': + resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} engines: {node: '>= 10'} cpu: [arm] os: [linux] + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} engines: {node: '>=14.0.0'} @@ -5053,22 +5327,50 @@ packages: - '@emnapi/wasi-threads' - tslib + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} engines: {node: '>= 10'} cpu: [x64] os: [win32] + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + '@tailwindcss/oxide@4.1.18': resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} engines: {node: '>= 10'} + '@tailwindcss/oxide@4.2.1': + resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} + engines: {node: '>= 20'} + '@tailwindcss/postcss@4.1.18': resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} @@ -5519,99 +5821,6 @@ packages: '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} - '@types/d3-array@3.2.2': - resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} - - '@types/d3-axis@3.0.6': - resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} - - '@types/d3-brush@3.0.6': - resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} - - '@types/d3-chord@3.0.6': - resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} - - '@types/d3-color@3.1.3': - resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} - - '@types/d3-contour@3.0.6': - resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} - - '@types/d3-delaunay@6.0.4': - resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} - - '@types/d3-dispatch@3.0.7': - resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} - - '@types/d3-drag@3.0.7': - resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} - - '@types/d3-dsv@3.0.7': - resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} - - '@types/d3-ease@3.0.2': - resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} - - '@types/d3-fetch@3.0.7': - resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} - - '@types/d3-force@3.0.10': - resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} - - '@types/d3-format@3.0.4': - resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} - - '@types/d3-geo@3.1.0': - resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} - - '@types/d3-hierarchy@3.1.7': - resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} - - '@types/d3-interpolate@3.0.4': - resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} - - '@types/d3-path@3.1.1': - resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} - - '@types/d3-polygon@3.0.2': - resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} - - '@types/d3-quadtree@3.0.6': - resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} - - '@types/d3-random@3.0.3': - resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} - - '@types/d3-scale-chromatic@3.1.0': - resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} - - '@types/d3-scale@4.0.9': - resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} - - '@types/d3-selection@3.0.11': - resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} - - '@types/d3-shape@3.1.7': - resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} - - '@types/d3-time-format@4.0.3': - resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} - - '@types/d3-time@3.0.4': - resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} - - '@types/d3-timer@3.0.2': - resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} - - '@types/d3-transition@3.0.9': - resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} - - '@types/d3-zoom@3.0.8': - resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} - - '@types/d3@7.4.3': - resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} - '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -5628,9 +5837,6 @@ packages: '@types/fs-extra@11.0.4': resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} - '@types/geojson@7946.0.16': - resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} - '@types/graceful-fs@4.1.9': resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} @@ -5700,8 +5906,8 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} - '@types/phoenix@1.6.7': - resolution: {integrity: sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==} + '@types/pg@8.16.0': + resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} '@types/pluralize@0.0.30': resolution: {integrity: sha512-kVww6xZrW/db5BR9OqiT71J9huRdQ+z/r+LbDuT7/EK50mCmj5FoaIARnVv0rvjUS/YpDox0cDU9lpQT011VBA==} @@ -5735,21 +5941,6 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} - '@types/topojson-client@3.1.5': - resolution: {integrity: sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==} - - '@types/topojson-server@3.0.4': - resolution: {integrity: sha512-5+ieK8ePfP+K2VH6Vgs1VCt+fO1U8XZHj0UsF+NktaF0DavAo1q3IvCBXgokk/xmtvoPltSUs6vxuR/zMdOE1g==} - - '@types/topojson-simplify@3.0.3': - resolution: {integrity: sha512-sBO5UZ0O2dB0bNwo0vut2yLHhj3neUGi9uL7/ROdm8Gs6dtt4jcB9OGDKr+M2isZwQM2RuzVmifnMZpxj4IGNw==} - - '@types/topojson-specification@1.0.5': - resolution: {integrity: sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==} - - '@types/topojson@3.2.6': - resolution: {integrity: sha512-ppfdlxjxofWJ66XdLgIlER/85RvpGyfOf8jrWf+3kVIjEatFxEZYD/Ea83jO672Xu1HRzd/ghwlbcZIUNHTskw==} - '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -5768,9 +5959,6 @@ packages: '@types/wrap-ansi@3.0.0': resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} - '@types/ws@8.18.1': - resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -6012,6 +6200,14 @@ packages: '@vue/shared@3.5.26': resolution: {integrity: sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==} + '@xmldom/is-dom-node@1.0.1': + resolution: {integrity: sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==} + engines: {node: '>= 16'} + + '@xmldom/xmldom@0.8.11': + resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} + engines: {node: '>=10.0.0'} + '@zxcvbn-ts/core@3.0.4': resolution: {integrity: sha512-aQeiT0F09FuJaAqNrxynlAwZ2mW/1MdXakKWNmGM1Qp/VaY6CnB/GfnMS2T8gB2231Esp1/maCWd8vTG4OuShw==} @@ -6186,6 +6382,9 @@ packages: as-table@1.0.55: resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + assertion-error@1.1.0: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} @@ -6291,13 +6490,14 @@ packages: resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} engines: {node: '>= 18'} - better-auth@1.4.9: - resolution: {integrity: sha512-usSdjuyTzZwIvM8fjF8YGhPncxV3MAg3dHUO9uPUnf0yklXUSYISiH1+imk6/Z+UBqsscyyPRnbIyjyK97p7YA==} + better-auth@1.4.18: + resolution: {integrity: sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg==} peerDependencies: '@lynx-js/react': '*' '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 '@sveltejs/kit': ^2.0.0 '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 better-sqlite3: ^12.0.0 drizzle-kit: '>=0.31.4' drizzle-orm: '>=0.41.0' @@ -6321,6 +6521,8 @@ packages: optional: true '@tanstack/react-start': optional: true + '@tanstack/solid-start': + optional: true better-sqlite3: optional: true drizzle-kit: @@ -6350,8 +6552,8 @@ packages: vue: optional: true - better-call@1.1.7: - resolution: {integrity: sha512-6gaJe1bBIEgVebQu/7q9saahVzvBsGaByEnE8aDVncZEDiJO7sdNB28ot9I6iXSbR25egGmmZ6aIURXyQHRraQ==} + better-call@1.1.8: + resolution: {integrity: sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==} peerDependencies: zod: ^4.0.0 peerDependenciesMeta: @@ -6381,6 +6583,13 @@ packages: '@internationalized/date': ^3.8.1 svelte: ^5.33.0 + bits-ui@2.16.1: + resolution: {integrity: sha512-/TQ975f98yfjbNaUtIRZZG761f3ghABUkuWImUPf0oP4+jL8TSKQqfM+hfYTRtvS5TTlLAEa7vcyKVBTWUItBw==} + engines: {node: '>=20'} + peerDependencies: + '@internationalized/date': ^3.8.1 + svelte: ^5.33.0 + blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} @@ -6659,8 +6868,9 @@ packages: resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} engines: {node: '>=20'} - commander@2.20.3: - resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} @@ -6864,6 +7074,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + custom-event-polyfill@1.0.7: + resolution: {integrity: sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==} + d3-array@2.12.1: resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} @@ -7038,9 +7251,6 @@ packages: resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} engines: {node: '>=0.11'} - date-fns@4.1.0: - resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} - dateformat@3.0.3: resolution: {integrity: sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==} @@ -7415,6 +7625,10 @@ packages: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} + enhanced-resolve@5.19.0: + resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} + engines: {node: '>=10.13.0'} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -7510,6 +7724,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} @@ -7931,6 +8148,10 @@ packages: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} + generic-pool@3.9.0: + resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} + engines: {node: '>= 4'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -8014,12 +8235,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@13.24.0: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} @@ -8163,8 +8384,8 @@ packages: peerDependencies: hono: ^4.1.1 - hono@4.11.1: - resolution: {integrity: sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg==} + hono@4.11.3: + resolution: {integrity: sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==} engines: {node: '>=16.9.0'} hosted-git-info@2.8.9: @@ -8224,10 +8445,6 @@ packages: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} - iceberg-js@0.8.1: - resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} - engines: {node: '>=20.0.0'} - iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -8498,6 +8715,10 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isomorphic-dompurify@2.25.0: + resolution: {integrity: sha512-bcpJzu9DOjN21qaCVpcoCwUX1ytpvA6EFqCK5RNtPg5+F0Jz9PX50jl6jbEicBNeO87eDDfC7XtPs4zjDClZJg==} + engines: {node: '>=18'} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -8700,6 +8921,15 @@ packages: canvas: optional: true + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -8746,6 +8976,18 @@ packages: jspdf@2.5.2: resolution: {integrity: sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==} + jsrepo@3.5.1: + resolution: {integrity: sha512-dY33HlGfb7HcGN3qWvmvMulQY60HUGkgnoxo3DdIMj4wf3/OzaZLDRep6pZoldmVJoF/RD8eAp+xJEnoSRFeiw==} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + svelte: + optional: true + vue: + optional: true + just-extend@6.2.0: resolution: {integrity: sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==} @@ -8798,70 +9040,140 @@ packages: cpu: [arm64] os: [android] + lightningcss-android-arm64@1.31.1: + resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + lightningcss-darwin-arm64@1.30.2: resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] + lightningcss-darwin-arm64@1.31.1: + resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-darwin-x64@1.30.2: resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] + lightningcss-darwin-x64@1.31.1: + resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + lightningcss-freebsd-x64@1.30.2: resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] + lightningcss-freebsd-x64@1.31.1: + resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.30.2: resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] + lightningcss-linux-arm-gnueabihf@1.31.1: + resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + lightningcss-linux-arm64-gnu@1.30.2: resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + lightningcss-linux-arm64-gnu@1.31.1: + resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + lightningcss-linux-arm64-musl@1.31.1: + resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + lightningcss-linux-x64-gnu@1.31.1: + resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + lightningcss-linux-x64-musl@1.31.1: + resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] + lightningcss-win32-arm64-msvc@1.31.1: + resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + lightningcss-win32-x64-msvc@1.30.2: resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] + lightningcss-win32-x64-msvc@1.31.1: + resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + lightningcss@1.30.2: resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} engines: {node: '>= 12.0.0'} + lightningcss@1.31.1: + resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} + engines: {node: '>= 12.0.0'} + lilconfig@2.1.0: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} engines: {node: '>=10'} @@ -8883,6 +9195,9 @@ packages: resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==} engines: {node: '>=4'} + loadjs@4.3.0: + resolution: {integrity: sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==} + local-pkg@0.5.1: resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} engines: {node: '>=14'} @@ -9427,6 +9742,10 @@ packages: encoding: optional: true + node-forge@1.3.3: + resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==} + engines: {node: '>= 6.13.0'} + node-gyp-build@4.8.4: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true @@ -9437,6 +9756,9 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + node-rsa@1.1.1: + resolution: {integrity: sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==} + nodemailer@6.10.1: resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==} engines: {node: '>=6.0.0'} @@ -9568,6 +9890,10 @@ packages: resolution: {integrity: sha512-dXeeGrfPJJ4rMdw+NrqiCRtbzVX2ogq//R0Xns08zql2HjV3Zi2SBJ65saqfDaJzd2bcHqvGWH+M44EQCHPAcA==} engines: {node: ^20.19.0 || >=22.12.0} + oxc-parser@0.112.0: + resolution: {integrity: sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==} + engines: {node: ^20.19.0 || >=22.12.0} + oxc-transform@0.96.0: resolution: {integrity: sha512-dQPNIF+gHpSkmC0+Vg9IktNyhcn28Y8R3eTLyzn52UNymkasLicl3sFAtz7oEVuFmCpgGjaUTKkwk+jW2cHpDQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -9612,6 +9938,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + papaparse@5.5.3: resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} @@ -9692,6 +10021,40 @@ packages: performance-now@2.1.0: resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.11.0: + resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.11.0: + resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.11.0: + resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.18.0: + resolution: {integrity: sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -9740,6 +10103,9 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + plyr@3.8.4: + resolution: {integrity: sha512-DrzLbK9Wol3zeiuZCleD9aUOl0KAaBHR9H6WVVVYPZ4Ya+LYxUFTgSF1jooHcMQCv96Ws96wCaZzIoP3bES8pQ==} + pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} @@ -9828,6 +10194,22 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + postgres@3.4.7: resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} engines: {node: '>=12'} @@ -9852,58 +10234,6 @@ packages: prettier: ^3.0.0 svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 - prettier-plugin-tailwindcss@0.5.14: - resolution: {integrity: sha512-Puaz+wPUAhFp8Lo9HuciYKM2Y2XExESjeT+9NQoVFXZsPPnc9VYss2SpxdQ6vbatmt8/4+SN0oe0I1cPDABg9Q==} - engines: {node: '>=14.21.3'} - peerDependencies: - '@ianvs/prettier-plugin-sort-imports': '*' - '@prettier/plugin-pug': '*' - '@shopify/prettier-plugin-liquid': '*' - '@trivago/prettier-plugin-sort-imports': '*' - '@zackad/prettier-plugin-twig-melody': '*' - prettier: ^3.0 - prettier-plugin-astro: '*' - prettier-plugin-css-order: '*' - prettier-plugin-import-sort: '*' - prettier-plugin-jsdoc: '*' - prettier-plugin-marko: '*' - prettier-plugin-organize-attributes: '*' - prettier-plugin-organize-imports: '*' - prettier-plugin-sort-imports: '*' - prettier-plugin-style-order: '*' - prettier-plugin-svelte: '*' - peerDependenciesMeta: - '@ianvs/prettier-plugin-sort-imports': - optional: true - '@prettier/plugin-pug': - optional: true - '@shopify/prettier-plugin-liquid': - optional: true - '@trivago/prettier-plugin-sort-imports': - optional: true - '@zackad/prettier-plugin-twig-melody': - optional: true - prettier-plugin-astro: - optional: true - prettier-plugin-css-order: - optional: true - prettier-plugin-import-sort: - optional: true - prettier-plugin-jsdoc: - optional: true - prettier-plugin-marko: - optional: true - prettier-plugin-organize-attributes: - optional: true - prettier-plugin-organize-imports: - optional: true - prettier-plugin-sort-imports: - optional: true - prettier-plugin-style-order: - optional: true - prettier-plugin-svelte: - optional: true - prettier-plugin-tailwindcss@0.6.14: resolution: {integrity: sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==} engines: {node: '>=14.21.3'} @@ -9965,6 +10295,61 @@ packages: prettier-plugin-svelte: optional: true + prettier-plugin-tailwindcss@0.7.2: + resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==} + 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 + prettier@3.6.2: resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} engines: {node: '>=14'} @@ -10100,8 +10485,8 @@ packages: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} - quansync@0.2.11: - resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} queue-lit@1.5.2: resolution: {integrity: sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==} @@ -10117,6 +10502,9 @@ packages: raf@3.4.1: resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + rangetouch@2.0.1: + resolution: {integrity: sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==} + raw-body@2.5.3: resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} engines: {node: '>= 0.8'} @@ -10244,6 +10632,9 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + redis@4.7.1: + resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==} + regenerate-unicode-properties@10.2.2: resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} engines: {node: '>=4'} @@ -10425,8 +10816,8 @@ packages: '@sveltejs/kit': optional: true - runed@0.36.0: - resolution: {integrity: sha512-CK84KPwAausPQEyWF9t6miCuNW5isAKPMswDsz7jhdueiZZ9du/UrgWc/aggLts8QuppT8KucryrHDFBAqk9Ww==} + runed@0.37.1: + resolution: {integrity: sha512-MeFY73xBW8IueWBm012nNFIGy19WUGPLtknavyUPMpnyt350M47PhGSGrGoSLbidwn+Zlt/O0cp8/OZE3LASWA==} peerDependencies: '@sveltejs/kit': ^2.21.0 svelte: ^5.7.0 @@ -10463,6 +10854,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + samlify@2.10.2: + resolution: {integrity: sha512-y5s1cHwclqwP8h7K2Wj9SfP1q+1S9+jrs5OAegYTLAiuFi7nDvuKqbiXLmUTvYPMpzHcX94wTY2+D604jgTKvA==} + sander@0.5.1: resolution: {integrity: sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==} @@ -10562,6 +10956,9 @@ packages: shiki@3.20.0: resolution: {integrity: sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg==} + shiki@3.22.0: + resolution: {integrity: sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g==} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -10588,6 +10985,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-icons@16.9.0: + resolution: {integrity: sha512-aKst2C7cLkFyaiQ/Crlwxt9xYOpGPk05XuJZ0ZTJNNCzHCKYrGWz2ebJSi5dG8CmTCxUF/BGs6A8uyJn/EQxqw==} + engines: {node: '>=0.12.18'} + simple-swizzle@0.2.4: resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} @@ -10659,6 +11060,10 @@ packages: split2@3.2.2: resolution: {integrity: sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + split@1.0.1: resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==} @@ -11099,6 +11504,9 @@ packages: tailwindcss@4.1.18: resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + tailwindcss@4.2.1: + resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} + tapable@2.3.0: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} @@ -11106,6 +11514,7 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me test-exclude@6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} @@ -11212,10 +11621,6 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} - topojson-client@3.1.0: - resolution: {integrity: sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==} - hasBin: true - totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -11449,6 +11854,12 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + unconfig@7.5.0: + resolution: {integrity: sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -11615,6 +12026,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-polyfill@1.1.14: + resolution: {integrity: sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -11653,6 +12067,10 @@ packages: utrie@1.0.2: resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -11917,6 +12335,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} @@ -12018,10 +12437,20 @@ packages: utf-8-validate: optional: true + xml-crypto@6.1.2: + resolution: {integrity: sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==} + engines: {node: '>=16'} + + xml-escape@1.1.0: + resolution: {integrity: sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg==} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} + xml@1.0.1: + resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} + xmlbuilder2@4.0.3: resolution: {integrity: sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==} engines: {node: '>=20.0'} @@ -12029,6 +12458,14 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xpath@0.0.32: + resolution: {integrity: sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==} + engines: {node: '>=0.6.0'} + + xpath@0.0.33: + resolution: {integrity: sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==} + engines: {node: '>=0.6.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -12122,6 +12559,9 @@ packages: zod@4.2.1: resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -12144,6 +12584,12 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 + '@authenio/xml-encryption@2.0.2': + dependencies: + '@xmldom/xmldom': 0.8.11 + escape-html: 1.0.3 + xpath: 0.0.32 + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -13483,20 +13929,50 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} - '@better-auth/core@1.4.9(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.2.1))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)': + '@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)': dependencies: '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@standard-schema/spec': 1.1.0 - better-call: 1.1.7(zod@4.2.1) + better-call: 1.1.8(zod@4.3.6) jose: 6.1.3 kysely: 0.28.9 nanostores: 1.1.0 - zod: 4.2.1 + zod: 4.3.6 - '@better-auth/telemetry@1.4.9(@better-auth/core@1.4.9(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.2.1))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0))': + '@better-auth/sso@1.4.18(@better-auth/utils@0.3.0)(better-auth@1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)))': dependencies: - '@better-auth/core': 1.4.9(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.2.1))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + better-auth: 1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)) + fast-xml-parser: 5.2.5 + jose: 6.1.3 + samlify: 2.10.2 + zod: 4.3.6 + + '@better-auth/sso@1.4.18(@better-auth/utils@0.3.0)(better-auth@1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@24.10.4)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)))': + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + better-auth: 1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@24.10.4)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)) + fast-xml-parser: 5.2.5 + jose: 6.1.3 + samlify: 2.10.2 + zod: 4.3.6 + + '@better-auth/sso@1.4.18(@better-auth/utils@0.3.0)(better-auth@1.4.9(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)))': + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + better-auth: 1.4.9(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)) + fast-xml-parser: 5.2.5 + jose: 6.1.3 + samlify: 2.10.2 + zod: 4.3.6 + + '@better-auth/telemetry@1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0))': + dependencies: + '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 @@ -13504,37 +13980,6 @@ snapshots: '@better-fetch/fetch@1.1.21': {} - '@carbon/charts-svelte@1.22.18(svelte@5.46.0)': - dependencies: - '@carbon/charts': 1.22.18 - '@ibm/telemetry-js': 1.10.2 - svelte: 5.46.0 - - '@carbon/charts@1.22.18': - dependencies: - '@carbon/colors': 11.43.0 - '@carbon/utils-position': 1.3.0 - '@ibm/telemetry-js': 1.10.2 - '@types/d3': 7.4.3 - '@types/topojson': 3.2.6 - d3: 7.9.0 - d3-cloud: 1.2.8 - d3-sankey: 0.12.3 - date-fns: 4.1.0 - dompurify: 3.3.1 - html-to-image: 1.11.13 - lodash-es: 4.17.22 - topojson-client: 3.1.0 - tslib: 2.8.1 - - '@carbon/colors@11.43.0': - dependencies: - '@ibm/telemetry-js': 1.10.2 - - '@carbon/utils-position@1.3.0': - dependencies: - '@ibm/telemetry-js': 1.10.2 - '@cloudflare/kv-asset-handler@0.4.0': dependencies: mime: 3.0.0 @@ -14172,18 +14617,18 @@ snapshots: dependencies: '@hapi/hoek': 9.3.0 - '@hono/node-server@1.19.7(hono@4.11.1)': + '@hono/node-server@1.19.7(hono@4.11.3)': dependencies: - hono: 4.11.1 + hono: 4.11.3 - '@hono/standard-validator@0.1.5(@standard-schema/spec@1.1.0)(hono@4.11.1)': + '@hono/standard-validator@0.1.5(@standard-schema/spec@1.1.0)(hono@4.11.3)': dependencies: '@standard-schema/spec': 1.1.0 - hono: 4.11.1 + hono: 4.11.3 - '@hono/zod-validator@0.7.6(hono@4.11.1)(zod@4.2.1)': + '@hono/zod-validator@0.7.6(hono@4.11.3)(zod@4.2.1)': dependencies: - hono: 4.11.1 + hono: 4.11.3 zod: 4.2.1 '@humanfs/core@0.19.1': {} @@ -14209,8 +14654,6 @@ snapshots: '@hutson/parse-repository-url@3.0.2': {} - '@ibm/telemetry-js@1.10.2': {} - '@img/sharp-darwin-arm64@0.33.5': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.0.4 @@ -14386,7 +14829,8 @@ snapshots: dependencies: '@swc/helpers': 0.5.17 - '@ioredis/commands@1.4.0': {} + '@ioredis/commands@1.4.0': + optional: true '@istanbuljs/load-nyc-config@1.1.0': dependencies: @@ -14609,11 +15053,11 @@ snapshots: d3-time-format: 4.1.0 lodash-es: 4.17.22 - '@lucide/svelte@0.544.0(svelte@5.46.0)': + '@lucide/svelte@0.545.0(svelte@5.46.0)': dependencies: svelte: 5.46.0 - '@lucide/svelte@0.545.0(svelte@5.46.0)': + '@lucide/svelte@0.562.0(svelte@5.46.0)': dependencies: svelte: 5.46.0 @@ -14685,6 +15129,13 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@napi-rs/wasm-runtime@1.1.1': + dependencies: + '@emnapi/core': 1.7.1 + '@emnapi/runtime': 1.7.1 + '@tybys/wasm-util': 0.10.1 + optional: true + '@noble/ciphers@2.1.1': {} '@noble/hashes@2.0.1': {} @@ -14767,6 +15218,70 @@ snapshots: '@oxc-minify/binding-win32-x64-msvc@0.96.0': optional: true + '@oxc-parser/binding-android-arm-eabi@0.112.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.112.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.112.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.112.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.112.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.112.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.112.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.112.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.112.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.112.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.112.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.112.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.112.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.112.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.112.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.112.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.112.0': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.112.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.112.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.112.0': + optional: true + + '@oxc-project/types@0.112.0': {} + '@oxc-transform/binding-android-arm64@0.96.0': optional: true @@ -14873,7 +15388,6 @@ snapshots: '@parcel/watcher-win32-arm64': 2.5.1 '@parcel/watcher-win32-ia32': 2.5.1 '@parcel/watcher-win32-x64': 2.5.1 - optional: true '@playwright/test@1.57.0': dependencies: @@ -14884,10 +15398,10 @@ snapshots: standardwebhooks: 1.0.0 zod: 4.2.1 - '@polar-sh/sveltekit@0.2.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(zod@4.2.1)': + '@polar-sh/sveltekit@0.2.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(zod@4.2.1)': dependencies: '@polar-sh/sdk': 0.20.2(zod@4.2.1) - '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)) + '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)) transitivePeerDependencies: - zod @@ -14899,6 +15413,10 @@ snapshots: dependencies: cross-spawn: 7.0.6 + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -15255,6 +15773,32 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@redis/bloom@1.2.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/client@1.6.1': + dependencies: + cluster-key-slot: 1.1.2 + generic-pool: 3.9.0 + yallist: 4.0.0 + + '@redis/graph@1.1.1(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/json@1.0.7(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/search@1.2.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/time-series@1.1.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + '@remirror/core-constants@3.0.0': {} '@rolldown/pluginutils@1.0.0-beta.40': {} @@ -15380,17 +15924,17 @@ snapshots: '@scalar/helpers@0.2.4': {} - '@scalar/hono-api-reference@0.9.30(hono@4.11.1)': + '@scalar/hono-api-reference@0.9.30(hono@4.11.3)': dependencies: '@scalar/core': 0.3.28 - hono: 4.11.1 + hono: 4.11.3 '@scalar/types@0.5.4': dependencies: '@scalar/helpers': 0.2.4 nanoid: 5.1.5 type-fest: 5.0.0 - zod: 4.2.1 + zod: 4.3.6 '@sentry/profiling-node@7.120.4': dependencies: @@ -15413,6 +15957,13 @@ snapshots: '@types/hast': 3.0.4 hast-util-to-html: 9.0.5 + '@shikijs/core@3.22.0': + dependencies: + '@shikijs/types': 3.22.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + '@shikijs/engine-javascript@1.29.2': dependencies: '@shikijs/types': 1.29.2 @@ -15425,6 +15976,12 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.4 + '@shikijs/engine-javascript@3.22.0': + dependencies: + '@shikijs/types': 3.22.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.4 + '@shikijs/engine-oniguruma@1.29.2': dependencies: '@shikijs/types': 1.29.2 @@ -15435,6 +15992,11 @@ snapshots: '@shikijs/types': 3.20.0 '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/engine-oniguruma@3.22.0': + dependencies: + '@shikijs/types': 3.22.0 + '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/langs@1.29.2': dependencies: '@shikijs/types': 1.29.2 @@ -15443,6 +16005,10 @@ snapshots: dependencies: '@shikijs/types': 3.20.0 + '@shikijs/langs@3.22.0': + dependencies: + '@shikijs/types': 3.22.0 + '@shikijs/rehype@3.20.0': dependencies: '@shikijs/types': 3.20.0 @@ -15460,6 +16026,10 @@ snapshots: dependencies: '@shikijs/types': 3.20.0 + '@shikijs/themes@3.22.0': + dependencies: + '@shikijs/types': 3.22.0 + '@shikijs/transformers@3.20.0': dependencies: '@shikijs/core': 3.20.0 @@ -15475,6 +16045,11 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + '@shikijs/types@3.22.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + '@shikijs/vscode-textmate@10.0.2': {} '@sideway/address@4.1.5': @@ -15835,17 +16410,17 @@ snapshots: '@stablelib/base64@1.0.1': {} - '@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.2.1)': + '@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod@4.2.1)': dependencies: '@standard-schema/spec': 1.1.0 '@types/json-schema': 7.0.15 - quansync: 0.2.11 + quansync: 1.0.0 optionalDependencies: zod: 4.2.1 - '@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.2.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod-openapi@5.4.5(zod@4.2.1))(zod@4.2.1)': + '@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod@4.2.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod-openapi@5.4.5(zod@4.2.1))(zod@4.2.1)': dependencies: - '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.2.1) + '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod@4.2.1) '@standard-schema/spec': 1.1.0 openapi-types: 12.1.3 optionalDependencies: @@ -15949,11 +16524,11 @@ snapshots: storybook: 8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6) ts-dedent: 2.2.0 - '@storybook/addon-svelte-csf@5.0.10(@storybook/svelte@8.6.15(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0))(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': + '@storybook/addon-svelte-csf@5.0.10(@storybook/svelte@8.6.15(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0))(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@storybook/csf': 0.1.13 '@storybook/svelte': 8.6.15(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0) - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) dedent: 1.7.1 es-toolkit: 1.43.0 esrap: 1.4.9 @@ -15961,7 +16536,7 @@ snapshots: storybook: 8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6) svelte: 5.46.0 svelte-ast-print: 0.4.2(svelte@5.46.0) - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) zimmerframe: 1.1.4 transitivePeerDependencies: - babel-plugin-macros @@ -15984,26 +16559,26 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@storybook/builder-vite@10.1.10(esbuild@0.25.12)(rollup@4.54.0)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': + '@storybook/builder-vite@10.1.10(esbuild@0.25.12)(rollup@4.54.0)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@storybook/csf-plugin': 10.1.10(esbuild@0.25.12)(rollup@4.54.0)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) - '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@storybook/csf-plugin': 10.1.10(esbuild@0.25.12)(rollup@4.54.0)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) storybook: 8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6) ts-dedent: 2.2.0 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - esbuild - msw - rollup - webpack - '@storybook/builder-vite@8.6.15(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': + '@storybook/builder-vite@8.6.15(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@storybook/csf-plugin': 8.6.15(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6)) browser-assert: 1.2.1 storybook: 8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6) ts-dedent: 2.2.0 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) '@storybook/components@8.6.15(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))': dependencies: @@ -16030,14 +16605,14 @@ snapshots: - supports-color - utf-8-validate - '@storybook/csf-plugin@10.1.10(esbuild@0.25.12)(rollup@4.54.0)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': + '@storybook/csf-plugin@10.1.10(esbuild@0.25.12)(rollup@4.54.0)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: storybook: 8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6) unplugin: 2.3.11 optionalDependencies: esbuild: 0.25.12 rollup: 4.54.0 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) '@storybook/csf-plugin@8.6.14(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))': dependencies: @@ -16084,28 +16659,28 @@ snapshots: react-dom: 19.2.3(react@19.2.3) storybook: 8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6) - '@storybook/svelte-vite@10.1.10(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(esbuild@0.25.12)(rollup@4.54.0)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': + '@storybook/svelte-vite@10.1.10(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(esbuild@0.25.12)(rollup@4.54.0)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@storybook/builder-vite': 10.1.10(esbuild@0.25.12)(rollup@4.54.0)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@storybook/builder-vite': 10.1.10(esbuild@0.25.12)(rollup@4.54.0)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) '@storybook/svelte': 10.1.10(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0) - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) magic-string: 0.30.21 storybook: 8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6) svelte: 5.46.0 svelte2tsx: 0.7.46(svelte@5.46.0)(typescript@5.9.3) typescript: 5.9.3 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - esbuild - msw - rollup - webpack - '@storybook/svelte-vite@8.6.15(@babel/core@7.28.5)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.4)(typescript@5.9.3)))(postcss@8.5.6)(sass@1.97.1)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': + '@storybook/svelte-vite@8.6.15(@babel/core@7.28.5)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.4)(typescript@5.9.3)))(postcss@8.5.6)(sass@1.97.1)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@storybook/builder-vite': 8.6.15(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@storybook/builder-vite': 8.6.15(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) '@storybook/svelte': 8.6.15(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0) - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) magic-string: 0.30.21 storybook: 8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6) svelte: 5.46.0 @@ -16114,7 +16689,7 @@ snapshots: sveltedoc-parser: 4.2.1 ts-dedent: 2.2.0 typescript: 5.9.3 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@babel/core' - coffeescript @@ -16150,15 +16725,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@storybook/sveltekit@8.6.15(@babel/core@7.28.5)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.4)(typescript@5.9.3)))(postcss@8.5.6)(sass@1.97.1)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': + '@storybook/sveltekit@8.6.15(@babel/core@7.28.5)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.4)(typescript@5.9.3)))(postcss@8.5.6)(sass@1.97.1)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@storybook/addon-actions': 8.6.15(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6)) - '@storybook/builder-vite': 8.6.15(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@storybook/builder-vite': 8.6.15(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) '@storybook/svelte': 8.6.15(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0) - '@storybook/svelte-vite': 8.6.15(@babel/core@7.28.5)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.4)(typescript@5.9.3)))(postcss@8.5.6)(sass@1.97.1)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@storybook/svelte-vite': 8.6.15(@babel/core@7.28.5)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.4)(typescript@5.9.3)))(postcss@8.5.6)(sass@1.97.1)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) storybook: 8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6) svelte: 5.46.0 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@babel/core' - '@sveltejs/vite-plugin-svelte' @@ -16187,104 +16762,66 @@ snapshots: dependencies: storybook: 8.6.15(bufferutil@4.1.0)(prettier@3.7.4)(utf-8-validate@6.0.6) - '@supabase/auth-js@2.89.0': - dependencies: - tslib: 2.8.1 - - '@supabase/functions-js@2.89.0': - dependencies: - tslib: 2.8.1 - - '@supabase/postgrest-js@2.89.0': - dependencies: - tslib: 2.8.1 - - '@supabase/realtime-js@2.89.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)': - dependencies: - '@types/phoenix': 1.6.7 - '@types/ws': 8.18.1 - tslib: 2.8.1 - ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - '@supabase/storage-js@2.89.0': - dependencies: - iceberg-js: 0.8.1 - tslib: 2.8.1 - - '@supabase/supabase-js@2.89.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)': - dependencies: - '@supabase/auth-js': 2.89.0 - '@supabase/functions-js': 2.89.0 - '@supabase/postgrest-js': 2.89.0 - '@supabase/realtime-js': 2.89.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - '@supabase/storage-js': 2.89.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - '@sveltejs/acorn-typescript@1.0.8(acorn@8.15.0)': dependencies: acorn: 8.15.0 - '@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))': + '@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))': dependencies: - '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)) + '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)) import-meta-resolve: 4.2.0 - '@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1)))': + '@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1)))': dependencies: - '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1)) + '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1)) import-meta-resolve: 4.2.0 - '@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1)))': + '@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))': dependencies: - '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1)) + '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)) import-meta-resolve: 4.2.0 - '@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))': + '@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) import-meta-resolve: 4.2.0 - '@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))': + '@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) import-meta-resolve: 4.2.0 - '@sveltejs/adapter-node@2.1.2(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))': + '@sveltejs/adapter-node@2.1.2(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))': dependencies: '@rollup/plugin-commonjs': 25.0.8(rollup@4.54.0) '@rollup/plugin-json': 6.1.0(rollup@4.54.0) '@rollup/plugin-node-resolve': 15.3.1(rollup@4.54.0) - '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)) + '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)) rollup: 4.54.0 - '@sveltejs/adapter-vercel@4.0.5(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(encoding@0.1.13)': + '@sveltejs/adapter-vercel@4.0.5(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(encoding@0.1.13)': dependencies: - '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)) + '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)) '@vercel/nft': 0.26.5(encoding@0.1.13) esbuild: 0.19.12 transitivePeerDependencies: - encoding - supports-color - '@sveltejs/adapter-vercel@4.0.5(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1)))(encoding@0.1.13)': + '@sveltejs/adapter-vercel@4.0.5(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(encoding@0.1.13)': dependencies: - '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1)) + '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)) '@vercel/nft': 0.26.5(encoding@0.1.13) esbuild: 0.19.12 transitivePeerDependencies: - encoding - supports-color - '@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1))': + '@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)) + '@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)) '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 @@ -16297,13 +16834,13 @@ snapshots: set-cookie-parser: 2.7.2 sirv: 3.0.2 svelte: 5.46.0 - vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1) + vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1) - '@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1))': + '@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1)) + '@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1)) '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 @@ -16316,13 +16853,13 @@ snapshots: set-cookie-parser: 2.7.2 sirv: 3.0.2 svelte: 5.46.0 - vite: 5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1) + vite: 5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1) - '@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1))': + '@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1)) + '@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)) '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 @@ -16335,13 +16872,13 @@ snapshots: set-cookie-parser: 2.7.2 sirv: 3.0.2 svelte: 5.46.0 - vite: 5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1) + vite: 5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1) - '@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 @@ -16354,13 +16891,13 @@ snapshots: set-cookie-parser: 2.7.2 sirv: 3.0.2 svelte: 5.46.0 - vite: 6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) - '@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 @@ -16373,114 +16910,114 @@ snapshots: set-cookie-parser: 2.7.2 sirv: 3.0.2 svelte: 5.46.0 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) - '@sveltejs/vite-plugin-svelte-inspector@3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1))': + '@sveltejs/vite-plugin-svelte-inspector@3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1))': dependencies: - '@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)) + '@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)) debug: 4.4.3 svelte: 5.46.0 - vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1) + vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte-inspector@3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1))': + '@sveltejs/vite-plugin-svelte-inspector@3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1))': dependencies: - '@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1)) + '@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1)) debug: 4.4.3 svelte: 5.46.0 - vite: 5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1) + vite: 5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte-inspector@3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1))': + '@sveltejs/vite-plugin-svelte-inspector@3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1))': dependencies: - '@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1)) + '@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)) debug: 4.4.3 svelte: 5.46.0 - vite: 5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1) + vite: 5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) debug: 4.4.3 svelte: 5.46.0 - vite: 6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) debug: 4.4.3 svelte: 5.46.0 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1))': + '@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)) + '@sveltejs/vite-plugin-svelte-inspector': 3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)) debug: 4.4.3 deepmerge: 4.3.1 kleur: 4.1.5 magic-string: 0.30.21 svelte: 5.46.0 - vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1) - vitefu: 1.1.1(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)) + vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1) + vitefu: 1.1.1(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1))': + '@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1)) + '@sveltejs/vite-plugin-svelte-inspector': 3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1)) debug: 4.4.3 deepmerge: 4.3.1 kleur: 4.1.5 magic-string: 0.30.21 svelte: 5.46.0 - vite: 5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1) - vitefu: 1.1.1(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1)) + vite: 5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1) + vitefu: 1.1.1(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1)) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1))': + '@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1)) + '@sveltejs/vite-plugin-svelte-inspector': 3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)) debug: 4.4.3 deepmerge: 4.3.1 kleur: 4.1.5 magic-string: 0.30.21 svelte: 5.46.0 - vite: 5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1) - vitefu: 1.1.1(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1)) + vite: 5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1) + vitefu: 1.1.1(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) debug: 4.4.3 deepmerge: 4.3.1 kleur: 4.1.5 magic-string: 0.30.21 svelte: 5.46.0 - vite: 6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) - vitefu: 1.1.1(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + vite: 6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vitefu: 1.1.1(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) debug: 4.4.3 deepmerge: 4.3.1 magic-string: 0.30.21 svelte: 5.46.0 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) - vitefu: 1.1.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vitefu: 1.1.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) transitivePeerDependencies: - supports-color @@ -16498,6 +17035,16 @@ snapshots: dependencies: tslib: 2.8.1 + '@tailwindcss/cli@4.2.1': + dependencies: + '@parcel/watcher': 2.5.1 + '@tailwindcss/node': 4.2.1 + '@tailwindcss/oxide': 4.2.1 + enhanced-resolve: 5.19.0 + mri: 1.2.0 + picocolors: 1.1.1 + tailwindcss: 4.2.1 + '@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.19(ts-node@10.9.2(@types/node@24.10.4)(typescript@5.9.3)))': dependencies: tailwindcss: 3.4.19(ts-node@10.9.2(@types/node@24.10.4)(typescript@5.9.3)) @@ -16522,42 +17069,88 @@ snapshots: source-map-js: 1.2.1 tailwindcss: 4.1.18 + '@tailwindcss/node@4.2.1': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.19.0 + jiti: 2.6.1 + lightningcss: 1.31.1 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.1 + '@tailwindcss/oxide-android-arm64@4.1.18': optional: true + '@tailwindcss/oxide-android-arm64@4.2.1': + optional: true + '@tailwindcss/oxide-darwin-arm64@4.1.18': optional: true + '@tailwindcss/oxide-darwin-arm64@4.2.1': + optional: true + '@tailwindcss/oxide-darwin-x64@4.1.18': optional: true + '@tailwindcss/oxide-darwin-x64@4.2.1': + optional: true + '@tailwindcss/oxide-freebsd-x64@4.1.18': optional: true + '@tailwindcss/oxide-freebsd-x64@4.2.1': + optional: true + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': optional: true + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + optional: true + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': optional: true + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + optional: true + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': optional: true + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + optional: true + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': optional: true + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + optional: true + '@tailwindcss/oxide-linux-x64-musl@4.1.18': optional: true + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + optional: true + '@tailwindcss/oxide-wasm32-wasi@4.1.18': optional: true + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + optional: true + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': optional: true + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + optional: true + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': optional: true + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + optional: true + '@tailwindcss/oxide@4.1.18': optionalDependencies: '@tailwindcss/oxide-android-arm64': 4.1.18 @@ -16573,6 +17166,21 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 + '@tailwindcss/oxide@4.2.1': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-x64': 4.2.1 + '@tailwindcss/oxide-freebsd-x64': 4.2.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-x64-musl': 4.2.1 + '@tailwindcss/oxide-wasm32-wasi': 4.2.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 + '@tailwindcss/postcss@4.1.18': dependencies: '@alloc/quick-lru': 5.2.0 @@ -16591,14 +17199,14 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.1.18 - '@tailwindcss/vite@4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': + '@tailwindcss/vite@4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@tailwindcss/node': 4.1.18 '@tailwindcss/oxide': 4.1.18 tailwindcss: 4.1.18 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) - '@tanstack/directive-functions-plugin@1.142.1(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1))': + '@tanstack/directive-functions-plugin@1.142.1(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1))': dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.28.5 @@ -16608,12 +17216,12 @@ snapshots: babel-dead-code-elimination: 1.0.11 pathe: 2.0.3 tiny-invariant: 1.3.3 - vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1) + vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1) transitivePeerDependencies: - supports-color optional: true - '@tanstack/directive-functions-plugin@1.142.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': + '@tanstack/directive-functions-plugin@1.142.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.28.5 @@ -16623,7 +17231,7 @@ snapshots: babel-dead-code-elimination: 1.0.11 pathe: 2.0.3 tiny-invariant: 1.3.3 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -16674,19 +17282,19 @@ snapshots: transitivePeerDependencies: - crossws - '@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1))': + '@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1))': dependencies: '@tanstack/react-router': 1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start-client': 1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start-server': 1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/router-utils': 1.141.0 '@tanstack/start-client-core': 1.143.4 - '@tanstack/start-plugin-core': 1.143.4(@tanstack/react-router@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(crossws@0.4.1(srvx@0.9.8))(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)) + '@tanstack/start-plugin-core': 1.143.4(@tanstack/react-router@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(crossws@0.4.1(srvx@0.9.8))(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)) '@tanstack/start-server-core': 1.143.4(crossws@0.4.1(srvx@0.9.8)) pathe: 2.0.3 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1) + vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1) transitivePeerDependencies: - '@rsbuild/core' - crossws @@ -16695,19 +17303,19 @@ snapshots: - webpack optional: true - '@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': + '@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@tanstack/react-router': 1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start-client': 1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start-server': 1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/router-utils': 1.141.0 '@tanstack/start-client-core': 1.143.4 - '@tanstack/start-plugin-core': 1.143.4(@tanstack/react-router@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(crossws@0.4.1(srvx@0.9.8))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@tanstack/start-plugin-core': 1.143.4(@tanstack/react-router@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(crossws@0.4.1(srvx@0.9.8))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) '@tanstack/start-server-core': 1.143.4(crossws@0.4.1(srvx@0.9.8)) pathe: 2.0.3 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@rsbuild/core' - crossws @@ -16755,7 +17363,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.143.4(@tanstack/react-router@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1))': + '@tanstack/router-plugin@1.143.4(@tanstack/react-router@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) @@ -16773,12 +17381,12 @@ snapshots: zod: 3.25.76 optionalDependencies: '@tanstack/react-router': 1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1) + vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1) transitivePeerDependencies: - supports-color optional: true - '@tanstack/router-plugin@1.143.4(@tanstack/react-router@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': + '@tanstack/router-plugin@1.143.4(@tanstack/react-router@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) @@ -16796,7 +17404,7 @@ snapshots: zod: 3.25.76 optionalDependencies: '@tanstack/react-router': 1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -16813,7 +17421,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/server-functions-plugin@1.142.1(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1))': + '@tanstack/server-functions-plugin@1.142.1(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1))': dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.28.5 @@ -16822,7 +17430,7 @@ snapshots: '@babel/template': 7.27.2 '@babel/traverse': 7.28.5 '@babel/types': 7.28.5 - '@tanstack/directive-functions-plugin': 1.142.1(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)) + '@tanstack/directive-functions-plugin': 1.142.1(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)) babel-dead-code-elimination: 1.0.11 tiny-invariant: 1.3.3 transitivePeerDependencies: @@ -16830,7 +17438,7 @@ snapshots: - vite optional: true - '@tanstack/server-functions-plugin@1.142.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': + '@tanstack/server-functions-plugin@1.142.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.28.5 @@ -16839,7 +17447,7 @@ snapshots: '@babel/template': 7.27.2 '@babel/traverse': 7.28.5 '@babel/types': 7.28.5 - '@tanstack/directive-functions-plugin': 1.142.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@tanstack/directive-functions-plugin': 1.142.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) babel-dead-code-elimination: 1.0.11 tiny-invariant: 1.3.3 transitivePeerDependencies: @@ -16857,7 +17465,7 @@ snapshots: '@tanstack/start-fn-stubs@1.142.9': {} - '@tanstack/start-plugin-core@1.143.4(@tanstack/react-router@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(crossws@0.4.1(srvx@0.9.8))(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1))': + '@tanstack/start-plugin-core@1.143.4(@tanstack/react-router@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(crossws@0.4.1(srvx@0.9.8))(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1))': dependencies: '@babel/code-frame': 7.26.2 '@babel/core': 7.28.5 @@ -16865,9 +17473,9 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.40 '@tanstack/router-core': 1.143.4 '@tanstack/router-generator': 1.143.4 - '@tanstack/router-plugin': 1.143.4(@tanstack/react-router@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)) + '@tanstack/router-plugin': 1.143.4(@tanstack/react-router@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)) '@tanstack/router-utils': 1.141.0 - '@tanstack/server-functions-plugin': 1.142.1(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)) + '@tanstack/server-functions-plugin': 1.142.1(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)) '@tanstack/start-client-core': 1.143.4 '@tanstack/start-server-core': 1.143.4(crossws@0.4.1(srvx@0.9.8)) babel-dead-code-elimination: 1.0.11 @@ -16877,8 +17485,8 @@ snapshots: srvx: 0.9.8 tinyglobby: 0.2.15 ufo: 1.6.1 - vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1) - vitefu: 1.1.1(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)) + vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1) + vitefu: 1.1.1(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)) xmlbuilder2: 4.0.3 zod: 3.25.76 transitivePeerDependencies: @@ -16890,7 +17498,7 @@ snapshots: - webpack optional: true - '@tanstack/start-plugin-core@1.143.4(@tanstack/react-router@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(crossws@0.4.1(srvx@0.9.8))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': + '@tanstack/start-plugin-core@1.143.4(@tanstack/react-router@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(crossws@0.4.1(srvx@0.9.8))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/code-frame': 7.26.2 '@babel/core': 7.28.5 @@ -16898,9 +17506,9 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.40 '@tanstack/router-core': 1.143.4 '@tanstack/router-generator': 1.143.4 - '@tanstack/router-plugin': 1.143.4(@tanstack/react-router@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@tanstack/router-plugin': 1.143.4(@tanstack/react-router@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) '@tanstack/router-utils': 1.141.0 - '@tanstack/server-functions-plugin': 1.142.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@tanstack/server-functions-plugin': 1.142.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) '@tanstack/start-client-core': 1.143.4 '@tanstack/start-server-core': 1.143.4(crossws@0.4.1(srvx@0.9.8)) babel-dead-code-elimination: 1.0.11 @@ -16910,8 +17518,8 @@ snapshots: srvx: 0.9.8 tinyglobby: 0.2.15 ufo: 1.6.1 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) - vitefu: 1.1.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vitefu: 1.1.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) xmlbuilder2: 4.0.3 zod: 3.25.76 transitivePeerDependencies: @@ -17003,14 +17611,14 @@ snapshots: '@testing-library/dom': 9.3.4 svelte: 5.46.0 - '@testing-library/svelte@5.3.0(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1))(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.30.2)(sass@1.97.1))': + '@testing-library/svelte@5.3.0(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1))(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/svelte-core': 1.0.0(svelte@5.46.0) svelte: 5.46.0 optionalDependencies: - vite: 5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1) - vitest: 2.1.9(@types/node@22.19.3)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.30.2)(sass@1.97.1) + vite: 5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1) + vitest: 2.1.9(@types/node@22.19.3)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1) '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)': dependencies: @@ -17280,123 +17888,6 @@ snapshots: '@types/cookie@0.6.0': {} - '@types/d3-array@3.2.2': {} - - '@types/d3-axis@3.0.6': - dependencies: - '@types/d3-selection': 3.0.11 - - '@types/d3-brush@3.0.6': - dependencies: - '@types/d3-selection': 3.0.11 - - '@types/d3-chord@3.0.6': {} - - '@types/d3-color@3.1.3': {} - - '@types/d3-contour@3.0.6': - dependencies: - '@types/d3-array': 3.2.2 - '@types/geojson': 7946.0.16 - - '@types/d3-delaunay@6.0.4': {} - - '@types/d3-dispatch@3.0.7': {} - - '@types/d3-drag@3.0.7': - dependencies: - '@types/d3-selection': 3.0.11 - - '@types/d3-dsv@3.0.7': {} - - '@types/d3-ease@3.0.2': {} - - '@types/d3-fetch@3.0.7': - dependencies: - '@types/d3-dsv': 3.0.7 - - '@types/d3-force@3.0.10': {} - - '@types/d3-format@3.0.4': {} - - '@types/d3-geo@3.1.0': - dependencies: - '@types/geojson': 7946.0.16 - - '@types/d3-hierarchy@3.1.7': {} - - '@types/d3-interpolate@3.0.4': - dependencies: - '@types/d3-color': 3.1.3 - - '@types/d3-path@3.1.1': {} - - '@types/d3-polygon@3.0.2': {} - - '@types/d3-quadtree@3.0.6': {} - - '@types/d3-random@3.0.3': {} - - '@types/d3-scale-chromatic@3.1.0': {} - - '@types/d3-scale@4.0.9': - dependencies: - '@types/d3-time': 3.0.4 - - '@types/d3-selection@3.0.11': {} - - '@types/d3-shape@3.1.7': - dependencies: - '@types/d3-path': 3.1.1 - - '@types/d3-time-format@4.0.3': {} - - '@types/d3-time@3.0.4': {} - - '@types/d3-timer@3.0.2': {} - - '@types/d3-transition@3.0.9': - dependencies: - '@types/d3-selection': 3.0.11 - - '@types/d3-zoom@3.0.8': - dependencies: - '@types/d3-interpolate': 3.0.4 - '@types/d3-selection': 3.0.11 - - '@types/d3@7.4.3': - dependencies: - '@types/d3-array': 3.2.2 - '@types/d3-axis': 3.0.6 - '@types/d3-brush': 3.0.6 - '@types/d3-chord': 3.0.6 - '@types/d3-color': 3.1.3 - '@types/d3-contour': 3.0.6 - '@types/d3-delaunay': 6.0.4 - '@types/d3-dispatch': 3.0.7 - '@types/d3-drag': 3.0.7 - '@types/d3-dsv': 3.0.7 - '@types/d3-ease': 3.0.2 - '@types/d3-fetch': 3.0.7 - '@types/d3-force': 3.0.10 - '@types/d3-format': 3.0.4 - '@types/d3-geo': 3.1.0 - '@types/d3-hierarchy': 3.1.7 - '@types/d3-interpolate': 3.0.4 - '@types/d3-path': 3.1.1 - '@types/d3-polygon': 3.0.2 - '@types/d3-quadtree': 3.0.6 - '@types/d3-random': 3.0.3 - '@types/d3-scale': 4.0.9 - '@types/d3-scale-chromatic': 3.1.0 - '@types/d3-selection': 3.0.11 - '@types/d3-shape': 3.1.7 - '@types/d3-time': 3.0.4 - '@types/d3-time-format': 4.0.3 - '@types/d3-timer': 3.0.2 - '@types/d3-transition': 3.0.9 - '@types/d3-zoom': 3.0.8 - '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -17416,8 +17907,6 @@ snapshots: '@types/jsonfile': 6.1.4 '@types/node': 24.10.4 - '@types/geojson@7946.0.16': {} - '@types/graceful-fs@4.1.9': dependencies: '@types/node': 24.10.4 @@ -17495,7 +17984,11 @@ snapshots: '@types/normalize-package-data@2.4.4': {} - '@types/phoenix@1.6.7': {} + '@types/pg@8.16.0': + dependencies: + '@types/node': 24.10.4 + pg-protocol: 1.11.0 + pg-types: 2.2.0 '@types/pluralize@0.0.30': {} @@ -17525,33 +18018,6 @@ snapshots: '@types/stack-utils@2.0.3': {} - '@types/topojson-client@3.1.5': - dependencies: - '@types/geojson': 7946.0.16 - '@types/topojson-specification': 1.0.5 - - '@types/topojson-server@3.0.4': - dependencies: - '@types/geojson': 7946.0.16 - '@types/topojson-specification': 1.0.5 - - '@types/topojson-simplify@3.0.3': - dependencies: - '@types/geojson': 7946.0.16 - '@types/topojson-specification': 1.0.5 - - '@types/topojson-specification@1.0.5': - dependencies: - '@types/geojson': 7946.0.16 - - '@types/topojson@3.2.6': - dependencies: - '@types/geojson': 7946.0.16 - '@types/topojson-client': 3.1.5 - '@types/topojson-server': 3.0.4 - '@types/topojson-simplify': 3.0.3 - '@types/topojson-specification': 1.0.5 - '@types/trusted-types@2.0.7': optional: true @@ -17565,10 +18031,6 @@ snapshots: '@types/wrap-ansi@3.0.0': {} - '@types/ws@8.18.1': - dependencies: - '@types/node': 24.10.4 - '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': @@ -17772,7 +18234,7 @@ snapshots: - encoding - supports-color - '@vitejs/plugin-react@5.1.2(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@5.1.2(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -17780,11 +18242,11 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.53 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@1.6.1(vitest@1.6.1(@types/node@20.19.27)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.30.2)(sass@1.97.1))': + '@vitest/coverage-v8@1.6.1(vitest@1.6.1(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 @@ -17799,7 +18261,7 @@ snapshots: std-env: 3.10.0 strip-literal: 2.1.1 test-exclude: 6.0.0 - vitest: 1.6.1(@types/node@20.19.27)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.30.2)(sass@1.97.1) + vitest: 1.6.1(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1) transitivePeerDependencies: - supports-color @@ -17823,38 +18285,38 @@ snapshots: chai: 5.3.3 tinyrainbow: 1.2.0 - '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1))': + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1) + vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1) optional: true - '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1))': + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1) + vite: 5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1) - '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1))': + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1) + vite: 5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1) - '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@2.0.5': dependencies: @@ -17977,6 +18439,10 @@ snapshots: '@vue/shared@3.5.26': {} + '@xmldom/is-dom-node@1.0.1': {} + + '@xmldom/xmldom@0.8.11': {} + '@zxcvbn-ts/core@3.0.4': dependencies: fastest-levenshtein: 1.0.16 @@ -18123,6 +18589,10 @@ snapshots: dependencies: printable-characters: 1.0.42 + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + assertion-error@1.1.0: {} assertion-error@2.0.1: {} @@ -18264,66 +18734,68 @@ snapshots: node-addon-api: 8.5.0 node-gyp-build: 4.8.4 - better-auth@1.4.9(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(kysely@0.28.9)(postgres@3.4.7))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@20.19.27)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.30.2)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)): + better-auth@1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)): dependencies: - '@better-auth/core': 1.4.9(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.2.1))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) - '@better-auth/telemetry': 1.4.9(@better-auth/core@1.4.9(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.2.1))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)) + '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) + '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 '@noble/hashes': 2.0.1 - better-call: 1.1.7(zod@4.2.1) + better-call: 1.1.8(zod@4.3.6) defu: 6.1.4 jose: 6.1.3 kysely: 0.28.9 nanostores: 1.1.0 - zod: 4.2.1 + zod: 4.3.6 optionalDependencies: - '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)) - '@tanstack/react-start': 1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)) + '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)) + '@tanstack/react-start': 1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)) drizzle-kit: 0.31.8 - drizzle-orm: 0.44.7(kysely@0.28.9)(postgres@3.4.7) + drizzle-orm: 0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7) + pg: 8.18.0 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) solid-js: 1.9.10 svelte: 5.46.0 - vitest: 2.1.9(@types/node@20.19.27)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.30.2)(sass@1.97.1) + vitest: 2.1.9(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1) vue: 3.5.26(typescript@5.9.3) - better-auth@1.4.9(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(kysely@0.28.9)(postgres@3.4.7))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@24.10.4)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.30.2)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)): + better-auth@1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@24.10.4)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)): dependencies: - '@better-auth/core': 1.4.9(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.2.1))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) - '@better-auth/telemetry': 1.4.9(@better-auth/core@1.4.9(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.2.1))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)) + '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) + '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 '@noble/hashes': 2.0.1 - better-call: 1.1.7(zod@4.2.1) + better-call: 1.1.8(zod@4.3.6) defu: 6.1.4 jose: 6.1.3 kysely: 0.28.9 nanostores: 1.1.0 - zod: 4.2.1 + zod: 4.3.6 optionalDependencies: - '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) - '@tanstack/react-start': 1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@tanstack/react-start': 1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) drizzle-kit: 0.31.8 - drizzle-orm: 0.44.7(kysely@0.28.9)(postgres@3.4.7) + drizzle-orm: 0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7) + pg: 8.18.0 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) solid-js: 1.9.10 svelte: 5.46.0 - vitest: 2.1.9(@types/node@24.10.4)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.30.2)(sass@1.97.1) + vitest: 2.1.9(@types/node@24.10.4)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1) vue: 3.5.26(typescript@5.9.3) - better-call@1.1.7(zod@4.2.1): + better-call@1.1.8(zod@4.3.6): dependencies: '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 rou3: 0.7.12 set-cookie-parser: 2.7.2 optionalDependencies: - zod: 4.2.1 + zod: 4.3.6 better-opn@3.0.2: dependencies: @@ -18342,28 +18814,28 @@ snapshots: nanoid: 5.1.6 svelte: 5.46.0 - bits-ui@2.14.4(@internationalized/date@3.10.1)(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0): + bits-ui@2.14.4(@internationalized/date@3.10.1)(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0): dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/dom': 1.7.4 '@internationalized/date': 3.10.1 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0) + runed: 0.35.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0) svelte: 5.46.0 - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0) + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0) tabbable: 6.3.0 transitivePeerDependencies: - '@sveltejs/kit' - bits-ui@2.14.4(@internationalized/date@3.10.1)(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0): + bits-ui@2.16.1(@internationalized/date@3.10.1)(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0): dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/dom': 1.7.4 '@internationalized/date': 3.10.1 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0) + runed: 0.35.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0) svelte: 5.46.0 - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0) + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0) tabbable: 6.3.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -18668,7 +19140,7 @@ snapshots: commander@14.0.2: {} - commander@2.20.3: {} + commander@14.0.3: {} commander@4.1.1: {} @@ -18910,6 +19382,8 @@ snapshots: csstype@3.2.3: {} + custom-event-polyfill@1.0.7: {} + d3-array@2.12.1: dependencies: internmap: 1.0.1 @@ -19112,15 +19586,13 @@ snapshots: dependencies: '@babel/runtime': 7.28.4 - date-fns@4.1.0: {} - dateformat@3.0.3: {} dayjs@1.11.19: {} - db0@0.3.4(drizzle-orm@0.44.7(kysely@0.28.9)(postgres@3.4.7)): + db0@0.3.4(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7)): optionalDependencies: - drizzle-orm: 0.44.7(kysely@0.28.9)(postgres@3.4.7) + drizzle-orm: 0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7) debug@2.6.9: dependencies: @@ -19202,7 +19674,8 @@ snapshots: delegates@1.0.0: {} - denque@2.1.0: {} + denque@2.1.0: + optional: true depd@2.0.0: {} @@ -19212,8 +19685,7 @@ snapshots: detect-indent@6.1.0: {} - detect-libc@1.0.3: - optional: true + detect-libc@1.0.3: {} detect-libc@2.1.2: {} @@ -19320,9 +19792,11 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.44.7(kysely@0.28.9)(postgres@3.4.7): + drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7): optionalDependencies: + '@types/pg': 8.16.0 kysely: 0.28.9 + pg: 8.18.0 postgres: 3.4.7 dunder-proto@1.0.1: @@ -19357,6 +19831,11 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + enhanced-resolve@5.19.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -19593,6 +20072,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@1.0.5: {} escape-string-regexp@2.0.0: {} @@ -20028,7 +20509,7 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-mdx@14.0.4(fumadocs-core@16.2.1(@tanstack/react-router@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)): + fumadocs-mdx@14.0.4(fumadocs-core@16.2.1(@tanstack/react-router@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 @@ -20051,7 +20532,7 @@ snapshots: zod: 4.2.1 optionalDependencies: react: 19.2.3 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -20111,6 +20592,8 @@ snapshots: generator-function@2.0.1: {} + generic-pool@3.9.0: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -20368,21 +20851,21 @@ snapshots: highlight.js@11.11.1: {} - hono-openapi@1.1.2(@hono/standard-validator@0.1.5(@standard-schema/spec@1.1.0)(hono@4.11.1))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.2.1))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.2.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod-openapi@5.4.5(zod@4.2.1))(zod@4.2.1))(@types/json-schema@7.0.15)(hono@4.11.1)(openapi-types@12.1.3): + hono-openapi@1.1.2(@hono/standard-validator@0.1.5(@standard-schema/spec@1.1.0)(hono@4.11.3))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod@4.2.1))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod@4.2.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod-openapi@5.4.5(zod@4.2.1))(zod@4.2.1))(@types/json-schema@7.0.15)(hono@4.11.3)(openapi-types@12.1.3): dependencies: - '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.2.1) - '@standard-community/standard-openapi': 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.2.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod-openapi@5.4.5(zod@4.2.1))(zod@4.2.1) + '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod@4.2.1) + '@standard-community/standard-openapi': 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod@4.2.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod-openapi@5.4.5(zod@4.2.1))(zod@4.2.1) '@types/json-schema': 7.0.15 openapi-types: 12.1.3 optionalDependencies: - '@hono/standard-validator': 0.1.5(@standard-schema/spec@1.1.0)(hono@4.11.1) - hono: 4.11.1 + '@hono/standard-validator': 0.1.5(@standard-schema/spec@1.1.0)(hono@4.11.3) + hono: 4.11.3 - hono-rate-limiter@0.4.2(hono@4.11.1): + hono-rate-limiter@0.4.2(hono@4.11.3): dependencies: - hono: 4.11.1 + hono: 4.11.3 - hono@4.11.1: {} + hono@4.11.3: {} hosted-git-info@2.8.9: {} @@ -20455,8 +20938,6 @@ snapshots: human-signals@5.0.0: {} - iceberg-js@0.8.1: {} - iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -20543,6 +21024,7 @@ snapshots: standard-as-callback: 2.1.0 transitivePeerDependencies: - supports-color + optional: true is-alphabetical@2.0.1: {} @@ -20706,6 +21188,16 @@ snapshots: isexe@2.0.0: {} + isomorphic-dompurify@2.25.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): + dependencies: + dompurify: 3.3.1 + jsdom: 26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - bufferutil + - canvas + - supports-color + - utf-8-validate + istanbul-lib-coverage@3.2.2: {} istanbul-lib-instrument@5.2.1: @@ -21154,6 +21646,33 @@ snapshots: - supports-color - utf-8-validate + jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -21196,6 +21715,15 @@ snapshots: dompurify: 2.5.8 html2canvas: 1.4.1 + jsrepo@3.5.1(svelte@5.46.0)(vue@3.5.26(typescript@5.9.3)): + dependencies: + commander: 14.0.3 + oxc-parser: 0.112.0 + unconfig: 7.5.0 + optionalDependencies: + svelte: 5.46.0 + vue: 3.5.26(typescript@5.9.3) + just-extend@6.2.0: {} katex@0.16.27: @@ -21259,36 +21787,69 @@ snapshots: lightningcss-android-arm64@1.30.2: optional: true + lightningcss-android-arm64@1.31.1: + optional: true + lightningcss-darwin-arm64@1.30.2: optional: true + lightningcss-darwin-arm64@1.31.1: + optional: true + lightningcss-darwin-x64@1.30.2: optional: true + lightningcss-darwin-x64@1.31.1: + optional: true + lightningcss-freebsd-x64@1.30.2: optional: true + lightningcss-freebsd-x64@1.31.1: + optional: true + lightningcss-linux-arm-gnueabihf@1.30.2: optional: true + lightningcss-linux-arm-gnueabihf@1.31.1: + optional: true + lightningcss-linux-arm64-gnu@1.30.2: optional: true + lightningcss-linux-arm64-gnu@1.31.1: + optional: true + lightningcss-linux-arm64-musl@1.30.2: optional: true + lightningcss-linux-arm64-musl@1.31.1: + optional: true + lightningcss-linux-x64-gnu@1.30.2: optional: true + lightningcss-linux-x64-gnu@1.31.1: + optional: true + lightningcss-linux-x64-musl@1.30.2: optional: true + lightningcss-linux-x64-musl@1.31.1: + optional: true + lightningcss-win32-arm64-msvc@1.30.2: optional: true + lightningcss-win32-arm64-msvc@1.31.1: + optional: true + lightningcss-win32-x64-msvc@1.30.2: optional: true + lightningcss-win32-x64-msvc@1.31.1: + optional: true + lightningcss@1.30.2: dependencies: detect-libc: 2.1.2 @@ -21305,6 +21866,22 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.2 lightningcss-win32-x64-msvc: 1.30.2 + lightningcss@1.31.1: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.31.1 + lightningcss-darwin-arm64: 1.31.1 + lightningcss-darwin-x64: 1.31.1 + lightningcss-freebsd-x64: 1.31.1 + lightningcss-linux-arm-gnueabihf: 1.31.1 + lightningcss-linux-arm64-gnu: 1.31.1 + lightningcss-linux-arm64-musl: 1.31.1 + lightningcss-linux-x64-gnu: 1.31.1 + lightningcss-linux-x64-musl: 1.31.1 + lightningcss-win32-arm64-msvc: 1.31.1 + lightningcss-win32-x64-msvc: 1.31.1 + lilconfig@2.1.0: {} lilconfig@3.1.3: {} @@ -21324,6 +21901,8 @@ snapshots: pify: 3.0.0 strip-bom: 3.0.0 + loadjs@4.3.0: {} + local-pkg@0.5.1: dependencies: mlly: 1.8.0 @@ -21353,9 +21932,11 @@ snapshots: lodash.debounce@4.0.8: {} - lodash.defaults@4.2.0: {} + lodash.defaults@4.2.0: + optional: true - lodash.isarguments@3.1.0: {} + lodash.isarguments@3.1.0: + optional: true lodash.ismatch@4.4.0: {} @@ -22072,11 +22653,11 @@ snapshots: nf3@0.1.12: {} - nitro@3.0.1-alpha.1(chokidar@4.0.3)(drizzle-orm@0.44.7(kysely@0.28.9)(postgres@3.4.7))(ioredis@5.8.2)(lru-cache@11.2.4)(rollup@4.54.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)): + nitro@3.0.1-alpha.1(chokidar@4.0.3)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(ioredis@5.8.2)(lru-cache@11.2.4)(rollup@4.54.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: consola: 3.4.2 crossws: 0.4.1(srvx@0.9.8) - db0: 0.3.4(drizzle-orm@0.44.7(kysely@0.28.9)(postgres@3.4.7)) + db0: 0.3.4(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7)) h3: 2.0.1-rc.5(crossws@0.4.1(srvx@0.9.8)) jiti: 2.6.1 nf3: 0.1.12 @@ -22087,10 +22668,10 @@ snapshots: srvx: 0.9.8 undici: 7.16.0 unenv: 2.0.0-rc.24 - unstorage: 2.0.0-alpha.4(chokidar@4.0.3)(db0@0.3.4(drizzle-orm@0.44.7(kysely@0.28.9)(postgres@3.4.7)))(ioredis@5.8.2)(lru-cache@11.2.4)(ofetch@2.0.0-alpha.3) + unstorage: 2.0.0-alpha.4(chokidar@4.0.3)(db0@0.3.4(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7)))(ioredis@5.8.2)(lru-cache@11.2.4)(ofetch@2.0.0-alpha.3) optionalDependencies: rollup: 4.54.0 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -22124,8 +22705,7 @@ snapshots: dependencies: semver: 7.7.3 - node-addon-api@7.1.1: - optional: true + node-addon-api@7.1.1: {} node-addon-api@8.5.0: {} @@ -22135,12 +22715,18 @@ snapshots: optionalDependencies: encoding: 0.1.13 + node-forge@1.3.3: {} + node-gyp-build@4.8.4: {} node-int64@0.4.0: {} node-releases@2.0.27: {} + node-rsa@1.1.1: + dependencies: + asn1: 0.2.6 + nodemailer@6.10.1: {} nopt@5.0.0: @@ -22299,6 +22885,31 @@ snapshots: '@oxc-minify/binding-win32-arm64-msvc': 0.96.0 '@oxc-minify/binding-win32-x64-msvc': 0.96.0 + oxc-parser@0.112.0: + dependencies: + '@oxc-project/types': 0.112.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.112.0 + '@oxc-parser/binding-android-arm64': 0.112.0 + '@oxc-parser/binding-darwin-arm64': 0.112.0 + '@oxc-parser/binding-darwin-x64': 0.112.0 + '@oxc-parser/binding-freebsd-x64': 0.112.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.112.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.112.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.112.0 + '@oxc-parser/binding-linux-arm64-musl': 0.112.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.112.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.112.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.112.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.112.0 + '@oxc-parser/binding-linux-x64-gnu': 0.112.0 + '@oxc-parser/binding-linux-x64-musl': 0.112.0 + '@oxc-parser/binding-openharmony-arm64': 0.112.0 + '@oxc-parser/binding-wasm32-wasi': 0.112.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.112.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.112.0 + '@oxc-parser/binding-win32-x64-msvc': 0.112.0 + oxc-transform@0.96.0: optionalDependencies: '@oxc-transform/binding-android-arm64': 0.96.0 @@ -22353,6 +22964,8 @@ snapshots: p-try@2.2.0: {} + pako@1.0.11: {} + papaparse@5.5.3: {} parent-module@1.0.1: @@ -22427,6 +23040,41 @@ snapshots: performance-now@2.1.0: optional: true + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.11.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.11.0(pg@8.18.0): + dependencies: + pg: 8.18.0 + + pg-protocol@1.11.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.18.0: + dependencies: + pg-connection-string: 2.11.0 + pg-pool: 3.11.0(pg@8.18.0) + pg-protocol: 1.11.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -22463,6 +23111,14 @@ snapshots: pluralize@8.0.0: {} + plyr@3.8.4: + dependencies: + core-js: 3.47.0 + custom-event-polyfill: 1.0.7 + loadjs: 4.3.0 + rangetouch: 2.0.1 + url-polyfill: 1.1.14 + pngjs@5.0.0: {} polished@4.3.1: @@ -22558,6 +23214,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + postgres@3.4.7: {} posthog-js@1.310.1: @@ -22589,18 +23255,18 @@ snapshots: prettier: 3.7.4 svelte: 5.46.0 - prettier-plugin-tailwindcss@0.5.14(prettier-plugin-svelte@3.4.1(prettier@3.6.2)(svelte@5.46.0))(prettier@3.6.2): - dependencies: - prettier: 3.6.2 - optionalDependencies: - prettier-plugin-svelte: 3.4.1(prettier@3.6.2)(svelte@5.46.0) - prettier-plugin-tailwindcss@0.6.14(prettier-plugin-svelte@3.4.1(prettier@3.7.4)(svelte@5.46.0))(prettier@3.7.4): dependencies: prettier: 3.7.4 optionalDependencies: prettier-plugin-svelte: 3.4.1(prettier@3.7.4)(svelte@5.46.0) + prettier-plugin-tailwindcss@0.7.2(prettier-plugin-svelte@3.4.1(prettier@3.6.2)(svelte@5.46.0))(prettier@3.6.2): + dependencies: + prettier: 3.6.2 + optionalDependencies: + prettier-plugin-svelte: 3.4.1(prettier@3.6.2)(svelte@5.46.0) + prettier@3.6.2: {} prettier@3.7.4: {} @@ -22759,7 +23425,7 @@ snapshots: dependencies: side-channel: 1.1.0 - quansync@0.2.11: {} + quansync@1.0.0: {} queue-lit@1.5.2: {} @@ -22772,6 +23438,8 @@ snapshots: performance-now: 2.1.0 optional: true + rangetouch@2.0.1: {} + raw-body@2.5.3: dependencies: bytes: 3.1.2 @@ -22918,11 +23586,22 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 - redis-errors@1.2.0: {} + redis-errors@1.2.0: + optional: true redis-parser@3.0.0: dependencies: redis-errors: 1.2.0 + optional: true + + redis@4.7.1: + dependencies: + '@redis/bloom': 1.2.0(@redis/client@1.6.1) + '@redis/client': 1.6.1 + '@redis/graph': 1.1.1(@redis/client@1.6.1) + '@redis/json': 1.0.7(@redis/client@1.6.1) + '@redis/search': 1.2.0(@redis/client@1.6.1) + '@redis/time-series': 1.1.0(@redis/client@1.6.1) regenerate-unicode-properties@10.2.2: dependencies: @@ -23161,33 +23840,33 @@ snapshots: esm-env: 1.2.2 svelte: 5.46.0 - runed@0.35.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0): + runed@0.35.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 svelte: 5.46.0 optionalDependencies: - '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)) + '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)) - runed@0.35.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0): + runed@0.35.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 svelte: 5.46.0 optionalDependencies: - '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) - runed@0.36.0(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(zod@4.2.1): + runed@0.37.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(zod@4.3.6): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 svelte: 5.46.0 optionalDependencies: - '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) - zod: 4.2.1 + '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + zod: 4.3.6 rusha@0.8.14: {} @@ -23213,6 +23892,20 @@ snapshots: safer-buffer@2.1.2: {} + samlify@2.10.2: + dependencies: + '@authenio/xml-encryption': 2.0.2 + '@xmldom/xmldom': 0.8.11 + camelcase: 6.3.0 + node-forge: 1.3.3 + node-rsa: 1.1.1 + pako: 1.0.11 + uuid: 8.3.2 + xml: 1.0.1 + xml-crypto: 6.1.2 + xml-escape: 1.1.0 + xpath: 0.0.32 + sander@0.5.1: dependencies: es6-promise: 3.3.1 @@ -23345,6 +24038,17 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + shiki@3.22.0: + dependencies: + '@shikijs/core': 3.22.0 + '@shikijs/engine-javascript': 3.22.0 + '@shikijs/engine-oniguruma': 3.22.0 + '@shikijs/langs': 3.22.0 + '@shikijs/themes': 3.22.0 + '@shikijs/types': 3.22.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -23379,6 +24083,8 @@ snapshots: signal-exit@4.1.0: {} + simple-icons@16.9.0: {} + simple-swizzle@0.2.4: dependencies: is-arrayish: 0.3.4 @@ -23455,6 +24161,8 @@ snapshots: dependencies: readable-stream: 3.6.2 + split2@4.2.0: {} + split@1.0.1: dependencies: through: 2.3.8 @@ -23482,7 +24190,8 @@ snapshots: as-table: 1.0.55 get-source: 2.0.12 - standard-as-callback@2.1.0: {} + standard-as-callback@2.1.0: + optional: true standard-version@9.5.0: dependencies: @@ -23752,19 +24461,19 @@ snapshots: '@tiptap/pm': 3.14.0 svelte: 5.46.0 - svelte-toolbelt@0.10.6(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0): + svelte-toolbelt@0.10.6(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)))(svelte@5.46.0) + runed: 0.35.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0) style-to-object: 1.0.14 svelte: 5.46.0 transitivePeerDependencies: - '@sveltejs/kit' - svelte-toolbelt@0.10.6(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0): + svelte-toolbelt@0.10.6(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0) + runed: 0.35.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0) style-to-object: 1.0.14 svelte: 5.46.0 transitivePeerDependencies: @@ -23922,6 +24631,8 @@ snapshots: tailwindcss@4.1.18: {} + tailwindcss@4.2.1: {} + tapable@2.3.0: {} tar@6.2.1: @@ -24020,10 +24731,6 @@ snapshots: toidentifier@1.0.1: {} - topojson-client@3.1.0: - dependencies: - commander: 2.20.3 - totalist@3.0.1: {} tough-cookie@5.1.2: @@ -24249,6 +24956,19 @@ snapshots: uglify-js@3.19.3: optional: true + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + + unconfig@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + defu: 6.1.4 + jiti: 2.6.1 + quansync: 1.0.0 + unconfig-core: 7.5.0 + undici-types@6.21.0: {} undici-types@7.16.0: {} @@ -24346,10 +25066,10 @@ snapshots: unsplash-js@7.0.20: {} - unstorage@2.0.0-alpha.4(chokidar@4.0.3)(db0@0.3.4(drizzle-orm@0.44.7(kysely@0.28.9)(postgres@3.4.7)))(ioredis@5.8.2)(lru-cache@11.2.4)(ofetch@2.0.0-alpha.3): + unstorage@2.0.0-alpha.4(chokidar@4.0.3)(db0@0.3.4(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7)))(ioredis@5.8.2)(lru-cache@11.2.4)(ofetch@2.0.0-alpha.3): optionalDependencies: chokidar: 4.0.3 - db0: 0.3.4(drizzle-orm@0.44.7(kysely@0.28.9)(postgres@3.4.7)) + db0: 0.3.4(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7)) ioredis: 5.8.2 lru-cache: 11.2.4 ofetch: 2.0.0-alpha.3 @@ -24364,6 +25084,8 @@ snapshots: dependencies: punycode: 2.3.1 + url-polyfill@1.1.14: {} + use-callback-ref@1.3.3(@types/react@19.2.7)(react@19.2.3): dependencies: react: 19.2.3 @@ -24402,6 +25124,8 @@ snapshots: base64-arraybuffer: 1.0.2 optional: true + uuid@8.3.2: {} + uuid@9.0.1: {} v8-compile-cache-lib@3.0.1: {} @@ -24440,13 +25164,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-node@1.6.1(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1): + vite-node@1.6.1(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1): dependencies: cac: 6.7.14 debug: 4.4.3 pathe: 1.1.2 picocolors: 1.1.1 - vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1) + vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1) transitivePeerDependencies: - '@types/node' - less @@ -24458,13 +25182,31 @@ snapshots: - supports-color - terser - vite-node@2.1.9(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1): + vite-node@1.6.1(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + pathe: 1.1.2 + picocolors: 1.1.1 + vite: 5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite-node@2.1.9(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 1.1.2 - vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1) + vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1) transitivePeerDependencies: - '@types/node' - less @@ -24477,13 +25219,13 @@ snapshots: - terser optional: true - vite-node@2.1.9(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1): + vite-node@2.1.9(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 1.1.2 - vite: 5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1) + vite: 5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1) transitivePeerDependencies: - '@types/node' - less @@ -24495,13 +25237,13 @@ snapshots: - supports-color - terser - vite-node@2.1.9(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1): + vite-node@2.1.9(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 1.1.2 - vite: 5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1) + vite: 5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1) transitivePeerDependencies: - '@types/node' - less @@ -24513,18 +25255,18 @@ snapshots: - supports-color - terser - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - typescript - vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1): + vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1): dependencies: esbuild: 0.21.5 postcss: 8.5.6 @@ -24532,10 +25274,10 @@ snapshots: optionalDependencies: '@types/node': 20.19.27 fsevents: 2.3.3 - lightningcss: 1.30.2 + lightningcss: 1.31.1 sass: 1.97.1 - vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1): + vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1): dependencies: esbuild: 0.21.5 postcss: 8.5.6 @@ -24543,10 +25285,10 @@ snapshots: optionalDependencies: '@types/node': 22.19.3 fsevents: 2.3.3 - lightningcss: 1.30.2 + lightningcss: 1.31.1 sass: 1.97.1 - vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1): + vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1): dependencies: esbuild: 0.21.5 postcss: 8.5.6 @@ -24554,10 +25296,10 @@ snapshots: optionalDependencies: '@types/node': 24.10.4 fsevents: 2.3.3 - lightningcss: 1.30.2 + lightningcss: 1.31.1 sass: 1.97.1 - vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2): + vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -24569,12 +25311,12 @@ snapshots: '@types/node': 24.10.4 fsevents: 2.3.3 jiti: 2.6.1 - lightningcss: 1.30.2 + lightningcss: 1.31.1 sass: 1.97.1 tsx: 4.21.0 yaml: 2.8.2 - vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -24586,32 +25328,32 @@ snapshots: '@types/node': 24.10.4 fsevents: 2.3.3 jiti: 2.6.1 - lightningcss: 1.30.2 + lightningcss: 1.31.1 sass: 1.97.1 tsx: 4.21.0 yaml: 2.8.2 - vitefu@1.1.1(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)): + vitefu@1.1.1(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)): optionalDependencies: - vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1) + vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1) - vitefu@1.1.1(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1)): + vitefu@1.1.1(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1)): optionalDependencies: - vite: 5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1) + vite: 5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1) - vitefu@1.1.1(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1)): + vitefu@1.1.1(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)): optionalDependencies: - vite: 5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1) + vite: 5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1) - vitefu@1.1.1(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)): + vitefu@1.1.1(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)): optionalDependencies: - vite: 6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) - vitefu@1.1.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)): + vitefu@1.1.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)): optionalDependencies: - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) - vitest@1.6.1(@types/node@20.19.27)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.30.2)(sass@1.97.1): + vitest@1.6.1(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1): dependencies: '@vitest/expect': 1.6.1 '@vitest/runner': 1.6.1 @@ -24630,12 +25372,12 @@ snapshots: strip-literal: 2.1.1 tinybench: 2.9.0 tinypool: 0.8.4 - vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1) - vite-node: 1.6.1(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1) + vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1) + vite-node: 1.6.1(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.27 - jsdom: 25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + jsdom: 26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - less - lightningcss @@ -24646,10 +25388,45 @@ snapshots: - supports-color - terser - vitest@2.1.9(@types/node@20.19.27)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.30.2)(sass@1.97.1): + vitest@1.6.1(@types/node@24.10.4)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1): + dependencies: + '@vitest/expect': 1.6.1 + '@vitest/runner': 1.6.1 + '@vitest/snapshot': 1.6.1 + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + acorn-walk: 8.3.4 + chai: 4.5.0 + debug: 4.4.3 + execa: 8.0.1 + local-pkg: 0.5.1 + magic-string: 0.30.21 + pathe: 1.1.2 + picocolors: 1.1.1 + std-env: 3.10.0 + strip-literal: 2.1.1 + tinybench: 2.9.0 + tinypool: 0.8.4 + vite: 5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1) + vite-node: 1.6.1(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.4 + jsdom: 26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vitest@2.1.9(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1)) + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -24665,12 +25442,12 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.1.1 tinyrainbow: 1.2.0 - vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1) - vite-node: 2.1.9(@types/node@20.19.27)(lightningcss@1.30.2)(sass@1.97.1) + vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1) + vite-node: 2.1.9(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.27 - jsdom: 25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + jsdom: 26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - less - lightningcss @@ -24683,10 +25460,10 @@ snapshots: - terser optional: true - vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.30.2)(sass@1.97.1): + vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1)) + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -24702,8 +25479,8 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.1.1 tinyrainbow: 1.2.0 - vite: 5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1) - vite-node: 2.1.9(@types/node@22.19.3)(lightningcss@1.30.2)(sass@1.97.1) + vite: 5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1) + vite-node: 2.1.9(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.3 @@ -24719,10 +25496,10 @@ snapshots: - supports-color - terser - vitest@2.1.9(@types/node@24.10.4)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.30.2)(sass@1.97.1): + vitest@2.1.9(@types/node@24.10.4)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1)) + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -24738,12 +25515,12 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.1.1 tinyrainbow: 1.2.0 - vite: 5.4.21(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1) - vite-node: 2.1.9(@types/node@24.10.4)(lightningcss@1.30.2)(sass@1.97.1) + vite: 5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1) + vite-node: 2.1.9(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.10.4 - jsdom: 25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + jsdom: 26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - less - lightningcss @@ -24910,8 +25687,18 @@ snapshots: bufferutil: 4.1.0 utf-8-validate: 6.0.6 + xml-crypto@6.1.2: + dependencies: + '@xmldom/is-dom-node': 1.0.1 + '@xmldom/xmldom': 0.8.11 + xpath: 0.0.33 + + xml-escape@1.1.0: {} + xml-name-validator@5.0.0: {} + xml@1.0.1: {} + xmlbuilder2@4.0.3: dependencies: '@oozcitak/dom': 2.0.2 @@ -24921,6 +25708,10 @@ snapshots: xmlchars@2.2.0: {} + xpath@0.0.32: {} + + xpath@0.0.33: {} + xtend@4.0.2: {} y18n@4.0.3: {} @@ -25012,4 +25803,6 @@ snapshots: zod@4.2.1: {} + zod@4.3.6: {} + zwitch@2.0.4: {} diff --git a/prd/ai-course-assistant/README.md b/prd/ai-course-assistant/README.md new file mode 100644 index 00000000..e75dab3f --- /dev/null +++ b/prd/ai-course-assistant/README.md @@ -0,0 +1,280 @@ +--- +name: AI Course Assistant +overview: An AI-powered assistant embedded in the course UI that helps teachers organize content—update lesson text, tag exercises, generate questions from existing content, and draft lessons. Cloud-only; self-hosted instances cannot use this feature. +todos: + - id: prd-review + content: Finalize PRD with stakeholders, resolve open questions + status: pending + - id: architecture-decision + content: Decide agent execution model (API-first vs local-first) + status: pending + - id: package-scaffold + content: Create packages/ai-assistant package with API client + status: pending + - id: agent-api-routes + content: Design and implement agent API routes with auth + org scoping + status: pending + - id: chat-ui + content: Build chat interface in right sidebar (Sheet) + status: pending + - id: nav-integration + content: Add AI assistant button to course navigation/header + status: pending + - id: self-host-gate + content: Hide assistant and block agent routes when PUBLIC_IS_SELFHOSTED + status: pending + - id: safeguards + content: Enforce org/course permission checks in all agent actions + status: pending +isProject: false +--- + +# AI Course Assistant + +## Purpose + +Deliver an AI assistant inside the course UI that teachers can use to: + +- **Update content**: "Update this piece of text in this lesson" +- **Tag exercises**: Tag an exercise and provide context +- **Generate questions**: "Given this text, come up with 5 new questions" +- **Draft lessons**: Ask the agent to write the initial draft of a lesson + +The assistant operates in a right sidebar chat interface, triggered by an "AI Assistant" button in the course navigation. + +## Goals + +- Help teachers organize and author course content faster +- Operate strictly within the current org/course context (no cross-org access) +- Integrate natively with existing ClassroomIO APIs (lessons, exercises, content) +- Cloud-only; self-hosted users cannot use the AI assistant + +## Non-Goals (v1) + +- Student-facing AI (tutors/teachers only) +- Cross-course or cross-org operations +- Offline or fully local execution without API +- Custom model selection or fine-tuning + +--- + +## Architecture Overview + +```mermaid +flowchart TB + subgraph client [Dashboard Client] + NavButton["AI Assistant Button"] + ChatUI["Right Sidebar Chat"] + end + subgraph cloud [Cloud / API] + AgentService["Agent Service (packages/ai-assistant or API)"] + CourseAPI["Course / Lesson / Exercise APIs"] + end + subgraph db [Database] + LessonTable["lesson / lesson_language"] + ExerciseTable["exercise / exercise_template"] + ContentTable["course_content"] + end + + NavButton -->|"Open"| ChatUI + ChatUI -->|"User message + context"| AgentService + AgentService -->|"Validate + Call"| CourseAPI + CourseAPI -->|"Persist"| db + CourseAPI -->|"Response"| ChatUI +``` + +--- + +## 1. Execution Model: API vs Local + +**Open question**: How does the agent apply changes—via API directly, or by suggesting edits locally in the UI? + +### Option A: API-First (Recommended for v1) + +- Agent sends **structured intents** (e.g. `{ type: "update_lesson_text", lessonId, locale, payload }`) to a dedicated **agent API** +- Agent API validates permissions, calls existing services (`updateLessonService`, `updateLessonLanguageService`, etc.), returns results +- Client receives success/failure and refreshes or updates the relevant store +- **Pros**: Single source of truth, audit trail, reuses existing safeguards +- **Cons**: Slightly more latency; requires agent backend + +### Option B: Local-First + +- Agent returns **edit suggestions** as structured data (e.g. JSON patches) +- Client applies changes in the editor UI; user must explicitly accept (e.g. "Apply" button) +- Actual persistence happens through existing APIs when user saves +- **Pros**: Lower latency, no new backend routes +- **Cons**: More complex client logic; risk of sync drift; harder to audit + +### Recommendation + +**Start with Option A (API-First)**: + +1. Reuse existing `courseTeamMemberMiddleware`, `orgMemberMiddleware`, and org/course checks +2. Agent executes actions via the same services used by the UI +3. Simpler mental model: agent is a "programmatic teacher" that talks to the same API +4. Easier to add rate limits, logging, and abuse prevention later + +--- + +## 2. UI Specification + +### Placement + +- **Button**: "AI Assistant" in the **course header** (alongside Search, Notifications), visible only to teachers (not students) +- **Panel**: Right sidebar implemented with `@cio/ui` Sheet (`side="right"`), ~400px width on desktop +- **Mobile**: Full-screen sheet or drawer (reuse `lesson-version-history.svelte` pattern) + +### Chat Interface + +- Message list with user/assistant turns +- Text input at bottom with send button +- Optional: quick actions / suggested prompts (e.g. "Draft a lesson", "Generate 5 questions") +- Loading state while agent processes +- Error states with retry + +### Context Awareness + +- Pass `courseId`, `orgId`, `lessonId` (if viewing a lesson), `exerciseId` (if viewing an exercise) to the agent +- Agent can reference: "Update the text in the lesson you're viewing" without the user specifying IDs + +--- + +## 3. Safeguards + +### Authorization (Reuse Existing Middleware) + +All agent API routes must use: + +| Middleware | Purpose | +|------------|---------| +| `authMiddleware` | Require authenticated user | +| `orgMemberMiddleware` | Require `cio-org-id` and org membership | +| `courseTeamMemberMiddleware` | Require course team (ADMIN/TUTOR) or org admin | + +### Scoping Rules + +1. **Org scope**: Every action is scoped to the org in `cio-org-id` header +2. **Course scope**: Lesson/exercise edits must belong to a course where the user is a team member +3. **No cross-org**: Agent must never accept or execute requests for lessons/exercises in another org + +### Implementation + +- Agent API extracts `courseId`, `lessonId`, `exerciseId` from request payload +- Before any mutation, verify: + - Course belongs to the org + - User is `courseTeamMember` or `orgAdmin` +- Reuse `isCourseTeamMemberOrOrgAdmin` and `isUserOrgTeamMember` from `@cio/db/queries` + +--- + +## 4. Self-Hosted vs Cloud + +### Cloud (ClassroomIO-Hosted) + +- AI assistant is **enabled** for teachers (within plan limits, if any) +- ClassroomIO provides the LLM API keys; no user configuration needed + +### Self-Hosted + +- AI assistant is **not available**. Self-hosters cannot use this feature. +- **Implementation**: Hide the "AI Assistant" button when `PUBLIC_IS_SELFHOSTED === 'true'`. Agent API routes return `403` or are not registered when self-hosted. + +--- + +## 5. Package Design: `packages/ai-assistant` + +The agent should live in a **package** that works natively with ClassroomIO APIs. + +### Package Responsibilities + +- **SDK / client**: Types and helpers for calling the agent API from the dashboard +- **Shared types**: Intent schemas, message formats +- **No UI**: UI stays in `apps/dashboard`; package is backend + types + +### Suggested Structure + +``` +packages/ai-assistant/ + src/ + index.ts # Public exports + types.ts # Message, intent, response types + client.ts # Agent API client (optional; dashboard may use classroomio RPC) + package.json + tsconfig.json +``` + +### Agent API (in `apps/api`) + +- New routes under e.g. `/course/:courseId/agent` or `/organization/agent` +- Routes: `POST /chat` (stream or non-stream), `POST /execute` (for structured intents) +- Agent service: interpret user message, map to intents, call lesson/exercise services, return result + +### Complexity Estimate + +| Component | Complexity | Notes | +|-----------|------------|-------| +| Package scaffold | Low | Thin wrapper around types + optional client | +| Agent API routes | Medium | Auth, validation, orchestration | +| LLM integration | Medium–High | Depends on provider (OpenAI, Anthropic, etc.) | +| Chat UI | Medium | Standard chat + Sheet | +| Self-host gate | Low | Env check + conditional render | + +--- + +## 6. Data Sources & APIs (Existing) + +| Action | API / Service | Auth | +|--------|---------------|------| +| Update lesson text | `PUT /course/:courseId/lesson/:lessonId/language/:locale` | courseTeamMember | +| Update lesson metadata | `PUT /course/:courseId/lesson/:lessonId` | courseTeamMember | +| Tag exercise (template tag) | Exercise template has `tag`; create/update exercise | courseTeamMember | +| Tag course (org tags) | `PUT /course/:courseId/tags` | courseTeamMember | +| Create lesson | `POST /course/:courseId/lesson` | courseTeamMember | +| Create exercise | Exercise creation flow | courseTeamMember | +| Get lesson content | `GET /course/:courseId/lesson/:lessonId` | courseMember | + +Agent actions should call these through the existing service layer, not bypass them. + +--- + +## 7. Suggested Implementation Phases + +### Phase 1: Foundation + +1. Add "AI Assistant" button to course header (hidden when self-hosted) +2. Add right Sheet with placeholder chat UI (no backend) +3. Create `packages/ai-assistant` with types and client stubs +4. Add agent API route skeleton with `courseTeamMemberMiddleware` + +### Phase 2: Chat + Execution + +1. Implement agent API `POST /chat` with LLM integration +2. Implement intent parsing: update lesson, generate questions, draft lesson +3. Wire agent to `updateLessonService`, `createLesson`, exercise services +4. Connect chat UI to API; display responses and errors + +### Phase 3: Polish + +1. Add quick-action prompts +2. Add context injection (current lesson/exercise) +3. Rate limiting and usage tracking (if needed) + +--- + +## 8. Open Questions + +1. **Streaming**: Should responses stream (SSE/WebSocket) or be non-streaming for v1? +2. **LLM provider**: OpenAI, Anthropic, or configurable per deployment? +3. **Rate limits**: Per-user, per-org, or per-course? Needed for cloud cost control? +4. **Exercise tagging**: Does "tag an exercise" mean org-level course tags, or exercise-template tags (e.g. mcq, coding)? Clarify semantics. +5. **Draft persistence**: When agent drafts a lesson, does it create a draft immediately or return text for user to paste? Recommendation: create draft via API, user reviews before publishing. + +--- + +## 9. Success Criteria + +- Teachers can open the AI assistant from the course header and chat in a right sidebar +- Agent can update lesson text, create draft lessons, and generate questions (as defined in Phase 2) +- No user can modify another org's data; all actions pass permission checks +- Self-hosted instances cannot use the assistant; UI and API are gated +- Agent package integrates cleanly with existing API and dashboard structure diff --git a/prd/ai-course-assistant/implementation-plan.md b/prd/ai-course-assistant/implementation-plan.md new file mode 100644 index 00000000..d6f448ed --- /dev/null +++ b/prd/ai-course-assistant/implementation-plan.md @@ -0,0 +1,66 @@ +# AI Course Assistant Implementation Plan + +## Scope Lock (v1) + +1. AI assistant is teacher-only (course team members; not students). +2. Agent executes via API (no local-only execution). +3. All actions are org/course-scoped; no cross-org access. +4. Self-hosted: assistant not available; hide UI and block agent routes. +5. Chat lives in a right sidebar (Sheet) from the course header. +6. Supported intents: update lesson text, generate questions from text, draft lesson; exercise tagging TBD (clarify org tags vs template tags). + +## Delivery Phases + +1. Phase A: Package + types + self-host gate +2. Phase B: Agent API routes + permission checks +3. Phase C: Chat UI + header button +4. Phase D: LLM integration + intent execution +5. Phase E: Polish (quick actions, context, rate limits) + +## Ticket Breakdown + +| ID | Area | Task | Key Files | Dependencies | Done When | +| --- | --- | --- | --- | --- | --- | +| AI-A1 | Package | Create `packages/ai-assistant` with types (Message, Intent, Response) | `packages/ai-assistant/src/types.ts` | None | Package builds; types exportable | +| AI-A2 | Package | Add optional API client for agent chat | `packages/ai-assistant/src/client.ts` | AI-A1 | Client can call agent endpoints (or dashboard uses RPC directly) | +| AI-A3 | Dashboard | Add `isAiAssistantAvailable` util (false when `PUBLIC_IS_SELFHOSTED`) | `apps/dashboard/src/lib/utils/ai-assistant.ts` | None | Util returns bool; hide button when self-hosted | +| AI-B1 | API | Add agent validation schemas (chat payload, intent payloads) | `packages/utils/src/validation/agent/` | None | Zod schemas for chat + intents | +| AI-B2 | API | Add agent routes under `/course/:courseId/agent`; do not register or return 403 when self-hosted | `apps/api/src/routes/course/agent.ts` | AI-B1 | `POST /chat`, `POST /execute` (or combined); blocked when `PUBLIC_IS_SELFHOSTED` | +| AI-B3 | API | Apply auth + courseTeamMember middleware to agent routes | `apps/api/src/routes/course/agent.ts` | AI-B2 | Unauthorized users get 403 | +| AI-B4 | API | Add agent service: parse message → intent → call lesson/exercise services | `apps/api/src/services/agent.ts` | AI-B2 | Service orchestrates intents; returns structured response | +| AI-B5 | API | Verify org/course scoping in every agent mutation | `apps/api/src/services/agent.ts` | AI-B4 | Cross-org requests rejected | +| AI-C1 | Dashboard | Add AI Assistant button to course header | `apps/dashboard/src/lib/features/course/components/course-header.svelte` | AI-A3 | Button visible when `isAiAssistantAvailable` and teacher | +| AI-C2 | Dashboard | Add right Sheet with chat UI (message list + input) | `apps/dashboard/src/lib/features/ai-assistant/ai-assistant-sheet.svelte` | AI-C1 | Sheet opens from button; placeholder chat | +| AI-C3 | Dashboard | Create AI assistant feature module (store, API class) | `apps/dashboard/src/lib/features/ai-assistant/` | AI-A1 | API class calls agent endpoint; store holds messages | +| AI-C4 | Dashboard | Wire chat input → API → message display | `apps/dashboard/src/lib/features/ai-assistant/` | AI-C3, AI-B2 | User can send message; response shows in chat | +| AI-D1 | API | Integrate LLM provider (OpenAI/Anthropic) for chat completion | `apps/api/src/services/agent.ts` | AI-B4 | Agent can generate text from user message | +| AI-D2 | API | Implement intent: update lesson text | Agent service + lessonLanguage service | AI-D1 | "Update this text..." → `updateLessonLanguageService` | +| AI-D3 | API | Implement intent: generate N questions from text | Agent service | AI-D1 | Returns generated questions as text; user copies or we add create flow later | +| AI-D4 | API | Implement intent: draft lesson | Agent service + `createLesson` | AI-D1 | Creates draft lesson; returns lesson ID for navigation | +| AI-D5 | API | Clarify and implement exercise tagging intent | Agent service + tag/course APIs | AI-D1 | Semantic clarified in PRD; implement | +| AI-E1 | Copy | Add i18n keys for AI assistant UI | `apps/dashboard/src/lib/utils/translations/en.json` | AI-C2 | No hardcoded strings | +| AI-E2 | API | Add rate limiting for agent endpoints | Middleware or route-level | AI-B2 | Prevents abuse; configurable limit | +| AI-E3 | Tests | Add auth/role tests for agent routes | API tests | AI-B3 | Unauthorized/student requests return 403 | + +## API Contract (Target) + +### Agent Chat + +- `POST /course/:courseId/agent/chat` + - Body: `{ message: string, context?: { lessonId?, exerciseId? } }` + - Response: `{ success: true, data: { message: string, intent?: string, actions?: [...] } }` + - Auth: `authMiddleware`, `courseTeamMemberMiddleware` + +### Optional Structured Execute + +- `POST /course/:courseId/agent/execute` + - Body: `{ intent: string, payload: Record }` + - For tool-calling or explicit intent execution (e.g. from LLM tool use) + - Auth: same as above + +## Open Items Before Implementation + +1. LLM provider choice (OpenAI vs Anthropic vs configurable). +2. Streaming: v1 non-streaming vs SSE streaming. +3. Exercise tagging semantics (org course tags vs exercise template tags). +4. Draft lesson: create immediately vs return text for paste. diff --git a/prd/classes/README.md b/prd/classes/README.md new file mode 100644 index 00000000..979e9a7c --- /dev/null +++ b/prd/classes/README.md @@ -0,0 +1,993 @@ +# Classes PRD + +## Purpose + +Enable instructors and org admins to group multiple courses — along with shared announcements, files, and community discussions — into a single **class**. Enrolling a student in a class auto-enrolls them in every course inside it, eliminating the need to manage per-course enrollment manually when delivering a structured program. + +## Problem Statement + +Currently, ClassroomIO has no entity above the individual course. Every course has its own independent enrollment roster. This creates several pain points: + +- An instructor running a program (e.g., a bootcamp, onboarding track, or certification pathway) must enroll students in each constituent course one by one. +- There is no shared space for class-wide announcements, files, or discussions that span multiple courses. +- Students cannot see a unified "program" view in the LMS — they only see a flat list of individual courses. +- Org admins have no way to bundle courses for a specific cohort of learners (e.g., "Q1 2026 New Hire Onboarding") without creating separate organizations or duplicating content. + +## Confirmed Decisions + +1. **Terminology**: "Class" in both the instructor-facing admin UI and the student-facing LMS. +2. **Auto-enrollment**: Joining a class automatically creates `groupmember` records for every course inside the class. Removing a student from a class does not remove them from individual course groups, preserving their submission and progress data. +3. **Shared materials**: Each class has three shared material surfaces: announcements (instructor-authored posts), a file library, and a community Q&A board. +4. **Many-to-many courses**: A course can belong to multiple classes. Courses and classes are independent entities linked by a join table. +5. **LMS visibility**: Classes are visible to students in the LMS under a dedicated "Classes" section. Students see enrolled classes and can navigate courses from within a class hub page. +6. **Roles**: Class roles mirror the existing system (`ADMIN`/roleId 1, `TUTOR`/roleId 2, `STUDENT`/roleId 3). Class admins and tutors can manage the class; class students get read access to materials. +7. **Class status**: Classes can be `ACTIVE`, `INACTIVE`, or `ARCHIVED`. `INACTIVE` classes are hidden from students in the LMS but preserved for instructors. `ARCHIVED` classes are soft-deleted and hidden everywhere. +8. **Course independence**: Courses in a class remain fully independent. A student enrolled in a class is a regular `groupmember` in each course — existing course pages, progress tracking, submissions, and grading are unchanged. +9. **Community reuse**: The existing `community_question` table gains a nullable `classId` FK. Class-level Q&A behaves identically to course-level community Q&A but scoped to the class. + +## Current-State Audit + +| Capability | Current State | Notes | +| --- | --- | --- | +| Organization structure | `organization` → `group` (1:1 with `course`) → `course` | No intermediate grouping layer above course | +| Course enrollment | `groupmember` rows per course's `group` | Per-course, no bulk enrollment mechanism | +| Student LMS view | Flat list of enrolled courses under `/lms/mylearning` | No program/class concept | +| Community Q&A | `community_question.courseId` scoped | Reusable with additional `classId` FK | +| Shared file library | Per-course via `assets` table; no org-wide shared files | New `class_file` table needed | +| Announcements | Per-course `course_newsfeed` | New `class_announcement` table needed | +| Org admin navigation | "Courses" section listing all courses | "Classes" nav item needed | +| LMS navigation | Home, My Learning, Exercises, Community, Explore, Settings | "Classes" nav item needed | +| Enrollment service | `enrollInCourse()` in `apps/api/src/services/course/invite.ts` | Must be called per-course when adding class member | + +**Key insight**: Because enrollment = `groupmember` rows per course, adding a student to a class is purely a matter of calling the existing enrollment logic for each course in the class within a single transaction. No course-level schema changes are needed. + +## Product Goals + +1. Org admins and instructors can create a class, add courses to it, and manage class membership from the admin dashboard. +2. Adding a student to a class auto-enrolls them in all courses currently in the class. +3. Adding a new course to an existing class auto-enrolls all current class students in that course. +4. Each class has a shared announcements feed, file library, and community board visible to all class members. +5. Students can see their enrolled classes in the LMS with a unified hub view showing all courses, announcements, and files. +6. Classes without students, and courses outside any class, continue to work exactly as they do today (zero regression). + +## Non-Goals (v1) + +- Class-level invite links (v1 enrollment is admin-managed; students cannot self-enroll in a class via a token). +- Class-specific exercise deadlines or content overrides (courses are shared as-is). +- Class progress analytics aggregated across all courses (available per-course; cross-course aggregation is a future improvement). +- Removing a student from a class also unenrolling them from individual courses (data preservation is the default). +- Class templates or class cloning. +- Public/discoverable class landing pages (classes are private to org members). +- Billing or pricing at the class level. +- Class-level attendance tracking separate from per-course attendance. + +## Data Sources Checked + +- `packages/db/src/schema.ts` — all table definitions and constraint patterns +- `apps/api/src/services/course/course.ts` — course creation flow +- `apps/api/src/services/course/invite.ts` — enrollment flow (`enrollInCourse`) +- `apps/api/src/routes/course/course.ts` — course router and sub-router mounting +- `apps/api/src/app.ts` — top-level router registration +- `packages/db/src/queries/course/people.ts` — group member management +- `packages/db/src/queries/group/group.ts` — group/member queries +- `packages/db/src/queries/community/community.ts` — community Q&A pattern +- `apps/dashboard/src/lib/features/lms/` — LMS navigation and pages +- `apps/dashboard/src/lib/features/org/` — org admin navigation +- `apps/dashboard/src/routes/lms/` — LMS SvelteKit routes +- `apps/dashboard/src/routes/courses/` — course admin SvelteKit routes + +## Functional Requirements + +### 1. Class CRUD (Admin) + +- Org admins can create a class with: name (required), description (optional), cover image (optional). +- Classes can be updated (name, description, cover image, status). +- Archiving a class sets `status = 'ARCHIVED'` and hides it from all views. Archived classes can be restored. +- A class belongs to exactly one organization. +- Class names do not need to be unique within an org (unlike course slugs), but should be descriptive. + +### 2. Course Management in a Class (Admin) + +- Org admins and class tutors can add existing org courses to a class. +- A course can belong to multiple classes simultaneously. +- Adding a course to a class triggers auto-enrollment: all existing `STUDENT` class members are enrolled in the new course (a `groupmember` row is created in the course's `group` for each student not already enrolled). +- Removing a course from a class does not unenroll any students from that course. Their progress and submissions are preserved. +- Courses within a class are ordered (integer `order` field) so instructors can sequence them. + +### 3. Class Membership (Admin) + +- Org admins can add members to a class by profile ID or email, assigning them a role (ADMIN, TUTOR, or STUDENT). +- Adding a STUDENT member triggers auto-enrollment: a `groupmember` row is created in every course currently in the class (skipping courses the student is already enrolled in; idempotent). +- Adding an ADMIN or TUTOR member does not auto-enroll them in courses (their course-level permissions are managed separately via course membership). +- Removing a member from a class removes the `class_member` row only. Individual `groupmember` rows in courses are left intact, preserving submissions, grades, and progress. +- Batch add: admins can add multiple members at once (array of `{ email, roleId }` pairs). + +### 4. Class Announcements (Admin + LMS) + +- Class admins and tutors can post announcements to the class feed. +- Announcements have a title and a rich-text body. +- Announcements are visible to all class members (students, tutors, and admins) in both the admin class view and the LMS class hub. +- Students cannot post announcements (read-only for students). +- Announcements are ordered by `createdAt` descending (newest first). + +### 5. Class File Library (Admin + LMS) + +- Class admins and tutors can upload files to the class file library. +- Each file record stores: name, URL (stored via the existing presigned upload flow), MIME type, size, uploader profile ID. +- All class members can view and download files. +- Only class admins and tutors can delete files. + +### 6. Class Community (Admin + LMS) + +- Class-level community Q&A reuses the existing `community_question` / `community_answer` infrastructure. +- A nullable `classId` FK is added to `community_question`. Questions with `classId` set are class-scoped (not visible on the course-level community board). +- All class members can post questions and answers in the class community. +- The existing voting, commenting, and answer-acceptance behavior is unchanged. + +### 7. LMS — My Classes (Student) + +- A "Classes" nav item appears in the LMS sidebar when the student is enrolled in at least one class. +- The `/lms/classes` page lists all classes the student is enrolled in. +- Each class card shows: class name, cover image, number of courses, brief description. +- Clicking a class opens the class hub page. + +### 8. LMS — Class Hub (Student) + +- The class hub at `/lms/classes/[classId]` is the student's central page for a class. +- It is divided into three tabs or sections: + - **Courses**: All courses in the class, each with the student's individual progress bar and a direct link to the course. Courses are shown in the instructor-defined order. + - **Announcements**: Class announcements in reverse-chronological order (read-only for students). + - **Resources**: The class file library (download only) and a link to the class community board. +- The class community board lives at `/lms/classes/[classId]/community` and behaves identically to the existing course community pages. + +### 9. Admin — Classes Section + +- "Classes" is added as a top-level nav item in the org admin sidebar, alongside "Courses". +- The `/courses/classes` page lists all org classes with name, course count, student count, and status badge. +- A "Create Class" button opens a creation form. +- Each class links to a class detail view with sub-pages: Overview, Courses, People, Announcements, Files. + +### 10. Access Control + +- Only `ADMIN` and `TUTOR` role members of a class (or org admins) can manage class content and membership. +- Students can read announcements and files, participate in the community, and view the course list. +- A student who is removed from the org loses access to all class content. +- An `INACTIVE` class is hidden from students in the LMS but all data is preserved. + +--- + +## Technical Design + +### Data Model + +#### New Enum: `CLASS_STATUS` + +```typescript +// packages/db/src/schema.ts +export const classStatus = pgEnum('CLASS_STATUS', ['ACTIVE', 'INACTIVE', 'ARCHIVED']); +``` + +#### New Table: `class` + +```typescript +// packages/db/src/schema.ts +export const lmsClass = pgTable( + 'class', + { + id: uuid() + .default(sql`gen_random_uuid()`) + .primaryKey() + .notNull(), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(), + organizationId: uuid('organization_id').notNull(), + name: varchar().notNull(), + description: text(), + coverImage: text('cover_image'), + status: classStatus().default('ACTIVE').notNull(), + createdByProfileId: uuid('created_by_profile_id') + }, + (table) => [ + foreignKey({ + columns: [table.organizationId], + foreignColumns: [organization.id], + name: 'class_organization_id_fkey' + }).onDelete('cascade'), + foreignKey({ + columns: [table.createdByProfileId], + foreignColumns: [profile.id], + name: 'class_created_by_profile_id_fkey' + }), + index('idx_class_organization_id').on(table.organizationId) + ] +); +``` + +#### New Table: `class_course` + +Many-to-many join between class and course, with an instructor-controlled display order. + +```typescript +// packages/db/src/schema.ts +export const classCourse = pgTable( + 'class_course', + { + id: uuid() + .default(sql`gen_random_uuid()`) + .primaryKey() + .notNull(), + classId: uuid('class_id').notNull(), + courseId: uuid('course_id').notNull(), + order: integer().default(0).notNull(), + addedAt: timestamp('added_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull() + }, + (table) => [ + foreignKey({ + columns: [table.classId], + foreignColumns: [lmsClass.id], + name: 'class_course_class_id_fkey' + }).onDelete('cascade'), + foreignKey({ + columns: [table.courseId], + foreignColumns: [course.id], + name: 'class_course_course_id_fkey' + }).onDelete('cascade'), + unique('class_course_class_id_course_id_unique').on(table.classId, table.courseId), + index('idx_class_course_class_id').on(table.classId) + ] +); +``` + +#### New Table: `class_member` + +Membership roster for a class. Does not replace `groupmember` — it is a separate layer. + +```typescript +// packages/db/src/schema.ts +export const classMember = pgTable( + 'class_member', + { + id: uuid() + .default(sql`gen_random_uuid()`) + .primaryKey() + .notNull(), + classId: uuid('class_id').notNull(), + profileId: uuid('profile_id'), + roleId: integer('role_id').notNull(), + email: text(), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull() + }, + (table) => [ + foreignKey({ + columns: [table.classId], + foreignColumns: [lmsClass.id], + name: 'class_member_class_id_fkey' + }).onDelete('cascade'), + foreignKey({ + columns: [table.profileId], + foreignColumns: [profile.id], + name: 'class_member_profile_id_fkey' + }), + foreignKey({ + columns: [table.roleId], + foreignColumns: [role.id], + name: 'class_member_role_id_fkey' + }), + unique('class_member_class_id_profile_id_unique').on(table.classId, table.profileId), + index('idx_class_member_class_id').on(table.classId), + index('idx_class_member_profile_id').on(table.profileId) + ] +); +``` + +#### New Table: `class_announcement` + +```typescript +// packages/db/src/schema.ts +export const classAnnouncement = pgTable( + 'class_announcement', + { + id: uuid() + .default(sql`gen_random_uuid()`) + .primaryKey() + .notNull(), + classId: uuid('class_id').notNull(), + authorProfileId: uuid('author_profile_id'), + title: varchar().notNull(), + body: text(), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow() + }, + (table) => [ + foreignKey({ + columns: [table.classId], + foreignColumns: [lmsClass.id], + name: 'class_announcement_class_id_fkey' + }).onDelete('cascade'), + foreignKey({ + columns: [table.authorProfileId], + foreignColumns: [profile.id], + name: 'class_announcement_author_profile_id_fkey' + }), + index('idx_class_announcement_class_id').on(table.classId) + ] +); +``` + +#### New Table: `class_file` + +```typescript +// packages/db/src/schema.ts +export const classFile = pgTable( + 'class_file', + { + id: uuid() + .default(sql`gen_random_uuid()`) + .primaryKey() + .notNull(), + classId: uuid('class_id').notNull(), + uploadedByProfileId: uuid('uploaded_by_profile_id'), + name: varchar().notNull(), + url: text().notNull(), + mimeType: varchar('mime_type'), + size: integer(), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull() + }, + (table) => [ + foreignKey({ + columns: [table.classId], + foreignColumns: [lmsClass.id], + name: 'class_file_class_id_fkey' + }).onDelete('cascade'), + foreignKey({ + columns: [table.uploadedByProfileId], + foreignColumns: [profile.id], + name: 'class_file_uploaded_by_profile_id_fkey' + }), + index('idx_class_file_class_id').on(table.classId) + ] +); +``` + +#### Modified Table: `community_question` + +Add a nullable `classId` FK so community questions can be scoped to a class: + +```typescript +// Add to community_question table columns: +classId: uuid('class_id') + +// Add to community_question table constraints: +foreignKey({ + columns: [table.classId], + foreignColumns: [lmsClass.id], + name: 'community_question_class_id_fkey' +}).onDelete('cascade') +``` + +When `classId` is set, the question is class-scoped. When `courseId` is set, it is course-scoped. The two are mutually exclusive in practice (enforced at the service layer). + +#### Relationship Diagram + +``` +organization + └── class + ├── class_course ──────────────────────→ course + │ (order, addedAt) └── group → groupmember ← (auto-created) + ├── class_member ──────────────────────→ profile + │ (roleId: ADMIN/TUTOR/STUDENT) + ├── class_announcement + │ (title, body, authorProfileId) + ├── class_file + │ (name, url, mimeType, size) + └── community_question (classId set) + └── community_answer +``` + +### Migration + +File: `packages/db/src/migrations/XXXX_add_classes.sql` + +```sql +-- 1. Create CLASS_STATUS enum +CREATE TYPE "CLASS_STATUS" AS ENUM ('ACTIVE', 'INACTIVE', 'ARCHIVED'); + +-- 2. Create class table +CREATE TABLE class ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + organization_id UUID NOT NULL REFERENCES organization(id) ON DELETE CASCADE, + name VARCHAR NOT NULL, + description TEXT, + cover_image TEXT, + status "CLASS_STATUS" NOT NULL DEFAULT 'ACTIVE', + created_by_profile_id UUID REFERENCES profile(id) +); + +CREATE INDEX idx_class_organization_id ON class(organization_id); + +-- 3. Create class_course join table +CREATE TABLE class_course ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + class_id UUID NOT NULL REFERENCES class(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES course(id) ON DELETE CASCADE, + "order" INTEGER NOT NULL DEFAULT 0, + added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(class_id, course_id) +); + +CREATE INDEX idx_class_course_class_id ON class_course(class_id); + +-- 4. Create class_member table +CREATE TABLE class_member ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + class_id UUID NOT NULL REFERENCES class(id) ON DELETE CASCADE, + profile_id UUID REFERENCES profile(id), + role_id INTEGER NOT NULL REFERENCES role(id), + email TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(class_id, profile_id) +); + +CREATE INDEX idx_class_member_class_id ON class_member(class_id); +CREATE INDEX idx_class_member_profile_id ON class_member(profile_id); + +-- 5. Create class_announcement table +CREATE TABLE class_announcement ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + class_id UUID NOT NULL REFERENCES class(id) ON DELETE CASCADE, + author_profile_id UUID REFERENCES profile(id), + title VARCHAR NOT NULL, + body TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX idx_class_announcement_class_id ON class_announcement(class_id); + +-- 6. Create class_file table +CREATE TABLE class_file ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + class_id UUID NOT NULL REFERENCES class(id) ON DELETE CASCADE, + uploaded_by_profile_id UUID REFERENCES profile(id), + name VARCHAR NOT NULL, + url TEXT NOT NULL, + mime_type VARCHAR, + size INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_class_file_class_id ON class_file(class_id); + +-- 7. Add classId to community_question +ALTER TABLE community_question ADD COLUMN class_id UUID REFERENCES class(id) ON DELETE CASCADE; +``` + +--- + +## Access Control Logic + +Two middleware functions are needed in `apps/api/src/middleware/`: + +### `classAccessMiddleware` (read access) + +Verifies the caller is either an org admin or a `class_member` for the given `:classId`. Used on all `GET /class/:classId/*` routes. + +```typescript +// apps/api/src/middleware/class-access.ts +export async function classAccessMiddleware(c, next) { + const { classId } = c.req.param(); + const user = c.get('user')!; + const isMember = await isClassMember(classId, user.profileId); + const isOrgAdmin = await isOrgAdminByClassId(classId, user.profileId); + if (!isMember && !isOrgAdmin) throw new AppError('Forbidden', ErrorCodes.FORBIDDEN, 403); + await next(); +} +``` + +### `classAdminMiddleware` (write access) + +Verifies the caller has `roleId` 1 (ADMIN) or 2 (TUTOR) within the class, or is an org admin. Used on all mutating routes. + +```typescript +// apps/api/src/middleware/class-admin.ts +export async function classAdminMiddleware(c, next) { + const { classId } = c.req.param(); + const user = c.get('user')!; + const role = await getClassMemberRole(classId, user.profileId); + const isOrgAdmin = await isOrgAdminByClassId(classId, user.profileId); + if (!isOrgAdmin && (!role || role > 2)) throw new AppError('Forbidden', ErrorCodes.FORBIDDEN, 403); + await next(); +} +``` + +--- + +## API and Route Plan + +### Validation Layer + +File: `packages/utils/src/validation/class/class.ts` + +```typescript +import { z } from 'zod'; + +export const ZCreateClass = z.object({ + name: z.string().min(1).max(255), + description: z.string().max(2000).optional(), + coverImage: z.string().url().optional() +}); +export type TCreateClass = z.infer; + +export const ZUpdateClass = z.object({ + name: z.string().min(1).max(255).optional(), + description: z.string().max(2000).nullable().optional(), + coverImage: z.string().url().nullable().optional(), + status: z.enum(['ACTIVE', 'INACTIVE', 'ARCHIVED']).optional() +}); +export type TUpdateClass = z.infer; + +export const ZAddCourseToClass = z.object({ + courseId: z.string().uuid(), + order: z.number().int().min(0).optional() +}); +export type TAddCourseToClass = z.infer; + +export const ZAddClassMembers = z.object({ + members: z.array( + z.object({ + email: z.string().email(), + roleId: z.number().int().min(1).max(3) + }) + ).min(1) +}); +export type TAddClassMembers = z.infer; + +export const ZUpdateClassMember = z.object({ + roleId: z.number().int().min(1).max(3) +}); +export type TUpdateClassMember = z.infer; + +export const ZCreateClassAnnouncement = z.object({ + title: z.string().min(1).max(500), + body: z.string().max(10000).optional() +}); +export type TCreateClassAnnouncement = z.infer; + +export const ZUpdateClassAnnouncement = z.object({ + title: z.string().min(1).max(500).optional(), + body: z.string().max(10000).nullable().optional() +}); +export type TUpdateClassAnnouncement = z.infer; + +export const ZAddClassFile = z.object({ + name: z.string().min(1).max(255), + url: z.string().url(), + mimeType: z.string().optional(), + size: z.number().int().positive().optional() +}); +export type TAddClassFile = z.infer; +``` + +Export from: `packages/utils/src/validation/class/index.ts` and re-export from `packages/utils/src/validation/index.ts`. + +### Query Layer + +File: `packages/db/src/queries/class/class.ts` + +| Function | Description | +| --- | --- | +| `createClass(data)` | Insert into `class`, return created row | +| `getClassById(classId)` | Single class with member count and course count | +| `getClassesByOrg(orgId)` | All non-archived classes for org, ordered by `createdAt` desc | +| `updateClass(classId, data)` | Update name, description, coverImage, status | +| `getClassMemberRole(classId, profileId)` | Return `roleId` for the given profile in the class, or null | +| `isClassMember(classId, profileId)` | Boolean check | +| `isOrgAdminByClassId(classId, profileId)` | Joins `class → organization → organizationmember` to check admin role | +| `addClassMember(classId, profileId, roleId, email)` | Insert `class_member` row (upsert on conflict) | +| `removeClassMember(classId, memberId)` | Delete `class_member` by `id` | +| `getClassMembers(classId)` | All members joined to `profile` for display | +| `getEnrolledClassesByProfile(orgId, profileId)` | All `ACTIVE` classes where profile is a class member, for LMS | +| `addCourseToClass(classId, courseId, order)` | Insert `class_course` row | +| `removeCourseFromClass(classId, courseId)` | Delete `class_course` row | +| `getCoursesByClass(classId)` | All courses in class ordered by `order`, joined to `course` | +| `getStudentMemberIdsByClass(classId)` | All `profileId` values with `roleId = 3` in class (for auto-enrollment) | +| `createClassAnnouncement(data)` | Insert `class_announcement` | +| `getClassAnnouncements(classId)` | All announcements ordered by `createdAt` desc, joined to `profile` | +| `updateClassAnnouncement(announcementId, data)` | Update title/body | +| `deleteClassAnnouncement(announcementId)` | Delete row | +| `addClassFile(data)` | Insert `class_file` | +| `getClassFiles(classId)` | All files ordered by `createdAt` desc | +| `deleteClassFile(fileId)` | Delete `class_file` by `id` | + +Export from: `packages/db/src/queries/class/index.ts` + +### Service Layer + +File: `apps/api/src/services/class/class.ts` + +#### `createClass(orgId, profileId, data: TCreateClass)` + +- Insert `class` row. +- Add creator as `class_member` with `roleId = 1` (ADMIN). +- Return the new class. + +#### `addCourseToClass(classId, courseId)` + +- Verify class exists and course belongs to the same org. +- Insert `class_course` row (error if already linked). +- Fetch all current STUDENT class members (`getStudentMemberIdsByClass`). +- For each student profile, look up their `groupmember` status in the course's group; if not already enrolled, call `enrollInCourse` (or insert `groupmember` directly if the course is free and no invite is needed). +- All writes in a single transaction. +- Return updated course list for the class. + +#### `removeCourseFromClass(classId, courseId)` + +- Delete `class_course` row. +- No changes to `groupmember` records. +- Return updated course list. + +#### `addMembersToClass(classId, members: TAddClassMembers['members'])` + +Transaction: +1. For each member in the batch, resolve `profileId` from `email` (look up `profile` by email, create if none exists as an invited-but-unregistered user following the same pattern as `addGroupMember`). +2. Insert `class_member` row (skip if already a member of the class). +3. If `roleId = 3` (STUDENT), fetch all courses in the class (`getCoursesByClass`). For each course, check if the student is already a `groupmember`; if not, insert a `groupmember` row with `roleId = 3`. + +#### `removeMemberFromClass(classId, memberId)` + +- Delete `class_member` row by `id`. +- Leave all `groupmember` rows in individual courses intact. + +#### `createAnnouncement(classId, profileId, data: TCreateClassAnnouncement)` + +- Verify caller is class admin or tutor. +- Insert `class_announcement` row. +- Return new announcement. + +#### `addFile(classId, profileId, data: TAddClassFile)` + +- Insert `class_file` row. +- Return new file record. + +#### `deleteFile(fileId, profileId)` + +- Verify caller is class admin or tutor. +- Delete `class_file` row. + +### Route Layer + +File: `apps/api/src/routes/class/class.ts` + +All routes require `authMiddleware`. Routes scoped to a `:classId` also apply `classAccessMiddleware` (read) or `classAdminMiddleware` (write) as noted. + +| Method | Path | Middleware | Description | +| --- | --- | --- | --- | +| `GET` | `/class` | `authMiddleware` | List org's classes (org admin sees all; tutors/students see their own) | +| `POST` | `/class` | `authMiddleware` + orgAdmin | Create class | +| `GET` | `/class/:classId` | `classAccessMiddleware` | Get class with member/course counts | +| `PUT` | `/class/:classId` | `classAdminMiddleware` | Update class name, description, cover, status | +| `DELETE` | `/class/:classId` | orgAdmin | Archive class (`status = 'ARCHIVED'`) | +| `GET` | `/class/:classId/courses` | `classAccessMiddleware` | List courses in class (ordered) | +| `POST` | `/class/:classId/courses` | `classAdminMiddleware` | Add course to class + auto-enroll students | +| `DELETE` | `/class/:classId/courses/:courseId` | `classAdminMiddleware` | Remove course from class | +| `GET` | `/class/:classId/members` | `classAdminMiddleware` | List class members with profile info | +| `POST` | `/class/:classId/members` | `classAdminMiddleware` | Batch-add members + auto-enroll students | +| `PUT` | `/class/:classId/members/:memberId` | `classAdminMiddleware` | Update member role | +| `DELETE` | `/class/:classId/members/:memberId` | `classAdminMiddleware` | Remove member from class | +| `GET` | `/class/:classId/announcements` | `classAccessMiddleware` | List announcements | +| `POST` | `/class/:classId/announcements` | `classAdminMiddleware` | Create announcement | +| `PUT` | `/class/:classId/announcements/:announcementId` | `classAdminMiddleware` | Update announcement | +| `DELETE` | `/class/:classId/announcements/:announcementId` | `classAdminMiddleware` | Delete announcement | +| `GET` | `/class/:classId/files` | `classAccessMiddleware` | List files | +| `POST` | `/class/:classId/files` | `classAdminMiddleware` | Add file record | +| `DELETE` | `/class/:classId/files/:fileId` | `classAdminMiddleware` | Delete file | + +#### Route Registration + +```typescript +// apps/api/src/routes/class/index.ts +export * from './class'; + +// apps/api/src/app.ts +import { classRouter } from '@api/routes/class'; +export const app = new Hono() + // ... existing routes ... + .route('/class', classRouter); +``` + +#### LMS Enrolled Classes Route + +Add to `apps/api/src/routes/organization/organization.ts`: + +``` +GET /organization/classes/enrolled +``` + +Returns all `ACTIVE` classes where the current user is a STUDENT `class_member`, including the course list with per-course progress. Used by the LMS "My Classes" and "Class Hub" pages. + +#### Build Verification + +```bash +pnpm --filter @cio/utils build +pnpm --filter @cio/db build +pnpm --filter @cio/api build +``` + +--- + +## Frontend Plan (Dashboard) + +### Request Types + +File: `apps/dashboard/src/lib/features/class/utils/types.ts` + +```typescript +import { classroomio, type InferResponseType } from '$lib/utils/services/api'; + +export type ListClassesRequest = typeof classroomio.class.$get; +export type CreateClassRequest = typeof classroomio.class.$post; +export type GetClassRequest = typeof classroomio.class[':classId']['$get']; +export type UpdateClassRequest = typeof classroomio.class[':classId']['$put']; +export type ListClassCoursesRequest = typeof classroomio.class[':classId']['courses']['$get']; +export type AddCourseToClassRequest = typeof classroomio.class[':classId']['courses']['$post']; +export type ListClassMembersRequest = typeof classroomio.class[':classId']['members']['$get']; +export type AddClassMembersRequest = typeof classroomio.class[':classId']['members']['$post']; +export type ListAnnouncementsRequest = typeof classroomio.class[':classId']['announcements']['$get']; +export type CreateAnnouncementRequest = typeof classroomio.class[':classId']['announcements']['$post']; +export type ListClassFilesRequest = typeof classroomio.class[':classId']['files']['$get']; + +export type ListClassesSuccess = Extract, { success: true }>; +export type ClassItem = ListClassesSuccess['data'][number]; + +export type GetClassSuccess = Extract, { success: true }>; +export type ClassDetail = GetClassSuccess['data']; + +export type ListClassCoursesSuccess = Extract, { success: true }>; +export type ClassCourse = ListClassCoursesSuccess['data'][number]; + +export type ListClassMembersSuccess = Extract, { success: true }>; +export type ClassMember = ListClassMembersSuccess['data'][number]; + +export type ListAnnouncementsSuccess = Extract, { success: true }>; +export type ClassAnnouncement = ListAnnouncementsSuccess['data'][number]; + +export type ListClassFilesSuccess = Extract, { success: true }>; +export type ClassFile = ListClassFilesSuccess['data'][number]; + +// LMS enrolled classes +export type EnrolledClassesRequest = typeof classroomio.organization.classes.enrolled['$get']; +export type EnrolledClassesSuccess = Extract, { success: true }>; +export type EnrolledClass = EnrolledClassesSuccess['data'][number]; +``` + +### API Class + +File: `apps/dashboard/src/lib/features/class/api/class.svelte.ts` + +```typescript +class ClassApi extends BaseApiWithErrors { + classes = $state([]); + currentClass = $state(null); + courses = $state([]); + members = $state([]); + announcements = $state([]); + files = $state([]); + loading = $state(false); + + async list() { /* GET /class */ } + async get(classId: string) { /* GET /class/:classId */ } + async create(data: TCreateClass) { /* POST /class */ } + async update(classId: string, data: TUpdateClass) { /* PUT /class/:classId */ } + async archive(classId: string) { /* DELETE /class/:classId */ } + + async listCourses(classId: string) { /* GET /class/:classId/courses */ } + async addCourse(classId: string, data: TAddCourseToClass) { /* POST /class/:classId/courses */ } + async removeCourse(classId: string, courseId: string) { /* DELETE /class/:classId/courses/:courseId */ } + + async listMembers(classId: string) { /* GET /class/:classId/members */ } + async addMembers(classId: string, data: TAddClassMembers) { /* POST /class/:classId/members */ } + async removeMember(classId: string, memberId: string) { /* DELETE /class/:classId/members/:memberId */ } + + async listAnnouncements(classId: string) { /* GET /class/:classId/announcements */ } + async createAnnouncement(classId: string, data: TCreateClassAnnouncement) { /* POST */ } + async deleteAnnouncement(classId: string, announcementId: string) { /* DELETE */ } + + async listFiles(classId: string) { /* GET /class/:classId/files */ } + async addFile(classId: string, data: TAddClassFile) { /* POST /class/:classId/files */ } + async deleteFile(classId: string, fileId: string) { /* DELETE /class/:classId/files/:fileId */ } +} + +export const classApi = new ClassApi(); +``` + +### LMS API Class + +File: `apps/dashboard/src/lib/features/lms/api/class.svelte.ts` + +```typescript +class LmsClassApi extends BaseApiWithErrors { + enrolledClasses = $state([]); + loading = $state(false); + + async listEnrolled() { /* GET /organization/classes/enrolled */ } +} + +export const lmsClassApi = new LmsClassApi(); +``` + +### Admin Pages + +| Route | File | Purpose | +| --- | --- | --- | +| `/courses/classes` | `apps/dashboard/src/routes/courses/classes/+page.svelte` | List all org classes | +| `/courses/classes/new` | `apps/dashboard/src/routes/courses/classes/new/+page.svelte` | Create class form | +| `/courses/classes/[classId]` | `apps/dashboard/src/routes/courses/classes/[classId]/+page.svelte` | Class overview | +| `/courses/classes/[classId]/courses` | `.../courses/+page.svelte` | Manage courses | +| `/courses/classes/[classId]/people` | `.../people/+page.svelte` | Manage members | +| `/courses/classes/[classId]/announcements` | `.../announcements/+page.svelte` | Manage announcements | +| `/courses/classes/[classId]/files` | `.../files/+page.svelte` | File library management | + +Feature pages live in: `apps/dashboard/src/lib/features/class/pages/` + +### LMS Pages + +| Route | File | Purpose | +| --- | --- | --- | +| `/lms/classes` | `apps/dashboard/src/routes/lms/classes/+page.svelte` | My enrolled classes | +| `/lms/classes/[classId]` | `apps/dashboard/src/routes/lms/classes/[classId]/+page.svelte` | Class hub (courses + announcements + files) | +| `/lms/classes/[classId]/community` | `.../community/+page.svelte` | Class community Q&A | + +Feature pages live in: `apps/dashboard/src/lib/features/lms/pages/class/` + +### Navigation Updates + +**Org admin sidebar** (`apps/dashboard/src/lib/features/org/`): +- Add "Classes" nav item at the same level as "Courses", linking to `/courses/classes`. +- Use a suitable icon (e.g., a group/layers icon). + +**LMS sidebar** (`apps/dashboard/src/lib/features/lms/`): +- Add "Classes" nav item (shown conditionally: only when `enrolledClasses.length > 0`). +- Position between "My Learning" and "Exercises". + +### Translations + +File: `apps/dashboard/src/lib/utils/translations/en.json` + +Add keys under `class`: + +```json +{ + "class": { + "title": "Classes", + "my_classes": "My Classes", + "create": "Create Class", + "create_description": "Group multiple courses and materials under one class for your students.", + "name": "Class Name", + "description": "Description", + "cover_image": "Cover Image", + "status": "Status", + "active": "Active", + "inactive": "Inactive", + "archived": "Archived", + "courses": "Courses", + "people": "People", + "announcements": "Announcements", + "files": "Files", + "community": "Community", + "add_course": "Add Course", + "remove_course": "Remove Course", + "add_members": "Add Members", + "remove_member": "Remove Member", + "course_count": "{count} courses", + "student_count": "{count} students", + "new_announcement": "New Announcement", + "announcement_title": "Announcement Title", + "announcement_body": "Write your announcement...", + "upload_file": "Upload File", + "no_classes": "No classes yet. Create one to get started.", + "no_courses": "No courses in this class yet.", + "no_members": "No members yet. Add students or tutors.", + "no_announcements": "No announcements yet.", + "no_files": "No files uploaded yet.", + "archive_confirm": "Archiving this class will hide it from all views. Are you sure?", + "auto_enroll_note": "Adding this course will enroll all current class students in it.", + "remove_course_note": "Removing this course will not unenroll any students.", + "remove_member_note": "Removing this member will not unenroll them from individual courses." + } +} +``` + +### Frontend Build Verification + +```bash +pnpm --filter @cio/dashboard build +``` + +--- + +## Implementation Order + +### Phase 1: Database + Schema + +1. Add `classStatus` enum to `packages/db/src/schema.ts`. +2. Add `lmsClass`, `classCourse`, `classMember`, `classAnnouncement`, `classFile` tables to schema. +3. Add `classId` FK column to `communityQuestion` table in schema. +4. Write and apply migration SQL: `packages/db/src/migrations/XXXX_add_classes.sql`. +5. Run `pnpm --filter @cio/db build`. + +### Phase 2: Validation + Queries + +1. Create `packages/utils/src/validation/class/class.ts` with all Zod schemas. +2. Export from `packages/utils/src/validation/class/index.ts` and re-export from `packages/utils/src/validation/index.ts`. +3. Run `pnpm --filter @cio/utils build`. +4. Create `packages/db/src/queries/class/class.ts` with all query functions. +5. Create `packages/db/src/queries/class/index.ts` and export. +6. Run `pnpm --filter @cio/db build`. + +### Phase 3: Service + Routes + +1. Create `apps/api/src/services/class/class.ts` with all service functions (auto-enrollment transactions are the most complex part). +2. Create access control utilities: `apps/api/src/middleware/class-access.ts` and `apps/api/src/middleware/class-admin.ts`. +3. Add new error codes to `apps/api/src/utils/errors.ts`: `CLASS_NOT_FOUND`, `CLASS_FORBIDDEN`, `COURSE_ALREADY_IN_CLASS`, `MEMBER_ALREADY_IN_CLASS`. +4. Create `apps/api/src/routes/class/class.ts` with all route handlers. +5. Create `apps/api/src/routes/class/index.ts` and register in `apps/api/src/app.ts`. +6. Add `GET /organization/classes/enrolled` to the organization router. +7. Run `pnpm --filter @cio/api build`. + +### Phase 4: Admin Frontend + +1. Add request/response types to `apps/dashboard/src/lib/features/class/utils/types.ts`. +2. Create `apps/dashboard/src/lib/features/class/api/class.svelte.ts` API class. +3. Add translation keys to `en.json`. +4. Create admin feature pages under `apps/dashboard/src/lib/features/class/pages/`. +5. Create SvelteKit route files under `apps/dashboard/src/routes/courses/classes/`. +6. Add "Classes" nav item to the org admin sidebar. +7. Run `pnpm --filter @cio/dashboard build`. + +### Phase 5: LMS Frontend + +1. Create `apps/dashboard/src/lib/features/lms/api/class.svelte.ts`. +2. Create LMS feature pages under `apps/dashboard/src/lib/features/lms/pages/class/`. +3. Create SvelteKit route files under `apps/dashboard/src/routes/lms/classes/`. +4. Add "Classes" nav item to the LMS sidebar (conditional on having enrolled classes). +5. Run `pnpm --filter @cio/dashboard build`. + +--- + +## Acceptance Criteria + +1. Org admin can create a class, add courses to it, and add students — all from the admin dashboard. +2. When a student is added to a class, `groupmember` rows are automatically created for every course in the class. +3. When a course is added to an existing class that already has students, all those students are enrolled in the new course. +4. Removing a student from a class does not remove their `groupmember` records in individual courses. +5. Removing a course from a class does not unenroll any students. +6. Students see a "Classes" section in the LMS sidebar (only when enrolled in at least one class). +7. The class hub page shows all courses with per-course progress bars, the announcements feed, and the file library. +8. Class admins and tutors can post announcements and upload files; students can only view them. +9. Class community Q&A is scoped to the class and does not appear on course community boards. +10. `INACTIVE` classes are hidden from the student LMS but remain fully accessible to admins and tutors. +11. `ARCHIVED` classes are hidden from all views. +12. Courses that are not in any class, and courses whose students were enrolled independently, continue to work exactly as they do today (zero regression on existing `groupmember` and course data). +13. All user-facing strings use translation keys (no hardcoded English). +14. `pnpm --filter @cio/api build` and `pnpm --filter @cio/dashboard build` pass after implementation. + +--- + +## Risks and Mitigations + +- **Risk**: Auto-enrollment transaction for large classes (hundreds of students, tens of courses) times out or partially fails. + - **Mitigation**: Process auto-enrollment in the service layer using a single transaction with batch inserts (insert multiple `groupmember` rows in one query using Drizzle's batch insert). For very large classes (> 500 students), offload to a background job via the existing email job queue or a new async task runner, and return a `202 Accepted` with a polling endpoint. + +- **Risk**: A student is already enrolled in a course independently before being added to a class. Duplicate `groupmember` rows violate the `(groupId, profileId)` unique constraint. + - **Mitigation**: The enrollment logic in `addMembersToClass` must check for existing membership before inserting. Use `INSERT ... ON CONFLICT DO NOTHING` or check with `isUserCourseMember` before inserting. This is already idempotent in the existing enrollment service. + +- **Risk**: The `community_question.classId` column causes existing community queries to break if they don't filter by `classId IS NULL` for course-scoped questions. + - **Mitigation**: Add `classId IS NULL` to all existing community query filters where `courseId` is used. The column is nullable so existing rows default to NULL and behavior is unchanged. Update `packages/db/src/queries/community/community.ts` alongside the schema migration. + +- **Risk**: Removing a course from a class causes confusion: students are still enrolled in the course but it no longer appears in the class hub. + - **Mitigation**: Show a confirmation dialog in the admin UI with the note: "Removing this course will not unenroll any students. They will still have access to it via My Learning." Use the `class.remove_course_note` translation key already specified. + +- **Risk**: Org nav gets crowded with a new "Classes" top-level item. + - **Mitigation**: Place "Classes" directly below "Courses" in the sidebar. Consider grouping them under a "Learning" section header if more items are added in future. diff --git a/prd/compliance-training-platform/README.md b/prd/compliance-training-platform/README.md new file mode 100644 index 00000000..b4f4275e --- /dev/null +++ b/prd/compliance-training-platform/README.md @@ -0,0 +1,979 @@ +# Compliance Training Platform PRD + +## Status +- Draft + +## Date +- February 21, 2026 + +## Purpose +Transform ClassroomIO into the go-to platform for companies that need to deliver, track, and prove compliance training for their employees. This PRD defines features for organizations in regulated industries (healthcare, finance, manufacturing, government) who must ensure their workforce stays compliant with regulations. + +--- + +## 1. Executive Summary + +### The Problem +Companies in regulated industries struggle to: +- **Assign** the right training to the right people based on their role/location +- **Track** who completed training on time and who is overdue +- **Remind** employees before deadlines and escalate to managers +- **Prove** compliance to auditors with tamper-proof records +- **Renew** certifications automatically when they expire + +### Target Customers +| Industry | Example Use Cases | +|----------|-------------------| +| Healthcare | HIPAA training, patient safety, infection control, medical device handling | +| Financial Services | Anti-money laundering, fraud prevention, data privacy, trading compliance | +| Manufacturing | OSHA safety, quality control (ISO), hazardous materials handling | +| Government | Security clearance, ethics training, procurement rules, FISMA | +| Education | FERPA, Title IX, campus safety, student data handling | +| Retail/Hospitality | Food safety, anti-harassment, PCI-DSS, workplace safety | + +### Solution Overview +ClassroomIO becomes a **Compliance Training Management System** that helps organizations: +1. **Deploy** compliance training programs efficiently +2. **Automate** assignment, reminders, and renewals +3. **Monitor** compliance status in real-time +4. **Report** audit-ready evidence instantly +5. **Scale** across thousands of employees and multiple locations + +--- + +## 2. Current State vs. Required Features + +### ✅ What ClassroomIO Already Does Well +| Feature | Current Capability | Compliance Gap | +|---------|-------------------|----------------| +| Course Creation | Rich content editor, videos, documents | ✅ Good enough | +| Assessments | Basic exercises with grading | ⚠️ Need more question types & pass/fail rules | +| Certificates | Generate PDF certificates | ⚠️ Need expiration, renewal, revocation | +| User Management | Invite students, track progress | ⚠️ Need bulk assignment by role/department | +| SSO | OIDC/SAML integration | ✅ Enterprise-ready | +| Basic Reporting | Course completion stats | ❌ Need compliance-specific dashboards | + +### ❌ Critical Missing Features for Compliance Training + +| Missing Feature | Why It Matters | Customer Pain | +|-----------------|----------------|---------------| +| **Mandatory Training Assignment** | Compliance training isn't optional - everyone in a role MUST complete it | Currently can only invite, can't enforce | +| **Recurring/Refresh Training** | Many regulations require annual/quarterly retraining | No automated renewal system | +| **Compliance Dashboard** | See who is compliant, who is overdue, by department/role | No bird's-eye view for compliance officers | +| **Policy Acknowledgment** | Track who read and agreed to policies | No policy attestation workflow | +| **Audit Trail** | Prove "who did what when" to auditors | No immutable activity log | +| **Automated Reminders** | Notify employees before deadlines, escalate to managers | Manual follow-up is unsustainable | +| **Evidence Export** | One-click export of training records for auditors | Manual report generation takes hours | + +--- + +## 3. Feature Requirements + +### 3.1 Compliance Training Programs (The Container) + +#### Purpose +A "Program" groups related training requirements (e.g., "Annual HIPAA Compliance" or "New Hire Safety Training"). This is the top-level entity compliance officers manage. + +#### Requirements + +**CP-1: Program Management** +- Create programs with title, description, and regulatory framework (e.g., "HIPAA", "OSHA", "SOX") +- Set program status: draft, active, archived +- Assign program owner (compliance officer responsible) +- Add custom fields for internal tracking (department codes, cost centers, etc.) + +**CP-2: Program Content** +- Link multiple courses/lessons to a program (e.g., HIPAA includes: Basics course, Security course, Quiz) +- Set completion rules: + - "Complete all courses" + - "Complete any 2 of 3 courses" + - "Complete Course A AND (Course B OR Course C)" +- Set passing criteria: minimum score, attempt limits, time between retakes + +**CP-3: Program Assignment Rules** +- Assign by: role, department, location, employment type, custom attributes +- Examples: + - "All nurses in California hospitals" + - "All new hires in first 30 days" + - "All employees with security clearance Level 2+" +- Support exclusions (e.g., "All managers EXCEPT contractors") + +**CP-4: Program Schedule** +- One-time assignment (e.g., new hire training) +- Recurring: every N months/years from completion date OR fixed date +- Grace period: days after due date before marked overdue +- Prerequisites: must complete Program X before starting Program Y + +#### Database Schema +```sql +-- Compliance training programs +CREATE TABLE compliance_program ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organization(id), + title VARCHAR NOT NULL, + description TEXT, + framework VARCHAR, -- 'HIPAA', 'OSHA', 'SOX', 'GDPR', 'Custom' + status VARCHAR DEFAULT 'draft', -- 'draft', 'active', 'archived' + owner_id UUID REFERENCES profile(id), + completion_rule JSONB NOT NULL DEFAULT '{"type": "all"}', -- { type: 'all' | 'any' | 'minimum', count: number } + passing_criteria JSONB NOT NULL DEFAULT '{"minScore": 80, "maxAttempts": 3}', + schedule_config JSONB NOT NULL DEFAULT '{}', -- { type: 'once' | 'recurring', intervalMonths: 12, graceDays: 7 } + custom_fields JSONB DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Program content (courses/exercises linked to program) +CREATE TABLE compliance_program_content ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + program_id UUID NOT NULL REFERENCES compliance_program(id), + content_type VARCHAR NOT NULL, -- 'course', 'exercise', 'lesson' + content_id UUID NOT NULL, + is_required BOOLEAN DEFAULT true, + order_index INTEGER DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Assignment rules (who gets this program) +CREATE TABLE compliance_program_assignment ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + program_id UUID NOT NULL REFERENCES compliance_program(id), + rule_type VARCHAR NOT NULL, -- 'role', 'group', 'location', 'attribute', 'all' + rule_value JSONB NOT NULL, -- { roleIds: [], groupIds: [], location: '', attributes: {} } + is_exclusion BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +--- + +### 3.2 Learner Assignments & Tracking + +#### Purpose +Track each learner's obligation, progress, and completion status for every program they're assigned. + +#### Requirements + +**LA-1: Assignment Lifecycle** +- **Assigned**: User meets assignment rule criteria → creates assignment +- **In Progress**: User started but not completed +- **Completed**: User met all completion rules +- **Overdue**: Past due date + grace period +- **Waived**: Admin exempted user (with reason and expiration) +- **Expired**: Previous completion past validity period → needs renewal + +**LA-2: Assignment Attributes** +- Assigned date, due date, completed date +- Current status and status history with timestamps +- Attempts count, best score, time spent +- Certificate issued (if applicable) +- Reminder history (when reminders were sent) + +**LA-3: Bulk Operations** +- Bulk assign to users matching criteria +- Bulk extend due dates (e.g., grant extension to entire department) +- Bulk waive with reason +- Bulk re-enroll (force retake even if completed) + +**LA-4: Self-Service Portal (for Learners)** +- "My Compliance" dashboard showing all assigned programs +- Visual indicators: due soon (yellow), overdue (red), completed (green) +- Download certificates for completed programs +- See upcoming renewals + +#### Database Schema +```sql +-- Individual learner assignments +CREATE TABLE compliance_assignment ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organization(id), + program_id UUID NOT NULL REFERENCES compliance_program(id), + user_id UUID NOT NULL REFERENCES user(id), + + -- Assignment details + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + due_date TIMESTAMPTZ NOT NULL, + grace_period_days INTEGER DEFAULT 0, + + -- Status tracking + status VARCHAR NOT NULL DEFAULT 'assigned', -- 'assigned', 'in_progress', 'completed', 'overdue', 'waived', 'expired' + status_changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + status_history JSONB DEFAULT '[]', -- [{ status, timestamp, reason }] + + -- Progress + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + progress_percent INTEGER DEFAULT 0, + + -- Results + attempts INTEGER DEFAULT 0, + best_score INTEGER, + passing_score INTEGER, + time_spent_minutes INTEGER, + + -- Certificate + certificate_id UUID, + + -- Waiver (if applicable) + waived_at TIMESTAMPTZ, + waived_by UUID REFERENCES profile(id), + waiver_reason TEXT, + waiver_expires_at TIMESTAMPTZ, + + -- For recurring programs + is_renewal BOOLEAN DEFAULT false, + previous_assignment_id UUID REFERENCES compliance_assignment(id), + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE(program_id, user_id, is_renewal) -- One active assignment per program per user +); + +-- Assignment content progress (track individual courses within program) +CREATE TABLE compliance_assignment_progress ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + assignment_id UUID NOT NULL REFERENCES compliance_assignment(id), + content_type VARCHAR NOT NULL, + content_id UUID NOT NULL, + status VARCHAR NOT NULL DEFAULT 'not_started', -- 'not_started', 'in_progress', 'completed', 'failed' + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + score INTEGER, + attempts INTEGER DEFAULT 0, + time_spent_minutes INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +--- + +### 3.3 Automated Notifications & Escalations + +#### Purpose +Ensure employees complete training on time without manual follow-up from compliance teams. + +#### Requirements + +**NE-1: Reminder Schedule** +- Configurable reminder schedule per program (e.g., 30, 14, 7, 1 days before due) +- Customizable email templates per reminder stage +- Include: program name, due date, progress, direct link to training + +**NE-2: Escalation Paths** +- **Level 1**: Reminder to learner +- **Level 2**: Notify learner + CC manager (when overdue) +- **Level 3**: Escalate to compliance admin + learner's skip-level manager +- **Level 4**: Flag for HR/executive review + +**NE-3: Manager Notifications** +- Weekly digest of team's compliance status +- Immediate alert when direct report goes overdue +- Dashboard showing team compliance rate + +**NE-4: Compliance Team Alerts** +- Daily summary of new overdue assignments +- Alert when large group approaches deadline (e.g., "50 employees due in 3 days") +- System health alerts (failed email deliveries, sync errors) + +**NE-5: Notification Preferences** +- Email, in-app notification, or both +- SMS for critical alerts (optional add-on) +- Digest mode vs. immediate notifications + +#### Database Schema +```sql +-- Notification templates +CREATE TABLE compliance_notification_template ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organization(id), + name VARCHAR NOT NULL, + trigger_type VARCHAR NOT NULL, -- 'reminder', 'overdue', 'escalation', 'completion' + trigger_days INTEGER, -- days before/after event (negative = before, positive = after) + subject_template VARCHAR NOT NULL, + body_template TEXT NOT NULL, + recipients VARCHAR[] NOT NULL, -- ['learner', 'manager', 'compliance_admin'] + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Notification queue and history +CREATE TABLE compliance_notification ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + assignment_id UUID NOT NULL REFERENCES compliance_assignment(id), + template_id UUID REFERENCES compliance_notification_template(id), + notification_type VARCHAR NOT NULL, + recipient_type VARCHAR NOT NULL, -- 'learner', 'manager', 'admin' + recipient_id UUID NOT NULL, + recipient_email VARCHAR NOT NULL, + scheduled_at TIMESTAMPTZ NOT NULL, + sent_at TIMESTAMPTZ, + status VARCHAR NOT NULL DEFAULT 'pending', -- 'pending', 'sent', 'delivered', 'failed', 'bounced' + error_message TEXT, + opened_at TIMESTAMPTZ, + clicked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Notification schedule (tracks what reminders are due) +CREATE TABLE compliance_notification_schedule ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + assignment_id UUID NOT NULL REFERENCES compliance_assignment(id), + reminder_type VARCHAR NOT NULL, -- '30_days', '14_days', '7_days', '1_day', 'overdue', 'escalation' + scheduled_at TIMESTAMPTZ NOT NULL, + is_sent BOOLEAN DEFAULT false, + sent_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +--- + +### 3.4 Policy Attestation (Read & Acknowledge) + +#### Purpose +Not all compliance is "training" - sometimes employees just need to read and acknowledge a policy. Track who has read and agreed to important documents. + +#### Requirements + +**PA-1: Policy Management** +- Upload policy documents (PDF, Word, HTML) +- Version control with version numbers and effective dates +- Categorize (Security, HR, Safety, Code of Conduct, etc.) +- Set required reading time (e.g., "Must view for at least 5 minutes") + +**PA-2: Attestation Workflow** +1. Employee receives notification of new/updated policy +2. Employee opens and reads the document (viewer tracks time) +3. Employee checks "I have read and understand this policy" +4. Employee digitally "signs" with name, date, employee ID +5. System records: user, policy version, timestamp, IP, time spent reading + +**PA-3: Attestation Tracking** +- Who has attested to which version +- Who hasn't attested (and how overdue) +- Bulk attestation for initial rollout (with verification) +- Re-attestation required when policy version changes + +**PA-4: Attestation Certificate** +- Generate proof of attestation per employee +- Include: policy name, version, attestation date, digital signature hash + +#### Database Schema +```sql +-- Policy documents +CREATE TABLE compliance_policy ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organization(id), + title VARCHAR NOT NULL, + description TEXT, + category VARCHAR NOT NULL, -- 'security', 'hr', 'safety', 'conduct', 'privacy' + status VARCHAR DEFAULT 'draft', -- 'draft', 'active', 'archived' + requires_reading_time_seconds INTEGER DEFAULT 0, + created_by UUID NOT NULL REFERENCES profile(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Policy versions +CREATE TABLE compliance_policy_version ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + policy_id UUID NOT NULL REFERENCES compliance_policy(id), + version_number INTEGER NOT NULL, + content_url TEXT NOT NULL, -- S3/MinIO URL + content_hash TEXT NOT NULL, + effective_date TIMESTAMPTZ NOT NULL, + requires_reattestation BOOLEAN DEFAULT true, + created_by UUID NOT NULL REFERENCES profile(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(policy_id, version_number) +); + +-- Who needs to attest to which policy version +CREATE TABLE compliance_policy_assignment ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + policy_version_id UUID NOT NULL REFERENCES compliance_policy_version(id), + assigned_to_type VARCHAR NOT NULL, -- 'role', 'group', 'department', 'all' + assigned_to_id UUID, + due_date TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Attestation records (immutable) +CREATE TABLE compliance_policy_attestation ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + policy_version_id UUID NOT NULL REFERENCES compliance_policy_version(id), + user_id UUID NOT NULL REFERENCES user(id), + attested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ip_address INET, + user_agent TEXT, + time_spent_seconds INTEGER, + digital_signature VARCHAR NOT NULL, -- hash of user+policy+timestamp + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(policy_version_id, user_id) +); +``` + +--- + +### 3.5 Certificates with Lifecycle Management + +#### Purpose +Issue certificates that expire, track renewals, and maintain a complete history for audit purposes. + +#### Requirements + +**CE-1: Certificate Templates** +- Design certificate templates per program +- Include: recipient name, program name, completion date, expiration date, certificate ID, QR code for verification +- Custom fields (CE credits, accreditation body, instructor name) + +**CE-2: Certificate Issuance** +- Auto-issue upon program completion +- Manual issue by admin (for external training imported into system) +- Bulk issue for historical data import + +**CE-3: Certificate Lifecycle** +- **Valid**: Currently valid certificate +- **Expiring Soon**: Within renewal window +- **Expired**: Past expiration date +- **Revoked**: Invalidated (with reason and revoked_by) +- **Renewed**: Replaced by newer certificate (link to new cert) + +**CE-4: Certificate Verification** +- Public verification page (for external auditors) +- QR code on certificate links to verification +- Shows: valid/invalid/revoked status, original issue details + +**CE-5: Certificate History** +- Complete chain: Original → Renewal 1 → Renewal 2 → Current +- Never delete - maintain full history even after revocation + +#### Database Schema +```sql +-- Certificate definitions (linked to programs) +CREATE TABLE compliance_certificate_definition ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organization(id), + program_id UUID NOT NULL REFERENCES compliance_program(id), + name VARCHAR NOT NULL, + template_url TEXT NOT NULL, -- S3/MinIO URL to template file + validity_period_months INTEGER NOT NULL, -- 0 = no expiration + renewal_window_days INTEGER DEFAULT 30, + ce_credits DECIMAL(4,2), -- continuing education credits + accreditation_body VARCHAR, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Issued certificates +CREATE TABLE compliance_certificate ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + certificate_number VARCHAR UNIQUE NOT NULL, -- human-readable number + definition_id UUID NOT NULL REFERENCES compliance_certificate_definition(id), + user_id UUID NOT NULL REFERENCES user(id), + assignment_id UUID NOT NULL REFERENCES compliance_assignment(id), + + -- Certificate data + issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + issued_by UUID NOT NULL REFERENCES profile(id), + expires_at TIMESTAMPTZ, + + -- Certificate file + certificate_url TEXT NOT NULL, + certificate_hash TEXT NOT NULL, + + -- Lifecycle + status VARCHAR DEFAULT 'valid', -- 'valid', 'expiring_soon', 'expired', 'revoked', 'renewed' + revoked_at TIMESTAMPTZ, + revoked_by UUID REFERENCES profile(id), + revoke_reason TEXT, + + -- Renewal chain + is_renewal BOOLEAN DEFAULT false, + previous_certificate_id UUID REFERENCES compliance_certificate(id), + renewed_by_certificate_id UUID REFERENCES compliance_certificate(id), + + -- Verification + verification_token VARCHAR UNIQUE NOT NULL, -- for public verification URL + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +--- + +### 3.6 Compliance Dashboard & Reporting + +#### Purpose +Give compliance officers and managers real-time visibility into organizational compliance posture. + +#### Requirements + +**CD-1: Organization Compliance Overview** +- Overall compliance rate (% of required training completed on time) +- Breakdown by: program, department, location, role +- Risk indicators: overdue count, expiring soon count, non-compliant departments +- Trend lines: compliance rate over time + +**CD-2: Program-Level Dashboard** +- Assignment status pie chart (completed, in progress, overdue, not started) +- Completion rate over time +- Average time to complete +- Pass/fail rates for assessments +- Upcoming due dates calendar view + +**CD-3: Individual Learner View** +- Complete training history +- Current assignments and status +- Certificates earned (with download links) +- Policy attestations +- Compliance "score" or rating + +**CD-4: Alerts & Warnings** +- High-risk situations: "50 people overdue on HIPAA training" +- Approaching deadlines: "200 certifications expire next month" +- Completion milestones: "90% of organization completed annual training" + +**CD-5: Manager Dashboard** +- My team's compliance rate +- Direct reports' individual status +- Overdue notifications sent +- Team comparison to organization average + +**CD-6: Audit Reports** +- One-click generation of compliance packages +- Include: training records, certificates, policy attestations, audit log excerpt +- Tamper-evident (checksums, signed manifest) +- Date range selection +- Filter by: program, department, individual + +#### API Endpoints +```typescript +// Organization compliance overview +GET /compliance/dashboard/overview + Response: { + overallComplianceRate: number; + totalAssignments: number; + completedOnTime: number; + completedLate: number; + overdue: number; + inProgress: number; + notStarted: number; + expiringSoon30Days: number; + expiringSoon90Days: number; + byProgram: ProgramComplianceSummary[]; + byDepartment: DepartmentComplianceSummary[]; + trends: { date: string; rate: number }[]; + } + +// Program detail dashboard +GET /compliance/dashboard/programs/:programId + Response: { + program: ComplianceProgram; + stats: { + totalAssigned: number; + completed: number; + inProgress: number; + overdue: number; + averageCompletionTime: number; + passRate: number; + }; + statusBreakdown: { status: string; count: number; percentage: number }[]; + upcomingDueDates: { date: string; count: number }[]; + recentCompletions: AssignmentSummary[]; + atRiskLearners: AssignmentSummary[]; -- overdue or due soon + } + +// Generate audit report +POST /compliance/reports/audit-package + Body: { + title: string; + dateRange: { start: string; end: string }; + filters: { + programs?: string[]; + departments?: string[]; + users?: string[]; + }; + include: { + trainingRecords: boolean; + certificates: boolean; + policyAttestations: boolean; + auditLog: boolean; + }; + } + Response: { + reportId: string; + status: 'generating' | 'ready' | 'failed'; + downloadUrl?: string; + expiresAt?: string; + manifest: { + generatedAt: string; + generatedBy: string; + recordCounts: Record; + checksums: Record; + }; + } + +// Learner compliance profile +GET /compliance/learners/:userId/profile + Response: { + user: UserSummary; + currentAssignments: AssignmentDetails[]; + completedPrograms: CompletedProgram[]; + certificates: CertificateSummary[]; + policyAttestations: AttestationSummary[]; + complianceScore: number; + riskLevel: 'low' | 'medium' | 'high'; + } +``` + +--- + +### 3.7 Audit Trail & Evidence + +#### Purpose +Provide immutable, tamper-evident records of all compliance-related activities for auditor review. + +#### Requirements + +**AT-1: Comprehensive Event Logging** +Log every compliance-relevant action: +- Program created, modified, archived +- Assignment created, completed, waived, expired +- Certificate issued, revoked, renewed +- Policy published, attested +- Notification sent, opened, clicked +- Report generated, downloaded +- Admin actions (bulk operations, settings changes) + +**AT-2: Log Entry Content** +- Timestamp (UTC, millisecond precision) +- Actor (user ID, role, email) +- Action type and description +- Affected resources (program, assignment, user) +- Before/after values for changes +- IP address and user agent +- Session ID + +**AT-3: Audit Log Characteristics** +- Append-only (no modifications allowed) +- Retained for 7+ years (configurable) +- Exportable to CSV/JSON/PDF +- Full-text searchable +- Filterable by: date range, actor, action type, resource + +**AT-4: Evidence Integrity** +- Cryptographic hash chain linking entries +- Regular integrity verification reports +- Tamper detection alerts + +#### Database Schema +```sql +-- Audit event log (append-only) +CREATE TABLE compliance_audit_event ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organization(id), + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Actor + actor_id UUID REFERENCES user(id), + actor_email VARCHAR, + actor_role VARCHAR, + + -- Action + action_category VARCHAR NOT NULL, -- 'program', 'assignment', 'certificate', 'policy', 'report', 'admin' + action_type VARCHAR NOT NULL, -- 'created', 'updated', 'completed', 'revoked', etc. + action_description TEXT, + + -- Resources + resource_type VARCHAR, -- 'program', 'assignment', 'user', 'certificate' + resource_id UUID, + resource_name VARCHAR, + + -- Details + changes JSONB, -- { field: { from: x, to: y } } + metadata JSONB, -- additional context + + -- Context + ip_address INET, + user_agent TEXT, + session_id UUID, + + -- Integrity + previous_hash VARCHAR, + entry_hash VARCHAR NOT NULL, -- hash of this entry including previous_hash + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_audit_org_time ON compliance_audit_event(organization_id, timestamp DESC); +CREATE INDEX idx_audit_action ON compliance_audit_event(action_category, action_type); +CREATE INDEX idx_audit_resource ON compliance_audit_event(resource_type, resource_id); +CREATE INDEX idx_audit_actor ON compliance_audit_event(actor_id); +``` + +--- + +## 4. User Experience Flows + +### 4.1 Compliance Officer Journey + +``` +1. Create Compliance Program + ↓ +2. Add Content (courses, assessments) + ↓ +3. Set Assignment Rules (who needs to complete) + ↓ +4. Configure Schedule & Reminders + ↓ +5. Launch Program → System auto-assigns to users + ↓ +6. Monitor Dashboard → See completion rates, overdue alerts + ↓ +7. Send Reminders / Escalate / Waive as needed + ↓ +8. Generate Audit Report → Export for auditor +``` + +### 4.2 Employee (Learner) Journey + +``` +1. Receive Email: "New Required Training: HIPAA Compliance" + ↓ +2. Click Link → Login (if needed) → Go to "My Compliance" dashboard + ↓ +3. See: List of assigned programs with due dates + ↓ +4. Click Program → Start Training + ↓ +5. Complete Courses/Assessments + ↓ +6. Receive Certificate + Confirmation Email + ↓ +7. Certificate stored in "My Certificates" for future reference +``` + +### 4.3 Manager Journey + +``` +1. Weekly Email Digest: "Your Team's Compliance Status" + ↓ +2. Click to Manager Dashboard + ↓ +3. See: Team compliance rate, who's overdue, who's due soon + ↓ +4. Drill into individual → See their assignments + ↓ +5. Send reminder to specific team member + ↓ +6. Get notified when team member completes overdue training +``` + +### 4.4 Auditor Journey + +``` +1. Compliance officer generates "Audit Package" + ↓ +2. Secure download link sent to auditor (time-limited) + ↓ +3. OR: Auditor given time-limited read-only portal access + ↓ +4. Auditor views/downloads: + - Training completion records + - Certificates with verification + - Policy attestations + - Activity audit log + ↓ +5. All auditor activity is itself logged +``` + +--- + +## 5. Implementation Roadmap + +### Phase 1: Core Assignment & Tracking (Weeks 1-6) +**Goal**: Basic compliance program delivery + +| Task | Duration | Deliverable | +|------|----------|-------------| +| Compliance program data model | 3 days | Database schema | +| Program CRUD API | 4 days | Backend endpoints | +| Assignment engine | 5 days | Auto-assignment logic | +| Basic compliance dashboard | 5 days | Compliance overview UI | +| "My Compliance" learner view | 4 days | Learner dashboard | +| Assignment tracking | 4 days | Progress tracking UI | +| **Milestone** | | Companies can assign and track compliance training | + +### Phase 2: Notifications & Reminders (Weeks 7-10) +**Goal**: Automated follow-up without manual work + +| Task | Duration | Deliverable | +|------|----------|-------------| +| Notification template system | 3 days | Template CRUD API/UI | +| Reminder scheduling engine | 4 days | Background job processor | +| Email delivery & tracking | 3 days | Sendgrid/SES integration | +| Manager notifications | 3 days | Manager digest & alerts | +| Escalation workflows | 4 days | Multi-level escalation | +| **Milestone** | | Fully automated reminder system | + +### Phase 3: Certificates & Renewals (Weeks 11-14) +**Goal**: Complete certification lifecycle + +| Task | Duration | Deliverable | +|------|----------|-------------| +| Certificate templates | 3 days | Template designer | +| Certificate generation | 3 days | PDF generation | +| Certificate lifecycle mgmt | 4 days | Expire, revoke, renew | +| Recurring assignment engine | 4 days | Auto-renewal logic | +| Expiration dashboard | 3 days | Renewals view | +| **Milestone** | | Automated renewal cycles | + +### Phase 4: Policy Attestation (Weeks 15-18) +**Goal**: Policy acknowledgment tracking + +| Task | Duration | Deliverable | +|------|----------|-------------| +| Policy document management | 3 days | Policy CRUD | +| Document viewer with tracking | 4 days | Time-on-page tracking | +| Attestation workflow | 3 days | Sign/acknowledge flow | +| Attestation dashboard | 3 days | Who's attested view | +| **Milestone** | | Policy attestation system | + +### Phase 5: Reporting & Audit (Weeks 19-24) +**Goal**: Audit-ready evidence and reporting + +| Task | Duration | Deliverable | +|------|----------|-------------| +| Audit event logging | 4 days | Event capture system | +| Audit log viewer & export | 4 days | Log UI | +| Advanced dashboards | 5 days | Drill-down reports | +| Audit package generator | 5 days | One-click export | +| Auditor portal (read-only) | 5 days | External auditor access | +| **Milestone** | | Full audit readiness | + +### Phase 6: Integrations & Scale (Weeks 25-30) +**Goal**: Enterprise integrations + +| Task | Duration | Deliverable | +|------|----------|-------------| +| HRIS integration (Workday, BambooHR) | 2 weeks | User sync | +| SCIM provisioning | 1 week | Automated user lifecycle | +| Advanced analytics | 1 week | Trend analysis, predictions | +| API for external systems | 1 week | Public API endpoints | +| **Milestone** | | Enterprise-ready platform | + +--- + +## 6. Technical Architecture + +### New API Routes +``` +/compliance/programs # CRUD for compliance programs +/compliance/assignments # Assignment management +/compliance/assignments/:id/progress # Track learner progress +/compliance/certificates # Certificate lifecycle +/compliance/policies # Policy document management +/compliance/attestations # Policy acknowledgment +/compliance/dashboard # Dashboard data +/compliance/reports # Report generation +/compliance/audit-events # Audit log access +/compliance/notifications # Notification management +/compliance/settings # Organization compliance settings +``` + +### Background Jobs +``` +assignment-processor # Create assignments based on rules +reminder-scheduler # Queue upcoming reminders +notification-sender # Send emails/SMS +expiration-checker # Mark expired certifications +renewal-processor # Create renewal assignments +report-generator # Generate large audit reports +audit-archiver # Archive old audit logs +``` + +### Frontend Features +``` +apps/dashboard/src/lib/features/compliance/ + pages/ + programs-list.svelte + program-editor.svelte + assignments.svelte + dashboard.svelte + reports.svelte + audit-log.svelte + settings.svelte + components/ + program-card.svelte + assignment-status-badge.svelte + compliance-chart.svelte + notification-template-editor.svelte + certificate-viewer.svelte + policy-uploader.svelte + attestation-flow.svelte + api/ + compliance.svelte.ts + utils/ + types.ts + constants.ts +``` + +--- + +## 7. Success Metrics + +### Product Metrics +| Metric | Target | Measurement | +|--------|--------|-------------| +| Time to create compliance program | < 30 minutes | User testing | +| Time to assign training to 1000 people | < 5 minutes | Performance test | +| Training completion rate | > 90% | Dashboard tracking | +| Automated reminder open rate | > 60% | Email analytics | +| Time to generate audit report | < 2 minutes | Performance test | + +### Business Metrics +| Metric | Target | +|--------|--------| +| Compliance-focused customer acquisition | 10 new customers in 6 months | +| Compliance feature NPS | > 50 | +| Customer retention (compliance segment) | > 95% | +| Expansion revenue from compliance add-on | 20% of ARR | + +--- + +## 8. Competitive Differentiation + +| Feature | ClassroomIO | Competitors (LMS) | Compliance Tools | +|---------|-------------|-------------------|------------------| +| Course creation quality | ✅ Strong | ✅ Strong | ❌ Weak | +| Compliance-specific features | 🆕 Building | ⚠️ Limited | ✅ Strong | +| Price | 💰 Affordable | 💰💰 Expensive | 💰💰💰 Very expensive | +| Open Source | ✅ Yes | ❌ No | ❌ No | +| Self-hosted option | ✅ Yes | ⚠️ Limited | ❌ Rare | +| Ease of use | ✅ Simple | ⚠️ Complex | ❌ Complex | + +**Positioning**: "The compliance training platform that doesn't suck - powerful enough for enterprise compliance, simple enough for anyone to use." + +--- + +## 9. Open Questions + +1. Should we offer pre-built compliance program templates (e.g., "HIPAA Basics")? +2. Do we need integration with third-party course libraries (Skillsoft, LinkedIn Learning)? +3. Should we support instructor-led training tracking (ILT) or self-paced only? +4. How important is mobile app support for field workers? +5. Do we need e-signature integration (DocuSign) for high-stakes certifications? +6. Should we build a marketplace for compliance content creators? + +--- + +## 10. Next Steps + +1. **Validate** this PRD with 3-5 compliance officers at target companies +2. **Prioritize** Phase 1 features for immediate development +3. **Design** the compliance dashboard UI mockups +4. **Prototype** the program creation wizard +5. **Plan** data migration strategy for existing ClassroomIO courses diff --git a/prd/course-cohorts/README.md b/prd/course-cohorts/README.md new file mode 100644 index 00000000..d58211fb --- /dev/null +++ b/prd/course-cohorts/README.md @@ -0,0 +1,814 @@ +# Course Cohorts PRD + +## Purpose + +Enable instructors to run a single course with multiple independent groups of learners (cohorts). Each cohort has its own enrollment, assignment submissions, grading, newsfeed, attendance, and progress tracking, while sharing the same course content. Cohorts can be deactivated to revoke access or scheduled to start at a future date. + +## Problem Statement + +Currently, ClassroomIO has a 1:1 relationship between a course and its membership group. If an instructor wants to run the same training for a new batch of students, they must clone the entire course. This leads to: +- Content duplication and drift between copies +- No unified view of all batches running under one course +- No way to deactivate a past batch while keeping content accessible for new ones +- Manual bookkeeping to track which copy belongs to which batch + +## Confirmed Decisions + +1. **Terminology**: "Cohorts" internally and in the instructor-facing UI. +2. **Opt-in**: Cohorts are opt-in per course. Courses without cohorts work exactly as they do today (default single group). +3. **Cross-cohort analytics**: Instructors can view aggregated analytics across all cohorts. +4. **Student retakes**: A student can be enrolled in multiple cohorts of the same course (e.g., retake scenarios). +5. **Deadlines**: Cohort-level deadlines are controlled by `cohort.startsAt` / `cohort.endsAt` and `cohort.status`. Exercise-level `dueBy` on the shared content is unrelated to cohort scheduling. +6. **Student visibility**: Students never see or choose cohorts. Cohorts are an instructor-only organizational concept. Students are enrolled into a specific cohort via invite links created by the instructor. The student experience is identical to today — they just see "the course." +7. **URLs**: The course landing page URL does not change. Cohort context is carried by the invite token, not by the URL. + +## Current-State Audit + +| Capability | Current State | Notes | +| --- | --- | --- | +| Course ↔ Group relationship | 1:1 via `course.groupId` | Single group per course | +| Student enrollment | `groupmember` rows in course's group | Tied to one group | +| Submissions | `submission.submittedBy` → `groupmember.id` | Already group-scoped | +| Question answers | `question_answer.groupMemberId` → `groupmember.id` | Already group-scoped | +| Attendance | `group_attendance.studentId` → `groupmember.id` | Already group-scoped | +| Lesson completion | `lesson_completion(lessonId, profileId)` unique | NOT group-scoped, uses `profileId` | +| Lesson comments | `lesson_comment.groupmemberId` → `groupmember.id` | Already group-scoped | +| Course newsfeed | `course_newsfeed.courseId` (no group filter) | Shared across all members, NOT group-scoped | +| Newsfeed comments | FK to `course_newsfeed` | Follows parent newsfeed | +| Course content | Lessons, exercises, sections belong to `courseId` | Shared (this is what we want) | +| Course creation | Creates group → course → groupmember → default newsfeed in transaction | `apps/api/src/services/course/course.ts` | +| Enrollment | `enrollInCourse()` adds to `course.groupId` group | `apps/api/src/services/course/invite.ts` | +| Course type enum | `COURSE_TYPE`: `SELF_PACED`, `LIVE_CLASS` | `packages/db/src/schema.ts:23` | + +**Key insight**: Submissions, question answers, attendance, and lesson comments are already scoped by `groupmember.id`. If each cohort gets its own `group`, these records are naturally isolated with zero schema changes to those tables. + +## Product Goals + +1. Allow a course to have multiple cohorts, each with its own independent group of learners. +2. Each cohort has isolated: enrollment, submissions, grading, newsfeed, attendance, progress tracking. +3. All cohorts share the same course content (lessons, exercises, sections, questions). +4. Instructors can deactivate a cohort (students lose access) or schedule access windows. +5. Courses without cohorts enabled continue to work exactly as they do today. +6. Instructors can view per-cohort and cross-cohort aggregated analytics. +7. Students can be enrolled in multiple cohorts of the same course. + +## Non-Goals (v1) + +- Per-cohort exercise deadline overrides (exercise `dueBy` differences between cohorts). +- Cohort-specific content variations (all cohorts see the same content). +- Automatic cohort assignment rules (e.g., auto-assign by domain). +- Cohort templates or cohort cloning. +- Cohort-level pricing (all cohorts share the course price). +- Notifications/emails when a cohort is about to start or end. + +## Data Sources Checked + +- `packages/db/src/schema.ts` — all table definitions +- `apps/api/src/services/course/course.ts` — course creation flow +- `apps/api/src/services/course/invite.ts` — enrollment flow +- `apps/api/src/routes/course/course.ts` — course routes +- `apps/api/src/routes/course/` — all course sub-routers +- `packages/db/src/queries/course/` — course query layer +- `packages/db/src/queries/course/people.ts` — member management +- `packages/db/src/queries/newsfeed/newsfeed.ts` — newsfeed queries +- `packages/db/src/queries/submission/submission.ts` — submission queries +- `packages/db/src/queries/mark/mark.ts` — grading queries +- `apps/dashboard/src/routes/courses/[id]/` — course detail pages +- `apps/dashboard/src/lib/features/course/` — course frontend feature + +## Functional Requirements + +### 1. Enable Cohorts on a Course + +- Instructors can enable cohorts on an existing course via course settings. +- When cohorts are enabled: + - A `course_cohort` record is created for each cohort. + - Each cohort gets its own `group` for membership isolation. + - The existing `course.groupId` group becomes the "Default" cohort (backward compatible). +- When cohorts are disabled (reverted): + - Only allowed if there is exactly one active cohort. + - That cohort's group becomes the direct `course.groupId` again. + +### 2. Cohort CRUD + +- Instructors can create, update, and archive cohorts. +- Each cohort has: name, description, status, optional start/end dates, optional max student cap. +- Cohort names must be unique within a course. +- Creating a cohort creates a new `group` and adds the creator as TUTOR in that group. +- A default welcome newsfeed post is created for each new cohort. + +### 3. Cohort Status and Access Control + +- Cohort statuses: `ACTIVE`, `INACTIVE`, `SCHEDULED`. +- Access rules enforced server-side (not just UI): + - `ACTIVE`: full access for enrolled students. + - `INACTIVE`: students cannot access course content. Instructors/admins can still view for grading/review. + - `SCHEDULED` with `startsAt` in the future: students see "This cohort starts on [date]" message. + - `endsAt` in the past: treated as `INACTIVE` regardless of status field. +- Deactivating a cohort does NOT remove group members or delete data. It only blocks access. + +### 4. Cohort-Scoped Enrollment + +- Students are enrolled into a specific cohort, not the course directly. +- Enrollment creates a `groupmember` in the cohort's `group`. +- A student can exist in multiple cohorts of the same course (retakes). +- Invite links are scoped to a cohort (not the course). The instructor creates an invite for a specific cohort, and the generated link carries that context via the invite token. +- The existing `course_invite` table gets a nullable `cohort_id` column. +- **Students never see or select cohorts.** The cohort is resolved server-side from the invite token. From the student's perspective, they click an invite link and join "the course" — the cohort assignment is invisible to them. +- **Free course direct enrollment** (no invite token): when cohorts are enabled, the instructor designates one cohort as the "open enrollment" cohort. The course-level `POST /course/:courseId/enroll` (no token) routes to that cohort's group. This is stored as `course.defaultCohortId`. If no default cohort is set, direct free enrollment returns an error asking the student to use an invite link. + +### 4a. URL and Landing Page Behavior + +- **Landing page** (`/course/[slug]`): Unchanged. Shows the shared course info. There is one course, one slug, one landing page regardless of how many cohorts exist. +- **Enroll page** (`/course/[slug]/enroll?invite_token=...`): Unchanged URL structure. The invite token carries the `cohortId` — the backend resolves it and enrolls the student into the correct cohort's group. +- **Free enrollment** (`/course/[slug]/enroll` with no token): Routes to the course's default cohort when cohorts are enabled. +- **Inside the LMS** (`/courses/[id]/...`): Students see the course as they do today. The backend resolves which cohort the student belongs to and returns cohort-scoped data (newsfeed, submissions, progress). If a student is in multiple cohorts (retake), the backend uses the most recently active cohort by default, with the option for the URL to carry `?cohort=[cohortId]` — but this is not surfaced in the UI as a "cohort picker." Instead, the course shows a simple "You have a previous enrollment" note with the option to switch, without exposing cohort terminology. + +### 5. Cohort-Scoped Newsfeed + +- Each cohort has its own newsfeed. +- Add `cohort_id` (nullable) to `course_newsfeed`. +- When querying newsfeed, filter by `cohortId`. +- For non-cohort courses, `cohort_id` is null and behavior is unchanged. + +### 6. Cohort-Scoped Lesson Completion + +- Add `cohort_id` (nullable) to `lesson_completion`. +- Change unique constraint from `(lesson_id, profile_id)` to `(lesson_id, profile_id, cohort_id)`. +- A student's lesson completion in one cohort does not affect another. +- For non-cohort courses, `cohort_id` is null and behavior is unchanged. + +### 7. Per-Cohort Views (Dashboard) + +- **Instructor view**: When viewing a cohort-enabled course, the instructor sees a cohort selector dropdown. Per-cohort pages: newsfeed, people, submissions, marks, attendance, analytics. All data is filtered by the selected cohort. +- **Student view**: Students never see a cohort selector or cohort names. The backend automatically resolves the student's cohort from their `groupmember` record and returns cohort-scoped data. If enrolled in multiple cohorts (retake), the most recently active cohort is used by default. + +### 8. Cross-Cohort Analytics + +- Instructors can view aggregated analytics across all cohorts of a course. +- Metrics: total students, lesson completion rates, exercise completion rates, average grades — broken down by cohort and as totals. + +### 9. Migration for Existing Courses + +- When a course has cohorts enabled, a "Default" cohort is auto-created pointing to the existing `course.groupId` group. +- All existing newsfeed posts for that course get the default cohort's `cohort_id` backfilled. +- All existing `lesson_completion` rows for members of that group get the default cohort's `cohort_id` backfilled. +- This ensures zero data loss and full backward compatibility. + +## Technical Design + +### Data Model + +#### New Table: `course_cohort` + +```typescript +// packages/db/src/schema.ts + +export const cohortStatus = pgEnum('COHORT_STATUS', ['ACTIVE', 'INACTIVE', 'SCHEDULED']); + +export const courseCohort = pgTable( + 'course_cohort', + { + id: uuid().default(sql`gen_random_uuid()`).primaryKey().notNull(), + courseId: uuid('course_id').notNull(), + groupId: uuid('group_id').notNull(), + name: varchar().notNull(), + description: text(), + status: cohortStatus().default('ACTIVE').notNull(), + startsAt: timestamp('starts_at', { withTimezone: true, mode: 'string' }), + endsAt: timestamp('ends_at', { withTimezone: true, mode: 'string' }), + maxStudents: integer('max_students'), + createdByProfileId: uuid('created_by_profile_id'), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow() + }, + (table) => [ + foreignKey({ + columns: [table.courseId], + foreignColumns: [course.id], + name: 'course_cohort_course_id_fkey' + }).onDelete('cascade'), + foreignKey({ + columns: [table.groupId], + foreignColumns: [group.id], + name: 'course_cohort_group_id_fkey' + }), + foreignKey({ + columns: [table.createdByProfileId], + foreignColumns: [profile.id], + name: 'course_cohort_created_by_profile_id_fkey' + }), + unique('course_cohort_course_name_unique').on(table.courseId, table.name), + index('idx_course_cohort_course_id').on(table.courseId), + index('idx_course_cohort_group_id').on(table.groupId) + ] +); +``` + +#### Modified Table: `course` + +Add cohort mode flag and default cohort reference: + +```typescript +// Add to course table definition +cohortsEnabled: boolean('cohorts_enabled').default(false).notNull(), +defaultCohortId: uuid('default_cohort_id') + +// Add to constraints +foreignKey({ + columns: [table.defaultCohortId], + foreignColumns: [courseCohort.id], + name: 'course_default_cohort_id_fkey' +}) +``` + +- `cohortsEnabled` — whether cohort mode is active for this course. +- `defaultCohortId` — the cohort used for free direct enrollment (no invite token). When cohorts are first enabled, this points to the auto-created "Default" cohort. Instructors can change it to any active cohort. + +#### Modified Table: `course_newsfeed` + +Add nullable cohort reference: + +```typescript +// Add to course_newsfeed table definition +cohortId: uuid('cohort_id') + +// Add to constraints +foreignKey({ + columns: [table.cohortId], + foreignColumns: [courseCohort.id], + name: 'course_newsfeed_cohort_id_fkey' +}) +``` + +#### Modified Table: `lesson_completion` + +Add nullable cohort reference and update unique constraint: + +```typescript +// Add to lesson_completion table definition +cohortId: uuid('cohort_id') + +// Add to constraints +foreignKey({ + columns: [table.cohortId], + foreignColumns: [courseCohort.id], + name: 'lesson_completion_cohort_id_fkey' +}) + +// Replace existing unique constraint: +// OLD: unique('unique_lesson_profile').on(table.lessonId, table.profileId) +// NEW: unique('unique_lesson_profile_cohort').on(table.lessonId, table.profileId, table.cohortId) +``` + +#### Modified Table: `course_invite` + +Add nullable cohort reference: + +```typescript +// Add to course_invite table definition +cohortId: uuid('cohort_id') + +// Add to constraints +foreignKey({ + columns: [table.cohortId], + foreignColumns: [courseCohort.id], + name: 'course_invite_cohort_id_fkey' +}) +``` + +#### Relationship Diagram + +``` +course (content: lessons, exercises, sections, questions) + ├── cohortsEnabled: boolean + ├── defaultCohortId → course_cohort (for free enrollment without invite token) + │ + ├── [cohortsEnabled = false] → course.groupId → group → groupmember (legacy 1:1 behavior) + │ + └── [cohortsEnabled = true] → course_cohort (one per batch) + ├── name, status, startsAt, endsAt + └── groupId → group → groupmember (enrollment per cohort) + ├── submission (already scoped by groupmember.id) + ├── question_answer (already scoped by groupmember.id) + ├── group_attendance (already scoped by groupmember.id) + └── lesson_comment (already scoped by groupmember.id) + + course_newsfeed.cohortId → scoped per cohort + lesson_completion.cohortId → scoped per cohort + + URL structure (unchanged for students): + /course/[slug] → landing page (shared, cohort-unaware) + /course/[slug]/enroll?invite_token=abc123 → enroll (cohort resolved from token) + /course/[slug]/enroll → enroll (free, routes to defaultCohortId) + /courses/[id]/... → LMS (cohort resolved from groupmember) +``` + +### Migration + +Create migration file: `packages/db/src/migrations/XXXX_add_course_cohorts.sql` + +```sql +-- 1. Create cohort status enum +CREATE TYPE "COHORT_STATUS" AS ENUM ('ACTIVE', 'INACTIVE', 'SCHEDULED'); + +-- 2. Create course_cohort table +CREATE TABLE course_cohort ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + course_id UUID NOT NULL REFERENCES course(id) ON DELETE CASCADE, + group_id UUID NOT NULL REFERENCES "group"(id), + name VARCHAR(255) NOT NULL, + description TEXT, + status "COHORT_STATUS" NOT NULL DEFAULT 'ACTIVE', + starts_at TIMESTAMPTZ, + ends_at TIMESTAMPTZ, + max_students INTEGER, + created_by_profile_id UUID REFERENCES profile(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(course_id, name) +); + +CREATE INDEX idx_course_cohort_course_id ON course_cohort(course_id); +CREATE INDEX idx_course_cohort_group_id ON course_cohort(group_id); + +-- 3. Add cohorts_enabled and default_cohort_id to course +ALTER TABLE course ADD COLUMN cohorts_enabled BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE course ADD COLUMN default_cohort_id UUID REFERENCES course_cohort(id); + +-- 4. Add cohort_id to course_newsfeed +ALTER TABLE course_newsfeed ADD COLUMN cohort_id UUID REFERENCES course_cohort(id); + +-- 5. Add cohort_id to lesson_completion +ALTER TABLE lesson_completion ADD COLUMN cohort_id UUID REFERENCES course_cohort(id); + +-- 6. Drop old unique constraint and create new one on lesson_completion +ALTER TABLE lesson_completion DROP CONSTRAINT IF EXISTS unique_lesson_profile; +ALTER TABLE lesson_completion ADD CONSTRAINT unique_lesson_profile_cohort + UNIQUE (lesson_id, profile_id, cohort_id); + +-- 7. Add cohort_id to course_invite +ALTER TABLE course_invite ADD COLUMN cohort_id UUID REFERENCES course_cohort(id); +``` + +### Access Control Logic + +```typescript +// apps/api/src/services/course/cohort.ts + +function canAccessCohort(cohort: { status: string; startsAt: string | null; endsAt: string | null }): boolean { + if (cohort.status === 'INACTIVE') return false; + + const now = new Date(); + if (cohort.status === 'SCHEDULED' && cohort.startsAt && now < new Date(cohort.startsAt)) return false; + if (cohort.endsAt && now > new Date(cohort.endsAt)) return false; + + return true; +} +``` + +This check must be enforced: +- In route middleware for any cohort-scoped endpoint. +- Before serving course content to students in a specific cohort. +- Exception: instructors/admins always have access for grading and review. + +## API and Route Plan + +### Validation Layer + +File: `packages/utils/src/validation/course/cohort.ts` + +```typescript +import { z } from 'zod'; + +export const ZCreateCohort = z.object({ + name: z.string().min(1).max(255), + description: z.string().max(2000).optional(), + status: z.enum(['ACTIVE', 'INACTIVE', 'SCHEDULED']).default('ACTIVE'), + startsAt: z.string().datetime().optional(), + endsAt: z.string().datetime().optional(), + maxStudents: z.number().int().positive().optional() +}); + +export type TCreateCohort = z.infer; + +export const ZUpdateCohort = z.object({ + name: z.string().min(1).max(255).optional(), + description: z.string().max(2000).optional(), + status: z.enum(['ACTIVE', 'INACTIVE', 'SCHEDULED']).optional(), + startsAt: z.string().datetime().nullable().optional(), + endsAt: z.string().datetime().nullable().optional(), + maxStudents: z.number().int().positive().nullable().optional() +}); + +export type TUpdateCohort = z.infer; + +export const ZEnableCohorts = z.object({ + enabled: z.boolean() +}); + +export type TEnableCohorts = z.infer; +``` + +Export from: `packages/utils/src/validation/course/index.ts` + +### Query Layer + +File: `packages/db/src/queries/course/cohort.ts` + +Functions: +- `createCohort(data)` — insert into `course_cohort` with given `groupId`. +- `getCohortById(cohortId)` — single cohort with group member count. +- `getCohortsByCourseId(courseId)` — all cohorts for a course, ordered by `createdAt`, with member counts. +- `updateCohort(cohortId, data)` — update name, description, status, dates, maxStudents. +- `getCohortByGroupId(groupId)` — look up cohort from a group ID (useful for resolving context). +- `getActiveCohortForMember(courseId, profileId)` — find which cohort(s) a student belongs to in a course. + +Export from: `packages/db/src/queries/course/index.ts` + +### Service Layer + +File: `apps/api/src/services/course/cohort.ts` + +Functions: + +#### `enableCohorts(courseId, profileId)` +- Verify course exists and user is admin/tutor. +- Create "Default" cohort pointing to existing `course.groupId`. +- Set `course.cohortsEnabled = true` and `course.defaultCohortId` to the new default cohort. +- Backfill `cohort_id` on existing `course_newsfeed` rows for this course. +- Backfill `cohort_id` on existing `lesson_completion` rows for members of this course's group. +- All in a transaction. + +#### `disableCohorts(courseId)` +- Verify there is exactly one active cohort. +- Set `course.cohortsEnabled = false`. +- Nullify `cohort_id` on newsfeed and lesson_completion rows. +- All in a transaction. + +#### `createCohort(courseId, profileId, data: TCreateCohort)` +- Verify course has cohorts enabled. +- Create new `group` (name = cohort name, organizationId from course's org). +- Insert `course_cohort` row. +- Add creator as TUTOR `groupmember` in the new group. +- Create default welcome newsfeed post for the cohort. +- All in a transaction. +- Return the new cohort. + +#### `updateCohort(cohortId, data: TUpdateCohort)` +- Validate dates (if `endsAt` provided, must be after `startsAt`). +- Update the `course_cohort` row. +- Return the updated cohort. + +#### `deactivateCohort(cohortId)` +- Set `status = 'INACTIVE'`. +- Return the updated cohort. + +#### `enrollInCohort(cohortId, user, context)` +- Verify cohort exists and `canAccessCohort()`. +- Check `maxStudents` cap if set. +- Add student to cohort's group via `groupmember`. +- Handle org membership creation if needed (same pattern as `enrollInCourse`). +- Idempotent: if student is already in this cohort's group, return success. + +#### `getCohortAnalytics(cohortId)` +- Same logic as `getCourseAnalytics` but filtered to the cohort's group members. + +#### `getCrossCohortsAnalytics(courseId)` +- Aggregate analytics across all cohorts of a course. +- Return per-cohort breakdown plus totals. + +### Route Layer + +File: `apps/api/src/routes/course/cohort.ts` + +All routes are nested under `/course/:courseId/cohort` and require `authMiddleware`. + +| Method | Path | Description | Auth | +| --- | --- | --- | --- | +| `POST` | `/course/:courseId/cohort/enable` | Enable/disable cohorts on course | Admin/Tutor | +| `GET` | `/course/:courseId/cohort` | List all cohorts for a course | Admin/Tutor | +| `POST` | `/course/:courseId/cohort` | Create a new cohort | Admin/Tutor | +| `GET` | `/course/:courseId/cohort/:cohortId` | Get cohort details | Admin/Tutor | +| `PUT` | `/course/:courseId/cohort/:cohortId` | Update cohort | Admin/Tutor | +| `POST` | `/course/:courseId/cohort/:cohortId/deactivate` | Deactivate cohort | Admin/Tutor | +| `POST` | `/course/:courseId/cohort/:cohortId/enroll` | Enroll student in cohort | Authenticated | +| `GET` | `/course/:courseId/cohort/:cohortId/analytics` | Cohort analytics | Admin/Tutor | +| `GET` | `/course/:courseId/cohort/analytics` | Cross-cohort analytics | Admin/Tutor | + +#### Route Registration + +```typescript +// apps/api/src/routes/course/cohort.ts +export const cohortRouter = new Hono() + .post('/enable', authMiddleware, zValidator('json', ZEnableCohorts), async (c) => { ... }) + .get('/', authMiddleware, async (c) => { ... }) + .post('/', authMiddleware, zValidator('json', ZCreateCohort), async (c) => { ... }) + // ... etc + +// apps/api/src/routes/course/index.ts +export * from './cohort'; + +// In the course router setup (apps/api/src/routes/course/course.ts or where sub-routers are mounted): +.route('/:courseId/cohort', cohortRouter) +``` + +### Modified Existing Endpoints + +Several existing endpoints need cohort-awareness: + +#### Newsfeed Routes (`apps/api/src/routes/course/newsfeed.ts`) +- `GET /course/:courseId/newsfeed` — accept optional `cohortId` query param. If course has cohorts enabled, `cohortId` is required and newsfeed is filtered by it. +- `POST /course/:courseId/newsfeed` — accept optional `cohortId` in body. If course has cohorts enabled, `cohortId` is required. + +#### Submission Routes (`apps/api/src/routes/course/submission.ts`) +- Already scoped by `groupmember.id`, but list endpoints that join through `course.groupId` need to accept a `cohortId` param to filter to the correct group. +- `GET /course/:courseId/submission/for-grading` — add `cohortId` query param. + +#### People Routes (`apps/api/src/routes/course/people.ts`) +- `GET /course/:courseId/people` — add `cohortId` query param to list members of a specific cohort's group. + +#### Marks Routes (`apps/api/src/routes/course/mark.ts`) +- `GET /course/:courseId/marks` — add `cohortId` query param to filter marks by cohort group. + +#### Attendance Routes (`apps/api/src/routes/course/attendance.ts`) +- Already scoped by `groupmember.id`. List endpoints need `cohortId` param. + +#### Course Progress (`apps/api/src/services/course/course.ts`) +- `getCourseProgress` — pass `cohortId` to filter `lesson_completion` by cohort. + +#### Enrollment (`apps/api/src/services/course/invite.ts`) +- `enrollInCourse` (no invite token, free courses) — if course has cohorts enabled: + - Use `course.defaultCohortId` as the target cohort. + - If `defaultCohortId` is null, return error: "This course requires an invite link to enroll." + - Verify cohort is active via `canAccessCohort()`. + - Enroll student into the default cohort's group, not `course.groupId`. +- `course_invite` creation — attach `cohortId` when course has cohorts enabled. Instructor selects the target cohort when creating an invite. +- `acceptStudentInvite` — read `cohort_id` from invite to determine target group. Enroll into the cohort's group instead of `course.groupId`. + +### Build Verification + +After implementing backend changes: + +```bash +pnpm --filter @cio/utils build +pnpm --filter @cio/db build +pnpm --filter @cio/api build +``` + +## Frontend Plan (Dashboard) + +### Request Types + +File: `apps/dashboard/src/lib/features/course/utils/types.ts` + +Add: + +```typescript +export type ListCohortsRequest = typeof classroomio.course[':courseId']['cohort']['$get']; +export type CreateCohortRequest = typeof classroomio.course[':courseId']['cohort']['$post']; +export type UpdateCohortRequest = typeof classroomio.course[':courseId']['cohort'][':cohortId']['$put']; +export type EnableCohortsRequest = typeof classroomio.course[':courseId']['cohort']['enable']['$post']; +export type DeactivateCohortRequest = typeof classroomio.course[':courseId']['cohort'][':cohortId']['deactivate']['$post']; +export type CohortAnalyticsRequest = typeof classroomio.course[':courseId']['cohort'][':cohortId']['analytics']['$get']; + +export type ListCohortsSuccess = Extract, { success: true }>; +export type Cohort = ListCohortsSuccess['data'][number]; +``` + +### API Class + +File: `apps/dashboard/src/lib/features/course/api/cohort.svelte.ts` + +```typescript +class CohortApi extends BaseApiWithErrors { + cohorts = $state([]); + selectedCohortId = $state(null); + loading = $state(false); + + get selectedCohort() { + return this.cohorts.find(c => c.id === this.selectedCohortId) ?? null; + } + + async list(courseId: string) { ... } + async create(courseId: string, data: TCreateCohort) { ... } + async update(courseId: string, cohortId: string, data: TUpdateCohort) { ... } + async enable(courseId: string, enabled: boolean) { ... } + async deactivate(courseId: string, cohortId: string) { ... } +} +``` + +### Cohort Selector Component + +File: `apps/dashboard/src/lib/features/course/components/cohort-selector.svelte` + +- Dropdown in the course header/sidebar. +- Shows cohort name and status badge (active/inactive/scheduled). +- Instructors can switch between cohorts. +- Includes "Manage Cohorts" link to cohort management page. + +### Cohort Management Page + +Route: `apps/dashboard/src/routes/courses/[id]/cohorts/+page.svelte` +Feature page: `apps/dashboard/src/lib/features/course/pages/cohorts.svelte` + +Features: +- List all cohorts with name, status, student count, dates. +- Create new cohort (name, description, status, dates, max students). +- Edit cohort settings. +- Deactivate/reactivate cohort. +- Status badges: Active (green), Inactive (gray), Scheduled (blue). + +### Enable Cohorts Toggle + +In course settings page (`apps/dashboard/src/routes/courses/[id]/settings/`): +- Add "Enable Cohorts" toggle. +- When toggled on: creates default cohort, shows success message. +- When toggled off: only works if single cohort, shows confirmation dialog. + +### Student Multi-Enrollment Handling + +Students are never exposed to cohort terminology. For the rare case where a student is enrolled in multiple cohorts of the same course (retake): +- The backend defaults to the most recently active cohort. +- The course page shows a subtle note: "You have a previous enrollment. [Switch]" — without using the word "cohort." +- Switching passes `?cohort=[cohortId]` as a query param internally, but the UI labels it generically (e.g., "January 2026 Training" vs "June 2026 Training" — using the cohort name set by the instructor). +- If enrolled in only one cohort (the common case), no extra UI is shown. + +### Modified Existing Pages + +These existing pages need to pass `cohortId` when the course has cohorts enabled: + +| Page | File | Change | +| --- | --- | --- | +| Newsfeed | `apps/dashboard/src/lib/features/course/pages/newsfeed.svelte` | Pass `cohortId` to newsfeed API calls | +| People | Course people page | Pass `cohortId` to filter group members | +| Submissions | `apps/dashboard/src/lib/features/course/pages/submissions.svelte` | Pass `cohortId` to list submissions | +| Marks | `apps/dashboard/src/lib/features/course/pages/marks.svelte` | Pass `cohortId` to filter marks | +| Attendance | `apps/dashboard/src/lib/features/course/pages/attendance.svelte` | Pass `cohortId` to filter attendance | +| Analytics | `apps/dashboard/src/lib/features/course/pages/analytics.svelte` | Pass `cohortId` or show cross-cohort toggle | +| Course Progress | Student progress tracking | Pass `cohortId` to filter `lesson_completion` | + +### Navigation Update + +File: `apps/dashboard/src/lib/features/course/` (course sidebar/nav) + +- Add "Cohorts" nav item (visible only when cohorts enabled, for instructors). +- Cohort selector visible in sidebar header when cohorts enabled. + +### Translations + +File: `apps/dashboard/src/lib/utils/translations/en.json` + +Add keys under `course.cohorts`: + +```json +{ + "course": { + "cohorts": { + "title": "Cohorts", + "create": "Create Cohort", + "enable": "Enable Cohorts", + "disable": "Disable Cohorts", + "name": "Cohort Name", + "description": "Description", + "status": "Status", + "active": "Active", + "inactive": "Inactive", + "scheduled": "Scheduled", + "starts_at": "Starts At", + "ends_at": "Ends At", + "max_students": "Max Students", + "student_count": "Students", + "deactivate": "Deactivate Cohort", + "reactivate": "Reactivate Cohort", + "selector_label": "Select Cohort", + "manage": "Manage Cohorts", + "default_name": "Default", + "default_welcome": "Welcome to this cohort!", + "enable_confirm": "This will organize your current students into a default cohort. You can then create additional cohorts.", + "disable_confirm": "You can only disable cohorts if there is exactly one active cohort.", + "deactivate_confirm": "Students in this cohort will lose access to the course. Their data will be preserved.", + "access_blocked": "This cohort is not currently active.", + "scheduled_message": "This cohort starts on {date}.", + "ended_message": "This cohort has ended.", + "no_cohorts": "No cohorts yet. Create one to get started.", + "cross_cohort_analytics": "All Cohorts", + "previous_enrollment": "You have a previous enrollment.", + "switch_enrollment": "Switch", + "requires_invite": "This course requires an invite link to enroll." + } + } +} +``` + +### Frontend Build Verification + +```bash +pnpm --filter @cio/dashboard build +``` + +## Implementation Order + +### Phase 1: Database + Schema (do first) + +1. Add `cohortStatus` enum to schema. +2. Add `courseCohort` table to schema. +3. Add `cohortsEnabled` and `defaultCohortId` columns to `course` table. +4. Add `cohortId` column to `course_newsfeed`. +5. Add `cohortId` column to `lesson_completion` and update unique constraint. +6. Add `cohortId` column to `course_invite`. +7. Generate and apply migration. +8. Run `pnpm --filter @cio/db build`. + +### Phase 2: Validation + Queries (do second) + +1. Create `packages/utils/src/validation/course/cohort.ts` with all Zod schemas. +2. Export from `packages/utils/src/validation/course/index.ts`. +3. Create `packages/db/src/queries/course/cohort.ts` with all query functions. +4. Export from `packages/db/src/queries/course/index.ts`. +5. Run `pnpm --filter @cio/utils build` and `pnpm --filter @cio/db build`. + +### Phase 3: Service + Routes (do third) + +1. Create `apps/api/src/services/course/cohort.ts` with all service functions. +2. Create `apps/api/src/routes/course/cohort.ts` with all route handlers. +3. Register cohort router in course route index. +4. Add new error codes to `apps/api/src/utils/errors.ts` if needed (e.g., `COHORT_NOT_FOUND`, `COHORTS_NOT_ENABLED`, `COHORT_ACCESS_DENIED`, `COHORT_FULL`). +5. Update existing newsfeed, submission, people, marks, attendance routes to accept `cohortId` param. +6. Update `enrollInCourse` to handle cohort-aware enrollment. +7. Run `pnpm --filter @cio/api build`. + +### Phase 4: Frontend — Cohort Management (do fourth) + +1. Add request types to `apps/dashboard/src/lib/features/course/utils/types.ts`. +2. Create `apps/dashboard/src/lib/features/course/api/cohort.svelte.ts` API class. +3. Add translation keys to `en.json`. +4. Create cohort management page: `apps/dashboard/src/routes/courses/[id]/cohorts/+page.svelte` and feature page. +5. Add "Enable Cohorts" toggle to course settings. +6. Add cohort selector component. +7. Add "Cohorts" to course navigation. +8. Run `pnpm --filter @cio/dashboard build`. + +### Phase 5: Frontend — Cohort-Scoped Views (do fifth) + +1. Update newsfeed page to pass `cohortId`. +2. Update people page to pass `cohortId`. +3. Update submissions page to pass `cohortId`. +4. Update marks page to pass `cohortId`. +5. Update attendance page to pass `cohortId`. +6. Update analytics page with per-cohort / cross-cohort toggle. +7. Update course progress to pass `cohortId`. +8. Add backend cohort resolution for student views (auto-resolve from `groupmember`, default to most recent active cohort). +9. Add subtle "previous enrollment" switcher for multi-cohort students (no cohort terminology exposed). +10. Run `pnpm --filter @cio/dashboard build`. + +## Acceptance Criteria + +1. Instructor can enable cohorts on an existing course and a "Default" cohort is auto-created with all existing members. +2. Instructor can create a new cohort with name, description, status, dates. +3. Students enrolled in Cohort A cannot see submissions, newsfeed, or attendance of Cohort B. +4. Deactivating a cohort blocks student access but preserves all data. +5. A student can be enrolled in multiple cohorts of the same course. +6. Courses without cohorts enabled work exactly as they do today (zero regression). +7. Lesson completion is tracked independently per cohort. +8. Newsfeed posts are scoped to the cohort they were created in. +9. Instructor can view aggregated analytics across all cohorts. +10. Invite links are scoped to a specific cohort when cohorts are enabled. +11. Cohort status `SCHEDULED` with future `startsAt` blocks student access until the start date. +12. Students never see the word "cohort" or any cohort selection UI. Cohort assignment is fully transparent to students. +13. Course landing page URL (`/course/[slug]`) is unchanged regardless of cohort state. +14. Free course enrollment routes to the course's default cohort when cohorts are enabled. +15. All new UI strings use translation keys (no hardcoded English). +16. `pnpm --filter @cio/api build` and `pnpm --filter @cio/dashboard build` pass after implementation. + +## Risks and Mitigations + +- **Risk**: Existing queries that join through `course.groupId` break when cohorts are enabled (course has multiple groups now). + - **Mitigation**: When cohorts are enabled, all member-facing queries must go through `course_cohort.groupId`, not `course.groupId`. The `course.groupId` field remains but only points to the default/first cohort's group. Queries should check `cohortsEnabled` and branch accordingly. + +- **Risk**: Lesson completion unique constraint migration fails on existing duplicate data. + - **Mitigation**: The new constraint includes `cohort_id` which is null for all existing rows. Since `(lesson_id, profile_id, NULL)` is still unique under the old data, the migration is safe. PostgreSQL treats each NULL as distinct in unique constraints, so we need a partial unique index instead: `UNIQUE (lesson_id, profile_id) WHERE cohort_id IS NULL` plus `UNIQUE (lesson_id, profile_id, cohort_id) WHERE cohort_id IS NOT NULL`. + +- **Risk**: Performance degradation with many cohorts per course. + - **Mitigation**: Index `course_cohort.course_id`. Member counts are computed via joins, not stored. For cross-cohort analytics, batch queries per cohort and aggregate in service layer. + +- **Risk**: Student enrolled in multiple cohorts (retake) doesn't know which enrollment they're viewing. + - **Mitigation**: Backend defaults to most recently active cohort. For the rare multi-enrollment case, a subtle "You have a previous enrollment" note is shown without cohort terminology. Students never see a cohort picker or cohort names directly. + +## Updated Unique Constraint Strategy for `lesson_completion` + +Due to PostgreSQL treating NULL as distinct in unique constraints, use two constraints: + +```sql +-- For non-cohort courses (cohort_id IS NULL) +CREATE UNIQUE INDEX unique_lesson_profile_no_cohort + ON lesson_completion (lesson_id, profile_id) + WHERE cohort_id IS NULL; + +-- For cohort courses (cohort_id IS NOT NULL) +CREATE UNIQUE INDEX unique_lesson_profile_cohort + ON lesson_completion (lesson_id, profile_id, cohort_id) + WHERE cohort_id IS NOT NULL; +``` + +This replaces the simple `UNIQUE(lesson_id, profile_id, cohort_id)` approach and ensures correct behavior for both cohort and non-cohort courses. diff --git a/prd/course-import/implementation-plan.md b/prd/course-import/implementation-plan.md new file mode 100644 index 00000000..7c16b458 --- /dev/null +++ b/prd/course-import/implementation-plan.md @@ -0,0 +1,239 @@ +# Course Importer Implementation Plan (Creator Business v1) + +## Purpose +Ship a long-running course importer that supports creator-focused sources: +1. Teachable API +2. Thinkific API +3. SCORM ZIP +4. CSV (platform-agnostic template) + +## Scope Lock (v1) +1. Import is organization-scoped and admin-only. +2. Import runs as an async DB-backed job (`PENDING`, `RUNNING`, `COMPLETED`, `FAILED`, `CANCELED`). +3. Content imported: +- Course metadata (title, description, cover where available) +- Sections/modules +- Lessons/lectures/pages +- Exercises/quizzes/questions/options (when available) +- Media as external links or attached assets (no bulk media re-hosting in v1) +4. Not in v1: +- Enrollment/user migration (except CSV enrollment template if enabled) +- Billing/payment migration +- Full SCORM runtime tracking parity + +## Delivery Order +1. Shared job infrastructure +2. Teachable provider +3. Thinkific provider +4. CSV provider +5. SCORM provider + +## Shared Foundation (All Providers) + +### A. Data Model + Queries +1. Add `course_import_job` table: +- `id`, `organizationId`, `createdByProfileId` +- `provider`, `status`, `attempt`, `maxAttempts` +- `leasedBy`, `leaseExpiresAt`, `heartbeatAt` +- `progressPercent`, `stage`, `checkpoint` (jsonb) +- `sourceCourseId`, `sourceCourseTitle`, `targetCourseId` +- `summary` (jsonb), `warnings` (jsonb), `error` (jsonb), timestamps +2. Add `course_import_log` table: +- `jobId`, `level`, `code`, `message`, `meta` (jsonb), timestamp +3. Add query module: +- `packages/db/src/queries/course-import/course-import.ts` +- Export from `packages/db/src/queries/index.ts` + +### B. Validation + API Contracts +1. Add validation module: +- `packages/utils/src/validation/course-import/course-import.ts` +2. Endpoints: +- `GET /organization/course-import/providers` +- `POST /organization/course-import/preview` +- `POST /organization/course-import/jobs` +- `GET /organization/course-import/jobs` +- `GET /organization/course-import/jobs/:jobId` +- `POST /organization/course-import/jobs/:jobId/retry` +- `POST /organization/course-import/jobs/:jobId/cancel` + +### C. Worker +1. Add worker entrypoint: +- `apps/api/src/worker.ts` +2. Claim jobs with DB lease + lock (`FOR UPDATE SKIP LOCKED`). +3. Stage-based execution with checkpoint resume. +4. Heartbeat updates and retry backoff. +5. Mark terminal state + structured error payload. + +### D. Dashboard +1. Add feature: +- `apps/dashboard/src/lib/features/course-import/*` +2. Add importer modal on org courses page. +3. Poll `GET /jobs/:jobId` until terminal state. +4. On completion, refresh courses and navigate to imported course. + +## Provider Plan: Teachable + +### Phase T1: Connection + Auth +1. Support API key connection (org-scoped credentials). +2. Validation endpoint to verify credentials. +3. Save encrypted credentials in org integration config. + +### Phase T2: Source Discovery +1. List source courses. +2. Fetch source course details: +- modules/lectures +- quizzes/questions if exposed +- media URLs and attachments + +### Phase T3: Mapping +1. Map Teachable course -> internal normalized model: +- `course`, `sections`, `lessons`, `exercises` +2. Build fallback rules for missing fields. +3. Generate deterministic slug if needed. + +### Phase T4: Import Execution +1. Create group + course shell. +2. Insert sections/lessons in chunked batches. +3. Insert exercises/questions/options in chunked batches. +4. Store warnings for unsupported Teachable entities. + +### Teachable Acceptance Criteria +1. Admin can connect Teachable and preview course counts. +2. Import completes for a representative course with sections and lessons. +3. Unsupported entities are logged as warnings, not fatal errors. + +## Provider Plan: Thinkific + +### Phase K1: Connection + Auth +1. Support `X-Auth-API-Key` and `X-Auth-Subdomain`. +2. Validate credentials + tenant reachability. + +### Phase K2: Source Discovery +1. List courses/products available for import. +2. Pull curriculum/chapters/lessons and quiz objects where available. + +### Phase K3: Mapping +1. Map Thinkific objects to normalized model. +2. Resolve product/course model differences via explicit mapping rules. +3. Capture unsupported types in warning map. + +### Phase K4: Import Execution +1. Reuse shared importer pipeline and chunked writes. +2. Track object-level import counts and warnings in `summary`. + +### Thinkific Acceptance Criteria +1. Admin can preview and import at least one multi-chapter course. +2. Imported ordering matches source ordering. +3. Job can resume from checkpoint after forced worker restart. + +## Provider Plan: CSV + +### Phase C1: CSV Spec (Platform-Agnostic) +1. Define required template: +- `course.csv` +- `sections.csv` +- `lessons.csv` +- `exercises.csv` +- `questions.csv` +- `options.csv` +2. Use `external_id` for parent-child linking. +3. Publish template download from dashboard. + +### Phase C2: Parser + Validator +1. Parse ZIP/CSV with strict header validation. +2. Validate row-level integrity: +- missing parents +- invalid enum values +- duplicate `external_id` +- unsupported question types +3. Produce per-row error report in job logs. + +### Phase C3: Import Execution +1. Convert CSV rows to normalized model. +2. Import with chunking and checkpointing. +3. Partial failure policy: +- fail-fast on schema errors +- continue-with-warning on optional fields + +### CSV Acceptance Criteria +1. User can download template, upload filled ZIP, and preview row counts. +2. Invalid rows return actionable row/column errors. +3. Valid files import deterministically with stable ordering. + +## Provider Plan: SCORM ZIP + +### Phase S1: Package Intake +1. Accept ZIP upload and verify `imsmanifest.xml`. +2. Detect SCORM version (1.2 or 2004). +3. Enforce size limits and safe extraction constraints. + +### Phase S2: Manifest Parsing +1. Parse organizations/items/resources from manifest. +2. Build normalized structure from SCO hierarchy. +3. Capture launch entrypoints and asset references. + +### Phase S3: Import Execution +1. Create course/sections/lessons from manifest hierarchy. +2. Attach SCO launch package as lesson asset/content reference. +3. Preserve sequence/order from manifest. + +### Phase S4: Runtime Boundary (v1) +1. Provide launchable SCORM content references. +2. Do not implement full runtime event/grade sync in v1. +3. Mark this explicitly in UI copy and docs. + +### SCORM Acceptance Criteria +1. Valid SCORM ZIP imports as a structured course. +2. Learner can open imported SCORM lesson launch URL. +3. Invalid manifests fail with clear parser errors. + +## Dashboard UX Plan +1. Import modal flow: +- pick provider +- connect/credential step (if needed) +- source selection or file upload +- preview summary +- start import job +2. Job progress view: +- status badge +- stage label +- progress bar +- warnings/errors panel +- retry/cancel actions +3. Completion: +- success snackbar +- deep link to imported course + +## Testing Plan +1. Unit: +- adapter mapping functions +- CSV validators +- SCORM manifest parser +2. Integration: +- create/retry/cancel job endpoints +- worker claim/lease/recovery logic +3. E2E: +- start import from dashboard +- observe progress +- verify imported course structure + +## Risks and Mitigations +1. Source API shape drift: +- versioned adapter layer + contract tests +2. Large imports timing out: +- async worker, chunking, heartbeat lease +3. Duplicate imports: +- idempotency keys and active-job uniqueness guard +4. Bad input files: +- strict validation and detailed row-level errors + +## Build/Verification Commands +1. `pnpm --filter @cio/utils build` +2. `pnpm --filter @cio/db build` +3. `pnpm --filter @cio/api build` +4. `pnpm --filter @cio/dashboard build` + +## Suggested First Sprint Slice +1. Shared foundation (schema + queries + worker skeleton + endpoints) +2. Teachable provider end-to-end +3. Dashboard import modal + job polling diff --git a/prd/course-templates/README.md b/prd/course-templates/README.md new file mode 100644 index 00000000..8655002a --- /dev/null +++ b/prd/course-templates/README.md @@ -0,0 +1,554 @@ +# Course Templates PRD + +## Purpose + +Enable instructors to create reusable course blueprints (templates) that can be used to quickly spin up new courses with pre-built structure, content, and settings. Templates reduce repetitive setup work and help organizations standardize course quality. + +## Problem Statement + +Currently, the only way to reuse course content in ClassroomIO is the clone feature, which creates a full copy of a specific course. This has limitations: + +- **No discoverability**: There is no way to browse reusable starting points when creating a course. Instructors must know which existing course to clone. +- **No separation of blueprint vs live course**: Cloned courses immediately become live courses with active status, mixing templates with real courses in the listing. +- **No selective content inclusion**: Clone copies everything — there is no way to include structure but exclude detailed content, or include exercises but exclude lesson notes. +- **No organizational template library**: Organizations cannot curate a set of approved course structures for their instructors to use. +- **`isTemplate` field is unused**: The `course` table already has an `isTemplate` boolean (defaults to `true`), but it is never meaningfully filtered on — every course has it set to `true`. + +## Confirmed Decisions + +1. **Templates are courses**: A template is a `course` row with `isTemplate = true` and `status = 'TEMPLATE'`. No new content tables are needed. The existing course editor is reused for building templates. +2. **Org-scoped**: Templates belong to an organization (via the existing `group` → `organizationId` relationship). Each org has its own template library. +3. **Built on clone infrastructure**: Creating a course from a template reuses the existing `cloneCourse()` service with modifications to mark the result as a real course (`isTemplate = false`). +4. **Templates are never published**: Templates do not appear in public course listings, student-facing pages, or enrollment flows. They are instructor-only blueprints. +5. **Non-destructive**: Deleting a template does not affect courses already created from it. Courses created from a template become fully independent copies. +6. **Selective content**: When saving an existing course as a template, instructors can choose which content types to include (structure, lesson content, exercises, settings). + +## Current-State Audit + +| Capability | Current State | Notes | +| --- | --- | --- | +| `isTemplate` field | Exists on `course` table, defaults to `true` | Never filtered on — effectively unused | +| Course cloning | Full deep-copy via `cloneCourse()` service | Copies sections, lessons, exercises, questions, options, translations | +| Clone UI | "Clone" option in course card dropdown menu | Component: `copy-course-modal.svelte` | +| Clone endpoint | `POST /course/:courseId/clone` | Accepts `title`, `description`, `slug`, `organizationId` | +| Course creation | Two-step modal: type selection → title/description | Component: `new-course-modal.svelte` | +| Exercise templates | Separate `exercise_template` table | Existing pattern for template-based creation at exercise level | +| Course listing filter | Queries `getOrgCourses()` with filters for title, tags, sort | No filter for `isTemplate` or `status = 'TEMPLATE'` | +| Course status | Text field, default `'ACTIVE'` | Used for soft delete (`'DELETED'`), no `'TEMPLATE'` value | + +## Data Sources Checked + +- `packages/db/src/schema.ts` — course table definition, `isTemplate` field +- `apps/api/src/services/course/clone.ts` — full clone implementation +- `apps/api/src/services/course/course.ts` — course creation flow +- `apps/api/src/routes/course/course.ts` — course routes including clone +- `apps/dashboard/src/lib/features/course/components/new-course-modal.svelte` — creation UI +- `apps/dashboard/src/lib/features/course/components/copy-course-modal.svelte` — clone UI +- `apps/dashboard/src/lib/features/course/api/course.svelte.ts` — frontend course API +- `apps/dashboard/src/lib/features/course/api/course-clone.svelte.ts` — frontend clone API +- `apps/dashboard/src/routes/org/[slug]/courses/+page.svelte` — course listing page +- `packages/db/src/queries/course/course.ts` — course queries +- `apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts` — org navigation + +## Product Goals + +1. Instructors can save any existing course as a reusable template with selective content inclusion. +2. Instructors can create templates from scratch using the existing course editor. +3. When creating a new course, instructors can choose to start from a template instead of a blank course. +4. Organizations have a template library (tab on the courses page) where templates are managed. +5. Templates are cleanly separated from live courses in all listings and queries. +6. Courses created from templates are fully independent — no ongoing link or sync. +7. The existing course creation and clone flows remain unchanged for users who do not use templates. + +## Non-Goals (v1) + +- Platform-wide template marketplace (orgs sharing templates publicly). +- ClassroomIO-provided starter templates (curated by the platform team). +- AI-generated templates (e.g., "Generate a template for a 4-week React course"). +- Template versioning or change tracking. +- Syncing updates from a template to courses already created from it. +- Template access control (e.g., restricting which instructors can use which templates). +- Template analytics beyond usage count. + +## UX Flow + +### Flow 1: Creating a Template from an Existing Course + +**Entry points:** +- Course card dropdown menu → "Save as Template" (alongside existing "Clone" option) +- Course Settings page → "Save as Template" button + +**Steps:** + +1. User clicks "Save as Template." +2. A modal appears with: + - **Template name** — pre-filled with `"{course title} Template"`, editable. + - **Description** — pre-filled from course description, editable. + - **Category** — optional free-text or select from existing categories (e.g., "Onboarding", "Compliance", "Technical"). + - **What to include** — checkboxes: + - Sections & structure (always on, disabled) + - Lesson content (notes, videos, documents) — default on + - Exercises & quizzes — default on + - Course settings (grading, certificates, metadata) — default on + - Landing page content — default off + - Info note: "Student data, submissions, and enrollments are never included." +3. User confirms → system clones the course into a new `course` row with `isTemplate = true`, `status = 'TEMPLATE'`, `isPublished = false`. +4. Success snackbar: "Template created" with a link to the templates tab. + +### Flow 2: Creating a Template from Scratch + +**Entry point:** Templates tab on the courses page → "Create Template" button. + +**Steps:** + +1. User clicks "Create Template." +2. Same two-step modal as course creation (type selection → title/description), but the resulting course is marked as a template. +3. Navigates to the course editor with a "Template" badge in the header. +4. User builds out the structure (sections, lessons, exercises) using the existing editor. +5. Template never appears in student-facing listings. + +### Flow 3: Creating a Course from a Template + +**Entry point:** Existing "Create Course" button on the courses page. + +**Steps:** + +1. User clicks "Create Course." +2. **Step 1 — Choose starting point** (replaces the current type selection step): + - Two cards: **"Blank Course"** and **"From Template"**. + - "Blank Course" proceeds to the existing flow (type → title/description). + - "From Template" opens the template picker (step 1b). +3. **Step 1b — Template picker:** + - Search bar to filter templates by name. + - Category filter tabs (if categories exist). + - Grid of template cards showing: name, description snippet, lesson count, exercise count, course type badge. + - Each card has a "Preview" action that expands a panel showing the full section/lesson outline (read-only). + - User selects a template and clicks "Use Template." +4. **Step 2 — Customize:** + - Title — pre-filled from template, editable. + - Description — pre-filled from template, editable. + - Type — inherited from template, overridable (Live Class / Self-paced). + - Subtitle: "Based on: {template name}". +5. User clicks "Create Course" → system clones the template into a real course (`isTemplate = false`, `status = 'ACTIVE'`) → navigates to the new course editor with all template content pre-populated. + +### Flow 4: Managing Templates + +**Entry point:** Courses page → "Templates" tab. + +The courses page (`/org/[slug]/courses`) gets a tab bar: **[My Courses]** | **[Templates]**. + +**Templates tab features:** +- Grid of template cards with: name, description, course type, lesson count, exercise count, usage count ("Used N times"). +- Card dropdown menu: Edit, Duplicate, Delete. +- "Create Template" button. +- **Edit**: Opens the course editor with a "Template" badge. Same editing experience as a real course. +- **Duplicate**: Clones the template into another template. +- **Delete**: Soft-deletes the template (`status = 'DELETED'`). Confirmation dialog: "This will not affect courses already created from this template." + +## Technical Design + +### Data Model Changes + +#### Modified Table: `course` + +1. **Fix `isTemplate` default** from `true` to `false`. New courses should not be flagged as templates by default. +2. **Add `templateSourceId`** — nullable UUID FK to `course.id`. Tracks which template a course was created from. Used for usage count analytics ("12 courses created from this template"). +3. **Add `templateCategory`** — nullable varchar. Optional category for organizing templates in the picker. + +```typescript +// packages/db/src/schema.ts — additions to course table + +isTemplate: boolean('is_template').default(false), // changed from true to false +templateSourceId: uuid('template_source_id'), +templateCategory: varchar('template_category'), + +// Add to constraints: +foreignKey({ + columns: [table.templateSourceId], + foreignColumns: [table.id], + name: 'course_template_source_id_fkey' +}).onDelete('set null') +``` + +No new tables needed. Templates are courses filtered by `isTemplate = true` and `status = 'TEMPLATE'`. + +#### Backward Compatibility + +Existing courses all have `isTemplate = true` due to the current default. A data migration will set `isTemplate = false` for all existing courses (they are real courses, not templates). The migration is safe because the field is not currently used in any queries or business logic. + +### Migration + +Create migration file: `packages/db/src/migrations/XXXX_add_course_templates.sql` + +```sql +-- 1. Fix isTemplate for all existing courses (they are real courses, not templates) +UPDATE course SET is_template = false WHERE is_template = true; + +-- 2. Change default for is_template to false +ALTER TABLE course ALTER COLUMN is_template SET DEFAULT false; + +-- 3. Add template_source_id column +ALTER TABLE course ADD COLUMN template_source_id UUID REFERENCES course(id) ON DELETE SET NULL; + +-- 4. Add template_category column +ALTER TABLE course ADD COLUMN template_category VARCHAR(255); + +-- 5. Add index for template queries +CREATE INDEX idx_course_is_template ON course(is_template) WHERE is_template = true; + +-- 6. Add index for template usage count +CREATE INDEX idx_course_template_source_id ON course(template_source_id) WHERE template_source_id IS NOT NULL; +``` + +### Validation Layer + +File: `packages/utils/src/validation/course/template.ts` + +```typescript +import { z } from 'zod'; + +export const ZSaveAsTemplate = z.object({ + title: z.string().min(1).max(255), + description: z.string().min(1), + category: z.string().max(100).optional(), + includeLessonContent: z.boolean().default(true), + includeExercises: z.boolean().default(true), + includeSettings: z.boolean().default(true), + includeLandingPage: z.boolean().default(false) +}); + +export type TSaveAsTemplate = z.infer; + +export const ZCreateFromTemplate = z.object({ + templateId: z.string().uuid(), + title: z.string().min(1), + description: z.string().min(1), + type: z.enum(['LIVE_CLASS', 'SELF_PACED']).optional(), + organizationId: z.string().min(1) +}); + +export type TCreateFromTemplate = z.infer; + +export const ZCreateTemplate = z.object({ + title: z.string().min(1), + description: z.string().min(1), + type: z.enum(['LIVE_CLASS', 'SELF_PACED']), + category: z.string().max(100).optional(), + organizationId: z.string().min(1) +}); + +export type TCreateTemplate = z.infer; + +export const ZUpdateTemplate = z.object({ + title: z.string().min(1).max(255).optional(), + description: z.string().min(1).optional(), + category: z.string().max(100).nullable().optional() +}); + +export type TUpdateTemplate = z.infer; +``` + +Export from: `packages/utils/src/validation/course/index.ts` + +### Query Layer + +File: `packages/db/src/queries/course/template.ts` + +Functions: +- `getOrgTemplates(organizationId)` — list all templates for an org. Returns templates with lesson count, exercise count, and usage count (number of courses with `templateSourceId` pointing to this template). Ordered by `createdAt` desc. +- `getTemplateById(templateId)` — single template with full details and usage count. +- `getTemplateUsageCount(templateId)` — count of courses created from this template. + +Export from: `packages/db/src/queries/course/index.ts` + +### Service Layer + +File: `apps/api/src/services/course/template.ts` + +#### `saveAsTemplate(courseId, userId, data: TSaveAsTemplate)` +- Fetch the source course. +- Clone it via a modified `cloneCourse()` that respects the `include*` flags: + - Always clone sections (structure). + - Skip lesson content (notes, videos, documents) if `includeLessonContent = false` — create lessons with title and order only. + - Skip exercises if `includeExercises = false`. + - Skip metadata fields if `includeSettings = false`. + - Skip landing page fields (bannerImage, metadata.videoUrl, etc.) if `includeLandingPage = false`. +- Set `isTemplate = true`, `status = 'TEMPLATE'`, `isPublished = false` on the new course. +- Set `templateCategory` if provided. +- Return the new template. + +#### `createTemplate(userId, data: TCreateTemplate)` +- Create a new course with `isTemplate = true`, `status = 'TEMPLATE'`, `isPublished = false`. +- Follow the same transaction as `createCourse()` (create group → course → add creator as TUTOR). +- Set `templateCategory` if provided. +- Return the new template. + +#### `createFromTemplate(templateId, userId, data: TCreateFromTemplate)` +- Fetch the template. Verify `isTemplate = true`. +- Clone via `cloneCourse()` with all content included. +- Override: `isTemplate = false`, `status = 'ACTIVE'`, `isPublished = false`. +- Override: `title`, `description`, `type` from user input. +- Set `templateSourceId = templateId` on the new course. +- Return the new course. + +#### `deleteTemplate(templateId)` +- Verify the course is a template (`isTemplate = true`). +- Soft delete: set `status = 'DELETED'`. +- Does not affect courses with `templateSourceId = templateId`. + +### Route Layer + +File: `apps/api/src/routes/course/template.ts` + +All routes nested under `/course/template` and require `authMiddleware` + admin/tutor role. + +| Method | Path | Description | +| --- | --- | --- | +| `GET` | `/course/template` | List org templates (query param: `organizationId`) | +| `POST` | `/course/template` | Create blank template | +| `GET` | `/course/template/:templateId` | Get template details | +| `PUT` | `/course/template/:templateId` | Update template metadata | +| `DELETE` | `/course/template/:templateId` | Soft-delete template | +| `POST` | `/course/:courseId/save-as-template` | Save existing course as template | +| `POST` | `/course/template/:templateId/create-course` | Create new course from template | +| `POST` | `/course/template/:templateId/duplicate` | Duplicate a template | + +#### Route Registration + +```typescript +// apps/api/src/routes/course/index.ts +export * from './template'; + +// Registration in course router +.route('/template', templateRouter) +``` + +### Modified Existing Queries + +#### `getOrgCourses(organizationId, ...)` +Add filter: `WHERE is_template = false AND status != 'TEMPLATE'` to exclude templates from the normal course listing. This ensures the "My Courses" tab only shows real courses. + +### Error Codes + +Add to `apps/api/src/utils/errors.ts`: + +```typescript +TEMPLATE_NOT_FOUND: 'TEMPLATE_NOT_FOUND', +NOT_A_TEMPLATE: 'NOT_A_TEMPLATE', +``` + +### Build Verification + +```bash +pnpm --filter @cio/utils build +pnpm --filter @cio/db build +pnpm --filter @cio/api build +``` + +## Frontend Plan (Dashboard) + +### Request Types + +File: `apps/dashboard/src/lib/features/course/utils/types.ts` + +```typescript +export type ListTemplatesRequest = typeof classroomio.course.template.$get; +export type CreateTemplateRequest = typeof classroomio.course.template.$post; +export type GetTemplateRequest = typeof classroomio.course.template[':templateId']['$get']; +export type UpdateTemplateRequest = typeof classroomio.course.template[':templateId']['$put']; +export type DeleteTemplateRequest = typeof classroomio.course.template[':templateId']['$delete']; +export type SaveAsTemplateRequest = typeof classroomio.course[':courseId']['save-as-template']['$post']; +export type CreateFromTemplateRequest = typeof classroomio.course.template[':templateId']['create-course']['$post']; + +export type ListTemplatesSuccess = Extract, { success: true }>; +export type CourseTemplate = ListTemplatesSuccess['data'][number]; +``` + +### API Class + +File: `apps/dashboard/src/lib/features/course/api/template.svelte.ts` + +```typescript +class TemplateApi extends BaseApiWithErrors { + templates = $state([]); + loading = $state(false); + + async list(organizationId: string) { ... } + async create(data: TCreateTemplate) { ... } + async update(templateId: string, data: TUpdateTemplate) { ... } + async remove(templateId: string) { ... } + async saveAsTemplate(courseId: string, data: TSaveAsTemplate) { ... } + async createFromTemplate(templateId: string, data: TCreateFromTemplate) { ... } + async duplicate(templateId: string) { ... } +} +``` + +### New/Modified Components + +#### `save-as-template-modal.svelte` +Location: `apps/dashboard/src/lib/features/course/components/save-as-template-modal.svelte` + +Modal with: title, description, category, content inclusion checkboxes, confirm button. + +#### `template-picker.svelte` +Location: `apps/dashboard/src/lib/features/course/components/template-picker.svelte` + +Grid of template cards with search, category filters, preview panel, and "Use Template" action. + +#### Modified: `new-course-modal.svelte` +Add a new initial step: "Blank Course" vs "From Template" card selection. "Blank" proceeds to existing flow. "From Template" shows `template-picker.svelte`, then proceeds to the customize step. + +#### Modified: Course card dropdown +Add "Save as Template" option alongside existing "Clone" option. + +#### Modified: Courses page +Add tab bar: **[My Courses]** | **[Templates]**. The templates tab renders the template management grid. + +### Template Badge + +When editing a template in the course editor, show a persistent badge in the header: a colored pill reading "Template" (e.g., `bg-blue-100 text-blue-800`). This makes it clear the user is editing a blueprint, not a live course. + +### Routes + +| Route | Page | +| --- | --- | +| `/org/[slug]/courses?tab=templates` | Templates tab on courses page (no new route file — tab state via query param) | + +No new route files needed — the templates tab lives on the existing courses page, controlled by a `tab` query parameter. + +### Translations + +File: `apps/dashboard/src/lib/utils/translations/en.json` + +Add keys under `course.templates`: + +```json +{ + "course": { + "templates": { + "title": "Templates", + "create": "Create Template", + "save_as_template": "Save as Template", + "from_template": "From Template", + "blank_course": "Blank Course", + "blank_course_description": "Start with an empty course", + "from_template_description": "Choose from your template library", + "choose_starting_point": "How would you like to start?", + "choose_template": "Choose a template", + "customize_course": "Customize your course", + "based_on": "Based on: {name}", + "use_template": "Use Template", + "preview": "Preview", + "template_name": "Template Name", + "category": "Category", + "what_to_include": "What to include", + "sections_structure": "Sections & structure", + "lesson_content": "Lesson content (notes, videos, documents)", + "exercises_quizzes": "Exercises & quizzes", + "course_settings": "Course settings (grading, certificates)", + "landing_page_content": "Landing page content", + "student_data_note": "Student data, submissions, and enrollments are never included.", + "template_created": "Template created", + "view_templates": "View in Templates", + "template_badge": "Template", + "used_times": "Used {count} times", + "no_templates": "No templates yet. Save an existing course as a template or create one from scratch.", + "delete_confirm": "This will not affect courses already created from this template.", + "lessons_count": "{count} lessons", + "exercises_count": "{count} exercises", + "search_templates": "Search templates..." + } + } +} +``` + +### Frontend Build Verification + +```bash +pnpm --filter @cio/dashboard build +``` + +## Implementation Order + +### Phase 1: Database + Schema + +1. Update `isTemplate` default from `true` to `false` in schema. +2. Add `templateSourceId` and `templateCategory` columns to `course` table in schema. +3. Create migration: fix existing data, add columns, add indexes. +4. Run `pnpm --filter @cio/db build`. + +### Phase 2: Validation + Queries + +1. Create `packages/utils/src/validation/course/template.ts` with all Zod schemas. +2. Export from `packages/utils/src/validation/course/index.ts`. +3. Create `packages/db/src/queries/course/template.ts` with query functions. +4. Update `getOrgCourses` to exclude templates. +5. Export from `packages/db/src/queries/course/index.ts`. +6. Run `pnpm --filter @cio/utils build` and `pnpm --filter @cio/db build`. + +### Phase 3: Service + Routes + +1. Create `apps/api/src/services/course/template.ts` with all service functions. +2. Refactor `cloneCourse()` to accept an options object for selective content cloning. +3. Create `apps/api/src/routes/course/template.ts` with all route handlers. +4. Register template router in course route index. +5. Add error codes to `apps/api/src/utils/errors.ts`. +6. Run `pnpm --filter @cio/api build`. + +### Phase 4: Frontend — Template Management + +1. Add request types to `apps/dashboard/src/lib/features/course/utils/types.ts`. +2. Create `apps/dashboard/src/lib/features/course/api/template.svelte.ts` API class. +3. Add translation keys to `en.json`. +4. Add "Templates" tab to courses page. +5. Create template card component for the grid. +6. Add "Save as Template" to course card dropdown and course settings. +7. Create `save-as-template-modal.svelte`. +8. Run `pnpm --filter @cio/dashboard build`. + +### Phase 5: Frontend — Create from Template + +1. Modify `new-course-modal.svelte` to add "Blank" vs "From Template" step. +2. Create `template-picker.svelte` with search, categories, preview. +3. Add customize step with template-prefilled fields. +4. Add "Template" badge to course editor header when editing a template. +5. Run `pnpm --filter @cio/dashboard build`. + +## Acceptance Criteria + +1. Instructor can save an existing course as a template with selective content inclusion. +2. Instructor can create a blank template from the templates tab. +3. Templates appear in the "Templates" tab and do not appear in the "My Courses" tab. +4. Instructor can create a new course from a template via the "Create Course" flow. +5. Courses created from templates are fully independent — editing the template does not affect the course and vice versa. +6. Template cards show lesson count, exercise count, and usage count. +7. Template picker supports search and category filtering. +8. Template preview shows the section/lesson outline in a read-only view. +9. Existing course creation flow ("Blank Course") is unchanged. +10. Existing clone functionality is unchanged. +11. Deleting a template does not affect courses created from it. +12. Templates are not visible to students in any listing or enrollment flow. +13. Editing a template uses the same course editor with a "Template" badge. +14. All existing courses have `isTemplate = false` after migration. +15. All new UI strings use translation keys (no hardcoded English). +16. `pnpm --filter @cio/api build` and `pnpm --filter @cio/dashboard build` pass after implementation. + +## Risks and Mitigations + +- **Risk**: Changing `isTemplate` default from `true` to `false` and bulk-updating existing courses could cause issues if any code relies on `isTemplate = true`. + - **Mitigation**: Codebase search confirms `isTemplate` is never used in filtering or conditional logic — it is only copied during clone. The migration is safe. + +- **Risk**: Refactoring `cloneCourse()` to support selective content could break existing clone functionality. + - **Mitigation**: The refactor adds an optional `options` parameter. When not provided, all content is cloned (current behavior). Existing clone callers are not affected. + +- **Risk**: Templates accumulate and become hard to find. + - **Mitigation**: Category filtering and search are built into the template picker from v1. Categories can be extended to tags in a future iteration. + +## Future Considerations + +- **Platform starter templates**: ClassroomIO-provided templates shown in a separate "ClassroomIO Templates" section of the picker. +- **AI-generated templates**: "Generate with AI" as a third option in the course creation flow alongside "Blank" and "From Template." +- **Template marketplace**: Orgs opt-in to share templates publicly. +- **Template versioning**: Track changes and allow courses to "pull updates" from the source template. +- **Template access control**: Restrict which instructors can use or edit certain templates. +- **Cohort integration**: When course cohorts ship, templates could include default cohort configuration. diff --git a/prd/course-widget-embed/README.md b/prd/course-widget-embed/README.md new file mode 100644 index 00000000..b52acb38 --- /dev/null +++ b/prd/course-widget-embed/README.md @@ -0,0 +1,684 @@ +# Course Widget Embed PRD (Org Widgets) + +## Purpose +Ship an organization-scoped widget system so admins can embed selected courses on external websites from `/org/[slug]/widgets`, with a visual editor and lightweight runtime. + +## Confirmed Decisions (2026-02-17) +1. Editor route is in dashboard org context: `/org/[slug]/widgets` (user-facing equivalent: `/org/id/widgets`). +2. Embed runtime must be plain Svelte (no SvelteKit in the embed runtime path) for minimal JS payload. +3. Widget rendering supports multiple layouts: +- cards +- table +- primary single course +- carousel +- tag-filtered list (tags as top badges) +4. Widget authoring UI opens an editor with left panel sections: +- `Select Courses` +- `Widgets` +- `Design` +5. Published widgets deploy to Cloudflare-backed static hosting/edge delivery (R2 + Workers). +6. Dashboard editing imports the widget package in dev mode for real preview parity. +7. Dashboard preview does not call a preview endpoint; it uses already-fetched dashboard state and passes payload into an iframe. +8. Public embed fetches computed widget payload (courses + design config) from server endpoints. + +## Problem Statement +Organizations currently show courses mainly on their own landing pages. There is no first-class way to publish reusable, brandable, copy-paste course widgets for external websites. + +This creates gaps: +- No reusable embed snippet per organization. +- No dashboard-managed widget lifecycle (draft, publish, version rollback). +- No lightweight runtime optimized for third-party pages. +- No structured design controls for layout/theme/branding. + +## Product Goals +1. Let org admins create and publish embeddable course widgets. +2. Keep embed runtime lightweight and isolated from host-site CSS/JS conflicts. +3. Reuse existing course and tag data models for content selection/filtering. +4. Provide clear plan-gated design controls (free vs paid capabilities). +5. Support versioned publishing with safe rollbacks. + +## Non-Goals (v1) +- Full website/page builder replacement. +- Server-rendered widget runtime. +- Cross-org shared widget templates marketplace. +- Arbitrary third-party JS execution inside widgets. + +## Data Sources Checked +- `apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts` +- `apps/dashboard/src/routes/org/[slug]/courses/+page.server.ts` +- `apps/dashboard/src/routes/org/[slug]/tags/+page.server.ts` +- `apps/dashboard/src/lib/features/org/api/org.svelte.ts` +- `apps/dashboard/src/lib/features/org/components/landing-page/landing-page.svelte` +- `apps/dashboard/src/lib/features/course/components/card.svelte` +- `apps/api/src/routes/organization/organization.ts` +- `apps/api/src/services/organization.ts` +- `packages/db/src/schema.ts` +- `packages/db/src/queries/course/course.ts` +- `packages/db/src/queries/tag/tag.ts` +- `packages/utils/src/plans/constants.ts` +- `packages/course-app/README.md` + +## User Roles + +### Admin (`ROLE.ADMIN`) +- Create/edit/delete/archive widgets. +- Select courses and widget layout. +- Configure design controls and plan-gated options. +- Publish widget versions and rollback. +- Copy embed snippet. + +### Tutor (`ROLE.TUTOR`) +- Create/edit widget drafts and use live preview. +- No publish, rollback, or destructive lifecycle actions in v1. + +### Student/Public +- No dashboard access. +- Consume published embed on external sites. + +## Functional Requirements + +### 1. Widget Lifecycle +- Create widget with: + - `name` + - `layoutType` + - selection mode (`manual` course selection or `published courses`) + - initial design config +- Status model: + - `DRAFT` + - `PUBLISHED` + - `ARCHIVED` +- Save model: + - manual save only (`Save` button) + - no autosave in v1 +- Publish creates immutable version snapshot. +- Rollback can point latest published to a previous version. + +### 2. Widget Editor UX (`/org/[slug]/widgets/:widgetId`) +- Left panel tabs: + - `Select Courses` + - `Widgets` + - `Design` +- Main panel: + - live preview using the same shared render package as embed + - desktop/mobile preview toggle + - embed code tab +- Live preview behavior: + - every design/content change updates a local draft state + - dashboard uses already-fetched state (courses/tags/widget draft) to build preview payload + - preview payload is passed down through iframe (`postMessage`) to preview runtime + - iframe mounts the same widget component contract used by public embed + - changes are preview-only until user clicks `Save` + +### 3. Select Courses +- Source modes: + - manual selected courses + - all published org courses +- Filters: + - tag include/exclude using existing org tags + - search by course title +- Ordering: + - manual order + - newest + - title A-Z + +### 4. Widget Layout Types +- `cards` +- `table` +- `primary_course` +- `carousel` +- `tag_filter` + +Each layout supports required shared props: +- `showCourseImage` +- `showCourseTypeBadge` +- `showLessonsCount` +- `showPrice` +- `showDescriptionExcerpt` +- `ctaLabel` + +### 5. Design Controls +Design panel is grouped as: + +#### Content +- limit courses toggle + max count +- load more label +- hide load more toggle +- show border toggle +- border width +- shadow size + style + color +- border radius scale +- show metadata toggles (lessons/type/price) +- shorten long description toggle +- show highlighted/featured style toggle + +#### Colors +- primary color +- background color +- text color +- badge/rating color +- border color +- highlight color + +#### Typography +- font family selector +- font size scale + +#### Filters +- add/remove tag filter rows +- top badge style for tag filter layout + +#### Branding +- `Show Classroomio Powered By` toggle + +#### Advanced +- custom CSS textarea (sanitized + size-limited) + +### 6. Plan Gating +- Free (`PLAN.BASIC`): + - **1 preset theme only** (default Classroomio theme) + - branding forced on + - custom CSS disabled +- Paid (`PLAN.EARLY_ADOPTER`, `PLAN.ENTERPRISE`): + - **All preset themes available** (3-5 curated themes) + - full color controls (custom colors) + - branding toggle available + - custom CSS enabled + +### 7. Public Embed Contract +- Copy-paste script embed: + - `` +- Loader pulls published widget payload via public key and mounts Svelte widget. +- Host page should not require framework dependencies. +- Payload shape must match dashboard preview payload contract. + +### 8. Runtime Constraints (Lightweight Requirement) +- Plain Svelte runtime package (`packages/widgets`) compiled in library mode (Vite `build.lib`). +- No SvelteKit runtime in embed bundle. +- Use style isolation: **Shadow DOM custom element** (``) with `:host` selector support. +- Performance budgets: + - loader <= 3KB gzip + - base runtime <= 35KB gzip (excluding fetched data) + - first render <= 1.2s on mid-tier 4G for <= 6 courses +- Accessibility: + - All interactive elements keyboard accessible + - ARIA labels on course cards (`aria-label="Course: ${title}"`) + - Alt text on course images + - Focus visible indicators + +### 9. Security and Safety +- Public widget key must be non-guessable (format: `wgt_`). +- Custom CSS sanitized with `css-tree` parser (whitelist approach): + - Max size: 5KB + - Reject: `@import`, `javascript:`, expression(), behavior:, binding:, `-moz-binding` + - Reject properties: `position: fixed` (can overlay host page maliciously) + - Allowed: standard visual styles (color, spacing, typography, flex/grid layout) +- Public endpoint returns only publish-approved fields (no internal org data). +- Rate limit public widget payload endpoint: 100 req/min per IP, 1000 req/min per widget key. +- iframe origin validation: strict `targetOrigin` matching dashboard domain. + +### 10. Analytics (Schema Reserved for Phase 2) +**Deferred to Phase 2**, but schema defined for forward compatibility: + +```typescript +// Table: widget_analytics_event +{ + id: string; + widget_id: string; + widget_version_id: string; + event_type: 'impression' | 'cta_click' | 'filter_interaction'; + course_id?: string; // for cta_click events + filter_tag_id?: string; // for filter_interaction events + session_id: string; // anonymous session fingerprint + timestamp: Date; + user_agent_hash: string; // privacy-preserving hash + country_code?: string; // from CF-IPCountry header +} +``` + +- Ingestion: Batch insert every 30 seconds or 1000 events +- Retention: 90 days raw, aggregated forever +- v1: No events endpoint; schema created but not populated + +### 11. Shared Payload Contract (Hard Requirement) +- One canonical payload schema defines widget data: + - selected courses + - resolved ordering/filters + - normalized design tokens and defaults + - plan-gated fields +- Dashboard preview builds payload client-side from already-fetched state and passes it to iframe. +- Public embed builds/fetches payload server-side from persisted widget config and course queries. +- The embed renderer accepts only this payload schema + minimal runtime options. +- No separate dashboard-only renderer data shape is allowed. + +### 11. Cloudflare Publish Pipeline (Phase E) + +#### Artifact Generation +1. **Build Trigger**: API call `POST /organization/widgets/:widgetId/publish` +2. **Build Process**: + ``` + a. API receives publish request + b. Compute final payload with resolved courses + design config + c. Trigger build worker (Cloudflare Worker or GitHub Actions) + d. Build @cio/widgets package with embedded design tokens + e. Generate hashed filenames (e.g., `widget.a3f2b1c.js`) + f. Upload to R2 bucket: `widgets/{orgId}/{widgetId}/v{version}/` + g. Return manifest URLs to API + h. API saves `artifact_manifest` to `widget_version` table + ``` + +#### Artifact Manifest Format +```json +{ + "version": "v1", + "widgetId": "wgt_xxx", + "orgId": "org_yyy", + "createdAt": "2026-02-19T12:00:00Z", + "files": { + "js": { + "url": "https://cdn.classroomio.com/widgets/org_yyy/wgt_xxx/v3/widget.a3f2b1c.js", + "integrity": "sha384-abc123...", + "size": 24567 + }, + "css": { + "url": "https://cdn.classroomio.com/widgets/org_yyy/wgt_xxx/v3/styles.7e8d9f0.css", + "integrity": "sha384-def456...", + "size": 3456 + } + }, + "designHash": "sha256:abc..." +} +``` + +#### Cleanup Policy +- Keep last 10 versions per widget +- Delete older artifacts after 30 days (R2 lifecycle rule) +- Soft-delete in DB (set `deleted_at`), hard-delete after 90 days + +#### Delivery Architecture +``` +User's Website Cloudflare +| | +|-- script src="loader.js" -----> |-- Worker: validate key +| |-- Check cache (KV) +| |-- Cache miss: fetch from R2 +|<-- loader.js -------------------| +| | +|-- loader fetches manifest ----->|-- Return artifact_manifest +| | +|-- loader injects JS/CSS ------->|-- Serve from R2 (immutable cache) +``` + +--- + +## Proposed Architecture + +### Package and App Boundaries +1. `packages/widgets` +- plain Svelte renderer components +- layout implementations +- shared design token mapping +- build output: ESM + browser bundle + +2. `apps/dashboard` +- widget editor UI and preview +- imports renderer from `@cio/widgets` in dev/prod build + +3. `apps/api` +- org-protected widget CRUD/publish routes +- public widget payload route +- shared `computeWidgetPayload(...)` service used for public payload routes + +4. Cloudflare publish target +- stores versioned JS/CSS artifacts +- serves loader + static assets + cache headers + +### Render Data Flow +1. Dashboard editor loads widget draft and related data into state (courses/tags/config). +2. Left panel changes update local draft JSON in state. +3. Dashboard builds preview payload from state and sends it to preview iframe via `postMessage`. +4. Preview iframe mounts `@cio/widgets` renderer with received payload. +5. Publish stores snapshot; public loader requests server-computed payload for published version and mounts same renderer. + +### Data Model (Proposed) +1. `widget` +- `id`, `organization_id`, `name`, `status`, `layout_type` +- `selection_mode`, `public_key`, `latest_published_version_id` +- `created_by`, `updated_by`, timestamps + +2. `widget_course` +- `widget_id`, `course_id`, `order` + +3. `widget_version` +- `id`, `widget_id`, `version` +- `config_snapshot` (jsonb) +- `artifact_manifest` (jsonb: js/css URLs + integrity) +- `published_at`, `published_by` + +## API Contract Additions (Target) +### Organization-Scoped (auth + org membership) +1. `GET /organization/widgets` +2. `POST /organization/widgets` +3. `GET /organization/widgets/:widgetId` +4. `PUT /organization/widgets/:widgetId` +5. `DELETE /organization/widgets/:widgetId` +6. `POST /organization/widgets/:widgetId/publish` +7. `POST /organization/widgets/:widgetId/rollback` + +### Public +1. `GET /widgets/:publicKey/payload` +2. `POST /widgets/:publicKey/events` (optional in v1, can be deferred) + +## UX Flow +1. Admin opens `/org/[slug]/widgets`. +2. Clicks `Create Widget`. +3. Editor opens with left tabs (`Select Courses`, `Widgets`, `Design`). +4. Admin previews and adjusts design. +5. Admin publishes, receives embed snippet. +6. External website adds script tag; widget renders courses. + +### Plan Gating UX Pattern +- **Free users**: Show paid features as disabled with upgrade tooltip + - Theme selector shows 1 option with "Upgrade to unlock more themes" on hover + - Custom color picker disabled with "Upgrade to customize colors" tooltip + - Custom CSS textarea shows lock icon with upgrade CTA + - "Powered by Classroomio" toggle is locked ON with tooltip +- **Paid users**: Full access to all controls, branding toggle available + +## Open Questions (Resolved) +1. ✅ **Free plan color tweaks** – **1 preset theme only** (default). Paid users get all preset themes (3-5) plus custom color picker. +2. ✅ **Events endpoint** – Deferred to Phase 2; schema reserved but not implemented in v1. +3. ✅ **Locale detection** – Deferred to Phase 2; widgets use single-language labels from org default locale in v1. +4. ✅ **CSS isolation** – **Shadow DOM custom element** preferred for strongest isolation. Fallback to scoped classes only if Shadow DOM causes critical theming issues during Phase C testing. + +## Additional Architecture Decisions + +### Widget Key Format +- Format: `wgt_` +- Example: `wgt_3J98t1WpEZ73CNmYv` +- Includes implicit typo detection via base58 checksum behavior +- Length: 23 characters (prefix + 16 chars) + +### Rollback Mechanics +- Rollback is **pointer-swap**, not append-only +- `latest_published_version_id` points to any previous version +- No new version created on rollback +- Analytics remain tied to version ID (history preserved) +- UI shows version history (read-only) with "Restore to this version" action + +### Caching Strategy +- Public payload endpoint: `Cache-Control: public, max-age=60, stale-while-revalidate=300` +- Widget JS/CSS artifacts: `Cache-Control: public, max-age=31536000, immutable` +- Loader script: `Cache-Control: public, max-age=3600` (versioned via query param) + +## Expanded TODOs (Priority) + +### TODO-2: Iframe Protocol Spec (Detailed) + +#### Message Types +```typescript +// Parent → Iframe +type ParentMessage = + | { type: 'WIDGET_PREVIEW_RENDER'; payload: WidgetPayload; requestId: string } + | { type: 'WIDGET_PREVIEW_PING' }; // health check + +// Iframe → Parent +type IframeMessage = + | { type: 'WIDGET_PREVIEW_READY'; iframeId: string; timestamp: number } + | { type: 'WIDGET_PREVIEW_RENDERED'; requestId: string; timestamp: number } + | { type: 'WIDGET_PREVIEW_ERROR'; requestId: string; error: string; timestamp: number } + | { type: 'WIDGET_PREVIEW_RESIZE'; height: number; width?: number } + | { type: 'WIDGET_PREVIEW_CTA_CLICK'; courseId: string }; // for preview interactivity +``` + +#### Payload Envelope +```typescript +interface WidgetPayload { + version: 'v1'; + widgetId: string; + layoutType: 'cards' | 'table' | 'primary_course' | 'carousel' | 'tag_filter'; + courses: Course[]; + design: DesignConfig; + planGatedFields: PlanGatedFields; + timestamp: number; // epoch ms for cache-busting +} +``` + +#### Security Rules +1. **Origin Validation**: + - Production: `targetOrigin` must match `VITE_DASHBOARD_URL` (e.g., `https://app.classroomio.com`) + - Development: allow `http://localhost:*` with port wildcard + - Never use `*` in production + +2. **Message Validation**: + - Reject messages without `type` field + - Reject unknown message types + - Validate payload schema with Zod before processing + +3. **iframe Sandboxing**: + - Use `sandbox="allow-scripts"` attribute + - No `allow-same-origin` (prevents cookie access) + +#### Reliability Rules +1. **Handshake Flow**: + ``` + Parent: mount iframe → wait for READY (timeout: 5s) + Iframe: mount complete → send READY + Parent: receive READY → send RENDER with payload + Iframe: render complete → send RENDERED or ERROR + ``` + +2. **Retry Logic**: + - READY timeout: 3 retry attempts, exponential backoff (1s, 2s, 4s) + - RENDERED timeout: 2 retry attempts, show error state in parent + +3. **Error Handling**: + - Render errors: Show fallback UI in parent ("Preview unavailable") + - Log error details to console with `[WidgetPreview]` prefix + - Surface user-friendly error in editor UI + +#### Sequence Diagram +``` +Parent (Dashboard) Iframe (Widget Runtime) + | | + |--- mount iframe ------------>| + | | + |<-- WIDGET_PREVIEW_READY -----| + | (iframe ready) | + | | + |--- WIDGET_PREVIEW_RENDER --->| + | (payload + config) | + | | + |<-- WIDGET_PREVIEW_RESIZE ----| + | (dynamic height) | + | | + |<-- WIDGET_PREVIEW_RENDERED --| + | OR WIDGET_PREVIEW_ERROR | +``` + +#### Documentation Output +- Spec file: `prd/course-widget-embed/iframe-protocol.md` (this section) +- Implementation: `packages/widgets/src/preview/iframe-bridge.ts` + `apps/dashboard/src/lib/features/widget/preview/iframe-parent.ts` + +### TODO-3: Preview Data Freshness (Implementation Details) + +#### Freshness States +| State | Indicator | User Action | Behavior | +|-------|-----------|-------------|----------| +| `fresh` | Green dot + "Live" | None | Data synced with server within last 5 minutes | +| `stale` | Yellow dot + "Refresh suggested" | Manual refresh button | Data older than 5 minutes or tab was backgrounded | +| `refreshing` | Spinner + "Syncing..." | Disabled | Fetch in progress | +| `error` | Red dot + "Sync failed" | Retry button | Last fetch failed, using cached data | + +#### Implementation Rules +1. **Initial Load**: `+page.server.ts` provides hydrated data with `serverTimestamp` +2. **Stale Detection**: + - Check `Date.now() - lastSyncTime > 5 * 60 * 1000` on mount + - Listen to `visibilitychange` event: when tab becomes visible, check staleness +3. **Background Sync**: + - Use `navigator.sendBeacon` or `fetch` with `keepalive` for refresh + - Debounce refresh calls (minimum 30s between requests) +4. **Warning UX**: + - Non-blocking toast when stale data detected + - Course/tag removed warning appears in preview area (not blocking) +5. **Consistency**: + - Draft state is never auto-refreshed; only source data (courses/tags list) + - User must explicitly refresh to see new courses/tags + +#### State Machine +``` + initial load +[empty] ----------> [fresh] + | + 5min timeout | visibility change + v + [stale] <------. + | | + manual or | | error + auto refresh | + v | + [refreshing] ----' + | + success | | failure + | | + v v + [fresh] [error] + ^ | + '-------' + retry +``` + +--- + +### TODO-4: CSS Sanitizer Implementation + +#### Library Choice +- **Primary**: `css-tree` for parsing + whitelist validation +- **Backup**: `reworkcss` if `css-tree` bundle size exceeds 15KB + +#### Validation Rules +```typescript +const CSS_SANITIZER_CONFIG = { + maxSizeBytes: 5120, // 5KB + + // Allowed at-rules + allowedAtRules: ['media', 'supports', 'keyframes', '-webkit-keyframes'], + + // Blocked at-rules (security) + blockedAtRules: ['import', 'charset', 'namespace', 'font-face', 'page', 'document'], + + // Allowed properties (whitelist approach) + allowedProperties: [ + // Layout + 'display', 'position', 'top', 'right', 'bottom', 'left', + 'flex', 'flex-direction', 'flex-wrap', 'flex-grow', 'flex-shrink', 'flex-basis', + 'justify-content', 'align-items', 'align-content', 'gap', 'row-gap', 'column-gap', + 'grid', 'grid-template', 'grid-column', 'grid-row', 'grid-area', + // Box model + 'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height', + 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', + 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', + 'border', 'border-radius', 'box-sizing', 'overflow', 'overflow-x', 'overflow-y', + // Visual + 'color', 'background', 'background-color', 'background-image', 'background-size', + 'background-position', 'background-repeat', 'opacity', 'box-shadow', + 'border-color', 'border-width', 'border-style', 'border-top', 'border-right', + // Typography + 'font-family', 'font-size', 'font-weight', 'font-style', 'line-height', + 'text-align', 'text-decoration', 'text-transform', 'letter-spacing', 'word-spacing', + 'white-space', 'word-break', 'overflow-wrap', + // Effects + 'transform', 'transition', 'animation', 'filter', 'backdrop-filter' + ], + + // Blocked values (regex patterns) + blockedValuePatterns: [ + /javascript:/i, + /expression\s*\(/i, + /behavior\s*:/i, + /binding\s*:/i, + /-moz-binding/i, + /url\s*\(\s*["']?\s*data:/i, // data URIs (potential XSS) + ], + + // Special property validations + propertyValidators: { + 'position': (value: string) => value !== 'fixed', // No fixed positioning + 'background-image': (value: string) => !value.includes('url') || value.startsWith('url(https://') + } +}; +``` + +#### Error Handling +- Invalid CSS is stripped silently (not rejected entirely) +- User sees warning: "Some CSS rules were removed for security" +- Log sanitized rules to console in dev mode only + +--- + +### TODO-5: Accessibility Requirements (v1) + +#### Keyboard Navigation +- Tab order: logical flow through course cards +- Enter/Space: activate CTA buttons +- Escape: close any open modals/filter dropdowns + +#### Screen Reader Support +- Course cards: `role="article"` with `aria-labelledby` pointing to title +- CTA buttons: descriptive `aria-label` (e.g., "View Introduction to Python course") +- Filter badges: `aria-pressed` state for active filters +- Live regions: announce filter results changes (`aria-live="polite"`) + +#### Visual Requirements +- Focus indicators: 2px solid outline with 2px offset (using `:focus-visible`) +- Color contrast: minimum 4.5:1 for text (validated in design tokens) +- Reduced motion: respect `prefers-reduced-motion` for carousel animations + +--- + +### TODO-6: Bundle Size Enforcement + +#### CI Configuration +```yaml +# packages/widgets/bundle-size.json +{ + "limits": { + "loader.js": "3kb", + "runtime.js": "35kb", + "runtime.css": "5kb" + }, + "failOnViolation": true +} +``` + +#### Build Script +```bash +#!/bin/bash +# packages/widgets/scripts/check-bundle-size.sh + +max_size_loader=$((3 * 1024)) # 3KB +max_size_runtime=$((35 * 1024)) # 35KB + +loader_size=$(gzip -c dist/loader.js | wc -c) +runtime_size=$(gzip -c dist/runtime.js | wc -c) + +if [ $loader_size -gt $max_size_loader ]; then + echo "❌ Loader exceeds budget: ${loader_size}B > ${max_size_loader}B" + exit 1 +fi + +if [ $runtime_size -gt $max_size_runtime ]; then + echo "❌ Runtime exceeds budget: ${runtime_size}B > ${max_size_runtime}B" + exit 1 +fi + +echo "✅ Bundle sizes within budget" +``` + +## Success Criteria +1. Admin can publish a widget and embed it externally in under 5 minutes. +2. Embed snippet works across static HTML, React, and WordPress hosts. +3. Runtime stays within performance budget and has no host CSS collisions. +4. Support tickets for “embed broken by host CSS” remain near zero. +5. Dashboard live preview matches public embed rendering for the same payload and version. +6. **Accessibility**: Widgets are keyboard-navigable (Tab order, Enter/Space to activate CTAs) and screen-reader friendly (ARIA labels on course cards, alt text on images). +7. **Security**: Zero XSS vectors through custom CSS or malformed payloads in penetration testing. diff --git a/prd/course-widget-embed/implementation-plan.md b/prd/course-widget-embed/implementation-plan.md new file mode 100644 index 00000000..a3bbadd5 --- /dev/null +++ b/prd/course-widget-embed/implementation-plan.md @@ -0,0 +1,172 @@ +# Course Widget Embed Implementation Plan (v1) + +## Scope Lock +1. Widget embed runtime is plain Svelte package output (no SvelteKit runtime dependency in embedded widget). +2. Admin + Tutor can edit drafts; publish/rollback/delete remain admin-only. +3. Org-scoped widgets only; no cross-org sharing. +4. Required layout types in v1: +- cards +- table +- primary course +- carousel +- tag filter badges +5. Editor route is `/org/[slug]/widgets` with create/edit flows. +6. Publish target is Cloudflare-hosted static assets + edge delivery. +7. Dashboard live preview is local state -> iframe (no preview API endpoint). +8. Public embed fetches server-computed payload from widget public endpoint. +9. Both paths must use the same shared payload schema. +10. Draft editing is manual-save only (no autosave in v1). +11. Shadow DOM is the default isolation strategy; fallback to scoped classes only if critical issues arise. +12. CSS sanitizer uses `css-tree` with 5KB limit and whitelist validation. + +## Delivery Phases +1. Phase A: Data model + validation + DB query layer. +2. Phase B: API services/routes (org + public payload). +3. Phase C: `@cio/widgets` package (plain Svelte runtime + loader contract). +4. Phase D: Dashboard widgets feature (list, editor, preview, publish). +5. Phase E: Cloudflare publish pipeline + observability + hardening. + +## Ticket Breakdown + +| ID | Area | Task | Key Files | Dependencies | Done When | +| --- | --- | --- | --- | --- | --- | +| CW-A1 | DB | Add widget schemas (`widget`, `widget_course`, `widget_version`) | `packages/db/src/schema.ts` | None | Tables/migrations compile with org-scoped FKs and indexes | +| CW-A2 | DB Queries | Implement widget query module | `packages/db/src/queries/widget/widget.ts` | CW-A1 | CRUD + version + selection queries implemented with error logging pattern | +| CW-A3 | Utils Validation | Add widget validation schemas | `packages/utils/src/validation/widget/widget.ts` + exports | CW-A1 | Zod schemas cover create/update/publish/public payload | +| CW-A4 | DB Types | Export new widget types | `packages/db/src/types.ts` | CW-A1 | Type generation/build includes widget entities | +| CW-B1 | API Service | Add widget service layer | `apps/api/src/services/widget.ts` | CW-A2,CW-A3 | Role/org checks + business rules in service layer | +| CW-B2 | API Routes | Add org widget routes | `apps/api/src/routes/organization/widgets.ts` | CW-B1 | CRUD/publish/rollback endpoints working | +| CW-B3 | API Routes | Add public widget payload route | `apps/api/src/routes/widgets/widgets.ts` | CW-B1 | Public payload returns publish-safe data only | +| CW-B4 | API Register | Register routes in app tree | `apps/api/src/routes/organization/index.ts`, `apps/api/src/app.ts` | CW-B2,CW-B3 | RPC + public routes reachable | +| CW-B5 | API Analytics | Track view/click events (optional gate) | `apps/api/src/services/widget.ts` + route | CW-B3 | Event path available or clearly deferred behind feature flag | +| CW-B6 | API Compute Service | Add canonical payload builder for public embed | `apps/api/src/services/widget-payload.ts`, `apps/api/src/routes/widgets/widgets.ts` | CW-B1 | Public payload route uses one compute function for courses + design config | +| CW-C1 | Widgets Package | Create `@cio/widgets` package (plain Svelte runtime) | `packages/widgets/*` | CW-A3 | Package builds to ESM/browser bundle without SvelteKit | +| CW-C2 | Layouts | Implement 5 layout renderers | `packages/widgets/src/layouts/*` | CW-C1 | All required layouts render from same config contract | +| CW-C3 | Design Tokens | Add config-to-style mapper + Shadow DOM isolation | `packages/widgets/src/styles/*` | CW-C1 | Host CSS does not break widget visuals; Shadow DOM encapsulation verified in tests | +| CW-C4 | Loader Contract | Add loader bootstrap interface | `packages/widgets/src/loader/*` | CW-C1 | Loader can mount widget by payload id/key | +| CW-C5 | Size Budget | Add bundle size CI checks (loader ≤3KB, runtime ≤35KB gzip) | `packages/widgets/package.json` scripts + CI workflow | CW-C1 | CI fails when runtime exceeds target budgets; budget file in `bundle-size.json` | +| CW-C6 | Renderer Contract | Add strict typed payload contract shared by dashboard and API | `packages/widgets/src/types/payload.ts` | CW-C1,CW-B6 | Renderer accepts canonical payload schema only | +| CW-D1 | Dashboard Types | Add request/response types for widget API | `apps/dashboard/src/lib/features/widget/utils/types.ts` | CW-B4 | Types follow `typeof classroomio...` pattern | +| CW-D2 | Dashboard API | Add widget API class | `apps/dashboard/src/lib/features/widget/api/widget.svelte.ts` | CW-D1 | All widget requests use `this.execute()` | +| CW-D3 | Widgets List Page | Add `/org/[slug]/widgets` list route/page | `apps/dashboard/src/routes/org/[slug]/widgets/+page.svelte`, `+page.server.ts` | CW-D2 | Admin can see/create/open widgets | +| CW-D4 | Editor Page | Add `/org/[slug]/widgets/[widgetId]` editor route/page | `apps/dashboard/src/routes/org/[slug]/widgets/[widgetId]/+page.svelte`, `+page.server.ts` | CW-D3,CW-D2 | Left panel tabs + live preview + save/publish works | +| CW-D5 | Sidebar Nav | Add widgets entry to org sidebar | `apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts` | CW-D3 | Sidebar highlights widgets route correctly | +| CW-D6 | Translations | Add all widget UI copy keys | `apps/dashboard/src/lib/utils/translations/en.json` | CW-D4 | No hardcoded user-facing strings | +| CW-D12 | Accessibility | Add ARIA labels, keyboard navigation, focus indicators | widget layouts + preview | CW-C2 | Widget passes axe-core automated checks | +| CW-D7 | Plan Gating UI | Add paid/free UI gate: 1 theme for free, all themes + custom colors for paid | widget feature pages + store | CW-D4 | Free users see 1 theme option with upgrade prompt; paid users see all themes + custom colors | +| CW-D8 | Live Preview Pipeline | Build local state -> iframe preview pipeline | `apps/dashboard/src/routes/org/[slug]/widgets/[widgetId]/+page.server.ts`, editor feature files, preview iframe files | CW-C6 | Editing left-panel settings re-renders iframe preview without API roundtrip | +| CW-D9 | Manual Save UX | Add explicit save/discard workflow and dirty-state handling | editor feature files + stores | CW-D8 | No autosave; unsaved changes are preserved locally until user saves/discards | +| CW-D10 | Iframe Protocol | Implement iframe message protocol with origin/schema validation per spec in README | preview parent + iframe runtime files | CW-D8 | `READY/RENDER/ERROR/RESIZE` flow works with 3 retry attempts and strict origin checks | +| CW-D11 | Freshness Controls | Add data freshness states + manual refresh button in editor | editor route/feature files | CW-D8 | Editor shows `Last synced`, supports manual refresh, and handles stale warnings | +| CW-E1 | Publish Pipeline | Build widget artifact + upload to Cloudflare | `scripts/widgets/*` or api worker hook | CW-C4,CW-B2 | Publish produces versioned URLs + manifest persisted | +| CW-E2 | Cloudflare Delivery | Configure edge/static delivery and cache policy | deployment config/docs | CW-E1 | Loader/assets served with immutable cache headers | +| CW-E3 | Hardening | Add CSS sanitizer (css-tree), key generation (base58), rate limiting | API service/routes | CW-B3 | Public endpoints pass security checks; CSS validator tests pass | +| CW-E4 | Tests | Unit/integration tests for config + publish + payload | API/db/package tests | CW-B4,CW-C2 | Core flows covered and passing in CI | +| CW-E5 | Rollout | Add feature flag + staged rollout checklist | env/config docs | CW-D4 | Safe enable/disable by environment | + +## API Contract (Target) + +### Org Protected +1. `GET /organization/widgets` +2. `POST /organization/widgets` +3. `GET /organization/widgets/:widgetId` +4. `PUT /organization/widgets/:widgetId` +5. `DELETE /organization/widgets/:widgetId` +6. `POST /organization/widgets/:widgetId/publish` +7. `POST /organization/widgets/:widgetId/rollback` + +### Public +1. `GET /widgets/:publicKey/payload` +2. `POST /widgets/:publicKey/events` (if enabled in v1) + +## Widget Runtime Notes (Strict) +1. Use plain Svelte components compiled via Vite library mode. +2. Do not ship SvelteKit runtime artifacts in embed bundle. +3. Keep dependency graph minimal (avoid heavy UI libraries in embed package). +4. Isolate styles (prefer Shadow DOM custom element or strict prefixed scoping). +5. Renderer input type is the canonical payload contract shared across dashboard preview and public embed. + +## Dashboard UI Notes +1. Follow existing route pattern: +- route file contains `Page.Root/Page.Header` +- feature page contains business logic +2. Keep form controls using `@cio/ui` components. +3. Store all user-facing strings in translations. +4. Keep preview component using the same package renderer used in published bundle. +5. Editor `+page.server.ts` provides initial data for local preview state (courses/tags/draft config). +6. Left-panel changes update local payload and send it to iframe via `postMessage` (debounced/throttled). +7. Preview iframe should not fetch widget data; it only renders payload received from parent state. +8. No autosave; user must click `Save` to persist draft changes. +9. Provide explicit `Discard changes` action for local draft reset. + +## Acceptance Criteria by Phase + +### Phase A +1. Migration runs cleanly. +2. Org isolation enforced at query/service level. +3. Validation schemas compile in `@cio/utils`. +4. CSS sanitizer config defined and testable. + +### Phase B +1. Admin can CRUD and publish widget via API. +2. Public payload endpoint returns only published widget versions. +3. Unauthorized org access returns proper 403/404 behavior. +4. Public payload endpoint uses canonical compute service for courses + design config. +5. CSS sanitization rejects malicious input in tests. +6. Rate limiting enforced on public endpoints. + +### Phase C +1. `@cio/widgets` package builds independently. +2. Runtime meets bundle budget and renders all required layouts. +3. Host-site CSS does not break widget UI in smoke tests (Shadow DOM verified). +4. Renderer consumes only canonical computed payload type. +5. Accessibility: keyboard navigation and ARIA labels implemented. +6. Bundle size CI check fails builds exceeding budget. + +### Phase D +1. Admin can create/edit/publish from `/org/[slug]/widgets`. +2. Tutors can edit drafts from `/org/[slug]/widgets` editor. +3. Editor left panel has `Select Courses`, `Widgets`, `Design`. +4. Live preview updates as settings change using local state passed to iframe. +5. Embed snippet is copyable from dashboard. +6. Plan gates visibly enforce free vs paid restrictions (disabled with tooltips). +7. Editing is manual-save only; no background persistence happens. +8. Iframe protocol is stable (`READY/RENDER/ERROR/RESIZE`) with strict origin validation and retry logic. +9. Data freshness UI supports manual refresh and stale-state warnings. +10. Accessibility: widgets pass axe-core automated checks. + +### Phase E +1. Publish creates versioned immutable artifact URLs in Cloudflare R2. +2. Rollback switches manifest pointer to previous version without data loss. +3. Monitoring/logs show publish failures and public payload errors. +4. Artifact cleanup policy (10 versions kept) verified. +5. Cache headers correctly configured for all asset types. + +## Verification Commands +1. `pnpm --filter @cio/utils build` +2. `pnpm --filter @cio/db build` +3. `pnpm --filter @cio/api build` +4. `pnpm --filter @cio/widgets build` +5. `pnpm --filter @cio/dashboard build` +6. Run widget API and package tests (unit/integration). + +## Suggested First Sprint Slice +1. CW-A1 to CW-A4 (DB + validation foundation) +2. CW-B1 to CW-B6 (API services + routes + payload compute) +3. CW-C1 + CW-C6 (widgets package scaffold + payload contract) +4. CW-D3 + CW-D8 + CW-D9 (widgets list + preview pipeline + manual save skeleton) + +This slice creates the core contracts and route surface so editor/publish can iterate quickly afterward. + +### Definition of Ready per Ticket +Each ticket must include: +- Data flow diagram (if applicable) +- Interface/type definitions +- Error scenarios and handling +- Test plan (unit + integration) + +### Definition of Done per Phase +- All builds pass (`pnpm --filter @cio/* build`) +- Bundle size check passes (Phase C+) +- Security review passed (Phase B+) +- Accessibility smoke test passed (Phase D+) diff --git a/prd/email-template-customization/README.md b/prd/email-template-customization/README.md new file mode 100644 index 00000000..80c6f0b3 --- /dev/null +++ b/prd/email-template-customization/README.md @@ -0,0 +1,343 @@ +# Email Template Customization and Translation PRD + +## Purpose +Enable organization admins to manage outbound email templates without code changes by editing: +- Email logo +- Email content +- Whether each email should be sent + +This also introduces localization so email content is not hardcoded to English only. + +## Problem Statement +Today, all templates in `packages/email/src/emails/*.ts` are: +- Hardcoded in English +- Hardcoded in code (no admin editing surface) +- Always sent when the related business event occurs +- Coupled to a static ClassroomIO logo in `getDefaultTemplate(...)` + +This limits localization, white-labeling, and product control. + +## Decision Summary +- v1 allows editing system-managed email template IDs only. +- Org admins cannot create new email event types or custom triggers from the UI. +- Engineering can add new system email IDs where legacy raw emails still exist. +- v1 supports per-template: + - `logo` + - `content` + - `isEnabled` (send toggle) +- v1 supports locale variants with fallback behavior. + +## Current-State Audit + +Legend: `available now`, `partial`, `missing` + +| Capability | Availability | Current State | Effort | Should Implement | +| --- | --- | --- | --- | --- | +| Edit logo per template | missing | Static logo URL in default wrapper | Medium | [x] | +| Edit content per template | missing | HTML strings are hardcoded in TS files | Medium | [x] | +| Per-email send toggle | missing | Email sends are trigger-based only | Medium | [x] | +| Multi-language email content | missing | English-only template copy | Medium | [x] | +| Create new email types | missing | Requires code updates in registry/constants | High | [ ] (v1 non-goal) | +| Template preview before save | missing | No preview API/UI | Low | [x] | +| Audit trail for template changes | missing | No history | Medium | [x] | + +## Existing Email IDs (Current Catalog) +From `packages/email/src/utils/constants.ts`: +- `forgotPassword` +- `welcome` +- `verifyEmail` +- `onPasswordReset` +- `inviteTeacher` +- `newsfeedPost` +- `newsfeedComment` +- `studentCourseWelcome` +- `studentCourseInvite` +- `teacherStudentJoined` +- `teacherStudentBuyRequest` +- `studentProvePayment` +- `teacherCourseWelcome` + +## Goals +1. Organization admins can configure logo, content, and send toggle for each existing email ID. +2. Admins can manage localized content using supported locales (`en`, `hi`, `fr`, `pt`, `de`, `vi`, `ru`, `es`, `pl`, `da`). +3. Send pipeline resolves the best template by organization + locale with safe fallback. +4. Template resolution should not break existing `sendEmail(...)` call sites. +5. Keep type-safe Zod field validation already provided by `@cio/email`. +6. Migrate raw `deliverEmail(...)` product notifications into managed template IDs. + +## Non-Goals (v1) +- Letting admins create brand new email types, custom event triggers, or campaign flows. +- Editing `subject`, `from`, or `replyTo` in dashboard. +- Drag-and-drop email builder. +- Advanced analytics (open rate, click rate). +- Attachments. + +## Legacy Raw Emails to Standardize +Current product emails bypassing template registry: +- `apps/api/src/services/submission/submission.ts`: + - student submission status update email + - teacher notification on new student submission + +Plan: +- Create system-managed IDs for these flows. +- Move content to template registry + editable overrides. +- Keep behavior compatible while migrating call sites. + +## Functional Requirements + +### Template Editing +Per existing email ID, admins can: +- Upload/select logo URL +- Edit localized HTML content +- Enable or disable sending + +### Localization +- Template content is saved per locale. +- Fallback order at send time: +1. Org + emailId + requested locale +2. Org + emailId + `en` +3. Code default template renderer (current hardcoded fallback) + +### Send Toggle +- If template for an email ID is disabled, send is skipped and logged. +- Skip must not fail business actions (invites, onboarding, etc.). +- `required` email IDs can be protected from disabling (recommendation below). + +### Placeholder Safety +- Content can use variable placeholders from each template schema (for example `{{inviteLink}}`, `{{studentName}}`). +- Save validation rejects unknown placeholders for the selected email ID. +- Render-time validation continues using existing Zod schema. + +## Product Rules (Recommended) +1. Email IDs classified as: + - `required`: `forgotPassword`, `verifyEmail`, `onPasswordReset` + - `optional`: all remaining IDs +2. `required` IDs cannot be disabled in UI/API. +3. Missing localized content shows explicit fallback badge (`Using English fallback`). + +## Proposed Architecture + +### Data Model (New) + +#### `organization_email_template` +Organization-scoped override rows for existing email IDs. + +Suggested fields: +- `id` (uuid, pk) +- `organization_id` (uuid, fk -> `organization.id`, indexed) +- `email_id` (text, constrained to known email IDs) +- `locale` (`LOCALE`, default `en`) +- `is_enabled` (boolean, default `true`) +- `logo_url` (text, nullable) +- `content` (text, nullable; null means fallback to code default content) +- `updated_by_profile_id` (uuid, fk -> `profile.id`) +- `created_at`, `updated_at` + +Constraints: +- unique `(organization_id, email_id, locale)` +- index `(organization_id, email_id)` +- index `(organization_id, locale)` + +Notes: +- Row presence means override intent. +- `content = null` allows logo/toggle-only overrides. + +#### `organization_email_template_audit` +Append-only audit trail of changes. + +Suggested fields: +- `id` (uuid, pk) +- `organization_id` (uuid, indexed) +- `email_template_id` (uuid, fk -> `organization_email_template.id`) +- `action` (`CREATED` | `UPDATED` | `DELETED` | `ENABLED` | `DISABLED`) +- `before` (jsonb) +- `after` (jsonb) +- `actor_profile_id` (uuid) +- `created_at` + +## Backend Plan + +### Validation (`packages/utils/src/validation/email-template/`) +- `ZListEmailTemplatesQuery` +- `ZGetEmailTemplateParams` +- `ZUpsertEmailTemplate` +- `ZDeleteEmailTemplate` +- `ZPreviewEmailTemplate` + +`ZUpsertEmailTemplate` (v1): +- `emailId: EmailId` +- `locale: TLocale` +- `isEnabled?: boolean` +- `logoUrl?: string | null` +- `content?: string | null` + +### Queries (`packages/db/src/queries/email-template/`) +- `listOrganizationEmailTemplates` +- `getOrganizationEmailTemplate` +- `upsertOrganizationEmailTemplate` +- `deleteOrganizationEmailTemplate` +- `createOrganizationEmailTemplateAudit` +- `getResolvedOrganizationEmailTemplate` (with fallback) + +### Services (`apps/api/src/services/email-template/`) +- `listEmailTemplatesService` +- `getEmailTemplateService` +- `upsertEmailTemplateService` +- `deleteEmailTemplateService` +- `previewEmailTemplateService` +- `resolveEmailTemplateForSendService` + +### Routes (`apps/api/src/routes/email-template/`) +Mounted under organization context: +- `GET /organization/email-templates` +- `GET /organization/email-templates/:emailId` +- `PUT /organization/email-templates/:emailId` +- `DELETE /organization/email-templates/:emailId` +- `POST /organization/email-templates/:emailId/preview` + +All routes: `authMiddleware` + `orgAdminMiddleware`. + +## Send Pipeline Integration + +### Current +`sendEmail(emailId, { to, fields, ... })` renders from registered code template directly. + +### Proposed +Add optional send context: +- `organizationId?: string` +- `locale?: TLocale` + +Flow: +1. Existing template schema validation remains unchanged. +2. If `organizationId` exists, resolve override with locale fallback. +3. Determine final `isEnabled`. +4. If disabled, return skipped result `{ success: true, skipped: true, reason: 'TEMPLATE_DISABLED' }`. +5. Render final email wrapper with resolved `logoUrl` and `content` (or default fallback). + +Implementation note: +- Keep backward compatibility by making new context optional. +- Existing call sites without org context continue to use default code templates. + +## Frontend Plan (Dashboard) + +### Route +- `apps/dashboard/src/routes/org/[slug]/settings/emails/+page.svelte` + +### Navigation +Add settings tab item under org settings: +- title: `Emails` +- path: `/settings/emails` + +### Feature Module +`apps/dashboard/src/lib/features/email-template/` +- `api/email-template.svelte.ts` +- `utils/types.ts` +- `utils/email-template-utils.ts` +- `components/email-template-list.svelte` +- `components/email-template-editor.svelte` +- `components/email-template-preview.svelte` +- `pages/email-template-settings.svelte` + +### UX (v1) +- Left panel: list of existing email IDs + enabled status. +- Top controls: locale selector + fallback indicator. +- Editor: + - logo upload/URL + - content editor (HTML) + - placeholder helper for selected email ID + - enable/disable switch +- Preview pane renders current locale result. +- Save + Reset actions. + +### Copy and Translations +- No hardcoded UI strings. +- Add keys in `apps/dashboard/src/lib/utils/translations/en.json` and other locales as needed. + +## Phased Rollout + +### Phase 0: Foundation +1. DB tables + migration. +2. Validation + queries + services + routes. +3. Add metadata catalog for email IDs and allowed placeholders. +4. Add system-managed IDs for legacy raw submission emails. + +### Phase 1: Dashboard Management +1. Add org settings email page. +2. List, edit, save, reset template overrides. +3. Preview endpoint + preview UI. + +### Phase 2: Send Pipeline Adoption +1. Add optional `organizationId`/`locale` context in send flow. +2. Update org-scoped call sites to pass context. +3. Add skip logging for disabled templates. + +### Phase 3: Hardening +1. Audit logs UI/read endpoint. +2. Placeholder validation improvements and warnings. +3. Performance optimizations and caching. + +## Migration Plan +No hard data migration is required for v1 because defaults already exist in code. + +Rollout strategy: +1. Ship schema and API with no behavioral change. +2. Ship UI and allow override creation. +3. Enable send-time override resolution. +4. Incrementally update send call sites to pass org/locale context. + +## Risks and Mitigations +- Risk: Admin enters broken HTML. + - Mitigation: sanitize on save + render preview with warnings. +- Risk: Required variables removed from content. + - Mitigation: placeholder validation against schema keys. +- Risk: Disabling critical auth emails blocks account flows. + - Mitigation: `required` email IDs are non-disableable. +- Risk: Missing org context in some send call sites. + - Mitigation: safe fallback to existing code templates until upgraded. +- Risk: Large refactor footprint across services. + - Mitigation: optional send context and gradual adoption by domain. + +## Success Metrics +- At least 80% of active organizations with non-default email templates in first 60 days. +- At least 90% of org-scoped email sends resolved through template override service. +- Zero critical auth incidents caused by template disable misconfiguration. +- Reduction in support requests about email branding/language by at least 50%. + +## Open Questions +1. Should `welcome` email be org-customizable or remain global/system-owned? +2. Do we allow plain-text editor mode in addition to HTML mode? +3. Should logo override be per-template or org-global default with optional per-template override? +4. Should locale resolution use recipient profile locale automatically when context locale is omitted? +5. Do we need test-send endpoint in v1 or can preview-only ship first? + +## Kanban Board (Ideation Phase) + +| Ticket | Title | Todo | In Progress | Verification | Done | +| --- | --- | --- | --- | --- | --- | +| ET-1 | DB: add `organization_email_template` table | [ ] | [ ] | [ ] | [ ] | +| ET-2 | DB: add `organization_email_template_audit` table | [ ] | [ ] | [ ] | [ ] | +| ET-3 | Validation schemas for email-template APIs | [ ] | [ ] | [ ] | [ ] | +| ET-4 | Queries for template CRUD + resolve fallback | [ ] | [ ] | [ ] | [ ] | +| ET-5 | Service layer for list/get/upsert/delete/preview | [ ] | [ ] | [ ] | [ ] | +| ET-6 | Organization routes `/email-templates` | [ ] | [ ] | [ ] | [ ] | +| ET-7 | Dashboard settings page for templates | [ ] | [ ] | [ ] | [ ] | +| ET-8 | Locale-aware editor and fallback indicators | [ ] | [ ] | [ ] | [ ] | +| ET-9 | Preview rendering endpoint + UI pane | [ ] | [ ] | [ ] | [ ] | +| ET-10 | Send pipeline context and override resolution | [ ] | [ ] | [ ] | [ ] | +| ET-11 | Update org-scoped send call sites | [ ] | [ ] | [ ] | [ ] | +| ET-12 | Required-email toggle guardrails | [ ] | [ ] | [ ] | [ ] | +| ET-13 | Audit log write/read integration | [ ] | [ ] | [ ] | [ ] | +| ET-14 | Caching/performance pass | [ ] | [ ] | [ ] | [ ] | +| ET-15 | Migrate raw submission emails to managed template IDs | [ ] | [ ] | [ ] | [ ] | + +## Verification Checklist +- [ ] `pnpm --filter @cio/db build` +- [ ] `pnpm --filter @cio/utils build` +- [ ] `pnpm --filter @cio/email build` +- [ ] `pnpm --filter @cio/api build` +- [ ] `pnpm --filter @cio/dashboard build` +- [ ] Manual: edit template logo + save + preview +- [ ] Manual: edit localized content + locale fallback behavior +- [ ] Manual: disable optional email and verify skip behavior +- [ ] Manual: ensure required emails cannot be disabled +- [ ] Manual: send flow still works for routes not yet migrated to org context diff --git a/prd/enterprise-sso [DONE]/README.md b/prd/enterprise-sso [DONE]/README.md new file mode 100644 index 00000000..1fedd7fa --- /dev/null +++ b/prd/enterprise-sso [DONE]/README.md @@ -0,0 +1,424 @@ +# Enterprise SSO PRD (SAML 2.0 + OIDC) + +## Purpose +Add enterprise SSO for ClassroomIO with per-organization configuration, supporting both SAML 2.0 and OIDC, while preserving the current Better Auth session model and multi-tenant org permissions. + +## Confirmed Decisions (2026-02-17) +1. IdPs to support: Auth0, Google Workspace, Okta. +2. Scope: per organization (not global tenant-wide config). +3. Protocols: OIDC for v1. SAML 2.0 deferred to v2. +4. Force SSO: enabled (orgs can disable email/password when SSO is enabled). +5. Provisioning: JIT only for v1. SCIM deferred to future phase. +6. SAML deferred to v2 (Better Auth SAML is in active development). +7. Break-glass: simplified emergency access via pre-configured admin bypass. +8. Domain enforcement: simplified to auto-join toggle. +9. Entitlement: SSO is gated to `PLAN.ENTERPRISE`. +10. SCIM deprovisioning: future phase, with interim login-time entitlement checks. + +## Architecture: Better Auth SSO Plugin + +v1 leverages **Better Auth's official SSO plugin** (`@better-auth/sso`) for OIDC support: +- Built-in OIDC discovery and flow handling +- Automatic callback URL generation +- Domain-based provider routing +- Claim mapping support +- Session management via Better Auth + +Our custom layer handles: +- Enterprise plan entitlement checks +- Organization-scoped provider management +- Force SSO policy enforcement +- Org membership JIT provisioning +- Admin configuration UI + +## Current-State Audit + +| Capability | Current State | Notes | +| --- | --- | --- | +| Auth framework | available | Better Auth with cookie sessions (`packages/db/src/auth.ts`) | +| Email/password auth | available | Enabled via `emailAndPassword` (`packages/db/src/auth/email-password.ts`) | +| Social login | partial | Google social login only (`packages/db/src/auth.ts`, `apps/dashboard/src/lib/features/ui/auth-ui.svelte`) | +| Session handling | available | `/api/auth/*` mounted in API, `auth.api.getSession` middleware (`apps/api/src/app.ts`) | +| Multi-tenant org model | available | `organization`, `organizationmember`, org role checks and `cio-org-id` header enforcement | +| Org invite flows | available | Role-aware organization invites already implemented | +| Enterprise SSO (SAML/OIDC per org) | missing | No SSO connection model, no discovery, no SAML/OIDC enterprise callback flows | +| Force SSO policy | missing | No org-level policy to block local email/password | +| SCIM | missing | No SCIM endpoints or provisioning sync | + +## Product Goals +1. Allow each organization to configure one SSO connection. +2. Support Auth0, Google Workspace, and Okta with OIDC. +3. Preserve existing Better Auth sessions/cookies and downstream auth middleware behavior. +4. Add Force SSO policy at org level. +5. Keep invite/onboarding flows compatible with SSO. +6. Ship JIT provisioning for v1. +7. Add simple domain-based auto-join policy. +8. Gate all SSO capabilities to Enterprise plan organizations. + +## Non-Goals (v1) +- **SAML 2.0** (deferred to v2 - Better Auth SAML is in active development). +- SCIM implementation (deferred to future phase). +- Multiple SSO connections per organization. +- Custom claim mapping UI (use standard claims). +- Global logout / SAML Single Logout (SLO). +- Arbitrary custom IdP templates beyond Auth0/Google Workspace/Okta. +- Cross-organization identity linking rules beyond current email + org membership model. + +## Data Sources Checked +- `packages/db/src/auth.ts` +- `packages/db/src/auth/email-password.ts` +- `apps/api/src/app.ts` +- `apps/api/src/middlewares/auth.ts` +- `apps/api/src/middlewares/org-member.ts` +- `apps/api/src/middlewares/org-admin.ts` +- `packages/db/src/schema.ts` +- `packages/db/src/queries/organization/organization.ts` +- `apps/api/src/services/account.ts` +- `apps/api/src/services/organization/invite.ts` +- `apps/api/src/services/course/invite.ts` +- `apps/dashboard/src/lib/utils/services/auth/client.ts` +- `apps/dashboard/src/lib/utils/services/auth/session.ts` +- `apps/dashboard/src/routes/(auth)/login/+page.svelte` +- `apps/dashboard/src/lib/features/ui/auth-ui.svelte` +- `apps/dashboard/src/lib/features/app/init.svelte.ts` +- `apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts` +- `apps/docs/content/docs/quickstart/self-hosting.mdx` +- `apps/docs/content/docs/meta.json` +- `apps/docs/content/docs/quickstart/meta.json` + +## Functional Requirements + +### 1. Per-Organization SSO Connection Management +- Org admins can create/update/disable one SSO connection per org. +- Supported providers: Auth0, Google Workspace, Okta. +- Supported protocols: OIDC and SAML 2.0. +- Connection supports test/verify before activation. + +### 2. SSO Discovery and Login Start +- On org domains (`{org}.domain.com` or custom domain), login should use that org context directly. +- On shared app domain, user can enter email to discover SSO route by org policy/domain mapping. +- Discovery response should indicate: + - SSO required or optional + - Provider type + - Whether password login is allowed + +### 3. OIDC Flow (Better Auth SSO Plugin) +Better Auth handles the OIDC flow: +- Authorization code flow with PKCE, `state`, and `nonce`. +- Auto-discovery from `.well-known/openid-configuration`. +- Token validation and userinfo retrieval. +- Claim mapping to user fields. + +Our layer handles: +- Provider registration via `auth.api.registerSSOProvider`. +- Post-sign-in org membership provisioning via `dbHooks`. +- Role mapping from `groups` claim. + +### 4. SAML 2.0 Flow (v2) +- **Deferred to v2** - will be implemented once Better Auth's SAML support is stable. + +### 5. JIT Provisioning and Membership Linking +- On successful SSO callback: + - Find or create user/profile + - Upsert org membership in `organizationmember` + - Link external identity (`connection + subject`) to local user +- If invited member row exists by email, bind `profileId` and mark verified +- Default JIT role for new users: `ROLE.STUDENT` + +### 6. Role Mapping +- Simple role mapping from IdP `groups` claim: + - Map group names to `ROLE.ADMIN`, `ROLE.TUTOR`, `ROLE.STUDENT` + - If no mapping matched, fallback to `ROLE.STUDENT` +- Configuration: simple key-value mapping in connection settings + +### 7. Force SSO +- Org-level `forceSso=true` disables email/password login for that org context +- Enforce server-side, not only UI-level +- Users attempting password login for a force-SSO org receive structured error + SSO redirect hints +- Existing email/password sessions: remain valid until expiry (no forced logout) +- Break-glass: pre-configured emergency admin can bypass (audited) + +### 8. Audit and Observability +- Use existing audit/logging infrastructure with structured event types: + - `sso.connection_created`, `sso.connection_updated`, `sso.connection_disabled` + - `sso.login_success`, `sso.login_failure` + - `sso.provisioned`, `sso.role_assigned` + - `sso.force_sso_block` +- Include orgId, connectionId, actor/profile (when available), IP, user agent + +### 9. Domain Auto-Join Policy +- Simple toggle: `auto_join_sso_domains` (boolean) +- When enabled, users with matching email domain can auto-join org via SSO +- When disabled, users must have pending invite to join via SSO +- Trust IdP-verified email domains + +### 10. Enterprise Plan Entitlement +- SSO configuration and login endpoints must verify org entitlement to `PLAN.ENTERPRISE` +- Non-enterprise orgs receive explicit upgrade-required responses + +## Technical Design + +### Architecture Summary +Leverage **Better Auth SSO plugin** (`@better-auth/sso`) for OIDC protocol handling. Our custom layer provides: +1. **Admin management** - CRUD SSO connections (stored in our schema for UI) +2. **Enterprise gating** - Verify `PLAN.ENTERPRISE` before allowing SSO +3. **Force SSO enforcement** - Block email/password when policy enabled +4. **JIT provisioning** - Create org membership post-authentication via `dbHooks` + +Better Auth handles: +- OIDC discovery, authorization, token exchange +- Session creation and management +- User creation/linking + +### Data Model + +#### `organization_auth_policy` +- `organization_id` (uuid, pk/fk) +- `force_sso` (boolean, default `false`) +- `default_role_id` (bigint, default `ROLE.STUDENT`) +- `allow_email_password_fallback` (boolean, default `true`) +- `auto_join_sso_domains` (boolean, default `false`) +- `break_glass_enabled` (boolean, default `true`) +- `created_at`, `updated_at` + +#### `organization_sso_connection` +- `id` (uuid, pk) +- `organization_id` (uuid, fk, indexed, unique) +- `provider` (`OKTA` | `GOOGLE_WORKSPACE` | `AUTH0`) +- `protocol` (`OIDC`) - enum for future SAML extension +- `display_name` (text) +- `is_active` (boolean, default `false`) +- `issuer` (text) - IdP issuer URL +- `domain` (text) - email domain for discovery +- `oidc_config` (jsonb) - clientId, scopes, etc (clientSecret stored in Better Auth) +- `role_mapping` (jsonb) - simple key-value: group name → role +- `better_auth_provider_id` (text) - reference to Better Auth's stored provider +- `created_by_profile_id`, `updated_by_profile_id` (uuid) +- `created_at`, `updated_at` + +#### `sso_identity_link` +- `id` (uuid, pk) +- `organization_id` (uuid, indexed) +- `connection_id` (uuid, fk) +- `user_id` (uuid, fk to `user.id`) +- `external_subject` (text) +- `email_at_link` (text) +- `last_login_at` (timestamp) +- `created_at`, `updated_at` + +Constraints: +- unique `(connection_id, external_subject)` +- unique `(organization_id, user_id, connection_id)` + +## API and Route Plan + +### Better Auth Integration +The SSO plugin provides these endpoints automatically: +- `POST /api/auth/sso/register` - Register OIDC provider (admin only) +- `GET/POST /api/auth/sso/authorize/:providerId` - Start SSO flow +- `GET/POST /api/auth/sso/callback/:providerId` - OIDC callback + +### Our Custom Layer + +#### Validation Layer +- `packages/utils/src/validation/organization/sso.ts` + - `ZCreateSsoConnection` + - `ZUpdateSsoConnection` + - `ZSsoDiscoveryQuery` + +#### Query Layer +- `packages/db/src/queries/organization/sso.ts` + - connection CRUD + - org auth policy read/write + +#### Service Layer +- `apps/api/src/services/organization/sso.ts` + - Admin CRUD with Enterprise plan check + - Call `auth.api.registerSSOProvider` on activation + - Call `auth.api.deleteSSOProvider` on deactivation +- `apps/api/src/services/auth/sso.ts` + - Discovery logic + - Force SSO enforcement + +#### Route Layer +- `apps/api/src/routes/organization/sso.ts` - Admin CRUD endpoints +- `apps/api/src/routes/sso/discovery.ts` - Public discovery endpoint +- Register in: + - `apps/api/src/routes/organization/index.ts` + - `apps/api/src/app.ts` + +#### Better Auth Hooks +- `dbHooks.user.afterCreate` - JIT org membership provisioning + +## Frontend Plan (Dashboard) + +### Login UX +- Update: + - `apps/dashboard/src/routes/(auth)/login/+page.svelte` + - `apps/dashboard/src/lib/features/ui/auth-ui.svelte` +- Add SSO discovery step: + - org-site context: direct SSO action for current org + - shared app login: email-based discovery before method selection +- If Force SSO for org, hide password fields and show SSO-only CTA + +### Org Settings UX +- Add new page: `apps/dashboard/src/routes/org/[slug]/settings/sso/+page.svelte` +- Add settings feature page: `apps/dashboard/src/lib/features/settings/pages/sso.svelte` +- Export page in `apps/dashboard/src/lib/features/settings/pages/index.ts` +- Add org navigation nested route in `apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts` +- Add controls for: + - Provider selection (Auth0, Google Workspace, Okta) + - Protocol selection (OIDC, SAML) + - Force SSO toggle + - Auto-join toggle + - Simple role mapping + - Break-glass enable/disable +- Add translation keys in `apps/dashboard/src/lib/utils/translations/en.json` + +### Admin SSO Configuration UI Spec + +#### Page Information Architecture +- `Connection` section: + - Current SSO connection status (provider, protocol, active/inactive) + - Actions: `Configure`, `Edit`, `Disable`, `Test`, `Activate` +- `Policies` section: + - Force SSO toggle + - Auto-join toggle + - Break-glass toggle +- `Role Mapping` section: + - Simple table: IdP group name → ClassroomIO role + +#### Setup Wizard (Create/Edit Connection) +- Step 1 `Choose Provider`: + - Provider: `Auth0`, `Google Workspace`, `Okta` + - Protocol: `OIDC` or `SAML` +- Step 2 `Configure Identity Provider`: + - Render provider/protocol-specific fields + - Show generated callback values to copy into IdP +- Step 3 `Test Connection` (optional): + - Run live test with state/nonce checks + - Display pass/fail results with remediation hints +- Step 4 `Activate`: + - Summary of config + - Activate now or save as draft + - If Force SSO enabled, require explicit confirmation + +#### Provider-Specific Form Fields (OIDC Only) +- Common fields: + - `display_name` (required) + - `domain` (required) - email domain for discovery (e.g., `company.com`) +- OIDC fields: + - `issuer` (required URL) - e.g., `https://company.okta.com` + - `client_id` (required) + - `client_secret` (required) - sent to Better Auth, not stored locally + - `scopes` (default `openid profile email`) + - Read-only: callback URL (`/api/auth/sso/callback/{providerId}`) + +#### Validation Rules and Error UX +- Field-level validation: + - URLs must be valid HTTPS (localhost exception for dev) + - Required fields present before activation + - Claim mapping must include `email` and `sub`/`NameID` +- Cross-field validation: + - `force_sso=true` requires active connection + - Connection can be saved as draft without full validation +- Error presentation: + - Inline field errors for input validation + - Structured backend errors for: issuer mismatch, invalid cert, callback mismatch, missing claims + +#### Safety and Recovery UX +- Draft mode supported; drafts cannot be used for login +- Disable/rollback action always available to org admins +- All admin actions write visible audit entries + +### Client Types/API Class +- Add request types in `apps/dashboard/src/lib/features/org/utils/types.ts` +- Add API class in `apps/dashboard/src/lib/features/org/api/sso.svelte.ts` +- Use `this.execute()` pattern only + +## Documentation Plan (`apps/docs`) + +### Required Documentation Updates +- Add core feature documentation: + - `apps/docs/content/docs/enterprise-sso.mdx` - feature overview +- Add how-to guides: + - `apps/docs/content/docs/how-to/sso-auth0.mdx` + - `apps/docs/content/docs/how-to/sso-google-workspace.mdx` + - `apps/docs/content/docs/how-to/sso-okta.mdx` +- Register pages in root docs nav: + - `apps/docs/content/docs/meta.json` under `---Core Features---` +- Update self-hosting reference: + - `apps/docs/content/docs/quickstart/self-hosting.mdx` + - Include SSO-related env vars and callback URL guidance + +### Required Topics in Docs +- SSO prerequisites and Enterprise plan requirement +- Provider-by-provider setup checklist (Auth0, Google Workspace, Okta) +- Force SSO behavior and user experience +- JIT behavior and invite interactions +- Break-glass policy +- Troubleshooting: callback mismatch, certificate issues, entitlement errors + +## Security Requirements +1. Encrypt all SSO secrets at rest +2. Strict signature/issuer/audience checks for SAML/OIDC +3. Anti-CSRF and replay protection via `state`/`nonce` and one-time callback state +4. Rate-limit discovery/start/callback endpoints +5. Full audit trail for admin config and auth outcomes via existing logging +6. No trust of frontend-enforced Force SSO alone; must be validated on backend + +## Rollout Plan + +### Phase 1: Foundation + OIDC + JIT +- Install `@better-auth/sso` plugin +- Schema + policy/connection management +- Integration with Better Auth SSO plugin +- OIDC flows for Auth0/Google Workspace/Okta via Better Auth +- SSO discovery + login UI +- Force SSO enforcement +- Domain auto-join policy + admin settings UI +- Enterprise plan entitlement checks +- JIT org membership via `dbHooks` +- Publish docs updates + +### Phase 2: SAML 2.0 +- Enable when Better Auth SAML is stable +- SAML flows for supported providers +- Force SSO parity for SAML + +### Phase 3: Future Enhancements +- SCIM provisioning (if customer demand) +- Multiple connections per org (if customer demand) +- Advanced claim mapping (if customer demand) + +## Acceptance Criteria +1. Org admin can configure an active SSO connection (OIDC). +2. User can successfully login through Auth0, Google Workspace, and Okta. +3. Successful SSO login creates/links user and org membership via JIT. +4. Force SSO org cannot authenticate through email/password for org-context access. +5. Auto-join policy is configurable in UI and enforced server-side. +6. SSO access is blocked for non-enterprise orgs. +7. Existing non-SSO orgs keep current auth behavior unchanged. +8. Audit events are logged for config changes and SSO auth events. +9. `apps/docs` includes SSO feature docs and provider how-to guides before GA. +10. Org admins can configure Auth0, Google Workspace, and Okta through a setup wizard. +11. Connection can be saved as draft without full validation. +12. UI surfaces actionable errors for provider setup and configuration issues. + +## Risks and Mitigations +- Risk: Force SSO lockout due to bad config. + - Mitigation: connection can be disabled by any org admin + break-glass emergency access. +- Risk: role escalation by incorrect claim mapping. + - Mitigation: simple mapping validation + default least privilege (`ROLE.STUDENT`). +- Risk: callback replay attacks. + - Mitigation: one-time state, timestamp windows, assertion ID tracking. + +## Finalized Decisions +1. **OIDC only for v1** - SAML deferred to v2 (Better Auth SAML in active development). +2. Use **Better Auth SSO plugin** (`@better-auth/sso`) for OIDC flow handling. +3. Break-glass local admin access is included with strict auditing. +4. Domain enforcement simplified to auto-join toggle. +5. SSO is gated to `PLAN.ENTERPRISE`. +6. SCIM deferred to future phase. +7. Single connection per org for v1. +8. Standard claims only, no custom claim mapping UI. diff --git a/prd/exercise-question-types [DONE]/README.md b/prd/exercise-question-types [DONE]/README.md new file mode 100644 index 00000000..60a89064 --- /dev/null +++ b/prd/exercise-question-types [DONE]/README.md @@ -0,0 +1,434 @@ +# Exercise Question Types Expansion PRD + +## Status + +- In progress + +## Date + +- February 24, 2026 + +## Implementation Plan + +- See [implementation-plan.md](./implementation-plan.md) for ticket breakdown and delivery sequencing. + +## Purpose + +Expand ClassroomIO exercise questions beyond `single answer`, `multiple answers`, and `paragraph`, so instructors can run stronger assessments, reduce manual grading where possible, and collect richer assignment submissions inside the LMS. + +## Problem Statement + +Current exercise functionality is constrained by three legacy question types and a grading/answer model tuned for those types. This creates four product issues: + +1. Limited assessment design for instructors. +2. Lower auto-grading coverage. +3. Weak first-class support for assignment-style submissions. +4. Increased instructor grading overhead and external-tool leakage. + +## Current-State Audit + +| Area | Current State | Gaps | +| --- | --- | --- | +| Question types | `RADIO`, `CHECKBOX`, `TEXTAREA` only | No true/false, numeric, fill-blank, matching, ordering, file-upload, hotspot | +| DB `question_type` | Seeded from `packages/db/src/utils/seed/questionType.ts` | Typename coverage too narrow | +| Validation constants | `QUESTION_TYPE` in `packages/utils/src/validation/constants.ts` contains only 3 numeric IDs | Hardcoded numeric assumptions are fragile as type set grows | +| Exercise validation | `packages/utils/src/validation/exercise/exercise.ts` has option rules for radio/checkbox + textarea | No schema-per-type authoring or submission validation | +| Exercise authoring UI | `edit-mode.svelte` renders only radio, checkbox, textarea | No renderer registry or extensibility contract | +| Exercise taking UI | `view-mode.svelte` answer flow assumes options or open text | No type-specific interaction patterns for new formats | +| Submission model | `question_answer` stores `answers[]` + `open_answer` | No structured payload for richer responses (fill blanks, ordering, matching, uploads) | +| Attempt controls | No max-attempt rule or attempt scoring strategy on `exercise` | Cannot configure highest/latest/average grading behavior | +| Test controls | No persisted timer/randomization/availability settings on exercise | Missing in-schema support for test-mode controls | +| File response support | No exercise-question-level upload config | Cannot do per-question file constraints | + +## Data Sources Checked + +- `packages/db/src/schema.ts` +- `packages/db/src/utils/seed/questionType.ts` +- `packages/db/src/queries/exercise/exercise.ts` +- `packages/utils/src/validation/constants.ts` +- `packages/utils/src/validation/exercise/exercise.ts` +- `apps/api/src/routes/course/exercise.ts` +- `apps/api/src/services/exercise/exercise.ts` +- `apps/api/src/services/exercise/utils.ts` +- `apps/api/src/services/submission/submission.ts` +- `apps/api/src/services/course/clone.ts` +- `apps/dashboard/src/lib/features/ui/question/constants.ts` +- `apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte` +- `apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte` +- `apps/dashboard/src/lib/features/course/components/exercise/functions.ts` +- `apps/dashboard/src/lib/features/course/components/exercise/store.ts` +- `apps/dashboard/src/lib/features/course/api/exercise.svelte.ts` + +## Primary Users + +- Instructors creating tests and assignments. +- Students answering exercises on desktop and mobile. +- Admins and tutors grading and reviewing progress. + +## Goals + +1. Support additional objective and subjective question types. +2. Increase auto-grading coverage for objective formats. +3. Add first-class assignment upload support at question level. +4. Keep authoring UX consistent across question types. +5. Add explicit exercise-level controls for attempts, scoring rule, answer reveal, and test behavior. + +## Non-Goals (v1) + +- Online proctoring. +- AI-generated grading feedback. +- Code execution/judging sandboxes. +- LTI/QTI export. +- Negative marking. +- Audio/video response question type. + +## Confirmed Decisions + +1. Code execution is out of scope. +2. Upload size limits are plan-based: Free `2MB`, Paid `10MB`. +3. Resubmission policy is exercise-level, not per question. +4. Rollout phases remain `P0`, `P1`, `P2`. +5. If an exercise contains `short answer` or `paragraph`, final grading is manual. +6. Numeric-answer grading mode is configurable. +7. Fill-in-the-blank does not support multiple accepted answers per blank in v1. +8. Partial credit is enabled by default where supported. +9. Partial credit settings are question-level with exercise-level override. +10. File upload configuration is per question. +11. Exercise settings include max attempts. +12. Correct-answer visibility is exercise-level configurable. +13. Test-only controls are in scope. +14. Allowed upload file types in v1: `PDF`, `DOCX`, `PPTX`, `XLSX`, `ZIP`, and images. +15. Attempt grading rule is exercise-level configurable: `highest`, `latest`, `average`. +16. Upload question accepts exactly one file in v1. + +## Proposed Question Types + +| Type | Auto-gradable | Partial Credit | Assignment Fit | Test Fit | Phase | +| --- | --- | --- | --- | --- | --- | +| True/False | Yes | No | Medium | High | P0 | +| Short Answer (single-line) | No | N/A | High | High | P0 | +| Numeric Answer | Yes | Optional | Medium | High | P0 | +| Fill in the Blank | Yes | Yes | Medium | High | P0 | +| File Upload | No | N/A | High | Low | P0 | +| Matching | Yes | Yes | Medium | High | P1 | +| Ordering/Sequence | Yes | Yes | Medium | High | P1 | +| Hotspot/Image Labeling | Yes | Optional | Medium | Medium | P2 | + +## Functional Requirements + +### Authoring + +- Instructor can choose question type from a type picker. +- Instructor can configure: + - prompt + - points + - required/optional + - per-question instructions + - type-specific settings +- Instructor can define answer key for auto-graded types. +- Instructor can configure scoring mode (`all_or_nothing` or `partial`) where supported. +- Instructor can preview question behavior before publish. + +### Student Experience + +- Students can answer all released types on desktop and mobile. +- Answers autosave while taking exercise. +- One exercise can contain mixed question types. +- File upload question enforces allowed MIME/extensions and plan-based limits. +- File upload question accepts one file in v1. + +### Grading + +- Objective types auto-grade immediately unless exercise is in manual-only mode. +- Manual types route to grading queue with teacher feedback. +- Instructors can override auto-graded scores. +- Gradebook stores per-question points and final exercise score. +- Exercise-level `auto-grade` toggle can only be enabled when all active question types are auto-gradable; otherwise it must be disabled with a hint message. + +### Attempts and Test Controls + +- Exercise-level settings: + - `maxAttempts` + - `attemptScoringRule` (`highest`, `latest`, `average`) + - `showCorrectAnswers` policy + - `timerSeconds` (optional) + - `randomizeQuestionOrder` + - `randomizeOptionOrder` + - availability window (`availableFrom`, `availableTo`) + +## Technical Design + +### 1. Canonical Question Type Registry + +Keep one canonical source for question type metadata used by API and dashboard. + +Target package: + +- `packages/question-types/src/` (new, published as `@cio/question-types`) + +Registry includes: + +- stable key (`TRUE_FALSE`, `SHORT_ANSWER`, etc.) +- DB typename +- DB id (resolved at startup/seed, not hardcoded in scattered files) +- auto-gradable flag +- partial-credit support +- submission payload schema key +- rollout phase + +### 1.1 Canonical Renderer Components + +Keep question renderer components in `@cio/ui` and keep all shared logic/contracts in `@cio/question-types`. + +Target package: + +- `packages/ui/src/custom/exercise-question/` (new) +- exports via `packages/ui/src/index.ts` + +Rules: + +- renderer registry/contracts live in `@cio/question-types`. +- answer serialization and shared form-state utilities live in `@cio/question-types`. +- render contract object lives in `@cio/question-types` (`ExerciseQuestionRenderContract` / `ExerciseQuestionListRenderContract`) and is deterministic for the same inputs. +- question type renderer components live in `@cio/ui/custom/exercise-question`. +- `@cio/ui/custom/exercise-question` is components-only and does not own shared exercise logic. +- dashboard screens consume type keys/contracts from `@cio/question-types` and renderers from `@cio/ui/custom/exercise-question`. + +### 1.2 Cross-App File Contract + +- The complete individual file list for reusable shared packages is defined in `implementation-plan.md` under `Shared Package File Contract (Cross-App Reuse)`. +- Shared package code must stay app-agnostic so any app can consume it to build an exercise/forms product. + +### 2. Data Model Changes + +#### `question` table + +Add: + +- `settings` (`jsonb`, default `{}`, not null) +- `scoring_mode` (`varchar`, default `all_or_nothing`) +- `manual_grading_required` (`boolean`, default `false`) + +`settings` examples: + +- numeric: `{ "mode": "exact" | "tolerance_abs" | "tolerance_pct", "tolerance": number }` +- upload: `{ "maxFiles": 1, "allowedTypes": [...], "maxSizeMb": 2|10 }` +- matching: `{ "shuffleLeft": true, "shuffleRight": true }` + +#### `question_answer` table + +Add: + +- `payload` (`jsonb`, default `{}`, not null) + +Keep existing `answers[]` and `open_answer` for backward compatibility during migration; new types write to `payload`. + +#### `exercise` table + +Add: + +- `max_attempts` (`integer`, default `1`) +- `attempt_scoring_rule` (`varchar`, default `latest`) +- `show_correct_answers` (`varchar`, default `after_grading`) +- `timer_seconds` (`integer`, nullable) +- `randomize_question_order` (`boolean`, default `false`) +- `randomize_option_order` (`boolean`, default `false`) +- `available_from` (`timestamptz`, nullable) +- `available_to` (`timestamptz`, nullable) + +#### `submission` table + +Add: + +- `attempt_number` (`integer`, default `1`) +- `finalized_score` (`double precision`, nullable) +- `grading_mode` (`varchar`, default `auto_or_manual`) + +### 3. Validation Layer + +Update/expand `packages/utils/src/validation/exercise/`: + +- `question-base.ts` (shared prompt/points/rules) +- `question-true-false.ts` +- `question-short-answer.ts` +- `question-numeric.ts` +- `question-fill-blank.ts` +- `question-file-upload.ts` +- `question-matching.ts` +- `question-ordering.ts` +- `question-hotspot.ts` +- compose into: + - `ZExerciseCreate` + - `ZExerciseUpdate` + - `ZExerciseSubmissionCreate` + +Validation must run: + +- in API routes +- in dashboard before submit/update +- in grading service for defensive checks + +### 4. API Layer Changes + +Primary touchpoints: + +- `apps/api/src/routes/course/exercise.ts` +- `apps/api/src/services/exercise/exercise.ts` +- `apps/api/src/services/exercise/utils.ts` +- `apps/api/src/services/submission/submission.ts` +- `packages/db/src/queries/exercise/exercise.ts` + +Key changes: + +1. Accept/store question `settings` and `scoringMode`. +2. Support typed answer payload writes to `question_answer.payload`. +3. Enforce attempt limits before creating a submission. +4. Compute final grade using `attemptScoringRule`. +5. Auto/manual grading decision based on question mix. +6. Support file-response references through existing assets model. + +### 5. Dashboard Layer Changes + +Primary touchpoints: + +- `packages/ui/src/custom/exercise-question/*` (new shared UI component set; components only) +- `packages/ui/src/index.ts` +- `apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte` +- `apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte` +- `apps/dashboard/src/lib/features/course/components/exercise/functions.ts` +- `apps/dashboard/src/lib/features/course/components/exercise/store.ts` +- `apps/dashboard/src/lib/features/course/api/exercise.svelte.ts` +- `apps/dashboard/src/lib/features/course/utils/types.ts` +- translations in `apps/dashboard/src/lib/utils/translations/en.json` + +Refactor plan: + +1. Introduce registry/contracts in `@cio/question-types` and map question type keys to shared UI renderers. +2. Keep existing mode structure (`edit`, `view`, `preview`) but remove hardcoded `if RADIO/CHECKBOX/TEXTAREA` branching. +3. Add type-specific renderer components under `packages/ui/src/custom/exercise-question`. +4. Add translation keys for new types and setting labels. +5. Add exercise-level `auto-grade` toggle with eligibility guard and hint message when blocked by manual-only question types. + +### 6. Legacy Compatibility and Migration + +1. Existing exercises keep working without migration rewrite of question data. +2. New columns default safely (`{}` / false / null / default enums). +3. `question_type` seed extends with new typenames. +4. Replace hardcoded numeric assumptions where present. + +Known cleanup item discovered during audit: + +- `apps/api/src/services/course/clone.ts` uses `QUESTION_TYPE_TEXTAREA = 2`, while shared constant maps `TEXTAREA = 3`. +- This must be corrected before rollout to avoid option-cloning regressions for paragraph questions. + +## Progress Update (Feb 26, 2026) + +Completed in branch: + +1. Shared package contracts/registry implemented in `@cio/question-types`. +2. Dashboard renderer logic centralized via shared render contracts and shared `@cio/ui/custom/exercise-question` components. +3. Shared question renderer component files implemented for legacy and expanded question types. +4. Clone service question-type ID mismatch fixed to use shared constants. + +Still pending for grading engine milestone: + +1. End-to-end server auto-grading engine across non-legacy types. +2. Exercise-level `auto-grade` toggle + disabled-state hint behavior. +3. Full per-type validation/attempt-rule enforcement rollout in API. + +## Rollout Plan + +### Phase 1 (P0) + +Question types: + +- True/False +- Short Answer +- Numeric +- Fill in the Blank +- File Upload + +Required deliverables: + +1. DB columns for settings/payload/attempt controls. +2. Validation and API support for P0 types. +3. Dashboard renderer registry + P0 components. +4. Attempt scoring rule support. +5. Migration + seed updates. + +### Phase 2 (P1) + +Question types: + +- Matching +- Ordering/Sequence + +Required deliverables: + +1. Partial-credit grading helpers. +2. Enhanced analytics aggregation for pair/order correctness. +3. UX polish for drag/drop and accessibility. + +### Phase 3 (P2) + +Question types: + +- Hotspot/Image Labeling + +Required deliverables: + +1. Image coordinate mapping schema. +2. Responsive touch/mobile interaction hardening. +3. Additional grading explainability in teacher UI. + +## Testing Requirements + +### API + +- Schema validation tests for each type (create/update/submit). +- Grading tests: + - all-or-nothing + - partial-credit + - manual-required fallback +- Attempt rule tests (`highest`, `latest`, `average`). +- Upload constraint tests by plan limit. + +### Dashboard + +- Component tests for each type renderer in edit/take modes. +- Store tests for autosave and answer serialization. +- Integration tests for submission payload generation. + +### Regression + +- Existing three types must pass unchanged behavior tests. +- Exercise cloning must preserve all supported question types correctly. + +## Build and Verification Commands + +1. `pnpm --filter @cio/db build` +2. `pnpm --filter @cio/utils build` +3. `pnpm --filter @cio/api build` +4. `pnpm --filter @cio/dashboard build` + +## Success Metrics (90 Days Post-Launch) + +1. 40% of newly created exercises include at least one new type. +2. 25% reduction in average grading time per exercise. +3. 20% increase in completion for assignment-heavy exercises. +4. 30% increase in auto-graded question share in test exercises. + +## Risks and Mitigations + +1. Hardcoded question-type IDs may cause logic drift. + - Mitigation: canonical registry + typename-based mapping. +2. Rich payload types can increase complexity in submission parsing. + - Mitigation: strict per-type schema and shared serializer/deserializer utilities. +3. File uploads can introduce abuse/security risk. + - Mitigation: MIME/size validation, one-file v1 cap, server-side checks, existing asset safeguards. + +## Open Questions + +1. Should `short answer` support optional case-insensitive exact-match auto-grading in v1.1? +2. For fill-in-the-blank, should punctuation normalization be strict or relaxed by default? +3. Should hotspot remain P2 or shift post-P2 depending on demand? diff --git a/prd/exercise-question-types [DONE]/implementation-plan.md b/prd/exercise-question-types [DONE]/implementation-plan.md new file mode 100644 index 00000000..4c141610 --- /dev/null +++ b/prd/exercise-question-types [DONE]/implementation-plan.md @@ -0,0 +1,245 @@ +# Exercise Question Types Expansion - Implementation Plan + +## Scope Lock (v1) + +1. Keep legacy `RADIO`, `CHECKBOX`, `TEXTAREA` behavior backward compatible. +2. Deliver P0 question types first: `TRUE_FALSE`, `SHORT_ANSWER`, `NUMERIC`, `FILL_BLANK`, `FILE_UPLOAD`. +3. Manual grading is required when exercise includes `SHORT_ANSWER` or `TEXTAREA`. +4. Attempt rules are exercise-level: `maxAttempts`, `attemptScoringRule` (`highest`, `latest`, `average`). +5. Correct-answer visibility is exercise-level policy. +6. File upload is one file per question in v1. +7. Upload limits are plan based: free `2MB`, paid `10MB`. +8. Use one canonical question-type registry to remove scattered hardcoded numeric IDs. +9. Existing submissions continue to read from `answers[]` and `openAnswer` during migration. +10. New type-rich answers write to `question_answer.payload`. +11. DB update policy for this plan: apply DB changes only in `packages/db/src/schema.ts`; do not generate new files in `packages/db/src/migrations`. +12. Question-type registry/contracts/state/serialization logic must live in `@cio/question-types`; `@cio/ui` must stay components-only. +13. Shared packages must be app-agnostic (no dashboard-local imports), so other apps can build exercise/form products from the same package APIs. +14. Exercise-level `auto-grade` toggle can be enabled only when all active questions are auto-gradable; if not, it stays disabled with a hint message. +15. Grading engine remains in `apps/api` as backend source of truth and is locked behind API contracts; no standalone grading package is introduced. +16. Submission grading workflow truth uses explicit `submission.gradingState` and `submission.overallStatus`; legacy `submission.statusId` is retained as board compatibility projection only. +17. Initial grading state on student submit is always `queued`. +18. Server-owned grading transitions are: `queued -> processing -> completed`, `queued -> processing -> awaiting_manual -> completed`, and `queued -> processing -> failed -> queued` (retry path). +19. `overallStatus` classification values are `auto_graded`, `manual_required`, and `hybrid`, computed deterministically from attempt snapshot and question capabilities. +20. Client routes do not directly own grading workflow transitions; API/service layer validates and applies allowed transitions. +21. Renderer filenames must not repeat mode suffix; canonical naming is `renderers/{question-type}/{mode}.svelte` (for example `renderers/true-false/edit.svelte`, not `true-false-edit.svelte`). +22. `@cio/ui/custom/exercise-question` must not contain hardcoded user-facing English strings (labels, helper text, placeholders, tooltips, validation messages, sr-only text); host app provides copy through render contracts/props. + +## Delivery Phases + +1. Phase A: Foundation (schema, seeds, shared registry, validation contracts) +2. Phase B: API + grading + submission state engine for P0 +3. Phase C: Dashboard authoring/taking/grading UX for P0 +4. Phase D: P1 types (`MATCHING`, `ORDERING`) +5. Phase E: P2 type (`HOTSPOT`) +6. Phase F: Regression hardening, rollout, and monitoring + +## Shared Package File Contract (Cross-App Reuse) + +These files are required deliverables so any app in the monorepo can consume shared packages and build a standalone exercise/forms product. + +### `@cio/question-types` package files + +1. `packages/question-types/src/question-type-keys.ts` +2. `packages/question-types/src/question-type-types.ts` +3. `packages/question-types/src/question-type-registry.ts` +4. `packages/question-types/src/question-type-lookup.ts` +5. `packages/question-types/src/question-type-capabilities.ts` +6. `packages/question-types/src/question-answer-payload.ts` +7. `packages/question-types/src/question-scoring-rules.ts` +8. `packages/question-types/src/exercise-types.ts` +9. `packages/question-types/src/render-contract.ts` +10. `packages/question-types/src/form-state.ts` +11. `packages/question-types/src/answer-serializer.ts` +12. `packages/question-types/src/renderer-registry.ts` +13. `packages/question-types/src/index.ts` +14. `packages/question-types/package.json` (package name: `@cio/question-types`) + +### `@cio/ui` shared exercise-question component files (core) + +1. `packages/ui/src/custom/exercise-question/question-form-root.svelte` +2. `packages/ui/src/custom/exercise-question/question-navigation.svelte` +3. `packages/ui/src/custom/exercise-question/question-renderer.svelte` +4. `packages/ui/src/custom/exercise-question/question-list.svelte` +5. `packages/ui/src/custom/exercise-question/renderer-contract.ts` +6. `packages/ui/src/custom/exercise-question/index.ts` +7. `packages/ui/src/index.ts` (exports `./custom/exercise-question`) + +### `@cio/ui` P0 renderer files + +1. `packages/ui/src/custom/exercise-question/renderers/radio/edit.svelte` +2. `packages/ui/src/custom/exercise-question/renderers/checkbox/edit.svelte` +3. `packages/ui/src/custom/exercise-question/renderers/textarea/edit.svelte` +4. `packages/ui/src/custom/exercise-question/renderers/radio/take.svelte` +5. `packages/ui/src/custom/exercise-question/renderers/checkbox/take.svelte` +6. `packages/ui/src/custom/exercise-question/renderers/textarea/take.svelte` +7. `packages/ui/src/custom/exercise-question/renderers/radio/preview.svelte` +8. `packages/ui/src/custom/exercise-question/renderers/checkbox/preview.svelte` +9. `packages/ui/src/custom/exercise-question/renderers/textarea/preview.svelte` +10. `packages/ui/src/custom/exercise-question/renderers/true-false/edit.svelte` +11. `packages/ui/src/custom/exercise-question/renderers/short-answer/edit.svelte` +12. `packages/ui/src/custom/exercise-question/renderers/numeric/edit.svelte` +13. `packages/ui/src/custom/exercise-question/renderers/fill-blank/edit.svelte` +14. `packages/ui/src/custom/exercise-question/renderers/file-upload/edit.svelte` +15. `packages/ui/src/custom/exercise-question/renderers/true-false/take.svelte` +16. `packages/ui/src/custom/exercise-question/renderers/short-answer/take.svelte` +17. `packages/ui/src/custom/exercise-question/renderers/numeric/take.svelte` +18. `packages/ui/src/custom/exercise-question/renderers/fill-blank/take.svelte` +19. `packages/ui/src/custom/exercise-question/renderers/file-upload/take.svelte` +20. `packages/ui/src/custom/exercise-question/renderers/true-false/preview.svelte` +21. `packages/ui/src/custom/exercise-question/renderers/short-answer/preview.svelte` +22. `packages/ui/src/custom/exercise-question/renderers/numeric/preview.svelte` +23. `packages/ui/src/custom/exercise-question/renderers/fill-blank/preview.svelte` +24. `packages/ui/src/custom/exercise-question/renderers/file-upload/preview.svelte` + +### `@cio/ui` P1/P2 renderer files (created when phase starts) + +1. `packages/ui/src/custom/exercise-question/renderers/matching/edit.svelte` +2. `packages/ui/src/custom/exercise-question/renderers/matching/take.svelte` +3. `packages/ui/src/custom/exercise-question/renderers/matching/preview.svelte` +4. `packages/ui/src/custom/exercise-question/renderers/ordering/edit.svelte` +5. `packages/ui/src/custom/exercise-question/renderers/ordering/take.svelte` +6. `packages/ui/src/custom/exercise-question/renderers/ordering/preview.svelte` +7. `packages/ui/src/custom/exercise-question/renderers/hotspot/edit.svelte` +8. `packages/ui/src/custom/exercise-question/renderers/hotspot/take.svelte` +9. `packages/ui/src/custom/exercise-question/renderers/hotspot/preview.svelte` + +### Renderer Filename Convention (Locked) + +1. Question type is expressed by folder: `renderers/{question-type}/`. +2. Mode is expressed only by filename: `edit.svelte`, `take.svelte`, `preview.svelte`. +3. Do not use mode-first folders (`renderers/edit/*`) or redundant suffixes like `-edit`, `-take`, or `-preview`. + +### UI Copy Ownership (Locked) + +1. Shared UI renderers are copyless primitives and must not embed end-user copy. +2. All labels/help/error/tooltip/placeholder/sr-only text must be provided by host app (dashboard) via typed contract props. +3. Translation key resolution stays in host apps (`apps/dashboard`), not in `packages/ui`. +4. Renderer utilities in `packages/ui` must not generate user-facing fallback words (for example `Step`); fallback semantics must come from host-provided copy. + +## Ticket Breakdown + +| ID | Phase | Area | Task | Key Files | Dependencies | Done When | +| --- | --- | --- | --- | --- | --- | --- | +| EX-A1 | A | DB Schema | Add new columns for `exercise`, `question`, `question_answer`, `submission` (attempt controls, settings, payload, grading metadata including workflow/classification fields) in schema only | `packages/db/src/schema.ts` | None | Schema changes compile via package builds and no new migration files are generated | +| EX-A2 | A | DB Seed | Extend `question_type` seed with new typenames/labels | `packages/db/src/utils/seed/questionType.ts` | EX-A1 | Re-seeding inserts missing types idempotently | +| EX-A3 | A | Shared Registry | Create canonical question type registry and metadata in dedicated package (`@cio/question-types`) | `packages/question-types/src/question-type-keys.ts`, `packages/question-types/src/question-type-types.ts`, `packages/question-types/src/question-type-registry.ts`, `packages/question-types/src/question-type-lookup.ts`, `packages/question-types/src/question-type-capabilities.ts`, `packages/question-types/src/question-answer-payload.ts`, `packages/question-types/src/question-scoring-rules.ts`, `packages/question-types/src/exercise-types.ts`, `packages/question-types/src/render-contract.ts`, `packages/question-types/src/form-state.ts`, `packages/question-types/src/answer-serializer.ts`, `packages/question-types/src/renderer-registry.ts`, `packages/question-types/src/index.ts`, `packages/question-types/package.json` | EX-A2 | API and dashboard consume one source of truth for type keys/traits and all shared registry files exist | +| EX-A4 | A | Validation | Replace fragile numeric-only assumptions with registry-backed schemas and enums | `packages/utils/src/validation/constants.ts`, `packages/utils/src/validation/exercise/exercise.ts` | EX-A3 | Validation accepts legacy + new types and rejects invalid per-type payloads | +| EX-A5 | A | DB Queries | Add read/write support for new columns and typed answer payload in query layer | `packages/db/src/queries/exercise/exercise.ts`, `packages/db/src/queries/submission/*` | EX-A1 | Query methods persist and return settings/payload/attempt fields | +| EX-A6 | A | Clone Safety | Fix clone question type mismatch (`TEXTAREA` id bug) using shared constant/typename | `apps/api/src/services/course/clone.ts` | EX-A3 | Exercise clone preserves paragraph questions and options correctly | +| EX-A7 | A | Shared Copy Contract | Add typed i18n/copy contract for exercise-question renderer labels and messages in shared question-types package | `packages/question-types/src/render-contract.ts`, `packages/question-types/src/exercise-types.ts`, `packages/question-types/src/index.ts`, `packages/question-types/src/renderer-registry.ts` | EX-A3 | Host apps can pass a strongly typed labels object that covers shared renderer copy needs | +| EX-B1 | B | API Validation | Split exercise schemas into base + per-type authoring/submission schemas | `packages/utils/src/validation/exercise/*` | EX-A4 | Route-level validation is discriminated by question type | +| EX-B2 | B | Exercise Service | Support create/update with `question.settings`, `scoringMode`, `manualGradingRequired`, and exercise attempt settings | `apps/api/src/services/exercise/exercise.ts`, `apps/api/src/services/exercise/utils.ts` | EX-A5, EX-B1 | Create/update persists all new fields and returns normalized exercise DTO | +| EX-B3 | B | Submission Service | Accept typed answers into `question_answer.payload` while preserving legacy fallback fields | `apps/api/src/services/submission/submission.ts` | EX-A5, EX-B1 | Submission creation supports mixed legacy/new question payloads | +| EX-B4 | B | Grading Engine | Implement auto-grading for P0 objective types (`TRUE_FALSE`, `NUMERIC`, `FILL_BLANK`) | `apps/api/src/services/submission/submission.ts`, `apps/api/src/services/exercise/utils.ts` | EX-B3 | Auto-grade computed at question level with configurable scoring mode | +| EX-B5 | B | Attempt Rules | Enforce `maxAttempts` and compute final score by `attemptScoringRule` | `apps/api/src/services/submission/submission.ts`, `packages/db/src/queries/submission/*` | EX-B3 | New attempts blocked when limit reached; final score follows configured rule | +| EX-B6 | B | Upload Handling | Add file-upload answer flow with allowed type/size validation and asset reference storage | `apps/api/src/routes/course/exercise.ts`, `apps/api/src/services/submission/submission.ts` | EX-B3 | Invalid upload types/sizes rejected; valid uploads linked in answer payload | +| EX-B7 | B | Routes | Update exercise and submission routes for new request/response contracts | `apps/api/src/routes/course/exercise.ts`, `apps/api/src/routes/course/submission.ts` | EX-B2, EX-B3 | RPC contract supports new fields without breaking legacy clients | +| EX-B8 | B | Manual Grading | Route mixed/manual exercises to manual grading path and allow override of auto score | `apps/api/src/services/submission/submission.ts`, `apps/api/src/services/mark/gradebook.ts` | EX-B4 | Teacher can override and persist final grade on submission | +| EX-B9 | B | Auto-Grade Guardrail | Enforce exercise-level `auto-grade` eligibility in API/service layer using question-type capability checks | `apps/api/src/services/exercise/exercise.ts`, `apps/api/src/services/submission/submission.ts`, `packages/question-types/src/question-type-capabilities.ts` | EX-A3, EX-B2 | API rejects `auto-grade=true` when any active question type requires manual grading | +| EX-B10 | B | Submission Workflow | Implement API-owned grading state machine and deterministic `overallStatus` computation on submission lifecycle | `packages/db/src/schema.ts`, `packages/utils/src/validation/submission/submission.ts`, `packages/db/src/queries/submission/submission.ts`, `apps/api/src/services/submission/submission.ts`, `apps/api/src/routes/course/submission.ts` | EX-A1, EX-B3, EX-B4, EX-B8 | Submit starts `queued`, transitions are validated server-side, and invalid direct state jumps are rejected | +| EX-B11 | B | Board Compatibility | Preserve existing 3 board columns by projecting workflow state to legacy `statusId` (`1=Submitted`, `2=In Progress`, `3=Graded`) in API responses | `apps/api/src/services/submission/submission.ts`, `apps/api/src/routes/course/submission.ts`, `packages/db/src/queries/submission/submission.ts` | EX-B10 | Submission board endpoints continue returning stable `statusId` while grading truth lives in workflow/classification fields | +| EX-C1 | C | Dashboard Types | Add request/response/data types for new exercise payloads in feature types | `apps/dashboard/src/lib/features/course/utils/types.ts` | EX-B7 | Frontend types inferred from API contract only | +| EX-C2 | C | Dashboard API | Update exercise API class to validate/send new create/update/submit payload shapes | `apps/dashboard/src/lib/features/course/api/exercise.svelte.ts` | EX-C1 | Create/update/submit flows pass new schema checks client-side | +| EX-C3 | C | Renderer Registry | Implement registry logic in `@cio/question-types` and wire dashboard to map question-type keys to shared `@cio/ui` renderer components | `packages/question-types/src/renderer-registry.ts`, `packages/question-types/src/exercise-types.ts`, `packages/question-types/src/index.ts`, `packages/ui/src/custom/exercise-question/index.ts`, `packages/ui/src/index.ts`, `apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte`, `apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte` | EX-A3 | No hardcoded `if RADIO/CHECKBOX/TEXTAREA` branching remains; registry/contracts come from `@cio/question-types` and components from `@cio/ui` | +| EX-C4 | C | Edit UX | Refactor authoring UI to use registry and per-type settings editors | `apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte` | EX-C3, EX-C2 | Instructor can configure P0 types and per-type settings in one flow | +| EX-C5 | C | Take UX | Refactor learner view to use registry and typed answer serializers | `apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte`, `apps/dashboard/src/lib/features/course/components/exercise/functions.ts` | EX-C3, EX-C2 | Student can answer mixed-type exercises and submit valid payload | +| EX-C6 | C | Store | Update exercise store/autosave model for typed answers and migration-safe hydration | `apps/dashboard/src/lib/features/course/components/exercise/store.ts` | EX-C5 | Autosave/restore works for legacy and new type payloads | +| EX-C7 | C | P0 Components | Add new UI question components (`true-false`, `short-answer`, `numeric`, `fill-blank`, `file-upload`) in shared UI package | `packages/ui/src/custom/exercise-question/renderers/true-false/edit.svelte`, `packages/ui/src/custom/exercise-question/renderers/short-answer/edit.svelte`, `packages/ui/src/custom/exercise-question/renderers/numeric/edit.svelte`, `packages/ui/src/custom/exercise-question/renderers/fill-blank/edit.svelte`, `packages/ui/src/custom/exercise-question/renderers/file-upload/edit.svelte`, `packages/ui/src/custom/exercise-question/renderers/true-false/take.svelte`, `packages/ui/src/custom/exercise-question/renderers/short-answer/take.svelte`, `packages/ui/src/custom/exercise-question/renderers/numeric/take.svelte`, `packages/ui/src/custom/exercise-question/renderers/fill-blank/take.svelte`, `packages/ui/src/custom/exercise-question/renderers/file-upload/take.svelte`, `packages/ui/src/custom/exercise-question/renderers/true-false/preview.svelte`, `packages/ui/src/custom/exercise-question/renderers/short-answer/preview.svelte`, `packages/ui/src/custom/exercise-question/renderers/numeric/preview.svelte`, `packages/ui/src/custom/exercise-question/renderers/fill-blank/preview.svelte`, `packages/ui/src/custom/exercise-question/renderers/file-upload/preview.svelte`, `packages/ui/src/custom/exercise-question/index.ts`, `packages/ui/src/index.ts` | EX-C3 | Edit/view/preview components exist for all P0 types and are exported from `@cio/ui` | +| EX-C8 | C | Grading UI | Update submission/grading views to display typed answers from payload | `apps/dashboard/src/lib/features/course/components/exercise/submissions/*` | EX-B8, EX-C5 | Teacher can review and grade all P0 answer shapes | +| EX-C9 | C | i18n | Add translation keys for new types/settings/status copy | `apps/dashboard/src/lib/utils/translations/en.json` | EX-C4, EX-C5 | No new hardcoded user-facing text | +| EX-C10 | C | Accessibility | Validate keyboard navigation and mobile rendering for new question interactions in shared UI component package | `packages/ui/src/custom/exercise-question/*` | EX-C7 | P0 UI passes accessibility checks and mobile smoke tests | +| EX-C11 | C | Edit UX Guardrail | Add exercise-level `auto-grade` toggle, disable state, and hint copy when question mix includes manual-only types | `apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte`, `apps/dashboard/src/lib/features/course/components/exercise/question-type-utils.ts`, `apps/dashboard/src/lib/utils/translations/en.json` | EX-C3, EX-B9 | Toggle enables only when all active question types are auto-gradable; hint appears under toggle when blocked | +| EX-C12 | C | Submission Board UX | Consume API workflow data while keeping 3-column board UX, and surface failed/manual-needed hints under In Progress column | `apps/dashboard/src/lib/features/course/pages/submissions.svelte`, `apps/dashboard/src/lib/features/course/components/exercise/mark-exercise-modal.svelte`, `apps/dashboard/src/lib/features/course/utils/types.ts`, `apps/dashboard/src/lib/features/course/api/submission.svelte.ts`, `apps/dashboard/src/lib/utils/translations/en.json` | EX-B10, EX-B11 | Board still behaves as `1/2/3` columns, with additional workflow clarity from API-provided grading fields | +| EX-C13 | C | Renderer Structure Refactor | Refactor shared renderer files to question-type folders with mode file names and update imports/registry/storybook references | `packages/ui/src/custom/exercise-question/renderers/*/edit.svelte`, `packages/ui/src/custom/exercise-question/renderers/*/take.svelte`, `packages/ui/src/custom/exercise-question/renderers/*/preview.svelte`, `packages/ui/src/custom/exercise-question/index.ts`, `packages/question-types/src/renderer-registry.ts`, `packages/storybook/src/molecules/exercise-question/*`, `apps/dashboard/src/lib/features/course/components/exercise/*` | EX-C3, EX-C7 | Renderer paths use `renderers/{question-type}/{mode}.svelte`; no mode-first folders or mode-suffixed filenames remain | +| EX-C14 | C | Copy Externalization | Remove hardcoded user-facing copy from shared question renderer files and wire host-provided labels from dashboard translations | `packages/ui/src/custom/exercise-question/question-renderer.svelte`, `packages/ui/src/custom/exercise-question/question-navigation.svelte`, `packages/ui/src/custom/exercise-question/renderers/*/edit.svelte`, `packages/ui/src/custom/exercise-question/renderers/*/take.svelte`, `packages/ui/src/custom/exercise-question/renderers/*/preview.svelte`, `packages/ui/src/custom/exercise-question/renderers/ordering-utils.ts`, `apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte`, `apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte`, `apps/dashboard/src/lib/features/course/utils/types.ts`, `apps/dashboard/src/lib/utils/translations/en.json`, `packages/storybook/src/molecules/exercise-question/*` | EX-A7, EX-C3 | Shared renderer package has no hardcoded end-user copy; dashboard injects all labels/messages from translation keys through typed contracts | +| EX-D1 | D | Matching Type | Implement `MATCHING` schema, authoring/taking UI in shared UI component package, serializer, and grader | validation, API service, `packages/ui/src/custom/exercise-question/renderers/matching/edit.svelte`, `packages/ui/src/custom/exercise-question/renderers/matching/take.svelte`, `packages/ui/src/custom/exercise-question/renderers/matching/preview.svelte` | EX-C7, EX-B4 | Matching questions can be authored, submitted, and auto-graded | +| EX-D2 | D | Ordering Type | Implement `ORDERING` schema, drag-sort UI in shared UI component package, serializer, and grader | validation, API service, `packages/ui/src/custom/exercise-question/renderers/ordering/edit.svelte`, `packages/ui/src/custom/exercise-question/renderers/ordering/take.svelte`, `packages/ui/src/custom/exercise-question/renderers/ordering/preview.svelte` | EX-C7, EX-B4 | Ordering questions can be authored, submitted, and auto-graded | +| EX-D3 | D | Partial Credit | Add partial-credit policy controls and explainability in grading details | `apps/api/src/services/submission/submission.ts`, submissions UI | EX-D1, EX-D2 | Teacher sees per-question partial-credit breakdown | +| EX-E1 | E | Hotspot Type | Implement `HOTSPOT` schema, image coordinate payload, authoring/taking UI in shared UI component package, grader | validation, API service, `packages/ui/src/custom/exercise-question/renderers/hotspot/edit.svelte`, `packages/ui/src/custom/exercise-question/renderers/hotspot/take.svelte`, `packages/ui/src/custom/exercise-question/renderers/hotspot/preview.svelte` | EX-C7 | Hotspot is fully supported end-to-end | +| EX-E2 | E | Hotspot Hardening | Improve responsive/touch behavior and precision tolerances for hotspot grading | hotspot UI + grading utilities | EX-E1 | Hotspot works reliably on mobile/tablet and desktop | +| EX-F1 | F | API Tests | Add create/update/submit tests per type, attempt rules, manual/auto grading paths | `apps/api/tests/*`, `packages/utils/src/validation/*` | EX-B8, EX-D3, EX-E1 | Core API behavior covered with passing tests | +| EX-F2 | F | Dashboard Tests | Add component/store/integration tests for authoring, taking, autosave, submission payloads across dashboard integration and shared UI component package | `apps/dashboard/src/lib/features/course/components/exercise/*`, `packages/ui/src/custom/exercise-question/*` | EX-C10, EX-D2, EX-E2 | UI and store behavior stable for legacy + new types | +| EX-F3 | F | Regression | Add clone and backward-compat regression tests for legacy question data | API/services/db test files | EX-A6, EX-F1 | Existing exercises run unchanged after rollout | +| EX-F4 | F | Rollout | Ship rollout checklist, migration runbook, and monitoring for submission/grading errors | `prd/exercise-question-types/README.md`, ops docs/config | EX-F1, EX-F2, EX-F3 | Controlled release enabled with rollback steps documented | + +## Acceptance Criteria by Phase + +### Phase A +1. Schema changes are limited to `packages/db/src/schema.ts` and no migration files are generated. +2. `question_type` seed is idempotent and includes all planned typenames. +3. Shared registry is imported by both API and dashboard. +4. Clone service no longer depends on mismatched hardcoded paragraph ID. +5. Shared renderer copy-contract types are defined in `@cio/question-types` and exported for host apps. + +### Phase B +1. Exercise create/update APIs accept and return new settings fields. +2. Submission API accepts typed payloads and still reads legacy answer shape. +3. Attempt limits and scoring rules are enforced server-side. +4. File-upload answer validations enforce plan limits and allowed types. +5. API/service layer blocks exercise-level `auto-grade` when question mix includes any manual-only type. +6. Student submit always creates submission with workflow state `queued` and board-compatible `statusId=1`. +7. API enforces valid grading transitions only (`queued -> processing -> ...`), with retry path (`failed -> queued`), and rejects invalid direct client transitions. +8. `overallStatus` is computed deterministically as `auto_graded`, `manual_required`, or `hybrid`. +9. API projects workflow states to board `statusId` mapping (`queued=1`, `processing|awaiting_manual|failed=2`, `completed=3`). + +### Phase C +1. Authoring and learner UIs support all P0 types. +2. Renderer registry removes hardcoded question-type branching in core screens. +3. Dashboard consumes registry/contracts from `@cio/question-types` and renderer components from `@cio/ui`, not dashboard-local question renderer files. +4. Grading UI can render typed payloads for teacher review. +5. No hardcoded UI copy is introduced. +6. Shared package files listed in `Shared Package File Contract` are implemented and exported. +7. Exercise-level `auto-grade` toggle is disabled with a clear hint when question mix is not fully auto-gradable. +8. Submission board continues to use 3 columns from backend-projected `statusId`, while exposing workflow hints (`failed`, `awaiting_manual`) without changing board IA. +9. Renderer files/imports follow canonical type-first paths: `renderers/{question-type}/{mode}.svelte`. +10. `@cio/ui/custom/exercise-question` does not contain hardcoded end-user copy; dashboard passes translated labels/messages via typed render contracts. + +### Phase D +1. Matching and ordering are fully authorable/submittable/gradable. +2. Partial-credit configuration and score explanation are visible to instructors. + +### Phase E +1. Hotspot works with responsive image rendering and touch input. +2. Hotspot grading handles coordinate tolerances consistently. + +### Phase F +1. Regression tests cover legacy three types and mixed-type exercises. +2. Rollout checklist includes migration, monitoring, and rollback steps. + +## Verification Commands + +1. `pnpm --filter @cio/db build` +2. `pnpm --filter @cio/utils build` +3. `pnpm --filter @cio/api build` +4. `pnpm --filter @cio/dashboard build` + +## Suggested First Sprint Slice + +1. EX-A1 to EX-A7 (schema, seed, registry, validation foundation, clone fix, shared copy contract) +2. EX-B1 to EX-B5 + EX-B10 + EX-B11 (P0 API contracts, typed submission, attempt/scoring enforcement, workflow state machine, board projection compatibility) +3. EX-C1 to EX-C6 + EX-C14 (frontend types/API + renderer registry + core edit/view/store refactor + shared copy externalization) + +This slice establishes the core contracts and de-risks the hardest migration paths before adding all P0 components and grading UI polish. + +## Implementation Progress (Updated Feb 26, 2026) + +Completed ticket IDs: + +1. `EX-A3` Shared question-type package (`@cio/question-types`) with registry/contracts/helpers. +2. `EX-A6` Clone safety bug fix using shared question type IDs. +3. `EX-A7` Shared copy contract typing for renderer labels/messages (`ExerciseQuestionLabels`) in `@cio/question-types`. +4. `EX-C3` Dashboard/UI renderer registry centralization (`@cio/question-types` contracts + `@cio/ui` components). +5. `EX-C7` Shared `@cio/ui/custom/exercise-question` renderer component set delivered (includes P0 and additional renderer files used by current branch). +6. `EX-C13` Renderer structure refactor completed with canonical type-first paths (`renderers/{question-type}/{mode}.svelte`) and updated imports/exports/storybook wiring. +7. `EX-C14` Shared renderer copy externalization completed; dashboard now injects labels/messages from translations through typed contracts. + +In-progress ticket IDs: + +1. `EX-A4` Validation migration is partial (constants wired to shared IDs; full per-type schema split still pending). +2. `EX-C4` Authoring UX refactor is mostly done, but exercise-level grading controls/guards are not complete. +3. `EX-C5` Learner UX refactor is active; answer submission/grading remains mixed legacy behavior. +4. `EX-B10` Submission workflow state machine has been started (schema fields + transition validation + API projection wiring); lifecycle hardening and tests remain. +5. `EX-B11` Board compatibility projection has been started (workflow-to-legacy `statusId` projection in submission service); endpoint/test hardening remains. +6. `EX-C12` Dashboard board workflow hint wiring has been started (workflow hints + guarded transitions in board UI); full UX/polish and edge-case handling remain. +7. `EX-D1`, `EX-D2`, `EX-E1` UI renderers exist, but full API validation/submission/grader paths are not complete. diff --git a/prd/exercise-sections/README.md b/prd/exercise-sections/README.md new file mode 100644 index 00000000..b7f3aff1 --- /dev/null +++ b/prd/exercise-sections/README.md @@ -0,0 +1,1135 @@ +# Exercise Sections PRD + +## Status + +- Draft + +## Date + +- February 26, 2026 + +## Implementation Plan + +- See [implementation-plan.md](./implementation-plan.md) for ticket breakdown and delivery sequencing. + +## Purpose + +Allow instructors to group exercise questions into named sections, so exercises can be structured into logical parts (e.g., "Section A: Multiple Choice", "Section B: Written Responses") with per-section navigation, descriptions, and configurable post-section behavior. + +## Problem Statement + +Today, every exercise is a flat list of questions. Instructors cannot: + +1. **Organize questions by topic or format** — a 30-question exam has no visual grouping, making it harder for students to navigate and for teachers to author. +2. **Provide per-section instructions** — if Section A requires different rules than Section B, the instructor must embed instructions into individual question prompts. +3. **Control post-section flow** — there is no way to submit answers after a subset of questions or to route students to a specific next section. +4. **Grade or review by section** — submissions are a single flat list, so teachers cannot quickly assess performance on distinct parts of an exercise. + +## Current-State Audit + +| Area | Current State | Gap | +| --- | --- | --- | +| `exercise` table | Has `title`, `description`, questions linked via `question.exercise_id` | No section concept | +| `question` table | Has `exercise_id`, `order`, `title`, `points`, `question_type_id` | No `section_id` or section ordering | +| Exercise store | `questionnaire.questions` is a flat `Question[]` | No section grouping or section metadata | +| Edit mode UI | Renders all questions in a single vertical list | No section headers, section CRUD, or drag-between-sections | +| Take mode (view-mode) | Shows one question at a time via `currentQuestionIndex` on flat array | No section-at-a-time mode, no section overview screen | +| Question navigation | `QuestionNavigation` component has `onPrevious`/`onNext` with `isLast` flag | No section awareness | +| Preview mode | Renders all questions via `QuestionList` | No section grouping | +| Submissions/Grading UI | `individual.svelte` and `summary.svelte` iterate flat question list | No section-level subtotals | +| Exercise templates | `exercise_template.questionnaire` is `{ questions: [...] }` JSON | No sections in template schema | +| Clone service | `cloneExercises` copies exercises → questions → options | No section cloning | +| Progress bar | Computes `currentQuestionIndex / questions.length` | No section-aware progress | + +## Data Sources Checked + +- `packages/db/src/schema.ts` — `exercise`, `question`, `questionAnswer`, `submission`, `exerciseTemplate` tables +- `apps/dashboard/src/lib/features/course/components/exercise/store.ts` +- `apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte` +- `apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte` +- `apps/dashboard/src/lib/features/course/components/exercise/preview.svelte` +- `apps/dashboard/src/lib/features/course/components/exercise/submissions/individual.svelte` +- `apps/dashboard/src/lib/features/course/components/exercise/submissions/summary.svelte` +- `apps/dashboard/src/lib/features/course/pages/exercise.svelte` +- `apps/api/src/services/course/clone.ts` +- `packages/ui/src/custom/exercise-question/question-navigation.svelte` +- `packages/ui/src/custom/exercise-question/question-list.svelte` +- `packages/question-types/src/exercise-types.ts` +- `prd/exercise-question-types/README.md` + +## Primary Users + +- **Instructors** creating structured exams and assignments with logical groupings. +- **Students** taking exercises and navigating between sections. +- **Admins/Tutors** grading and reviewing submissions by section. + +## Goals + +1. Allow instructors to create, edit, reorder, and delete sections within an exercise. +2. Allow students to navigate exercises section-by-section with configurable question display (one-at-a-time or all-at-once within a section). +3. Show section-level subtotals alongside overall totals in grading. +4. Support configurable post-section behavior (continue, go to specific section, submit). +5. Maintain full backward compatibility — existing sectionless exercises work unchanged. +6. Support sections in exercise templates and cloning. + +## Non-Goals (v1) + +- Per-section time limits (exercise-level timing does not exist yet; out of scope). +- Per-section scoring weights (section score is the sum of its question points). +- Conditional/branching logic beyond post-section routing (no "if student scored < 50% on Section A, skip to Section C"). +- Section-level randomization separate from exercise-level randomization. +- Nested sections (sections within sections). + +## Confirmed Decisions + +1. An exercise is either **flat questions** (no sections) or **sections with questions** — not a mix of both. If an exercise has sections, every question must belong to a section. +2. A question belongs to exactly **one** section. No multi-section membership. +3. Section scoring is the **sum of question points** within that section — no separate section-level weight multiplier. +4. Student display mode is **configurable per exercise**: either "one question at a time" (current behavior, but section-aware) or "one section at a time" (all questions in the current section shown together). +5. Students see a **section overview screen** before beginning each section (section title + description + question count + total points). +6. Students navigate sections **linearly by default**, but the post-section behavior can route them elsewhere. +7. Post-section behavior options: **"Continue to next section"** (default), **"Go to section [X]"** (select from list), **"Submit exercise"**. +8. Progress bar tracks **questions completed** (not sections). The question navigation sidebar shows questions **grouped by section** (e.g., "Section 1: Q1, Q2, Q3 | Section 2: Q1, Q2"). +9. Grading UI shows submissions **grouped by section** with per-section subtotals and an overall total. +10. Existing exercises without sections continue to work exactly as they do today. Sections are **fully optional**. +11. Exercise templates support sections. Exercise cloning preserves sections. +12. Instructors can **drag questions between sections**, **reorder sections**, and **select which section** when adding a new question. + +--- + +## Functional Requirements + +### FR-1: Section CRUD (Instructor — Edit Mode) + +#### FR-1.1: Create Section + +- Instructor can add a section to an exercise via an "Add Section" button (similar to Google Forms section add). +- When the first section is added to a previously sectionless exercise, all existing questions are automatically moved into "Section 1". +- A new section is created with default title "Untitled Section" and empty description. +- The new section is appended after the last section (or after the current section if contextually clear). + +#### FR-1.2: Edit Section + +- Instructor can edit the section **title** (required, min 1 character). +- Instructor can edit the section **description** (optional, supports plain text). +- Instructor can configure the **after-section behavior** via a dropdown at the bottom of each section: + - "Continue to next section" (default) + - "Go to section [X]" (dropdown listing all other sections by title) + - "Submit exercise" +- The last section's default after-section behavior is "Submit exercise". + +#### FR-1.3: Delete Section + +- Instructor can delete a section. +- When a section is deleted, the instructor is prompted: "Move questions to [other section] or delete them?" +- If the exercise has only one section and it's deleted, the exercise becomes sectionless and all questions remain as a flat list. + +#### FR-1.4: Reorder Sections + +- Instructor can reorder sections via drag-and-drop or an order modal (consistent with the existing question reorder pattern). +- When sections are reordered, the `order` field on each section is updated accordingly. + +#### FR-1.5: Move Questions Between Sections + +- Instructor can drag a question from one section to another. +- Instructor can move a question via a context menu: "Move to Section → [list of sections]". +- When adding a new question, the instructor selects which section it belongs to (defaults to the currently focused or last section). + +### FR-2: Student Experience (Take Mode) + +#### FR-2.1: Section Overview Screen + +- Before the first question of each section, the student sees a section overview: + - Section title + - Section description (if any) + - Number of questions in this section + - Total points available in this section + - "Begin Section" button +- This mirrors the existing exercise start screen pattern but at the section level. + +#### FR-2.2: Question Display Mode (Configurable) + +The exercise has a setting `sectionDisplayMode` with two options: + +- **`one_question`** (default): Questions are shown one at a time. The student navigates with Previous/Next buttons. A section header/indicator is visible showing which section the student is in (e.g., "Section 1 of 3 — Multiple Choice"). When the last question of a section is completed, the post-section behavior fires. +- **`all_questions`**: All questions in the current section are displayed together in a scrollable list. The student answers all questions and clicks a "Complete Section" button. Post-section behavior then fires. + +#### FR-2.3: Post-Section Behavior + +After completing a section: + +- **"Continue to next section"**: Show the next section's overview screen. +- **"Go to section [X]"**: Show section X's overview screen. +- **"Submit exercise"**: Submit all answers collected so far and show the completion/grading screen. + +If the student finishes the last section and its after-behavior is "Continue to next section" (or not explicitly set), the exercise auto-submits. + +#### FR-2.4: Section-Aware Navigation + +- The question navigation UI groups questions by section: + ``` + Section 1: Multiple Choice + Q1 Q2 Q3 Q4 + + Section 2: Written Responses + Q1 Q2 + ``` +- Answered questions are visually distinguished (filled dot, checkmark, etc.). +- The current section and question are highlighted. +- Students can only navigate within the current section (no jumping between sections) unless the exercise allows free navigation (future enhancement, not v1). + +#### FR-2.5: Progress Tracking + +- The progress bar reflects **total questions answered / total questions** across all sections. +- A secondary indicator shows the current section (e.g., "Section 2 of 3"). + +#### FR-2.6: Autosave + +- Existing autosave behavior extends to section-aware exercises. +- The autosaved state includes which section the student is currently in and which question within that section. +- On resume, the student returns to their last position (correct section and question). + +### FR-3: Preview Mode (Instructor) + +- Preview mode shows questions grouped under section headers. +- Each section header displays the section title and description. +- Questions are numbered within their section (Section 1: Q1, Q2; Section 2: Q1, Q2). + +### FR-4: Grading & Submissions + +#### FR-4.1: Section-Grouped Grading View + +- In the individual student submission view (`individual.svelte`), questions are grouped under section headers. +- Each section shows a subtotal: "Section 1: 15/20 points". +- An overall total is shown at the top/bottom: "Total: 35/50 points". + +#### FR-4.2: Summary Analytics + +- In the summary view (`summary.svelte`), chart data is grouped by section. +- Each section has its own aggregate view before the overall aggregate. + +### FR-5: Exercise Templates + +- Exercise templates (`exercise_template` table) support sections in the `questionnaire` JSONB field. +- Template schema extends to: + ```json + { + "sections": [ + { + "title": "Section Title", + "description": "Optional description", + "order": 0, + "afterBehavior": { "action": "continue" }, + "questions": [{ "title": "...", ... }] + } + ], + "questions": [...] + } + ``` +- If `sections` is present, `questions` at the root level is empty/ignored. +- If `sections` is absent, `questions` is used (backward compatible). + +### FR-6: Cloning + +- The clone service (`apps/api/src/services/course/clone.ts`) clones exercise sections alongside questions and options. +- Section ordering, titles, descriptions, and after-behavior settings are preserved. +- Question-to-section mappings are preserved via the section ID map. + +--- + +## Technical Design + +### 1. Data Model Changes + +#### 1.1: New `exercise_section` Table + +```sql +CREATE TABLE exercise_section ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + exercise_id UUID NOT NULL REFERENCES exercise(id) ON DELETE CASCADE, + title VARCHAR NOT NULL DEFAULT 'Untitled Section', + description TEXT, + "order" BIGINT NOT NULL DEFAULT 0, + after_behavior JSONB NOT NULL DEFAULT '{"action": "continue"}', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX idx_exercise_section_exercise_id ON exercise_section(exercise_id); +``` + +The `after_behavior` JSONB stores one of: + +```typescript +// Continue to the next section in order +{ action: 'continue' } + +// Jump to a specific section +{ action: 'go_to_section', sectionId: '' } + +// Submit the exercise immediately +{ action: 'submit' } +``` + +#### 1.2: `question` Table — Add `section_id` + +```sql +ALTER TABLE question ADD COLUMN section_id UUID REFERENCES exercise_section(id) ON DELETE SET NULL; +CREATE INDEX idx_question_section_id ON question(section_id); +``` + +- `section_id` is **nullable**. When `NULL`, the question belongs to a sectionless exercise (backward compatible). +- When a question is part of a sectioned exercise, `section_id` is required (enforced at the application level, not DB constraint, to keep migration safe). + +#### 1.3: `exercise` Table — Add `section_display_mode` + +```sql +ALTER TABLE exercise ADD COLUMN section_display_mode VARCHAR DEFAULT 'one_question'; +``` + +Values: `'one_question'` | `'all_questions'` + +This column is only meaningful when the exercise has sections. For sectionless exercises it is ignored. + +#### 1.4: Drizzle Schema Updates + +File: `packages/db/src/schema.ts` + +```typescript +export const exerciseSection = pgTable( + 'exercise_section', + { + id: uuid() + .default(sql`gen_random_uuid()`) + .primaryKey() + .notNull(), + exerciseId: uuid('exercise_id').notNull(), + title: varchar().notNull().default('Untitled Section'), + description: text(), + order: bigint({ mode: 'number' }).notNull().default(0), + afterBehavior: jsonb('after_behavior') + .notNull() + .default({ action: 'continue' }) + .$type(), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow() + }, + (table) => [ + foreignKey({ + columns: [table.exerciseId], + foreignColumns: [exercise.id], + name: 'exercise_section_exercise_id_fkey' + }).onDelete('cascade'), + index('idx_exercise_section_exercise_id').on(table.exerciseId) + ] +); +``` + +Add to `question` table: + +```typescript +sectionId: uuid('section_id'), +``` + +Add foreign key in `question` table constraints: + +```typescript +foreignKey({ + columns: [table.sectionId], + foreignColumns: [exerciseSection.id], + name: 'question_section_id_fkey' +}).onDelete('set null') +``` + +Add to `exercise` table: + +```typescript +sectionDisplayMode: varchar('section_display_mode').default('one_question'), +``` + +#### 1.5: TypeScript Types + +File: `packages/db/src/types.ts` (inferred from schema) + +```typescript +export type TExerciseSection = typeof exerciseSection.$inferSelect; +export type TExerciseSectionInsert = typeof exerciseSection.$inferInsert; +``` + +File: `packages/question-types/src/exercise-section-types.ts` (new) + +```typescript +export type ExerciseSectionAfterAction = 'continue' | 'go_to_section' | 'submit'; + +export interface ExerciseSectionAfterBehavior { + action: ExerciseSectionAfterAction; + sectionId?: string; // only when action === 'go_to_section' +} + +export type ExerciseSectionDisplayMode = 'one_question' | 'all_questions'; + +export interface ExerciseSectionModel { + id?: string; + title: string; + description?: string | null; + order: number; + afterBehavior: ExerciseSectionAfterBehavior; + questions: ExerciseQuestionModel[]; +} +``` + +### 2. Validation Layer + +File: `packages/utils/src/validation/exercise/exercise-section.ts` (new) + +```typescript +import { z } from 'zod'; + +export const ZExerciseSectionAfterBehavior = z.discriminatedUnion('action', [ + z.object({ action: z.literal('continue') }), + z.object({ action: z.literal('go_to_section'), sectionId: z.string().uuid() }), + z.object({ action: z.literal('submit') }) +]); + +export const ZExerciseSection = z.object({ + id: z.string().uuid().optional(), + title: z.string().min(1, 'Section title is required'), + description: z.string().optional().nullable(), + order: z.number().int().min(0), + afterBehavior: ZExerciseSectionAfterBehavior.default({ action: 'continue' }) +}); + +export const ZExerciseSectionCreate = ZExerciseSection.omit({ id: true }); +export const ZExerciseSectionUpdate = ZExerciseSection.partial().required({ id: true }); + +export type TExerciseSection = z.infer; +export type TExerciseSectionCreate = z.infer; +export type TExerciseSectionUpdate = z.infer; +``` + +Extend existing `ZExerciseUpdate` to accept optional `sections` array: + +```typescript +export const ZExerciseUpdate = z.object({ + title: z.string().optional(), + description: z.string().optional(), + dueBy: z.string().optional(), + questions: z.array(ZQuestion).optional(), + sections: z.array(ZExerciseSection).optional(), + sectionDisplayMode: z.enum(['one_question', 'all_questions']).optional() +}); +``` + +### 3. Database Queries + +File: `packages/db/src/queries/exercise/exercise-section.ts` (new) + +Required query functions: + +| Function | Description | +| --- | --- | +| `getExerciseSectionsByExerciseId(exerciseId)` | Fetch all sections for an exercise, ordered by `order` | +| `createExerciseSections(sections[])` | Bulk insert sections, return created rows | +| `updateExerciseSection(sectionId, data)` | Update a single section's title, description, order, or afterBehavior | +| `deleteExerciseSection(sectionId)` | Delete a section (questions cascade to `section_id = NULL`) | +| `reorderExerciseSections(exerciseId, orderedIds[])` | Batch update `order` field for all sections | +| `moveQuestionToSection(questionId, sectionId)` | Update a question's `section_id` | +| `bulkMoveQuestionsToSection(questionIds[], sectionId)` | Move multiple questions to a section | + +### 4. API Layer Changes + +#### 4.1: Exercise Fetch — Include Sections + +When fetching an exercise (`GET /course/:courseId/exercise/:exerciseId`), the response includes sections: + +```typescript +{ + id: string; + title: string; + description: string; + sectionDisplayMode: 'one_question' | 'all_questions'; + sections: Array<{ + id: string; + title: string; + description: string | null; + order: number; + afterBehavior: { action: string; sectionId?: string }; + questions: Array<{ /* existing question shape */ }>; + }>; + questions: Array<{ /* questions without sections (backward compat) */ }>; +} +``` + +- If the exercise has sections, `sections` is populated and `questions` only contains ungrouped questions (should be empty for a properly formed sectioned exercise). +- If the exercise has no sections, `sections` is an empty array and `questions` contains all questions (current behavior preserved). + +#### 4.2: Exercise Update — Accept Sections + +The `PUT /course/:courseId/exercise/:exerciseId` endpoint accepts an optional `sections` array: + +```typescript +{ + title?: string; + description?: string; + dueBy?: string; + sectionDisplayMode?: 'one_question' | 'all_questions'; + sections?: Array<{ + id?: string; // present for existing sections, absent for new + title: string; + description?: string; + order: number; + afterBehavior: { action: string; sectionId?: string }; + questionIds: string[]; // ordered list of question IDs in this section + }>; + questions?: Array<{ /* existing question update shape */ }>; +} +``` + +Service logic: + +1. Upsert sections (create new, update existing, delete removed). +2. Update `question.section_id` for each question based on `questionIds` in each section. +3. Update `question.order` within each section. +4. Validate that all `go_to_section` references point to valid section IDs within the same exercise. +5. Validate that if sections exist, every non-deleted question has a section assignment. + +#### 4.3: Exercise Submission — Section-Aware + +No changes to the submission API shape. Students still submit a flat array of `{ questionId, optionId?, answer? }`. The server groups answers by section for grading display, not at submission time. + +#### 4.4: Submission Fetch — Section-Grouped Response + +When fetching submissions for grading, the API returns section groupings: + +```typescript +{ + sections: Array<{ + sectionId: string; + sectionTitle: string; + subtotal: number; + maxPoints: number; + answers: Array<{ /* per-question answer + grade */ }>; + }>; + totalScore: number; + maxScore: number; +} +``` + +For sectionless exercises, `sections` is a single entry with no section metadata (backward compatible). + +### 5. Clone Service Changes + +File: `apps/api/src/services/course/clone.ts` + +Update `cloneExercises` to: + +1. After creating new exercises, fetch sections for all old exercises. +2. Create a section ID map (old → new). +3. Bulk insert new sections with the new exercise IDs. +4. When cloning questions, include the mapped `sectionId`. + +```typescript +// Pseudocode addition to cloneExercises(): +const oldExerciseIds = oldExercises.map((ex) => ex.id); +const oldSections = await getExerciseSectionsByExerciseIds(oldExerciseIds); + +if (oldSections.length > 0) { + const newSections = await createExerciseSections( + oldSections.map((section) => ({ + title: section.title, + description: section.description, + order: section.order, + afterBehavior: section.afterBehavior, + exerciseId: exerciseIdMap.get(section.exerciseId)! + })) + ); + + const sectionIdMap = new Map(); + oldSections.forEach((oldSection, index) => { + sectionIdMap.set(oldSection.id, newSections[index].id); + }); + + // When cloning questions, include mapped sectionId: + // sectionId: question.sectionId ? sectionIdMap.get(question.sectionId) : null +} +``` + +Update `afterBehavior` references: if a section's `afterBehavior.action === 'go_to_section'`, remap the `sectionId` using the section ID map. + +### 6. Exercise Template Changes + +File: `packages/db/src/schema.ts` — `exerciseTemplate` + +Extend the `questionnaire` JSONB type: + +```typescript +questionnaire: jsonb().default({}).$type<{ + sections?: Array<{ + title: string; + description?: string; + order: number; + afterBehavior: { action: string; sectionId?: string }; + questions: Array<{ + title: string; + name: string; + points: number; + order: number; + question_type: { id: number; label: string }; + options: Array<{ label: string; is_correct: boolean }>; + }>; + }>; + questions: Array<{ /* existing shape */ }>; +}>() +``` + +### 7. Dashboard Store Changes + +File: `apps/dashboard/src/lib/features/course/components/exercise/store.ts` + +#### 7.1: Extend Questionnaire Store + +```typescript +export interface ExerciseSectionState { + id: string; + title: string; + description: string | null; + order: number; + afterBehavior: ExerciseSectionAfterBehavior; + isDirty?: boolean; + deletedAt?: string; +} + +export const questionnaire: Writable<{ + title?: string | null; + dueBy?: string | null; + isDueByDirty?: boolean; + isTitleDirty?: boolean; + description?: string | null; + isDescriptionDirty?: boolean; + questions: Question[]; + sections: ExerciseSectionState[]; + sectionDisplayMode: ExerciseSectionDisplayMode; + totalSubmissions: number; +}> = writable({ + title: '', + dueBy: '', + isDueByDirty: false, + isTitleDirty: false, + description: '', + isDescriptionDirty: false, + questions: [], + sections: [], + sectionDisplayMode: 'one_question', + totalSubmissions: 0 +}); +``` + +#### 7.2: Extend Question Type + +Add `sectionId` to the `Question` type: + +```typescript +export type Question = Omit & { + isDirty?: boolean; + deletedAt?: string; + sectionId?: string | null; + options: QuestionOption[]; +}; +``` + +#### 7.3: Extend Metadata Store + +```typescript +const initAnswerState = { + answers: {}, + scores: {}, + grades: {}, + totalPossibleGrade: 0, + finalTotalGrade: 0, + currentQuestionIndex: 0, + currentSectionIndex: 0, // NEW + sectionPhase: 'overview', // NEW: 'overview' | 'questions' | 'completed' + isFinished: false, + progressValue: 0, + status: STATUS.PENDING, + comment: '', + exerciseId: null as string | null +}; +``` + +#### 7.4: New Store Helper Functions + +```typescript +export function handleAddSection() { + questionnaire.update((q) => { + const newSection: ExerciseSectionState = { + id: `${Date.now()}-section-form`, + title: 'Untitled Section', + description: null, + order: q.sections.length, + afterBehavior: { action: 'continue' }, + isDirty: true + }; + return { ...q, sections: [...q.sections, newSection] }; + }); +} + +export function handleRemoveSection(sectionId: string, moveQuestionsToSectionId?: string) { + questionnaire.update((q) => { + const section = q.sections.find((s) => s.id === sectionId); + if (!section) return q; + + let updatedQuestions = q.questions; + if (moveQuestionsToSectionId) { + updatedQuestions = q.questions.map((question) => + question.sectionId === sectionId + ? { ...question, sectionId: moveQuestionsToSectionId, isDirty: true } + : question + ); + } else { + updatedQuestions = q.questions.map((question) => + question.sectionId === sectionId + ? { ...question, deletedAt: new Date().toString() } + : question + ); + } + + return { + ...q, + sections: q.sections.map((s) => + s.id === sectionId ? { ...s, deletedAt: new Date().toString() } : s + ), + questions: updatedQuestions + }; + }); +} + +export function handleMoveQuestionToSection(questionId: string, sectionId: string) { + questionnaire.update((q) => ({ + ...q, + questions: q.questions.map((question) => + question.id === questionId + ? { ...question, sectionId, isDirty: true } + : question + ) + })); +} + +export function handleUpdateSectionAfterBehavior( + sectionId: string, + afterBehavior: ExerciseSectionAfterBehavior +) { + questionnaire.update((q) => ({ + ...q, + sections: q.sections.map((section) => + section.id === sectionId + ? { ...section, afterBehavior, isDirty: true } + : section + ) + })); +} + +export function getQuestionsForSection( + questions: Question[], + sectionId: string +): Question[] { + return questions + .filter((q) => q.sectionId === sectionId && !q.deletedAt) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); +} + +export function hasSections(sections: ExerciseSectionState[]): boolean { + return sections.filter((s) => !s.deletedAt).length > 0; +} +``` + +### 8. Dashboard UI Changes + +#### 8.1: Edit Mode — Section Headers + +File: `apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte` + +When sections exist, render questions grouped under collapsible section headers: + +``` +┌─────────────────────────────────────────┐ +│ Section 1 of 2 ✕ ⋮ │ +│ [Section Title Input ] │ +│ [Description (optional) ] │ +├─────────────────────────────────────────┤ +│ ┌─ Question Card ──────────────────┐ │ +│ │ Q1: What is... [Radio ▼] │ │ +│ │ ... │ │ +│ └──────────────────────────────────┘ │ +│ ┌─ Question Card ──────────────────┐ │ +│ │ Q2: Explain... [Textarea ▼] │ │ +│ │ ... │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ After section 1: [Continue ▼] │ +└─────────────────────────────────────────┘ + +┌─────────────────────────────────────────┐ +│ Section 2 of 2 ✕ ⋮ │ +│ ... │ +└─────────────────────────────────────────┘ + +[ + Add Section ] +``` + +- Each section has an inline title input and optional description textarea. +- The "After section N" dropdown appears at the bottom of each section, styled like the Google Forms pattern (see reference image). +- The three-dot menu (`⋮`) on each section header offers: "Move section up", "Move section down", "Delete section". +- The "Add question" button inside a section adds a question to that specific section. +- Questions can be dragged between sections. + +#### 8.2: New Shared UI Component — `SectionHeader` + +File: `packages/ui/src/custom/exercise-question/section-header.svelte` (new) + +```typescript +interface Props { + title: string; + description?: string | null; + sectionNumber: number; + totalSections: number; + questionCount: number; + totalPoints: number; + mode: 'edit' | 'take' | 'preview' | 'view'; +} +``` + +- In **edit** mode: editable title/description inputs. +- In **take** mode: read-only title, description, question count, points. +- In **preview/view** mode: read-only header. + +#### 8.3: New Shared UI Component — `SectionAfterBehavior` + +File: `packages/ui/src/custom/exercise-question/section-after-behavior.svelte` (new) + +A dropdown component that renders the "After section N" control: + +```typescript +interface Props { + sectionId: string; + allSections: Array<{ id: string; title: string }>; + afterBehavior: ExerciseSectionAfterBehavior; + onChange: (behavior: ExerciseSectionAfterBehavior) => void; + labels: { + afterSectionPrefix: string; // "After section" + continue: string; // "Continue to next section" + goToSection: string; // "Go to section" + submit: string; // "Submit exercise" + }; +} +``` + +#### 8.4: View Mode — Section-Aware Navigation + +File: `apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte` + +State machine for sectioned exercise student flow: + +``` +START → Section Overview (section 0) → Questions (section 0) + → [after-behavior] → Section Overview (section 1) → Questions (section 1) + → [after-behavior] → ... → Submit → Finished +``` + +Key state variables: + +```typescript +currentSectionIndex: number; // which section the student is on +sectionPhase: 'overview' | 'questions' | 'completed'; +currentQuestionIndex: number; // within the current section +``` + +When `sectionPhase === 'overview'`: render the `SectionHeader` in take mode with a "Begin Section" button. +When `sectionPhase === 'questions'`: render questions using existing `QuestionRenderer` / `QuestionNavigation`. +When the last question in a section is answered: evaluate `afterBehavior` and transition. + +#### 8.5: Updated `QuestionNavigation` Component + +File: `packages/ui/src/custom/exercise-question/question-navigation.svelte` + +Add section-awareness: + +```typescript +interface Props { + // ... existing props ... + sectionLabel?: string; // e.g., "Section 1 of 3" + completeSectionLabel?: string; // e.g., "Complete Section" (for all_questions mode) + onCompleteSection?: () => void; +} +``` + +#### 8.6: New Shared UI Component — `SectionNavigationSidebar` + +File: `packages/ui/src/custom/exercise-question/section-navigation-sidebar.svelte` (new) + +A sidebar/panel that shows the section-grouped question map: + +```typescript +interface Props { + sections: Array<{ + id: string; + title: string; + questions: Array<{ + key: string; + label: string; // "Q1", "Q2" + isAnswered: boolean; + isCurrent: boolean; + }>; + isCurrent: boolean; + }>; +} +``` + +Rendered as: + +``` +Section 1: Multiple Choice + [●] Q1 [●] Q2 [○] Q3 [○] Q4 + +Section 2: Written Responses + [○] Q1 [○] Q2 +``` + +Where `●` = answered, `○` = unanswered, and the current question is highlighted. + +#### 8.7: Preview Mode — Section-Grouped + +File: `apps/dashboard/src/lib/features/course/components/exercise/preview.svelte` + +When sections exist, group questions under section headers: + +```svelte +{#each activeSections as section, sectionIndex} + + {#each getQuestionsForSection(questions, section.id) as question, qIndex} + + {/each} +{/each} +``` + +#### 8.8: Grading Views — Section-Grouped + +File: `apps/dashboard/src/lib/features/course/components/exercise/submissions/individual.svelte` + +When sections exist, wrap question rendering in section groups: + +```svelte +{#each activeSections as section} +
+

{section.title}

+ {#each getQuestionsForSection(questions, section.id) as q, i} + + {/each} +
+ Section: {sectionScore}/{sectionMaxPoints} points +
+
+{/each} +
+ Total: {totalScore}/{totalMaxPoints} points +
+``` + +File: `apps/dashboard/src/lib/features/course/components/exercise/submissions/summary.svelte` + +Group chart data by section, rendering a section header before each section's question charts. + +### 9. Translation Keys + +File: `apps/dashboard/src/lib/utils/translations/en.json` + +Add under `course.navItem.lessons.exercises.all_exercises`: + +```json +{ + "section.add": "Add Section", + "section.untitled": "Untitled Section", + "section.description_placeholder": "Description (optional)", + "section.delete": "Delete Section", + "section.delete_confirm": "What should happen to the questions in this section?", + "section.delete_move": "Move to another section", + "section.delete_remove": "Delete questions", + "section.move_up": "Move section up", + "section.move_down": "Move section down", + "section.reorder": "Reorder Sections", + "section.after_behavior_prefix": "After section", + "section.after_continue": "Continue to next section", + "section.after_go_to": "Go to section", + "section.after_submit": "Submit exercise", + "section.overview_begin": "Begin Section", + "section.overview_questions": "questions", + "section.overview_points": "points", + "section.of": "of", + "section.complete": "Complete Section", + "section.display_mode_label": "Question display within sections", + "section.display_mode_one": "One question at a time", + "section.display_mode_all": "All questions at once", + "section.subtotal": "Section subtotal", + "section.move_question_to": "Move to section", + "section.add_question_to": "Add question to section", + "section.nav_label": "Section {number} of {total}" +} +``` + +### 10. Backward Compatibility + +| Scenario | Behavior | +| --- | --- | +| Existing exercise, no sections | `sections` array is empty. All code paths fall through to existing flat-question logic. No migration needed. | +| Existing exercise, first section added | All questions auto-assigned to "Section 1". `sectionId` set on all questions. | +| Sectioned exercise, last section deleted | All questions become sectionless. `sectionId` set to `NULL`. Exercise reverts to flat mode. | +| Old API clients that don't send `sections` | `sections` field is optional in update payload. Omitting it means "don't change sections." | +| Exercise templates without sections | `sections` field is absent in JSONB. Template import uses `questions` array (current behavior). | + +--- + +## Migration Plan + +### Database Migration + +1. Create `exercise_section` table. +2. Add `section_id` column to `question` table (nullable, FK to `exercise_section`). +3. Add `section_display_mode` column to `exercise` table (default `'one_question'`). +4. Add index on `question.section_id`. +5. No data migration needed — existing exercises remain sectionless. + +### Rollout Strategy + +1. **Feature flag**: `exercise_sections_enabled` (off by default). +2. Deploy database migration first (additive, no breaking changes). +3. Deploy API changes (backward compatible — sections are optional in all endpoints). +4. Deploy dashboard changes behind feature flag. +5. Enable flag for internal testing. +6. Gradual rollout to all users. + +--- + +## File Change Manifest + +### New Files + +| File | Description | +| --- | --- | +| `packages/db/src/queries/exercise/exercise-section.ts` | CRUD queries for exercise sections | +| `packages/utils/src/validation/exercise/exercise-section.ts` | Zod schemas for section validation | +| `packages/question-types/src/exercise-section-types.ts` | TypeScript types for sections | +| `packages/ui/src/custom/exercise-question/section-header.svelte` | Section header UI component | +| `packages/ui/src/custom/exercise-question/section-after-behavior.svelte` | After-section behavior dropdown | +| `packages/ui/src/custom/exercise-question/section-navigation-sidebar.svelte` | Section-grouped question nav | +| `apps/dashboard/src/lib/features/course/components/exercise/section-editor.svelte` | Section editing card (edit mode) | +| `apps/dashboard/src/lib/features/course/components/exercise/section-overview.svelte` | Section overview screen (take mode) | +| `apps/dashboard/src/lib/features/course/components/exercise/section-delete-modal.svelte` | Section deletion confirmation | + +### Modified Files + +| File | Changes | +| --- | --- | +| `packages/db/src/schema.ts` | Add `exerciseSection` table, `sectionId` to `question`, `sectionDisplayMode` to `exercise` | +| `packages/db/src/queries/exercise/index.ts` | Export new section queries | +| `packages/question-types/src/index.ts` | Export `exercise-section-types` | +| `packages/ui/src/custom/exercise-question/index.ts` | Export new section components | +| `packages/ui/src/custom/exercise-question/question-navigation.svelte` | Add section-aware props | +| `packages/ui/src/custom/exercise-question/question-list.svelte` | Support section-grouped rendering | +| `apps/api/src/routes/course/exercise.ts` | Accept/return sections in exercise endpoints | +| `apps/api/src/services/exercise/exercise.ts` | Section upsert logic in exercise update | +| `apps/api/src/services/course/clone.ts` | Clone sections, remap IDs and afterBehavior references | +| `apps/dashboard/src/lib/features/course/components/exercise/store.ts` | Add `sections`, `sectionDisplayMode`, section helpers | +| `apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte` | Render section-grouped editor | +| `apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte` | Section-aware take flow | +| `apps/dashboard/src/lib/features/course/components/exercise/preview.svelte` | Section-grouped preview | +| `apps/dashboard/src/lib/features/course/components/exercise/submissions/individual.svelte` | Section-grouped grading | +| `apps/dashboard/src/lib/features/course/components/exercise/submissions/summary.svelte` | Section-grouped analytics | +| `apps/dashboard/src/lib/features/course/pages/exercise.svelte` | "Add Section" button, section display mode setting | +| `apps/dashboard/src/lib/features/course/types.ts` | Extend `Question` with `sectionId` | +| `apps/dashboard/src/lib/utils/translations/en.json` | Section-related translation keys | + +--- + +## Testing Requirements + +### API + +- Section CRUD operations (create, read, update, delete). +- Exercise update with sections — questions correctly assigned to sections. +- Exercise fetch returns sections with nested questions in correct order. +- After-behavior validation — `go_to_section` must reference a valid section. +- Clone service preserves sections, question mappings, and remaps afterBehavior sectionIds. +- Backward compat: exercise without sections works identically to before. +- Submission fetch returns section-grouped data when sections exist. + +### Dashboard + +- Edit mode: add/edit/delete/reorder sections. +- Edit mode: drag question between sections. +- Edit mode: add question to specific section. +- Edit mode: after-behavior dropdown shows correct options and persists selection. +- Edit mode: deleting a section with "move questions" moves them correctly. +- Take mode (`one_question`): section overview → questions one at a time → post-section behavior. +- Take mode (`all_questions`): section overview → all questions shown → complete section → post-section behavior. +- Take mode: progress bar reflects total questions across all sections. +- Take mode: section navigation sidebar shows correct grouping and answered state. +- Take mode: autosave preserves section + question position on resume. +- Preview mode: questions grouped under section headers. +- Grading: individual view shows section subtotals. +- Grading: summary view shows section-grouped charts. + +### Regression + +- Existing sectionless exercises: all CRUD, take, preview, grading flows unchanged. +- Exercise cloning without sections works as before. +- Exercise templates without sections work as before. + +--- + +## Build and Verification Commands + +```bash +pnpm --filter @cio/db build +pnpm --filter @cio/utils build +pnpm --filter @cio/question-types build +pnpm --filter @cio/ui build +pnpm --filter @cio/api build +pnpm --filter @cio/dashboard build +``` + +--- + +## Success Metrics (90 Days Post-Launch) + +1. 30% of newly created exercises with 10+ questions use at least one section. +2. Students report improved navigation experience in sectioned exercises (qualitative feedback). +3. Grading time for sectioned exercises is comparable to flat exercises (no regression). +4. Zero backward-compatibility incidents for existing exercises. + +--- + +## Risks and Mitigations + +| Risk | Mitigation | +| --- | --- | +| Section-aware state machine in take mode adds complexity and potential navigation bugs | Comprehensive state machine tests; keep flat-exercise code path untouched | +| After-behavior "go to section" can create loops (A→B→A) | Validate at save time: warn if cycles detected, but allow (instructor may want loops intentionally for review) | +| Drag-and-drop between sections may be unreliable on mobile | Provide "Move to section" context menu as a fallback; drag-drop is enhancement only | +| Large exercises with many sections may slow down the editor | Lazy-render collapsed sections; only expand the active section | +| Clone service must remap `afterBehavior.sectionId` references | Explicit remap step in clone logic with validation | + +--- + +## Open Questions + +1. Should we support a "section shuffle" mode (randomize section order) independently of question shuffle? +2. Should the section navigation sidebar be collapsible or always visible during take mode? +3. Should after-behavior "Go to section X" carry over answered data, or should the target section reset? diff --git a/prd/exercise-sections/implementation-plan.md b/prd/exercise-sections/implementation-plan.md new file mode 100644 index 00000000..7b3b11c5 --- /dev/null +++ b/prd/exercise-sections/implementation-plan.md @@ -0,0 +1,1340 @@ +# Exercise Sections — Implementation Plan + +## Delivery Phases + +The work is broken into 4 sequential phases. Each phase is independently shippable and testable. Phases must be completed in order because later phases depend on earlier ones. + +--- + +## Phase 1: Database & Shared Types (Foundation) + +**Goal**: Schema changes, shared types, and validation — no UI or API behavior changes yet. + +### Ticket 1.1: Drizzle Schema — `exercise_section` Table + +**Priority**: P0 +**Estimate**: 1–2 hours + +**What to do**: + +1. Open `packages/db/src/schema.ts`. +2. Add the `exerciseSection` table definition **above** the `question` table (because `question` will reference it): + +```typescript +export const exerciseSection = pgTable( + 'exercise_section', + { + id: uuid() + .default(sql`gen_random_uuid()`) + .primaryKey() + .notNull(), + exerciseId: uuid('exercise_id').notNull(), + title: varchar().notNull().default('Untitled Section'), + description: text(), + order: bigint({ mode: 'number' }).notNull().default(0), + afterBehavior: jsonb('after_behavior') + .notNull() + .default({ action: 'continue' }), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow() + }, + (table) => [ + foreignKey({ + columns: [table.exerciseId], + foreignColumns: [exercise.id], + name: 'exercise_section_exercise_id_fkey' + }).onDelete('cascade'), + index('idx_exercise_section_exercise_id').on(table.exerciseId) + ] +); +``` + +3. Add `sectionId` column to the existing `question` table definition. Find the `question` table object and add this column after `exerciseId`: + +```typescript +sectionId: uuid('section_id'), +``` + +4. Add a foreign key constraint in the `question` table's constraint array: + +```typescript +foreignKey({ + columns: [table.sectionId], + foreignColumns: [exerciseSection.id], + name: 'question_section_id_fkey' +}).onDelete('set null') +``` + +5. Add `sectionDisplayMode` column to the existing `exercise` table definition: + +```typescript +sectionDisplayMode: varchar('section_display_mode').default('one_question'), +``` + +6. Run `pnpm --filter @cio/db build` and fix any type errors. + +**Files to change**: +- `packages/db/src/schema.ts` + +**How to verify**: +- `pnpm --filter @cio/db build` succeeds with no errors. + +--- + +### Ticket 1.2: Database Migration Script + +**Priority**: P0 +**Estimate**: 1 hour + +**What to do**: + +1. Generate a Drizzle migration from the schema changes in Ticket 1.1. +2. The generated SQL should create the `exercise_section` table, add `section_id` to `question`, add `section_display_mode` to `exercise`, and create the index. +3. Review the generated migration carefully to ensure: + - `section_id` on `question` is nullable (no `NOT NULL`). + - `section_display_mode` on `exercise` has default `'one_question'`. + - The `exercise_section` table has `ON DELETE CASCADE` for `exercise_id`. + - The `question.section_id` FK has `ON DELETE SET NULL`. + +**How to verify**: +- Migration runs successfully against a local database. +- Existing data is not affected. +- `SELECT * FROM exercise_section` returns empty. +- `SELECT section_id FROM question LIMIT 1` returns `NULL`. + +--- + +### Ticket 1.3: Shared TypeScript Types + +**Priority**: P0 +**Estimate**: 1 hour + +**What to do**: + +1. Create `packages/question-types/src/exercise-section-types.ts`: + +```typescript +import type { ExerciseQuestionModel } from './exercise-types'; + +export type ExerciseSectionAfterAction = 'continue' | 'go_to_section' | 'submit'; + +export interface ExerciseSectionAfterBehavior { + action: ExerciseSectionAfterAction; + sectionId?: string; +} + +export type ExerciseSectionDisplayMode = 'one_question' | 'all_questions'; + +export interface ExerciseSectionModel { + id?: string; + title: string; + description?: string | null; + order: number; + afterBehavior: ExerciseSectionAfterBehavior; + questions: ExerciseQuestionModel[]; +} +``` + +2. Export from `packages/question-types/src/index.ts`: + +```typescript +export * from './exercise-section-types'; +``` + +3. Run `pnpm --filter @cio/question-types build`. + +**Files to create**: +- `packages/question-types/src/exercise-section-types.ts` + +**Files to change**: +- `packages/question-types/src/index.ts` + +**How to verify**: +- `pnpm --filter @cio/question-types build` succeeds. +- Types are importable: `import { ExerciseSectionModel } from '@cio/question-types'` resolves. + +--- + +### Ticket 1.4: Zod Validation Schemas for Sections + +**Priority**: P0 +**Estimate**: 1–2 hours + +**What to do**: + +1. Create `packages/utils/src/validation/exercise/exercise-section.ts`: + +```typescript +import { z } from 'zod'; + +export const ZExerciseSectionAfterBehavior = z.discriminatedUnion('action', [ + z.object({ action: z.literal('continue') }), + z.object({ action: z.literal('go_to_section'), sectionId: z.string().uuid() }), + z.object({ action: z.literal('submit') }) +]); + +export const ZExerciseSection = z.object({ + id: z.string().uuid().optional(), + title: z.string().min(1, 'Section title is required'), + description: z.string().optional().nullable(), + order: z.number().int().min(0), + afterBehavior: ZExerciseSectionAfterBehavior.default({ action: 'continue' }), + questionIds: z.array(z.string()).optional() +}); + +export const ZExerciseSectionCreate = ZExerciseSection.omit({ id: true }); +export const ZExerciseSectionUpdate = ZExerciseSection.partial().required({ id: true }); + +export type TExerciseSection = z.infer; +``` + +2. Update the existing exercise validation file to accept optional `sections` and `sectionDisplayMode`. Find the existing `ZExerciseUpdate` in `packages/utils/src/validation/exercise/exercise.ts` and extend it: + +```typescript +import { ZExerciseSection } from './exercise-section'; + +// Add to ZExerciseUpdate: +sections: z.array(ZExerciseSection).optional(), +sectionDisplayMode: z.enum(['one_question', 'all_questions']).optional() +``` + +3. Export from the validation index: + +```typescript +export * from './exercise-section'; +``` + +**Files to create**: +- `packages/utils/src/validation/exercise/exercise-section.ts` + +**Files to change**: +- `packages/utils/src/validation/exercise/exercise.ts` +- `packages/utils/src/validation/exercise/index.ts` (or wherever exercise validation is exported) + +**How to verify**: +- `pnpm --filter @cio/utils build` succeeds. +- `ZExerciseSection.parse({ title: 'Test', order: 0 })` succeeds. +- `ZExerciseSection.parse({ title: '', order: 0 })` fails with "Section title is required". + +--- + +### Ticket 1.5: Database Query Functions for Sections + +**Priority**: P0 +**Estimate**: 2–3 hours + +**What to do**: + +1. Create `packages/db/src/queries/exercise/exercise-section.ts` with these functions: + +```typescript +import { db } from '../../db'; +import { exerciseSection, question } from '../../schema'; +import { eq, inArray, sql } from 'drizzle-orm'; + +export async function getExerciseSectionsByExerciseId(exerciseId: string) { + return db + .select() + .from(exerciseSection) + .where(eq(exerciseSection.exerciseId, exerciseId)) + .orderBy(exerciseSection.order); +} + +export async function getExerciseSectionsByExerciseIds(exerciseIds: string[]) { + if (exerciseIds.length === 0) return []; + return db + .select() + .from(exerciseSection) + .where(inArray(exerciseSection.exerciseId, exerciseIds)) + .orderBy(exerciseSection.order); +} + +export async function createExerciseSections( + sections: Array<{ + exerciseId: string; + title: string; + description?: string | null; + order: number; + afterBehavior?: { action: string; sectionId?: string }; + }> +) { + if (sections.length === 0) return []; + return db.insert(exerciseSection).values(sections).returning(); +} + +export async function updateExerciseSection( + sectionId: string, + data: Partial<{ + title: string; + description: string | null; + order: number; + afterBehavior: { action: string; sectionId?: string }; + }> +) { + return db + .update(exerciseSection) + .set({ ...data, updatedAt: new Date().toISOString() }) + .where(eq(exerciseSection.id, sectionId)) + .returning(); +} + +export async function deleteExerciseSection(sectionId: string) { + return db.delete(exerciseSection).where(eq(exerciseSection.id, sectionId)); +} + +export async function moveQuestionToSection(questionId: number, sectionId: string | null) { + return db + .update(question) + .set({ sectionId }) + .where(eq(question.id, questionId)); +} + +export async function bulkMoveQuestionsToSection(questionIds: number[], sectionId: string | null) { + if (questionIds.length === 0) return; + return db + .update(question) + .set({ sectionId }) + .where(inArray(question.id, questionIds)); +} +``` + +2. Export from the queries index file (create or update `packages/db/src/queries/exercise/index.ts`): + +```typescript +export * from './exercise-section'; +``` + +**Files to create**: +- `packages/db/src/queries/exercise/exercise-section.ts` + +**Files to change**: +- `packages/db/src/queries/exercise/index.ts` +- `packages/db/src/queries/index.ts` (if needed for top-level re-export) + +**How to verify**: +- `pnpm --filter @cio/db build` succeeds. +- Functions are importable from `@db/queries`. + +--- + +## Phase 2: API Layer + +**Goal**: API endpoints accept and return sections. No dashboard changes yet — testable via API calls. + +### Ticket 2.1: Exercise Fetch — Return Sections + +**Priority**: P0 +**Estimate**: 2–3 hours + +**What to do**: + +1. Open the exercise fetch service/query (likely `packages/db/src/queries/exercise/exercise.ts` or `apps/api/src/services/exercise/exercise.ts`). +2. After fetching an exercise and its questions, also fetch sections: + +```typescript +const sections = await getExerciseSectionsByExerciseId(exerciseId); +``` + +3. Group questions by `sectionId`: + - Questions with a `sectionId` go into their respective section's `questions` array. + - Questions without a `sectionId` go into the top-level `questions` array. +4. Return the response shaped as: + +```typescript +{ + ...exercise, + sectionDisplayMode: exercise.sectionDisplayMode, + sections: sections.map((section) => ({ + id: section.id, + title: section.title, + description: section.description, + order: section.order, + afterBehavior: section.afterBehavior, + questions: questionsInSection // sorted by order + })), + questions: unsectionedQuestions // sorted by order +} +``` + +5. For backward compatibility, if there are no sections, `sections` is `[]` and all questions are in `questions`. + +**Files to change**: +- Exercise fetch query/service file +- Exercise route handler (response shape) + +**How to verify**: +- `GET /course/:courseId/exercise/:exerciseId` returns `sections: []` for existing exercises. +- After manually inserting a section and assigning questions to it, the endpoint returns the section with nested questions. + +--- + +### Ticket 2.2: Exercise Update — Accept Sections + +**Priority**: P0 +**Estimate**: 3–4 hours + +**What to do**: + +1. Open the exercise update service (likely `apps/api/src/services/exercise/exercise.ts`). +2. Accept `sections` and `sectionDisplayMode` in the update payload. +3. Implement section upsert logic: + +``` +For each section in the payload: + - If section has `id` and exists in DB → update title, description, order, afterBehavior + - If section has no `id` → create new section + - Sections in DB that are NOT in the payload → delete them + +For each section: + - Get `questionIds` from the section payload + - Update each question's `sectionId` to this section's ID + - Update each question's `order` based on its position in `questionIds` +``` + +4. Validation rules: + - If `sections` is provided and non-empty, every non-deleted question must be assigned to a section. + - If any section has `afterBehavior.action === 'go_to_section'`, the referenced `sectionId` must exist in the same exercise. + - If `sections` is empty or omitted, don't touch existing sections. + +5. Update `sectionDisplayMode` on the exercise if provided. + +**Files to change**: +- `apps/api/src/services/exercise/exercise.ts` +- `apps/api/src/routes/course/exercise.ts` (accept new fields in validator) + +**How to verify**: +- `PUT /course/:courseId/exercise/:exerciseId` with `sections: [{ title: 'Part A', order: 0, questionIds: ['q1', 'q2'] }]` creates a section and assigns questions. +- Sending the same request with an updated title updates the section. +- Sending a request without the section deletes it. +- `sectionDisplayMode` persists correctly. +- Invalid `go_to_section` reference returns a 400 error. + +--- + +### Ticket 2.3: Submission Fetch — Section-Grouped Response + +**Priority**: P1 +**Estimate**: 2–3 hours + +**What to do**: + +1. Open the submission fetch service (used by the grading/submissions views). +2. When returning submission data for an exercise that has sections: + - Fetch sections for the exercise. + - Group question answers by section. + - Compute per-section subtotals (sum of points awarded) and max points (sum of question points). + - Return a `sections` array with grouped answers alongside the existing flat data. +3. For exercises without sections, return the existing flat response (backward compatible). + +**Response shape** (when sections exist): + +```typescript +{ + // existing fields... + sections: [ + { + sectionId: 'uuid', + sectionTitle: 'Part A', + subtotal: 15, + maxPoints: 20, + answers: [/* answers for questions in this section */] + }, + // ... + ], + totalScore: 35, + maxScore: 50 +} +``` + +**Files to change**: +- Submission fetch service/query +- Submission route handler + +**How to verify**: +- Submission fetch for sectionless exercise returns existing shape unchanged. +- Submission fetch for sectioned exercise returns `sections` with correct groupings and subtotals. + +--- + +### Ticket 2.4: Clone Service — Support Sections + +**Priority**: P1 +**Estimate**: 2–3 hours + +**What to do**: + +1. Open `apps/api/src/services/course/clone.ts`. +2. In `cloneExercises`, after creating new exercises: + +```typescript +// 4a. Fetch sections for old exercises +const oldExerciseIds = oldExercises.map((ex) => ex.id); +const oldSections = await getExerciseSectionsByExerciseIds(oldExerciseIds); + +let sectionIdMap = new Map(); + +if (oldSections.length > 0) { + // 4b. Create new sections with mapped exercise IDs + const newSections = await createExerciseSections( + oldSections.map((section) => ({ + title: section.title, + description: section.description, + order: section.order, + afterBehavior: section.afterBehavior, + exerciseId: exerciseIdMap.get(section.exerciseId)! + })) + ); + + // 4c. Build section ID map + oldSections.forEach((oldSection, index) => { + sectionIdMap.set(oldSection.id, newSections[index].id); + }); + + // 4d. Remap afterBehavior.sectionId references + for (const newSection of newSections) { + const behavior = newSection.afterBehavior as any; + if (behavior?.action === 'go_to_section' && behavior.sectionId) { + const remappedId = sectionIdMap.get(behavior.sectionId); + if (remappedId) { + await updateExerciseSection(newSection.id, { + afterBehavior: { action: 'go_to_section', sectionId: remappedId } + }); + } + } + } +} +``` + +3. When inserting cloned questions, include `sectionId`: + +```typescript +sectionId: question.sectionId ? sectionIdMap.get(question.sectionId) ?? null : null +``` + +4. Also copy `sectionDisplayMode` when creating the new exercise. + +**Files to change**: +- `apps/api/src/services/course/clone.ts` + +**How to verify**: +- Clone a course with sectioned exercises. +- Verify cloned exercise has same number of sections with correct titles. +- Verify questions are assigned to correct cloned sections. +- Verify afterBehavior `sectionId` references point to the NEW section IDs (not old ones). +- Verify `sectionDisplayMode` is preserved. + +--- + +## Phase 3: Dashboard — Edit Mode + +**Goal**: Instructors can create, edit, reorder, and delete sections. Questions can be moved between sections. + +### Ticket 3.1: Extend Exercise Store with Section State + +**Priority**: P0 +**Estimate**: 2–3 hours + +**What to do**: + +1. Open `apps/dashboard/src/lib/features/course/components/exercise/store.ts`. +2. Add the `ExerciseSectionState` interface and extend the `questionnaire` writable: + +```typescript +import type { + ExerciseSectionAfterBehavior, + ExerciseSectionDisplayMode +} from '@cio/question-types'; + +export interface ExerciseSectionState { + id: string; + title: string; + description: string | null; + order: number; + afterBehavior: ExerciseSectionAfterBehavior; + isDirty?: boolean; + deletedAt?: string; +} +``` + +Add `sections` and `sectionDisplayMode` to the questionnaire writable's type and default value (see PRD Section 7.1). + +3. Add helper functions (see PRD Section 7.4): + - `handleAddSection()` + - `handleRemoveSection(sectionId, moveQuestionsToSectionId?)` + - `handleMoveQuestionToSection(questionId, sectionId)` + - `handleUpdateSectionAfterBehavior(sectionId, afterBehavior)` + - `getQuestionsForSection(questions, sectionId)` + - `hasSections(sections)` + - `handleReorderSections(orderedSectionIds)` + +4. Update `handleAddQuestion()` to accept an optional `sectionId` parameter. When sections exist, the new question must have a `sectionId`. + +5. Extend `Question` type in `apps/dashboard/src/lib/features/course/types.ts` to include `sectionId`. + +6. Extend the metadata store to include `currentSectionIndex` and `sectionPhase`. + +**Files to change**: +- `apps/dashboard/src/lib/features/course/components/exercise/store.ts` +- `apps/dashboard/src/lib/features/course/types.ts` + +**How to verify**: +- Import and call `handleAddSection()` — store updates with a new section. +- `getQuestionsForSection()` returns filtered questions. +- `handleMoveQuestionToSection()` updates the question's `sectionId`. +- `pnpm --filter @cio/dashboard build` succeeds. + +--- + +### Ticket 3.2: Section Header UI Component + +**Priority**: P0 +**Estimate**: 2–3 hours + +**What to do**: + +1. Create `packages/ui/src/custom/exercise-question/section-header.svelte`: + +```typescript +interface Props { + title: string; + description?: string | null; + sectionNumber: number; + totalSections: number; + questionCount?: number; + totalPoints?: number; + mode: 'edit' | 'take' | 'preview' | 'view'; + onTitleChange?: (title: string) => void; + onDescriptionChange?: (description: string) => void; + onDelete?: () => void; + onMoveUp?: () => void; + onMoveDown?: () => void; + canMoveUp?: boolean; + canMoveDown?: boolean; + labels?: Record; +} +``` + +2. In **edit** mode, render: + - A badge showing "Section N of M" (styled similar to the Google Forms purple badge in the reference image). + - An editable title input (inline, not a dialog). + - An optional description textarea (placeholder: "Description (optional)"). + - A close button (✕) and a three-dot menu with "Move up", "Move down", "Delete section". + +3. In **take** mode, render: + - Section title (read-only, prominent). + - Section description (read-only). + - Question count and total points. + - No edit controls. + +4. In **preview/view** mode, render: + - Section title as a heading. + - Section description below it. + - No interactive controls. + +5. Export from `packages/ui/src/custom/exercise-question/index.ts`. + +**Files to create**: +- `packages/ui/src/custom/exercise-question/section-header.svelte` + +**Files to change**: +- `packages/ui/src/custom/exercise-question/index.ts` + +**How to verify**: +- Component renders correctly in all modes. +- Edit mode: typing in title/description fires callbacks. +- `pnpm --filter @cio/ui build` succeeds. + +--- + +### Ticket 3.3: Section After-Behavior Dropdown + +**Priority**: P0 +**Estimate**: 1–2 hours + +**What to do**: + +1. Create `packages/ui/src/custom/exercise-question/section-after-behavior.svelte`: + +```typescript +interface Props { + sectionIndex: number; + allSections: Array<{ id: string; title: string; order: number }>; + afterBehavior: ExerciseSectionAfterBehavior; + onChange: (behavior: ExerciseSectionAfterBehavior) => void; + labels: { + afterSectionPrefix: string; + continue: string; + goToSection: string; + submit: string; + }; +} +``` + +2. Render a row at the bottom of each section card: + - Label: "After section {N}" (left side). + - A `