diff --git a/.env.example b/.env.example index aa2f2dbe..bfa8d638 100644 --- a/.env.example +++ b/.env.example @@ -42,9 +42,18 @@ PRIVATE_SERVER_URL=http://api:3081 # When set, invite emails and other links point here instead of app.classroomio.com DASHBOARD_ORIGIN=http://localhost:3082 -# CSP: external domains allowed by Content-Security-Policy (required when self-hosted) +# CSP: external domains allowed by Content-Security-Policy. +# These are runtime env vars — set them when running the container (no rebuild needed). # Comma-separated. Include your CDN, API, video, and font domains. +# ALLOWED_EXTERNAL_DOMAINS overrides all per-directive vars below. ALLOWED_EXTERNAL_DOMAINS= +# Per-directive overrides (optional, comma-separated): +# CSP_SCRIPT_SRC_DOMAINS= +# CSP_STYLE_SRC_DOMAINS= +# CSP_CONNECT_SRC_DOMAINS= +# CSP_FRAME_SRC_DOMAINS= +# CSP_FONT_SRC_DOMAINS= +# CSP_MEDIA_SRC_DOMAINS= (applies to both img-src and media-src) # ────────────────────────────────────────────── # Optional — Email (pick one approach, or skip) diff --git a/apps/api/src/middlewares/course-member-or-automation-key.ts b/apps/api/src/middlewares/course-member-or-automation-key.ts new file mode 100644 index 00000000..4cc6a751 --- /dev/null +++ b/apps/api/src/middlewares/course-member-or-automation-key.ts @@ -0,0 +1,80 @@ +import { Context, Next } from 'hono'; +import type { TOrganizationApiKeyScope } from '@cio/utils/validation/organization'; + +import { ErrorCodes } from '@api/utils/errors'; +import { organizationApiKeyHasScopes } from '@api/services/organization/automation-key'; +import { getCourseOrganizationId } from '@cio/db/queries/tag'; +import { courseMemberMiddleware } from './course-member'; + +export const courseMemberOrAutomationKeyMiddleware = + (requiredScopes: readonly TOrganizationApiKeyScope[] = []) => + async (c: Context, next: Next) => { + try { + const automationKey = c.get('automationKey'); + + if (!automationKey) { + return courseMemberMiddleware(c, next); + } + + if (!organizationApiKeyHasScopes(automationKey.scopes ?? [], requiredScopes)) { + return c.json( + { + success: false, + error: 'Automation key is missing required scopes', + code: ErrorCodes.FORBIDDEN + }, + 403 + ); + } + + const courseId = c.req.param('courseId') || c.req.query('courseId'); + if (!courseId) { + return c.json( + { + success: false, + error: 'Course ID is required', + code: 'COURSE_ID_REQUIRED' + }, + 400 + ); + } + + const courseOrgId = await getCourseOrganizationId(courseId); + if (!courseOrgId) { + return c.json( + { + success: false, + error: 'Course not found', + code: ErrorCodes.COURSE_NOT_FOUND + }, + 404 + ); + } + + if (courseOrgId !== automationKey.organizationId) { + return c.json( + { + success: false, + error: 'Invalid permissions', + code: ErrorCodes.UNAUTHORIZED + }, + 403 + ); + } + + c.set('orgId', automationKey.organizationId); + c.set('actorId', automationKey.createdByProfileId); + + return next(); + } catch (error) { + console.error('Error in courseMemberOrAutomationKeyMiddleware:', error); + return c.json( + { + success: false, + error: 'Failed to verify course access', + code: 'COURSE_MEMBER_CHECK_FAILED' + }, + 500 + ); + } + }; diff --git a/apps/api/src/middlewares/course-team-member-or-automation-key.ts b/apps/api/src/middlewares/course-team-member-or-automation-key.ts new file mode 100644 index 00000000..5172e073 --- /dev/null +++ b/apps/api/src/middlewares/course-team-member-or-automation-key.ts @@ -0,0 +1,80 @@ +import { Context, Next } from 'hono'; +import type { TOrganizationApiKeyScope } from '@cio/utils/validation/organization'; + +import { ErrorCodes } from '@api/utils/errors'; +import { organizationApiKeyHasScopes } from '@api/services/organization/automation-key'; +import { getCourseOrganizationId } from '@cio/db/queries/tag'; +import { courseTeamMemberMiddleware } from './course-team-member'; + +export const courseTeamMemberOrAutomationKeyMiddleware = + (requiredScopes: readonly TOrganizationApiKeyScope[] = []) => + async (c: Context, next: Next) => { + try { + const automationKey = c.get('automationKey'); + + if (!automationKey) { + return courseTeamMemberMiddleware(c, next); + } + + if (!organizationApiKeyHasScopes(automationKey.scopes ?? [], requiredScopes)) { + return c.json( + { + success: false, + error: 'Automation key is missing required scopes', + code: ErrorCodes.FORBIDDEN + }, + 403 + ); + } + + const courseId = c.req.param('courseId') || c.req.query('courseId'); + if (!courseId) { + return c.json( + { + success: false, + error: 'Course ID is required', + code: 'COURSE_ID_REQUIRED' + }, + 400 + ); + } + + const courseOrgId = await getCourseOrganizationId(courseId); + if (!courseOrgId) { + return c.json( + { + success: false, + error: 'Course not found', + code: ErrorCodes.COURSE_NOT_FOUND + }, + 404 + ); + } + + if (courseOrgId !== automationKey.organizationId) { + return c.json( + { + success: false, + error: 'Invalid permissions', + code: ErrorCodes.UNAUTHORIZED + }, + 403 + ); + } + + c.set('orgId', automationKey.organizationId); + c.set('actorId', automationKey.createdByProfileId); + + return next(); + } catch (error) { + console.error('Error in courseTeamMemberOrAutomationKeyMiddleware:', error); + return c.json( + { + success: false, + error: 'Failed to verify course team access', + code: 'COURSE_TEAM_MEMBER_CHECK_FAILED' + }, + 500 + ); + } + }; diff --git a/apps/api/src/middlewares/org-member-or-automation-key.ts b/apps/api/src/middlewares/org-member-or-automation-key.ts new file mode 100644 index 00000000..71bfb303 --- /dev/null +++ b/apps/api/src/middlewares/org-member-or-automation-key.ts @@ -0,0 +1,45 @@ +import { Context, Next } from 'hono'; +import type { TOrganizationApiKeyScope } from '@cio/utils/validation/organization'; + +import { ErrorCodes } from '@api/utils/errors'; +import { organizationApiKeyHasScopes } from '@api/services/organization/automation-key'; +import { orgMemberMiddleware } from './org-member'; + +export const orgMemberOrAutomationKeyMiddleware = + (requiredScopes: readonly TOrganizationApiKeyScope[] = []) => + async (c: Context, next: Next) => { + try { + const automationKey = c.get('automationKey'); + + if (!automationKey) { + return orgMemberMiddleware(c, next); + } + + if (!organizationApiKeyHasScopes(automationKey.scopes ?? [], requiredScopes)) { + return c.json( + { + success: false, + error: 'Automation key is missing required scopes', + code: ErrorCodes.FORBIDDEN + }, + 403 + ); + } + + c.set('orgId', automationKey.organizationId); + c.set('userRole', null); + c.set('actorId', automationKey.createdByProfileId); + + return next(); + } catch (error) { + console.error('Error in orgMemberOrAutomationKeyMiddleware:', error); + return c.json( + { + success: false, + error: 'Failed to verify organization membership', + code: 'ORG_MEMBER_CHECK_FAILED' + }, + 500 + ); + } + }; diff --git a/apps/api/src/routes/course/content.ts b/apps/api/src/routes/course/content.ts index 616a7d0b..a9b60dae 100644 --- a/apps/api/src/routes/course/content.ts +++ b/apps/api/src/routes/course/content.ts @@ -1,13 +1,47 @@ -import { ZCourseContentDelete, ZCourseContentUpdate } from '@cio/utils/validation/course'; -import { deleteCourseContent, updateCourseContent } from '@api/services/course/content'; +import { ZCourseContentDelete, ZCourseContentReorder, ZCourseContentUpdate } from '@cio/utils/validation/course'; +import { deleteCourseContent, reorderCourseContent, updateCourseContent } from '@api/services/course/content'; import { Hono } from '@api/utils/hono'; +import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key'; import { authMiddleware } from '@api/middlewares/auth'; +import { courseTeamMemberOrAutomationKeyMiddleware } from '@api/middlewares/course-team-member-or-automation-key'; import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member'; +import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage'; import { handleError } from '@api/utils/errors'; import { zValidator } from '@hono/zod-validator'; export const contentRouter = new Hono() + .put( + '/reorder', + authOrAutomationKeyMiddleware, + courseTeamMemberOrAutomationKeyMiddleware(['course:write']), + zValidator('json', ZCourseContentReorder), + async (c) => { + try { + const courseId = c.req.param('courseId')!; + const automationKey = c.get('automationKey'); + const payload = c.req.valid('json'); + + if (automationKey?.type === 'mcp') { + await assertMcpAutomationUsageAllowed(automationKey, 'reorder_course_content'); + } + + const result = await reorderCourseContent(courseId, payload); + + if (automationKey?.type === 'mcp') { + await recordMcpAutomationUsage(automationKey, 'reorder_course_content', { + courseId, + sectionCount: payload.sections?.length ?? 0, + itemCount: payload.items?.length ?? 0 + }); + } + + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to reorder course content'); + } + } + ) .put('/', authMiddleware, courseTeamMemberMiddleware, zValidator('json', ZCourseContentUpdate), async (c) => { try { const courseId = c.req.param('courseId')!; diff --git a/apps/api/src/routes/course/course.ts b/apps/api/src/routes/course/course.ts index 274e6ada..f2aeff60 100644 --- a/apps/api/src/routes/course/course.ts +++ b/apps/api/src/routes/course/course.ts @@ -11,6 +11,7 @@ import { ZCourseGetBySlugParam, ZCourseGetParam, ZCourseGetQuery, + ZCourseLandingPageUpdate, ZCourseProgressParam, ZCourseProgressQuery, ZCourseUpdate, @@ -30,9 +31,11 @@ import { getCourseTags, replaceCourseTags } from '@api/services/tag'; import { Hono } from '@api/utils/hono'; import { attendanceRouter } from '@api/routes/course/attendance'; import { authMiddleware } from '@api/middlewares/auth'; +import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key'; import { cloneCourse } from '@api/services/course/clone'; import { contentRouter } from '@api/routes/course/content'; import { courseMemberMiddleware } from '@api/middlewares/course-member'; +import { courseTeamMemberOrAutomationKeyMiddleware } from '@api/middlewares/course-team-member-or-automation-key'; import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member'; import { createRateLimiter } from '@api/middlewares/rate-limiter'; import { enrollInCourse } from '@api/services/course/invite'; @@ -51,8 +54,10 @@ import { orgAdminMiddleware } from '@api/middlewares/org-admin'; import { orgMemberMiddleware } from '@api/middlewares/org-member'; import { paymentRequestRouter } from '@api/routes/course/payment-request'; import { presignRouter } from '@api/routes/course/presign'; +import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage'; import { sectionRouter } from '@api/routes/course/section'; import { submissionRouter } from '@api/routes/course/submission'; +import { updateCourseLandingPageService } from '@api/services/course/landing-page'; import { zValidator } from '@hono/zod-validator'; const enrollRateLimit = createRateLimiter({ @@ -233,6 +238,45 @@ export const courseRouter = new Hono() } } ) + /** + * PUT /course/:courseId/landing-page + * Updates landing-page-facing course fields such as copy, media, pricing, and reviews. + * Requires authentication and course team membership, or an automation key with course write scope. + */ + .put( + '/:courseId/landing-page', + authOrAutomationKeyMiddleware, + courseTeamMemberOrAutomationKeyMiddleware(['course:write']), + zValidator('param', ZCourseUpdateParam), + zValidator('json', ZCourseLandingPageUpdate), + async (c) => { + try { + const { courseId } = c.req.valid('param'); + const payload = c.req.valid('json'); + const automationKey = c.get('automationKey'); + + if (automationKey?.type === 'mcp') { + await assertMcpAutomationUsageAllowed(automationKey, 'update_course_landing_page'); + } + + const result = await updateCourseLandingPageService(courseId, payload); + + if (automationKey?.type === 'mcp') { + await recordMcpAutomationUsage(automationKey, 'update_course_landing_page', { courseId }); + } + + return c.json( + { + success: true, + data: result + }, + 200 + ); + } catch (error) { + return handleError(c, error, 'Failed to update course landing page'); + } + } + ) /** * PUT /course/:courseId * Updates a course diff --git a/apps/api/src/routes/course/exercise.ts b/apps/api/src/routes/course/exercise.ts index 0ca79173..be37e40f 100644 --- a/apps/api/src/routes/course/exercise.ts +++ b/apps/api/src/routes/course/exercise.ts @@ -21,24 +21,43 @@ import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from import { Hono } from '@api/utils/hono'; import { authMiddleware } from '@api/middlewares/auth'; +import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key'; import { courseMemberMiddleware } from '@api/middlewares/course-member'; +import { courseMemberOrAutomationKeyMiddleware } from '@api/middlewares/course-member-or-automation-key'; +import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage'; import { createSubmissionService, listExerciseSubmissionsOverview } from '@api/services/submission'; import { zValidator } from '@hono/zod-validator'; export const exerciseRouter = new Hono() // Exercise CRUD routes - .get('/', authMiddleware, courseMemberMiddleware, zValidator('query', ZExerciseListQuery), async (c) => { - try { - const courseId = c.req.param('courseId')!; - const user = c.get('user')!; - const { lessonId, sectionId } = c.req.valid('query'); - const exercises = await listExercises(courseId, { lessonId, sectionId }, user.id); + .get( + '/', + authOrAutomationKeyMiddleware, + courseMemberOrAutomationKeyMiddleware(['course:exercise:read']), + zValidator('query', ZExerciseListQuery), + async (c) => { + try { + const courseId = c.req.param('courseId')!; + const user = c.get('user'); + const automationKey = c.get('automationKey'); + const { lessonId, sectionId } = c.req.valid('query'); - return c.json({ success: true, data: exercises }, 200); - } catch (error) { - return handleError(c, error, 'Failed to list exercises'); + if (automationKey?.type === 'mcp') { + await assertMcpAutomationUsageAllowed(automationKey, 'list_course_exercises'); + } + + const exercises = await listExercises(courseId, { lessonId, sectionId }, user?.id); + + if (automationKey?.type === 'mcp') { + await recordMcpAutomationUsage(automationKey, 'list_course_exercises', { courseId, lessonId, sectionId }); + } + + return c.json({ success: true, data: exercises }, 200); + } catch (error) { + return handleError(c, error, 'Failed to list exercises'); + } } - }) + ) .get('/:exerciseId/submissions', authMiddleware, courseMemberMiddleware, async (c) => { try { const courseId = c.req.param('courseId')!; @@ -51,28 +70,65 @@ export const exerciseRouter = new Hono() return handleError(c, error, 'Failed to fetch exercise submissions overview'); } }) - .get('/:exerciseId', authMiddleware, courseMemberMiddleware, zValidator('param', ZExerciseGetParam), async (c) => { - try { - const { exerciseId } = c.req.valid('param'); - const user = c.get('user')!; - const exercise = await getExercise(exerciseId, undefined, user.id); + .get( + '/:exerciseId', + authOrAutomationKeyMiddleware, + courseMemberOrAutomationKeyMiddleware(['course:exercise:read']), + zValidator('param', ZExerciseGetParam), + async (c) => { + try { + const { exerciseId } = c.req.valid('param'); + const user = c.get('user'); + const automationKey = c.get('automationKey'); - return c.json({ success: true, data: exercise }, 200); - } catch (error) { - return handleError(c, error, 'Failed to fetch exercise'); + if (automationKey?.type === 'mcp') { + await assertMcpAutomationUsageAllowed(automationKey, 'get_course_exercise'); + } + + const exercise = await getExercise(exerciseId, undefined, user?.id); + + if (automationKey?.type === 'mcp') { + await recordMcpAutomationUsage(automationKey, 'get_course_exercise', { + courseId: c.req.param('courseId')!, + exerciseId + }); + } + + return c.json({ success: true, data: exercise }, 200); + } catch (error) { + return handleError(c, error, 'Failed to fetch exercise'); + } } - }) - .post('/', authMiddleware, courseMemberMiddleware, zValidator('json', ZExerciseCreate), async (c) => { - try { - const data = c.req.valid('json'); + ) + .post( + '/', + authOrAutomationKeyMiddleware, + courseMemberOrAutomationKeyMiddleware(['course:exercise:write']), + zValidator('json', ZExerciseCreate), + async (c) => { + try { + const data = c.req.valid('json'); + const automationKey = c.get('automationKey'); - const exercise = await createExercise(data); + if (automationKey?.type === 'mcp') { + await assertMcpAutomationUsageAllowed(automationKey, 'create_course_exercise'); + } - return c.json({ success: true, data: exercise }, 201); - } catch (error) { - return handleError(c, error, 'Failed to create exercise'); + const exercise = await createExercise(data); + + if (automationKey?.type === 'mcp') { + await recordMcpAutomationUsage(automationKey, 'create_course_exercise', { + courseId: c.req.param('courseId')!, + exerciseId: exercise.id + }); + } + + return c.json({ success: true, data: exercise }, 201); + } catch (error) { + return handleError(c, error, 'Failed to create exercise'); + } } - }) + ) /** * POST /course/:courseId/exercise/from-template * Creates an exercise from a template @@ -80,14 +136,19 @@ export const exerciseRouter = new Hono() */ .post( '/from-template', - authMiddleware, - courseMemberMiddleware, + authOrAutomationKeyMiddleware, + courseMemberOrAutomationKeyMiddleware(['course:exercise:write']), zValidator('json', ZExerciseFromTemplate), async (c) => { try { const courseId = c.req.param('courseId')!; + const automationKey = c.get('automationKey'); const { lessonId, sectionId, order, templateId } = c.req.valid('json'); + if (automationKey?.type === 'mcp') { + await assertMcpAutomationUsageAllowed(automationKey, 'create_course_exercise_from_template'); + } + // Fetch template from database const template = await fetchTemplateById(templateId); if (!template) { @@ -100,6 +161,14 @@ export const exerciseRouter = new Hono() const exercise = await createExerciseFromTemplate(courseId, lessonId, sectionId, order, template); + if (automationKey?.type === 'mcp') { + await recordMcpAutomationUsage(automationKey, 'create_course_exercise_from_template', { + courseId, + exerciseId: exercise.id, + templateId + }); + } + return c.json({ success: true, data: exercise }, 201); } catch (error) { return handleError(c, error, 'Failed to create exercise from template'); @@ -108,8 +177,8 @@ export const exerciseRouter = new Hono() ) .put( '/:exerciseId', - authMiddleware, - courseMemberMiddleware, + authOrAutomationKeyMiddleware, + courseMemberOrAutomationKeyMiddleware(['course:exercise:write']), zValidator('param', ZExerciseGetParam), zValidator('json', ZExerciseUpdate), async (c) => { @@ -117,9 +186,14 @@ export const exerciseRouter = new Hono() const { exerciseId } = c.req.valid('param'); const data = c.req.valid('json'); const courseId = c.req.param('courseId')!; - const user = c.get('user')!; + const user = c.get('user'); + const automationKey = c.get('automationKey'); - if (data.isUnlocked !== undefined) { + if (automationKey?.type === 'mcp') { + await assertMcpAutomationUsageAllowed(automationKey, 'update_course_exercise'); + } + + if (!automationKey && user && data.isUnlocked !== undefined) { const isAuthorized = await isCourseTeamMemberOrOrgAdmin(courseId, user.id); if (!isAuthorized) { @@ -136,6 +210,10 @@ export const exerciseRouter = new Hono() const exercise = await updateExerciseService(exerciseId, data); + if (automationKey?.type === 'mcp') { + await recordMcpAutomationUsage(automationKey, 'update_course_exercise', { courseId, exerciseId }); + } + return c.json({ success: true, data: exercise }, 200); } catch (error) { return handleError(c, error, 'Failed to update exercise'); diff --git a/apps/api/src/routes/organization/automation.ts b/apps/api/src/routes/organization/automation.ts index bc567d45..da75ced7 100644 --- a/apps/api/src/routes/organization/automation.ts +++ b/apps/api/src/routes/organization/automation.ts @@ -1,5 +1,6 @@ import { ZCreateOrganizationApiKey, + ZOrganizationAutomationUsageQuery, ZListOrganizationApiKeysQuery, ZOrganizationApiKeyParam } from '@cio/utils/validation/organization'; @@ -9,6 +10,7 @@ import { revokeOrganizationApiKeyService, rotateOrganizationApiKeyService } from '@api/services/organization/automation-key'; +import { getOrganizationAutomationUsageSummaryService } from '@api/services/organization/automation-usage'; import { Hono } from '@api/utils/hono'; import { authMiddleware } from '@api/middlewares/auth'; @@ -17,6 +19,23 @@ import { orgAdminMiddleware } from '@api/middlewares/org-admin'; import { zValidator } from '@hono/zod-validator'; export const automationRouter = new Hono() + .get( + '/usage', + authMiddleware, + orgAdminMiddleware, + zValidator('query', ZOrganizationAutomationUsageQuery), + async (c) => { + try { + const orgId = c.req.header('cio-org-id')!; + const query = c.req.valid('query'); + const usage = await getOrganizationAutomationUsageSummaryService(orgId, query.type); + + return c.json({ success: true, data: usage }, 200); + } catch (error) { + return handleError(c, error, 'Failed to load organization automation usage'); + } + } + ) .get('/keys', authMiddleware, orgAdminMiddleware, zValidator('query', ZListOrganizationApiKeysQuery), async (c) => { try { const orgId = c.req.header('cio-org-id')!; diff --git a/apps/api/src/routes/organization/course-import.ts b/apps/api/src/routes/organization/course-import.ts index 4df2bb29..ea5c5569 100644 --- a/apps/api/src/routes/organization/course-import.ts +++ b/apps/api/src/routes/organization/course-import.ts @@ -1,15 +1,24 @@ +import { ZAutomationDraftTagAssignment, ZAutomationDraftTagParam } from '@cio/utils/validation/tag'; import { + ZCourseImportCourseParam, ZCourseImportDraftCreate, + ZCourseImportDraftCreateFromCourse, ZCourseImportDraftGetParam, ZCourseImportDraftPublish, + ZCourseImportDraftPublishToCourse, ZCourseImportDraftUpdate } from '@cio/utils/validation/course-import'; import { + createCourseImportDraftFromCourseService, createCourseImportDraftService, getCourseImportDraftService, + getCourseImportStructureService, publishCourseImportDraftService, + publishCourseImportDraftToExistingCourseService, + updateCourseImportDraftTagsService, updateCourseImportDraftService } from '@api/services/course-import/course-import'; +import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage'; import { Hono } from '@api/utils/hono'; import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key'; @@ -18,6 +27,33 @@ import { orgAdminOrAutomationKeyMiddleware } from '@api/middlewares/org-admin-or import { zValidator } from '@hono/zod-validator'; export const courseImportRouter = new Hono() + .get( + '/courses/:courseId/structure', + authOrAutomationKeyMiddleware, + orgAdminOrAutomationKeyMiddleware(['course_import:draft:read']), + zValidator('param', ZCourseImportCourseParam), + async (c) => { + try { + const orgId = c.get('orgId')!; + const automationKey = c.get('automationKey'); + const { courseId } = c.req.valid('param'); + + if (automationKey?.type === 'mcp') { + await assertMcpAutomationUsageAllowed(automationKey, 'get_course_structure'); + } + + const structure = await getCourseImportStructureService(orgId, courseId); + + if (automationKey?.type === 'mcp') { + await recordMcpAutomationUsage(automationKey, 'get_course_structure', { courseId }); + } + + return c.json({ success: true, data: structure }, 200); + } catch (error) { + return handleError(c, error, 'Failed to fetch course structure'); + } + } + ) .post( '/drafts', authOrAutomationKeyMiddleware, @@ -27,16 +63,56 @@ export const courseImportRouter = new Hono() try { const orgId = c.get('orgId')!; const actorId = c.get('actorId')!; + const automationKey = c.get('automationKey'); const payload = c.req.valid('json'); + if (automationKey?.type === 'mcp') { + await assertMcpAutomationUsageAllowed(automationKey, 'create_course_draft'); + } + const draft = await createCourseImportDraftService(orgId, actorId, payload); + if (automationKey?.type === 'mcp') { + await recordMcpAutomationUsage(automationKey, 'create_course_draft', { draftId: draft.id }); + } + return c.json({ success: true, data: draft }, 201); } catch (error) { return handleError(c, error, 'Failed to create course import draft'); } } ) + .post( + '/drafts/from-course', + authOrAutomationKeyMiddleware, + orgAdminOrAutomationKeyMiddleware(['course_import:draft:create']), + zValidator('json', ZCourseImportDraftCreateFromCourse), + async (c) => { + try { + const orgId = c.get('orgId')!; + const actorId = c.get('actorId')!; + const automationKey = c.get('automationKey'); + const payload = c.req.valid('json'); + + if (automationKey?.type === 'mcp') { + await assertMcpAutomationUsageAllowed(automationKey, 'create_course_draft_from_course'); + } + + const draft = await createCourseImportDraftFromCourseService(orgId, actorId, payload); + + if (automationKey?.type === 'mcp') { + await recordMcpAutomationUsage(automationKey, 'create_course_draft_from_course', { + draftId: draft.id, + courseId: payload.courseId + }); + } + + return c.json({ success: true, data: draft }, 201); + } catch (error) { + return handleError(c, error, 'Failed to create course draft from existing course'); + } + } + ) .get( '/drafts/:draftId', authOrAutomationKeyMiddleware, @@ -45,16 +121,54 @@ export const courseImportRouter = new Hono() async (c) => { try { const orgId = c.get('orgId')!; + const automationKey = c.get('automationKey'); const { draftId } = c.req.valid('param'); + if (automationKey?.type === 'mcp') { + await assertMcpAutomationUsageAllowed(automationKey, 'get_course_draft'); + } + const draft = await getCourseImportDraftService(orgId, draftId); + if (automationKey?.type === 'mcp') { + await recordMcpAutomationUsage(automationKey, 'get_course_draft', { draftId }); + } + return c.json({ success: true, data: draft }, 200); } catch (error) { return handleError(c, error, 'Failed to fetch course import draft'); } } ) + .put( + '/drafts/:draftId/tags', + authOrAutomationKeyMiddleware, + orgAdminOrAutomationKeyMiddleware(['course_import:draft:update']), + zValidator('param', ZAutomationDraftTagParam), + zValidator('json', ZAutomationDraftTagAssignment), + async (c) => { + try { + const orgId = c.get('orgId')!; + const automationKey = c.get('automationKey'); + const { draftId } = c.req.valid('param'); + const payload = c.req.valid('json'); + + if (automationKey?.type === 'mcp') { + await assertMcpAutomationUsageAllowed(automationKey, 'tag_course_draft'); + } + + const draft = await updateCourseImportDraftTagsService(orgId, draftId, payload.tagNames, payload.mode); + + if (automationKey?.type === 'mcp') { + await recordMcpAutomationUsage(automationKey, 'tag_course_draft', { draftId }); + } + + return c.json({ success: true, data: draft }, 200); + } catch (error) { + return handleError(c, error, 'Failed to update course draft tags'); + } + } + ) .put( '/drafts/:draftId', authOrAutomationKeyMiddleware, @@ -64,11 +178,20 @@ export const courseImportRouter = new Hono() async (c) => { try { const orgId = c.get('orgId')!; + const automationKey = c.get('automationKey'); const { draftId } = c.req.valid('param'); const payload = c.req.valid('json'); + if (automationKey?.type === 'mcp') { + await assertMcpAutomationUsageAllowed(automationKey, 'update_course_draft'); + } + const draft = await updateCourseImportDraftService(orgId, draftId, payload); + if (automationKey?.type === 'mcp') { + await recordMcpAutomationUsage(automationKey, 'update_course_draft', { draftId }); + } + return c.json({ success: true, data: draft }, 200); } catch (error) { return handleError(c, error, 'Failed to update course import draft'); @@ -85,14 +208,58 @@ export const courseImportRouter = new Hono() try { const orgId = c.get('orgId')!; const actorId = c.get('actorId')!; + const automationKey = c.get('automationKey'); const { draftId } = c.req.valid('param'); const payload = c.req.valid('json'); + if (automationKey?.type === 'mcp') { + await assertMcpAutomationUsageAllowed(automationKey, 'publish_course_draft'); + } + const result = await publishCourseImportDraftService(orgId, actorId, draftId, payload); + if (automationKey?.type === 'mcp') { + await recordMcpAutomationUsage(automationKey, 'publish_course_draft', { + draftId, + courseId: result.courseId + }); + } + return c.json({ success: true, data: result }, 201); } catch (error) { return handleError(c, error, 'Failed to publish course import draft'); } } + ) + .post( + '/drafts/:draftId/publish-existing-course', + authOrAutomationKeyMiddleware, + orgAdminOrAutomationKeyMiddleware(['course_import:draft:publish']), + zValidator('param', ZCourseImportDraftGetParam), + zValidator('json', ZCourseImportDraftPublishToCourse), + async (c) => { + try { + const orgId = c.get('orgId')!; + const automationKey = c.get('automationKey'); + const { draftId } = c.req.valid('param'); + const payload = c.req.valid('json'); + + if (automationKey?.type === 'mcp') { + await assertMcpAutomationUsageAllowed(automationKey, 'publish_course_draft_to_existing_course'); + } + + const result = await publishCourseImportDraftToExistingCourseService(orgId, draftId, payload); + + if (automationKey?.type === 'mcp') { + await recordMcpAutomationUsage(automationKey, 'publish_course_draft_to_existing_course', { + draftId, + courseId: payload.courseId + }); + } + + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to publish course draft to existing course'); + } + } ); diff --git a/apps/api/src/routes/organization/organization.ts b/apps/api/src/routes/organization/organization.ts index 93c7e5fa..698b4a33 100644 --- a/apps/api/src/routes/organization/organization.ts +++ b/apps/api/src/routes/organization/organization.ts @@ -37,15 +37,19 @@ import { TOrganization } from '@db/types'; import { automationRouter } from '@api/routes/organization/automation'; import { assetsRouter } from '@api/routes/organization/assets'; import { authMiddleware } from '@api/middlewares/auth'; +import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key'; import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key'; +import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage'; import { courseImportRouter } from '@api/routes/organization/course-import'; import { getLMSExercisesService } from '@api/services/exercise'; import { handleError } from '@api/utils/errors'; import { inviteTeamMembers } from '@api/services/organization/invite'; import { orgAdminMiddleware } from '@api/middlewares/org-admin'; import { orgMemberMiddleware } from '@api/middlewares/org-member'; +import { orgMemberOrAutomationKeyMiddleware } from '@api/middlewares/org-member-or-automation-key'; import { quizRouter } from '@api/routes/organization/quiz'; import { tagsRouter } from '@api/routes/organization/tags'; +import { ROLE } from '@cio/utils/constants'; import { zValidator } from '@hono/zod-validator'; export const organizationRouter = new Hono() @@ -287,17 +291,18 @@ export const organizationRouter = new Hono() */ .get( '/courses', - authMiddleware, - orgMemberMiddleware, + authOrAutomationKeyMiddleware, + orgMemberOrAutomationKeyMiddleware(['course:read']), zValidator('query', ZGetOrganizationCoursesQuery), async (c) => { try { - const user = c.get('user')!; + const user = c.get('user'); const orgId = c.get('orgId'); const userRole = c.get('userRole'); + const automationKey = c.get('automationKey'); const { tags } = c.req.valid('query'); - if (!orgId || userRole === null) { + if (!orgId) { return c.json( { success: false, @@ -308,22 +313,25 @@ export const organizationRouter = new Hono() ); } + if (automationKey?.type === 'mcp') { + await assertMcpAutomationUsageAllowed(automationKey, 'list_org_courses'); + } + const tagSlugs = tags ?.split(',') .map((value) => value.trim()) .filter(Boolean); - - console.log('user', user); - console.log('userRole', userRole); - console.log('orgId', orgId); - console.log('tagSlugs', tagSlugs); const result = await getOrganizationCourses( orgId, - user.id, - userRole, + user?.id ?? automationKey?.createdByProfileId ?? '', + userRole ?? ROLE.ADMIN, tagSlugs && tagSlugs.length > 0 ? tagSlugs : undefined ); + if (automationKey?.type === 'mcp') { + await recordMcpAutomationUsage(automationKey, 'list_org_courses', { tags: tagSlugs }); + } + return c.json( { success: true, diff --git a/apps/api/src/routes/organization/tags.ts b/apps/api/src/routes/organization/tags.ts index c3048e2a..9477c5fc 100644 --- a/apps/api/src/routes/organization/tags.ts +++ b/apps/api/src/routes/organization/tags.ts @@ -1,4 +1,5 @@ import { + ZAutomationCourseTagAssignment, ZTagCreate, ZTagGroupCreate, ZTagGroupParam, @@ -7,6 +8,7 @@ import { ZTagUpdate } from '@cio/utils/validation/tag'; import { + assignTagsToCoursesByName, createOrganizationTag, createOrganizationTagGroup, getPublicOrganizationTagGroups, @@ -18,8 +20,11 @@ import { ZGetCoursesBySiteName } from '@cio/utils/validation/organization'; import { Hono } from '@api/utils/hono'; import { authMiddleware } from '@api/middlewares/auth'; +import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key'; import { handleError } from '@api/utils/errors'; import { orgAdminMiddleware } from '@api/middlewares/org-admin'; +import { orgAdminOrAutomationKeyMiddleware } from '@api/middlewares/org-admin-or-automation-key'; +import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage'; import { zValidator } from '@hono/zod-validator'; export const tagsRouter = new Hono() @@ -72,6 +77,39 @@ export const tagsRouter = new Hono() return handleError(c, error, 'Failed to create tag group'); } }) + .put( + '/courses/assign', + authOrAutomationKeyMiddleware, + orgAdminOrAutomationKeyMiddleware(['course:tag:write']), + zValidator('json', ZAutomationCourseTagAssignment), + async (c) => { + try { + const orgId = c.get('orgId')!; + const automationKey = c.get('automationKey'); + const data = c.req.valid('json'); + + if (automationKey?.type === 'mcp') { + await assertMcpAutomationUsageAllowed(automationKey, 'tag_courses'); + } + + const result = await assignTagsToCoursesByName(orgId, data); + + if (automationKey?.type === 'mcp') { + await recordMcpAutomationUsage(automationKey, 'tag_courses', { courseIds: data.courseIds }); + } + + return c.json( + { + success: true, + data: result + }, + 200 + ); + } catch (error) { + return handleError(c, error, 'Failed to assign course tags'); + } + } + ) .put( '/groups/:groupId', authMiddleware, diff --git a/apps/api/src/services/course-import/course-import.ts b/apps/api/src/services/course-import/course-import.ts index 2112ee93..51946c9f 100644 --- a/apps/api/src/services/course-import/course-import.ts +++ b/apps/api/src/services/course-import/course-import.ts @@ -1,22 +1,37 @@ import { AppError, ErrorCodes } from '@api/utils/errors'; +import { env } from '@api/config/env'; +import { getDashboardBaseUrl } from '@api/config/dashboard-url'; +import { createCourse, updateCourse } from '@api/services/course/course'; +import { createCourseSection, listCourseSections, updateCourseSectionService } from '@api/services/course/section'; +import { createLesson, listLessons, updateLessonService } from '@api/services/lesson'; +import { upsertLessonLanguageService, updateLessonLanguageService } from '@api/services/lesson-language'; import { createCourseImportDraft, getCourseImportDraftById, getCourseImportDraftByIdempotencyKey, updateCourseImportDraft } from '@cio/db/queries/course-import'; -import type { TCourseImportDraft, TLocale } from '@db/types'; -import type { - TCourseImportDraftCreate, - TCourseImportDraftPayload, - TCourseImportDraftPublish, - TCourseImportDraftUpdate +import { getCourseById } from '@cio/db/queries/course'; +import { getLessonLanguagesByLessonIds } from '@cio/db/queries/lesson'; +import { getOrganizationById } from '@cio/db/queries/organization'; +import { getCourseTagsForOrganization } from '@cio/db/queries/tag'; +import type { TCourse, TCourseImportDraft, TLessonLanguage, TLocale, TOrganization } from '@db/types'; +import { + type TCourseImportDraftCreate, + type TCourseImportDraftCreateFromCourse, + type TCourseImportDraftPayload, + type TCourseImportDraftPublish, + type TCourseImportDraftPublishToCourse, + type TCourseImportDraftUpdate, + ZCourseImportDraftPayload, + ZCourseImportWarning } from '@cio/utils/validation/course-import'; -import { ZCourseImportDraftPayload } from '@cio/utils/validation/course-import'; -import { createCourse, updateCourse } from '@api/services/course/course'; -import { createCourseSection } from '@api/services/course/section'; -import { createLesson } from '@api/services/lesson'; -import { upsertLessonLanguageService } from '@api/services/lesson-language'; +import { getCourseOrganizationId } from '@cio/db/queries/tag'; +import { assignCourseTagsByName } from '@api/services/tag'; +import { db } from '@cio/db/drizzle'; +import * as schema from '@cio/db/schema'; +import { generateSlug } from '@cio/utils/functions'; +import { eq } from 'drizzle-orm'; type DraftSummary = { sectionCount: number; @@ -25,6 +40,30 @@ type DraftSummary = { warningCount: number; }; +type CourseStructureSnapshot = { + courseId: string; + draft: TCourseImportDraftPayload; +}; + +type PublishDraftResult = { + courseId: string; + courseUrl: string; + bannerImageUrl: string | null; + tagNames: string[]; + createdSections: number; + createdLessons: number; + localeCount: number; +}; + +type PublishDraftToExistingCourseResult = PublishDraftResult & { + updatedSections: number; + updatedLessons: number; + createdLessonLanguages: number; + updatedLessonLanguages: number; + untouchedSections: number; + untouchedLessons: number; +}; + function buildDraftSummary(draft: TCourseImportDraftPayload): DraftSummary { return { sectionCount: draft.sections.length, @@ -34,10 +73,314 @@ function buildDraftSummary(draft: TCourseImportDraftPayload): DraftSummary { }; } +async function generateUniqueCourseSlug(baseSlug: string): Promise { + let suffix = 0; + let candidate = baseSlug; + + while (true) { + const existing = await db + .select({ id: schema.course.id }) + .from(schema.course) + .where(eq(schema.course.slug, candidate)) + .limit(1); + + if (existing.length === 0) { + return candidate; + } + + suffix += 1; + candidate = `${baseSlug}-${suffix}`; + } +} + +async function ensureCourseSlug(courseId: string, title: string | null | undefined): Promise { + const [course] = await getCourseById(courseId); + if (course?.slug) { + return course.slug; + } + + const baseSlug = generateSlug(title, { fallback: 'course' }); + const uniqueSlug = await generateUniqueCourseSlug(baseSlug); + await updateCourse(courseId, { slug: uniqueSlug }); + return uniqueSlug; +} + +function buildCourseBaseUrl(organization: TOrganization) { + if (organization.customDomain && organization.isCustomDomainVerified) { + return `https://${organization.customDomain}`; + } + + return getDashboardBaseUrl(organization.siteName ?? undefined); +} + +async function resolveCourseUrl(organizationId: string, courseId: string, title: string) { + const organization = await getOrganizationById(organizationId); + if (!organization) { + throw new AppError('Organization not found', ErrorCodes.ORG_NOT_FOUND, 404); + } + + const slug = await ensureCourseSlug(courseId, title); + return `${buildCourseBaseUrl(organization)}/course/${encodeURIComponent(slug)}`; +} + +async function resolveUnsplashBannerImage(courseTitle: string, query?: string) { + if (!env.UNSPLASH_API_KEY) { + return null; + } + + const searchQuery = (query?.trim() || courseTitle.trim() || 'education').slice(0, 120); + const response = await fetch( + `https://api.unsplash.com/search/photos?page=1&per_page=15&auto=format&fit=crop&w=2970&q=80&client_id=${env.UNSPLASH_API_KEY}&query=${encodeURIComponent(searchQuery)}`, + { method: 'GET' } + ); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as { + results?: Array<{ + urls?: { + regular?: string; + }; + }>; + }; + + const photos = data.results ?? []; + if (photos.length === 0) { + return null; + } + + const photo = photos[Math.floor(Math.random() * photos.length)]; + return photo?.urls?.regular ?? null; +} + +async function resolvePublishBannerImage( + title: string, + overrides: Pick +) { + if (overrides.bannerImageUrl) { + return overrides.bannerImageUrl; + } + + if (!overrides.generateBannerImage && !overrides.bannerImageQuery) { + return null; + } + + try { + return await resolveUnsplashBannerImage(title, overrides.bannerImageQuery); + } catch (error) { + console.error('resolvePublishBannerImage error:', error); + return null; + } +} + +async function maybeApplyCourseBannerImage(courseId: string, imageUrl: string | null) { + if (!imageUrl) { + return null; + } + + await updateCourse(courseId, { + logo: imageUrl, + bannerImage: imageUrl + }); + + return imageUrl; +} + +function normalizeDraftTagNames(tagNames: string[]) { + return Array.from(new Map(tagNames.map((tag) => [tag.trim().toLowerCase(), tag.trim()])).values()).filter(Boolean); +} + function parseStoredDraft(record: TCourseImportDraft): TCourseImportDraftPayload { return ZCourseImportDraftPayload.parse(record.draft); } +async function assertCourseBelongsToOrganization(orgId: string, courseId: string) { + const courseOrgId = await getCourseOrganizationId(courseId); + + if (!courseOrgId) { + throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404); + } + + if (courseOrgId !== orgId) { + throw new AppError('Invalid permissions', ErrorCodes.UNAUTHORIZED, 403); + } +} + +async function getCourseRecord(orgId: string, courseId: string): Promise { + await assertCourseBelongsToOrganization(orgId, courseId); + + const [course] = await getCourseById(courseId); + if (!course) { + throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404); + } + + return course; +} + +function inferDraftLocale(languages: TLessonLanguage[]): TLocale { + const locale = languages[0]?.locale; + return (locale ?? 'en') as TLocale; +} + +function getPrimaryLessonContent( + lessonExternalId: string, + lessonLanguages: TCourseImportDraftPayload['lessonLanguages'], + preferredLocale: TLocale +) { + const localized = lessonLanguages.find( + (lessonLanguage) => + lessonLanguage.lessonExternalId === lessonExternalId && lessonLanguage.locale === preferredLocale + ); + + if (localized) { + return localized.content; + } + + return lessonLanguages.find((lessonLanguage) => lessonLanguage.lessonExternalId === lessonExternalId)?.content; +} + +async function buildCourseStructureSnapshot(orgId: string, courseId: string): Promise { + const course = await getCourseRecord(orgId, courseId); + const [sections, lessons, tags] = await Promise.all([ + listCourseSections(courseId), + listLessons(courseId), + getCourseTagsForOrganization(orgId, courseId) + ]); + + if (lessons.length === 0) { + throw new AppError( + 'Course must contain at least one lesson before it can be imported into a draft', + ErrorCodes.VALIDATION_ERROR, + 400 + ); + } + + const lessonIds = lessons.map((lesson) => lesson.id); + const lessonLanguages = await getLessonLanguagesByLessonIds(lessonIds); + const fallbackLocale = inferDraftLocale(lessonLanguages); + const warnings: TCourseImportDraftPayload['warnings'] = []; + const normalizedSections = [...sections] + .sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0)) + .map((section) => ({ + externalId: section.id, + title: section.title ?? 'Untitled Section', + order: Number(section.order ?? 0) + })); + + let sectionReferenceMap = new Map(normalizedSections.map((section) => [section.externalId, section.externalId])); + + if (normalizedSections.length === 0 || lessons.some((lesson) => !lesson.sectionId)) { + const syntheticSectionId = `synthetic:${courseId}:ungrouped`; + normalizedSections.push({ + externalId: syntheticSectionId, + title: 'Ungrouped', + order: normalizedSections.length + }); + sectionReferenceMap = new Map(normalizedSections.map((section) => [section.externalId, section.externalId])); + warnings.push( + ZCourseImportWarning.parse({ + code: 'UNGROUPED_CONTENT_NORMALIZED', + message: 'Some lessons were ungrouped. A synthetic section was added to the draft.', + severity: 'warning' + }) + ); + } + + const normalizedLessons = [...lessons] + .sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0)) + .map((lesson) => ({ + externalId: lesson.id, + sectionExternalId: + lesson.sectionId && sectionReferenceMap.has(lesson.sectionId) + ? lesson.sectionId + : normalizedSections[normalizedSections.length - 1]!.externalId, + title: lesson.title, + order: Number(lesson.order ?? 0), + isUnlocked: lesson.isUnlocked ?? undefined, + public: lesson.public ?? undefined + })); + + const normalizedLessonLanguages: TCourseImportDraftPayload['lessonLanguages'] = []; + + for (const lesson of lessons) { + const localized = lessonLanguages.filter((lessonLanguage) => lessonLanguage.lessonId === lesson.id); + + if (localized.length > 0) { + const validLocalized = localized + .map((lessonLanguage) => ({ + lessonExternalId: lesson.id, + locale: lessonLanguage.locale as TLocale, + content: lessonLanguage.content?.trim() ?? '' + })) + .filter((lessonLanguage) => lessonLanguage.content.length > 0); + + if (validLocalized.length > 0) { + normalizedLessonLanguages.push(...validLocalized); + continue; + } + + warnings.push( + ZCourseImportWarning.parse({ + code: 'EMPTY_LOCALE_CONTENT_SKIPPED', + message: `Lesson "${lesson.title}" had empty localized content and required a fallback.`, + severity: 'warning' + }) + ); + } + + if (lesson.note?.trim()) { + normalizedLessonLanguages.push({ + lessonExternalId: lesson.id, + locale: fallbackLocale, + content: lesson.note + }); + warnings.push( + ZCourseImportWarning.parse({ + code: 'LEGACY_NOTE_FALLBACK', + message: `Lesson "${lesson.title}" used legacy note content because no lesson_language rows were found.`, + severity: 'info' + }) + ); + } + } + + if (normalizedLessonLanguages.length === 0) { + throw new AppError( + 'Course lessons must contain note or localized content before they can be imported into a draft', + ErrorCodes.VALIDATION_ERROR, + 400 + ); + } + + const draft = ZCourseImportDraftPayload.parse({ + course: { + title: course.title, + description: course.description, + type: course.type, + locale: fallbackLocale, + metadata: course.metadata + }, + tags: tags.map((tag) => tag.name), + sections: normalizedSections, + lessons: normalizedLessons, + lessonLanguages: normalizedLessonLanguages, + sourceReferences: [ + { + type: 'course', + label: course.title + } + ], + warnings + }); + + return { + courseId, + draft + }; +} + export async function createCourseImportDraftService(orgId: string, profileId: string, data: TCourseImportDraftCreate) { try { if (data.idempotencyKey) { @@ -76,6 +419,48 @@ export async function createCourseImportDraftService(orgId: string, profileId: s } } +export async function createCourseImportDraftFromCourseService( + orgId: string, + profileId: string, + data: TCourseImportDraftCreateFromCourse +) { + const snapshot = await buildCourseStructureSnapshot(orgId, data.courseId); + + return createCourseImportDraftService(orgId, profileId, { + sourceType: 'course', + idempotencyKey: data.idempotencyKey, + summary: { + sourceCourseId: data.courseId, + ...(data.summary ?? {}) + }, + sourceArtifacts: [ + { + type: 'course', + courseId: data.courseId, + label: snapshot.draft.course.title + }, + ...(data.sourceArtifacts ?? []) + ], + draft: snapshot.draft + }); +} + +export async function getCourseImportStructureService(orgId: string, courseId: string) { + try { + return await buildCourseStructureSnapshot(orgId, courseId); + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + throw new AppError( + error instanceof Error ? error.message : 'Failed to fetch course structure', + ErrorCodes.COURSE_IMPORT_FETCH_FAILED, + 500 + ); + } +} + export async function getCourseImportDraftService(orgId: string, draftId: string) { try { const draft = await getCourseImportDraftById(orgId, draftId); @@ -138,18 +523,52 @@ export async function updateCourseImportDraftService(orgId: string, draftId: str } } +export async function updateCourseImportDraftTagsService( + orgId: string, + draftId: string, + tagNames: string[], + mode: 'merge' | 'replace' +) { + const existing = await getCourseImportDraftService(orgId, draftId); + + if (existing.status === 'PUBLISHED') { + throw new AppError('Published drafts cannot be edited', ErrorCodes.FORBIDDEN, 403); + } + + const draft = parseStoredDraft(existing); + const nextTags = + mode === 'replace' ? normalizeDraftTagNames(tagNames) : normalizeDraftTagNames([...draft.tags, ...tagNames]); + + return updateCourseImportDraftService(orgId, draftId, { + draft: { + ...draft, + tags: nextTags + } + }); +} + export async function publishCourseImportDraftService( orgId: string, profileId: string, draftId: string, overrides: TCourseImportDraftPublish -) { +): Promise { try { const existing = await getCourseImportDraftService(orgId, draftId); if (existing.status === 'PUBLISHED' && existing.publishedCourseId) { + const [publishedCourse] = await getCourseById(existing.publishedCourseId); + const courseUrl = await resolveCourseUrl( + orgId, + existing.publishedCourseId, + publishedCourse?.title ?? existing.title + ); + const draft = parseStoredDraft(existing); return { courseId: existing.publishedCourseId, + courseUrl, + bannerImageUrl: publishedCourse?.logo ?? null, + tagNames: normalizeDraftTagNames(draft.tags), createdSections: 0, createdLessons: 0, localeCount: 0 @@ -182,6 +601,9 @@ export async function publishCourseImportDraftService( await updateCourse(courseId, { metadata: courseMetadata }); } + const bannerImageUrl = await resolvePublishBannerImage(courseTitle, overrides); + await maybeApplyCourseBannerImage(courseId, bannerImageUrl); + const sectionIdMap = new Map(); for (const section of [...draft.sections].sort((a, b) => a.order - b.order)) { const createdSection = await createCourseSection(courseId, { @@ -203,6 +625,7 @@ export async function publishCourseImportDraftService( const createdLesson = await createLesson(courseId, { courseId, title: lesson.title, + note: getPrimaryLessonContent(lesson.externalId, draft.lessonLanguages, draft.course.locale as TLocale), sectionId, order: lesson.order, isUnlocked: lesson.isUnlocked, @@ -224,6 +647,16 @@ export async function publishCourseImportDraftService( }); } + const appliedTagNames = normalizeDraftTagNames(draft.tags); + if (appliedTagNames.length > 0) { + await assignCourseTagsByName(orgId, courseId, { + tagNames: appliedTagNames, + mode: 'replace' + }); + } + + const courseUrl = await resolveCourseUrl(orgId, courseId, courseTitle); + await updateCourseImportDraft(orgId, draftId, { status: 'PUBLISHED', publishedCourseId: courseId, @@ -236,6 +669,9 @@ export async function publishCourseImportDraftService( return { courseId, + courseUrl, + bannerImageUrl, + tagNames: appliedTagNames, createdSections: sectionIdMap.size, createdLessons: lessonIdMap.size, localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size @@ -252,3 +688,184 @@ export async function publishCourseImportDraftService( ); } } + +export async function publishCourseImportDraftToExistingCourseService( + orgId: string, + draftId: string, + overrides: TCourseImportDraftPublishToCourse +): Promise { + try { + const existingDraft = await getCourseImportDraftService(orgId, draftId); + const draft = parseStoredDraft(existingDraft); + const course = await getCourseRecord(orgId, overrides.courseId); + const courseTitle = overrides.title ?? draft.course.title; + const courseDescription = overrides.description ?? draft.course.description; + const courseType = overrides.type ?? draft.course.type; + const courseMetadata = overrides.metadata ?? draft.course.metadata; + const preferredLocale = draft.course.locale as TLocale; + const bannerImageUrl = await resolvePublishBannerImage(courseTitle, overrides); + + await updateCourse(course.id, { + title: courseTitle, + description: courseDescription, + type: courseType, + metadata: courseMetadata + }); + + await maybeApplyCourseBannerImage(course.id, bannerImageUrl); + + const [existingSections, existingLessons] = await Promise.all([ + listCourseSections(course.id), + listLessons(course.id) + ]); + const existingSectionIds = new Set(existingSections.map((section) => section.id)); + const existingLessonIds = new Set(existingLessons.map((lesson) => lesson.id)); + const existingLessonLanguages = await getLessonLanguagesByLessonIds(existingLessons.map((lesson) => lesson.id)); + + const sectionIdMap = new Map(); + let createdSections = 0; + let updatedSections = 0; + + for (const section of [...draft.sections].sort((a, b) => a.order - b.order)) { + if (existingSectionIds.has(section.externalId)) { + const updatedSection = await updateCourseSectionService(section.externalId, { + title: section.title, + order: section.order + }); + sectionIdMap.set(section.externalId, updatedSection.id); + updatedSections += 1; + continue; + } + + const createdSection = await createCourseSection(course.id, { + courseId: course.id, + title: section.title, + order: section.order + }); + sectionIdMap.set(section.externalId, createdSection.id); + createdSections += 1; + } + + const lessonIdMap = new Map(); + let createdLessons = 0; + let updatedLessons = 0; + + for (const lesson of [...draft.lessons].sort((a, b) => a.order - b.order)) { + const sectionId = sectionIdMap.get(lesson.sectionExternalId); + if (!sectionId) { + throw new AppError(`Lesson "${lesson.title}" references an unknown section`, ErrorCodes.VALIDATION_ERROR, 400); + } + + const note = getPrimaryLessonContent(lesson.externalId, draft.lessonLanguages, preferredLocale); + + if (existingLessonIds.has(lesson.externalId)) { + const updatedLesson = await updateLessonService(lesson.externalId, { + title: lesson.title, + note, + sectionId, + order: lesson.order, + isUnlocked: lesson.isUnlocked, + public: lesson.public + }); + lessonIdMap.set(lesson.externalId, updatedLesson.id); + updatedLessons += 1; + continue; + } + + const createdLesson = await createLesson(course.id, { + courseId: course.id, + title: lesson.title, + note, + sectionId, + order: lesson.order, + isUnlocked: lesson.isUnlocked, + public: lesson.public + }); + lessonIdMap.set(lesson.externalId, createdLesson.id); + createdLessons += 1; + } + + const existingLessonLanguageMap = new Map( + existingLessonLanguages.map((lessonLanguage) => [ + `${lessonLanguage.lessonId}:${lessonLanguage.locale}`, + lessonLanguage + ]) + ); + let createdLessonLanguages = 0; + let updatedLessonLanguages = 0; + + for (const lessonLanguage of draft.lessonLanguages) { + const lessonId = lessonIdMap.get(lessonLanguage.lessonExternalId); + if (!lessonId) { + throw new AppError('Referenced lesson was not created', ErrorCodes.VALIDATION_ERROR, 400); + } + + const languageKey = `${lessonId}:${lessonLanguage.locale}`; + if (existingLessonLanguageMap.has(languageKey)) { + await updateLessonLanguageService(lessonId, lessonLanguage.locale as TLocale, { + content: lessonLanguage.content + }); + updatedLessonLanguages += 1; + continue; + } + + await upsertLessonLanguageService(lessonId, { + locale: lessonLanguage.locale as TLocale, + content: lessonLanguage.content + }); + createdLessonLanguages += 1; + } + + const appliedTagNames = normalizeDraftTagNames(draft.tags); + if (appliedTagNames.length > 0) { + await assignCourseTagsByName(orgId, course.id, { + tagNames: appliedTagNames, + mode: 'merge' + }); + } + + const referencedSectionIds = new Set([...sectionIdMap.values()].filter((id) => existingSectionIds.has(id))); + const referencedLessonIds = new Set([...lessonIdMap.values()].filter((id) => existingLessonIds.has(id))); + const courseUrl = await resolveCourseUrl(orgId, course.id, courseTitle); + + await updateCourseImportDraft(orgId, draftId, { + status: 'PUBLISHED', + publishedCourseId: course.id, + summary: { + ...(existingDraft.summary ?? {}), + ...buildDraftSummary(draft), + publishedCourseId: course.id, + updatedSections, + updatedLessons, + createdLessonLanguages, + updatedLessonLanguages + } + }); + + return { + courseId: course.id, + courseUrl, + bannerImageUrl, + tagNames: appliedTagNames, + createdSections, + updatedSections, + createdLessons, + updatedLessons, + createdLessonLanguages, + updatedLessonLanguages, + untouchedSections: existingSections.length - referencedSectionIds.size, + untouchedLessons: existingLessons.length - referencedLessonIds.size, + localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size + }; + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + throw new AppError( + error instanceof Error ? error.message : 'Failed to publish course import draft to existing course', + ErrorCodes.COURSE_IMPORT_PUBLISH_FAILED, + 500 + ); + } +} diff --git a/apps/api/src/services/course/content.ts b/apps/api/src/services/course/content.ts index 5a2f9a8b..702a7a3d 100644 --- a/apps/api/src/services/course/content.ts +++ b/apps/api/src/services/course/content.ts @@ -1,13 +1,21 @@ import { AppError, ErrorCodes } from '@api/utils/errors'; import { ContentType } from '@cio/utils/constants'; -import { db } from '@cio/db/drizzle'; +import { db, type DbOrTxClient } from '@cio/db/drizzle'; import { sql, inArray } from 'drizzle-orm'; -import { lesson, exercise } from '@cio/db/schema'; +import { courseSection, lesson, exercise } from '@cio/db/schema'; import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content'; import { deleteLesson } from '@cio/db/queries/lesson'; -import { deleteCourseSection, getCourseSectionById } from '@cio/db/queries/course'; +import { deleteCourseSection, getCourseSectionById, getCourseSectionsByCourseId } from '@cio/db/queries/course'; import { deleteExercise } from '@cio/db/queries/exercise/exercise'; -import type { TCourseContentDelete, TCourseContentUpdate } from '@cio/utils/validation/course'; +import type { TCourseContentDelete, TCourseContentReorder, TCourseContentUpdate } from '@cio/utils/validation/course'; + +type CourseContentReorderResult = { + courseId: string; + updatedSections: number; + updatedItems: number; + updatedLessons: number; + updatedExercises: number; +}; function buildUpdatePayload(item: TCourseContentUpdate['items'][number]) { const payload: Record = {}; @@ -64,122 +72,175 @@ async function assertCourseContentItems(courseId: string, items: TCourseContentU }); } +async function assertCourseSections(courseId: string, sections: NonNullable) { + const existingSections = await getCourseSectionsByCourseId(courseId); + const sectionIds = new Set(existingSections.map((section) => section.id)); + + sections.forEach((section) => { + if (!sectionIds.has(section.id)) { + throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404); + } + }); +} + +async function applyCourseContentUpdates(tx: DbOrTxClient, items: TCourseContentUpdate['items']) { + const updates = items + .map((item) => ({ + item, + payload: buildUpdatePayload(item) + })) + .filter(({ payload }) => Object.keys(payload).length > 0); + + if (updates.length === 0) { + return { + updatedItems: 0, + updatedLessons: 0, + updatedExercises: 0 + }; + } + + const updatedAt = new Date().toISOString(); + + const lessonUpdates = updates.filter(({ item }) => item.type === ContentType.Lesson); + const exerciseUpdates = updates.filter(({ item }) => item.type === ContentType.Exercise); + + if (lessonUpdates.length > 0) { + const lessonIds = lessonUpdates.map(({ item }) => item.id); + const lessonOrderUpdates = lessonUpdates + .filter(({ payload }) => payload.order !== undefined) + .map(({ item, payload }) => ({ id: item.id, value: payload.order })); + const lessonSectionUpdates = lessonUpdates + .filter(({ payload }) => payload.sectionId !== undefined) + .map(({ item, payload }) => ({ id: item.id, value: payload.sectionId })); + const lessonUnlockUpdates = lessonUpdates + .filter(({ payload }) => payload.isUnlocked !== undefined) + .map(({ item, payload }) => ({ id: item.id, value: payload.isUnlocked })); + + const lessonSet: Record = { + updatedAt + }; + + if (lessonOrderUpdates.length > 0) { + lessonSet.order = sql`case ${lesson.id} ${sql.join( + lessonOrderUpdates.map((update) => sql`when ${update.id} then ${update.value}`), + sql` ` + )} else ${lesson.order} end`; + } + + if (lessonSectionUpdates.length > 0) { + lessonSet.sectionId = sql`case ${lesson.id} ${sql.join( + lessonSectionUpdates.map((update) => sql`when ${update.id} then ${update.value}`), + sql` ` + )} else ${lesson.sectionId} end`; + } + + if (lessonUnlockUpdates.length > 0) { + lessonSet.isUnlocked = sql`case ${lesson.id} ${sql.join( + lessonUnlockUpdates.map((update) => sql`when ${update.id} then ${update.value}`), + sql` ` + )} else ${lesson.isUnlocked} end`; + } + + const updatedLessons = await tx + .update(lesson) + .set(lessonSet) + .where(inArray(lesson.id, lessonIds)) + .returning({ id: lesson.id }); + + if (updatedLessons.length !== lessonIds.length) { + throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404); + } + } + + if (exerciseUpdates.length > 0) { + const exerciseIds = exerciseUpdates.map(({ item }) => item.id); + const exerciseOrderUpdates = exerciseUpdates + .filter(({ payload }) => payload.order !== undefined) + .map(({ item, payload }) => ({ id: item.id, value: payload.order })); + const exerciseSectionUpdates = exerciseUpdates + .filter(({ payload }) => payload.sectionId !== undefined) + .map(({ item, payload }) => ({ id: item.id, value: payload.sectionId })); + const exerciseUnlockUpdates = exerciseUpdates + .filter(({ payload }) => payload.isUnlocked !== undefined) + .map(({ item, payload }) => ({ id: item.id, value: payload.isUnlocked })); + + const exerciseSet: Record = { + updatedAt + }; + + if (exerciseOrderUpdates.length > 0) { + exerciseSet.order = sql`case ${exercise.id} ${sql.join( + exerciseOrderUpdates.map((update) => sql`when ${update.id} then ${update.value}`), + sql` ` + )} else ${exercise.order} end`; + } + + if (exerciseSectionUpdates.length > 0) { + exerciseSet.sectionId = sql`case ${exercise.id} ${sql.join( + exerciseSectionUpdates.map((update) => sql`when ${update.id} then ${update.value}`), + sql` ` + )} else ${exercise.sectionId} end`; + } + + if (exerciseUnlockUpdates.length > 0) { + exerciseSet.isUnlocked = sql`case ${exercise.id} ${sql.join( + exerciseUnlockUpdates.map((update) => sql`when ${update.id} then ${update.value}`), + sql` ` + )} else ${exercise.isUnlocked} end`; + } + + const updatedExercises = await tx + .update(exercise) + .set(exerciseSet) + .where(inArray(exercise.id, exerciseIds)) + .returning({ id: exercise.id }); + + if (updatedExercises.length !== exerciseIds.length) { + throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404); + } + } + + return { + updatedItems: updates.length, + updatedLessons: lessonUpdates.length, + updatedExercises: exerciseUpdates.length + }; +} + +async function applySectionOrderUpdates(tx: DbOrTxClient, sections: NonNullable) { + if (sections.length === 0) { + return 0; + } + + const sectionIds = sections.map((section) => section.id); + const updatedAt = new Date().toISOString(); + const sectionSet: Record = { + updatedAt, + order: sql`case ${courseSection.id} ${sql.join( + sections.map((section) => sql`when ${section.id} then ${section.order}`), + sql` ` + )} else ${courseSection.order} end` + }; + + const updatedSections = await tx + .update(courseSection) + .set(sectionSet) + .where(inArray(courseSection.id, sectionIds)) + .returning({ id: courseSection.id }); + + if (updatedSections.length !== sectionIds.length) { + throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404); + } + + return sections.length; +} + export async function updateCourseContent(courseId: string, items: TCourseContentUpdate['items']) { try { await assertCourseContentItems(courseId, items); await db.transaction(async (tx) => { - const updates = items - .map((item) => ({ - item, - payload: buildUpdatePayload(item) - })) - .filter(({ payload }) => Object.keys(payload).length > 0); - - if (updates.length === 0) { - return; - } - - const updatedAt = new Date().toISOString(); - - const lessonUpdates = updates.filter(({ item }) => item.type === ContentType.Lesson); - const exerciseUpdates = updates.filter(({ item }) => item.type === ContentType.Exercise); - - if (lessonUpdates.length > 0) { - const lessonIds = lessonUpdates.map(({ item }) => item.id); - const lessonOrderUpdates = lessonUpdates - .filter(({ payload }) => payload.order !== undefined) - .map(({ item, payload }) => ({ id: item.id, value: payload.order })); - const lessonSectionUpdates = lessonUpdates - .filter(({ payload }) => payload.sectionId !== undefined) - .map(({ item, payload }) => ({ id: item.id, value: payload.sectionId })); - const lessonUnlockUpdates = lessonUpdates - .filter(({ payload }) => payload.isUnlocked !== undefined) - .map(({ item, payload }) => ({ id: item.id, value: payload.isUnlocked })); - - const lessonSet: Record = { - updatedAt - }; - - if (lessonOrderUpdates.length > 0) { - lessonSet.order = sql`case ${lesson.id} ${sql.join( - lessonOrderUpdates.map((update) => sql`when ${update.id} then ${update.value}`), - sql` ` - )} else ${lesson.order} end`; - } - - if (lessonSectionUpdates.length > 0) { - lessonSet.sectionId = sql`case ${lesson.id} ${sql.join( - lessonSectionUpdates.map((update) => sql`when ${update.id} then ${update.value}`), - sql` ` - )} else ${lesson.sectionId} end`; - } - - if (lessonUnlockUpdates.length > 0) { - lessonSet.isUnlocked = sql`case ${lesson.id} ${sql.join( - lessonUnlockUpdates.map((update) => sql`when ${update.id} then ${update.value}`), - sql` ` - )} else ${lesson.isUnlocked} end`; - } - - const updatedLessons = await tx - .update(lesson) - .set(lessonSet) - .where(inArray(lesson.id, lessonIds)) - .returning({ id: lesson.id }); - - if (updatedLessons.length !== lessonIds.length) { - throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404); - } - } - - if (exerciseUpdates.length > 0) { - const exerciseIds = exerciseUpdates.map(({ item }) => item.id); - const exerciseOrderUpdates = exerciseUpdates - .filter(({ payload }) => payload.order !== undefined) - .map(({ item, payload }) => ({ id: item.id, value: payload.order })); - const exerciseSectionUpdates = exerciseUpdates - .filter(({ payload }) => payload.sectionId !== undefined) - .map(({ item, payload }) => ({ id: item.id, value: payload.sectionId })); - const exerciseUnlockUpdates = exerciseUpdates - .filter(({ payload }) => payload.isUnlocked !== undefined) - .map(({ item, payload }) => ({ id: item.id, value: payload.isUnlocked })); - - const exerciseSet: Record = { - updatedAt - }; - - if (exerciseOrderUpdates.length > 0) { - exerciseSet.order = sql`case ${exercise.id} ${sql.join( - exerciseOrderUpdates.map((update) => sql`when ${update.id} then ${update.value}`), - sql` ` - )} else ${exercise.order} end`; - } - - if (exerciseSectionUpdates.length > 0) { - exerciseSet.sectionId = sql`case ${exercise.id} ${sql.join( - exerciseSectionUpdates.map((update) => sql`when ${update.id} then ${update.value}`), - sql` ` - )} else ${exercise.sectionId} end`; - } - - if (exerciseUnlockUpdates.length > 0) { - exerciseSet.isUnlocked = sql`case ${exercise.id} ${sql.join( - exerciseUnlockUpdates.map((update) => sql`when ${update.id} then ${update.value}`), - sql` ` - )} else ${exercise.isUnlocked} end`; - } - - const updatedExercises = await tx - .update(exercise) - .set(exerciseSet) - .where(inArray(exercise.id, exerciseIds)) - .returning({ id: exercise.id }); - - if (updatedExercises.length !== exerciseIds.length) { - throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404); - } - } + await applyCourseContentUpdates(tx, items); }); } catch (error) { if (error instanceof AppError) { @@ -194,6 +255,48 @@ export async function updateCourseContent(courseId: string, items: TCourseConten } } +export async function reorderCourseContent( + courseId: string, + payload: TCourseContentReorder +): Promise { + try { + if (payload.sections?.length) { + await assertCourseSections(courseId, payload.sections); + } + + if (payload.items?.length) { + await assertCourseContentItems(courseId, payload.items); + } + + return await db.transaction(async (tx) => { + const updatedSections = payload.sections?.length ? await applySectionOrderUpdates(tx, payload.sections) : 0; + const itemResult = payload.items?.length + ? await applyCourseContentUpdates(tx, payload.items) + : { + updatedItems: 0, + updatedLessons: 0, + updatedExercises: 0 + }; + + return { + courseId, + updatedSections, + ...itemResult + }; + }); + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + throw new AppError( + error instanceof Error ? error.message : 'Failed to reorder course content', + ErrorCodes.INTERNAL_ERROR, + 500 + ); + } +} + export async function deleteCourseContent(courseId: string, payload: TCourseContentDelete) { try { if (payload.sectionId) { diff --git a/apps/api/src/services/course/landing-page.ts b/apps/api/src/services/course/landing-page.ts new file mode 100644 index 00000000..a51cca6f --- /dev/null +++ b/apps/api/src/services/course/landing-page.ts @@ -0,0 +1,224 @@ +import * as schema from '@cio/db/schema'; + +import { AppError, ErrorCodes } from '@api/utils/errors'; +import type { TCourse, TOrganization } from '@db/types'; +import type { TCourseLandingPageMetadataUpdate, TCourseLandingPageUpdate } from '@cio/utils/validation/course'; + +import { db } from '@cio/db/drizzle'; +import { env } from '@api/config/env'; +import { eq } from 'drizzle-orm'; +import { generateSlug } from '@cio/utils/functions'; +import { getCourseById } from '@cio/db/queries/course'; +import { getCourseOrganizationId } from '@cio/db/queries/tag'; +import { getDashboardBaseUrl } from '@api/config/dashboard-url'; +import { getOrganizationById } from '@cio/db/queries/organization'; +import { updateCourse } from '@api/services/course/course'; + +type CourseLandingPageUpdateResult = { + course: TCourse; + courseUrl: string; + bannerImageUrl: string | null; +}; + +async function generateUniqueCourseSlug(baseSlug: string): Promise { + let suffix = 0; + let candidate = baseSlug; + + while (true) { + const existing = await db + .select({ id: schema.course.id }) + .from(schema.course) + .where(eq(schema.course.slug, candidate)) + .limit(1); + + if (existing.length === 0) { + return candidate; + } + + suffix += 1; + candidate = `${baseSlug}-${suffix}`; + } +} + +async function ensureCourseSlug(courseId: string, title: string | null | undefined): Promise { + const [course] = await getCourseById(courseId); + if (course?.slug) { + return course.slug; + } + + const baseSlug = generateSlug(title, { fallback: 'course' }); + const uniqueSlug = await generateUniqueCourseSlug(baseSlug); + await updateCourse(courseId, { slug: uniqueSlug }); + return uniqueSlug; +} + +function buildCourseBaseUrl(organization: TOrganization) { + if (organization.customDomain && organization.isCustomDomainVerified) { + return `https://${organization.customDomain}`; + } + + return getDashboardBaseUrl(organization.siteName ?? undefined); +} + +async function resolveCourseUrl(organizationId: string, courseId: string, title: string) { + const organization = await getOrganizationById(organizationId); + if (!organization) { + throw new AppError('Organization not found', ErrorCodes.ORG_NOT_FOUND, 404); + } + + const slug = await ensureCourseSlug(courseId, title); + return `${buildCourseBaseUrl(organization)}/course/${encodeURIComponent(slug)}`; +} + +async function resolveUnsplashBannerImage(courseTitle: string, query?: string) { + if (!env.UNSPLASH_API_KEY) { + return null; + } + + const searchQuery = (query?.trim() || courseTitle.trim() || 'education').slice(0, 120); + const response = await fetch( + `https://api.unsplash.com/search/photos?page=1&per_page=15&auto=format&fit=crop&w=2970&q=80&client_id=${env.UNSPLASH_API_KEY}&query=${encodeURIComponent(searchQuery)}`, + { method: 'GET' } + ); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as { + results?: Array<{ + urls?: { + regular?: string; + }; + }>; + }; + + const photos = data.results ?? []; + if (photos.length === 0) { + return null; + } + + const photo = photos[Math.floor(Math.random() * photos.length)]; + return photo?.urls?.regular ?? null; +} + +async function resolveLandingPageImage( + courseTitle: string, + payload: Pick +) { + if (payload.imageUrl) { + return payload.imageUrl; + } + + if (!payload.generateImage && !payload.imageQuery) { + return null; + } + + try { + return await resolveUnsplashBannerImage(courseTitle, payload.imageQuery); + } catch (error) { + console.error('resolveLandingPageImage error:', error); + return null; + } +} + +function mergeLandingPageMetadata( + existing: TCourse['metadata'] | null | undefined, + update: TCourseLandingPageMetadataUpdate | undefined +): TCourse['metadata'] | undefined { + if (!update) { + return existing ?? undefined; + } + + const current: NonNullable = { + goals: '', + description: '', + requirements: '', + allowNewStudent: true, + ...(existing ?? {}) + }; + + return { + ...current, + ...update, + reward: update.reward + ? { + show: update.reward.show ?? current.reward?.show ?? false, + description: update.reward.description ?? current.reward?.description ?? '' + } + : current.reward, + instructor: update.instructor + ? { + name: update.instructor.name ?? current.instructor?.name ?? '', + role: update.instructor.role ?? current.instructor?.role ?? '', + coursesNo: update.instructor.coursesNo ?? current.instructor?.coursesNo ?? 0, + description: update.instructor.description ?? current.instructor?.description ?? '', + imgUrl: update.instructor.imgUrl ?? current.instructor?.imgUrl ?? '' + } + : current.instructor, + certificate: update.certificate + ? { + templateUrl: update.certificate.templateUrl ?? current.certificate?.templateUrl ?? '' + } + : current.certificate, + sectionDisplay: update.sectionDisplay + ? { ...(current.sectionDisplay ?? {}), ...update.sectionDisplay } + : current.sectionDisplay, + reviews: update.reviews ?? current.reviews, + lessonTabsOrder: update.lessonTabsOrder ?? current.lessonTabsOrder + }; +} + +function normalizeReviews(reviews: NonNullable['reviews'] | undefined) { + return reviews?.map((review, index) => ({ + id: review.id ?? Date.now() + index, + hide: review.hide ?? false, + name: review.name, + avatar_url: review.avatar_url, + rating: review.rating, + created_at: review.created_at ?? Date.now(), + description: review.description + })); +} + +export async function updateCourseLandingPageService( + courseId: string, + payload: TCourseLandingPageUpdate +): Promise { + const [existingCourse] = await getCourseById(courseId); + if (!existingCourse) { + throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404); + } + + const nextTitle = payload.title ?? existingCourse.title; + const metadata = mergeLandingPageMetadata(existingCourse.metadata ?? undefined, payload.metadata); + const imageUrl = await resolveLandingPageImage(nextTitle, payload); + + const updatedCourse = await updateCourse(courseId, { + title: payload.title, + description: payload.description, + overview: payload.overview, + cost: payload.cost, + currency: payload.currency, + metadata: metadata + ? { + ...metadata, + reviews: normalizeReviews(metadata.reviews) + } + : undefined, + ...(imageUrl ? { logo: imageUrl, bannerImage: imageUrl } : {}) + }); + + const organizationId = await getCourseOrganizationId(courseId); + if (!organizationId) { + throw new AppError('Course organization not found', ErrorCodes.COURSE_NOT_FOUND, 404); + } + + const courseUrl = await resolveCourseUrl(organizationId, courseId, updatedCourse.title); + + return { + course: updatedCourse, + courseUrl, + bannerImageUrl: updatedCourse.logo || imageUrl || null + }; +} diff --git a/apps/api/src/services/organization/automation-key.ts b/apps/api/src/services/organization/automation-key.ts index 3eb3aa40..0da75eb6 100644 --- a/apps/api/src/services/organization/automation-key.ts +++ b/apps/api/src/services/organization/automation-key.ts @@ -15,13 +15,19 @@ import { listOrganizationApiKeys, updateOrganizationApiKey } from '@cio/db/queries/organization'; +import { assertOrganizationAutomationKeyCreationAllowed } from './automation-usage'; const DEFAULT_SCOPES: Record = { mcp: [ 'course_import:draft:create', 'course_import:draft:read', 'course_import:draft:update', - 'course_import:draft:publish' + 'course_import:draft:publish', + 'course:read', + 'course:write', + 'course:tag:write', + 'course:exercise:read', + 'course:exercise:write' ], api: ['public_api:*'], zapier: ['public_api:*'] @@ -86,6 +92,8 @@ export async function createOrganizationApiKeyService( createdByProfileId: string, payload: TCreateOrganizationApiKey ): Promise<{ key: OrganizationApiKeySummary; secret: string }> { + await assertOrganizationAutomationKeyCreationAllowed(organizationId, payload.type); + const secret = generateOrganizationApiKeySecret(payload.type); const key = await createOrganizationApiKey({ organizationId, diff --git a/apps/api/src/services/organization/automation-usage.ts b/apps/api/src/services/organization/automation-usage.ts new file mode 100644 index 00000000..a3d3e2d4 --- /dev/null +++ b/apps/api/src/services/organization/automation-usage.ts @@ -0,0 +1,188 @@ +import { AppError, ErrorCodes } from '@api/utils/errors'; +import { + countActiveOrganizationApiKeys, + countOrganizationAutomationUsageSince, + countOrganizationAutomationUsageSinceByKey, + createOrganizationAutomationUsage, + getActiveOrganizationPlan, + getOrganizationAutomationCreditsUsedSince, + listRecentOrganizationAutomationUsage +} from '@cio/db/queries/organization'; +import type { TOrganizationApiKey, TOrganizationApiKeyType, TPlan } from '@db/types'; +import { + AUTOMATION_TYPE, + getMcpAutomationCategory, + getMcpAutomationLimits, + MCP_TOOL_CREDIT_COST, + type TMcpToolName +} from '@cio/utils/plans'; + +export type OrganizationAutomationUsageSummary = { + type: TOrganizationApiKeyType; + planName: TPlan; + billingPeriodStart: string; + billingPeriodEnd: string; + monthlyCreditsIncluded: number; + monthlyCreditsUsed: number; + monthlyCreditsRemaining: number; + activeKeys: number; + maxActiveKeys: number; + rateLimits: ReturnType['rateLimits']; + recentActions: Array<{ + action: string; + creditsConsumed: number; + createdAt: string; + }>; +}; + +function getBillingPeriodStart(date: Date = new Date()) { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0)); +} + +function getBillingPeriodEnd(date: Date = new Date()) { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1, 0, 0, 0, 0)); +} + +function getMinuteWindowStart(date: Date = new Date()) { + return new Date(date.getTime() - 60_000); +} + +async function getOrganizationPlanName(orgId: string): Promise { + const activePlan = await getActiveOrganizationPlan(orgId); + return (activePlan?.planName as TPlan | null) ?? 'BASIC'; +} + +export async function assertOrganizationAutomationKeyCreationAllowed( + organizationId: string, + type: TOrganizationApiKeyType +): Promise { + if (type !== AUTOMATION_TYPE.MCP) { + return; + } + + const planName = await getOrganizationPlanName(organizationId); + const limits = getMcpAutomationLimits(planName); + const activeKeys = await countActiveOrganizationApiKeys(organizationId, type); + + if (activeKeys >= limits.maxActiveKeys) { + throw new AppError( + `Your plan allows up to ${limits.maxActiveKeys} active MCP key${limits.maxActiveKeys === 1 ? '' : 's'}`, + ErrorCodes.AUTOMATION_KEY_LIMIT_EXCEEDED, + 403 + ); + } +} + +export async function getOrganizationAutomationUsageSummaryService( + organizationId: string, + type: TOrganizationApiKeyType +): Promise { + const planName = await getOrganizationPlanName(organizationId); + const billingPeriodStart = getBillingPeriodStart(); + const billingPeriodEnd = getBillingPeriodEnd(); + + if (type !== AUTOMATION_TYPE.MCP) { + return { + type, + planName, + billingPeriodStart: billingPeriodStart.toISOString(), + billingPeriodEnd: billingPeriodEnd.toISOString(), + monthlyCreditsIncluded: 0, + monthlyCreditsUsed: 0, + monthlyCreditsRemaining: 0, + activeKeys: await countActiveOrganizationApiKeys(organizationId, type), + maxActiveKeys: 0, + rateLimits: { + perKey: { readPerMinute: 0, writePerMinute: 0, publishPerMinute: 0 }, + perOrg: { readPerMinute: 0, writePerMinute: 0, publishPerMinute: 0 } + }, + recentActions: [] + }; + } + + const limits = getMcpAutomationLimits(planName); + const [monthlyCreditsUsed, activeKeys, recentActions] = await Promise.all([ + getOrganizationAutomationCreditsUsedSince(organizationId, type, billingPeriodStart.toISOString()), + countActiveOrganizationApiKeys(organizationId, type), + listRecentOrganizationAutomationUsage(organizationId, type, 10) + ]); + + return { + type, + planName, + billingPeriodStart: billingPeriodStart.toISOString(), + billingPeriodEnd: billingPeriodEnd.toISOString(), + monthlyCreditsIncluded: limits.monthlyCredits, + monthlyCreditsUsed, + monthlyCreditsRemaining: Math.max(limits.monthlyCredits - monthlyCreditsUsed, 0), + activeKeys, + maxActiveKeys: limits.maxActiveKeys, + rateLimits: limits.rateLimits, + recentActions: recentActions.map((action) => ({ + action: action.action, + creditsConsumed: action.creditsConsumed, + createdAt: action.createdAt + })) + }; +} + +export async function assertMcpAutomationUsageAllowed( + automationKey: TOrganizationApiKey, + toolName: TMcpToolName +): Promise { + const planName = await getOrganizationPlanName(automationKey.organizationId); + const limits = getMcpAutomationLimits(planName); + const category = getMcpAutomationCategory(toolName); + const now = new Date(); + const billingPeriodStart = getBillingPeriodStart(now).toISOString(); + const minuteWindowStart = getMinuteWindowStart(now).toISOString(); + + const [creditsUsed, keyRequestsInWindow, orgRequestsInWindow] = await Promise.all([ + getOrganizationAutomationCreditsUsedSince(automationKey.organizationId, automationKey.type, billingPeriodStart), + countOrganizationAutomationUsageSinceByKey(automationKey.id, category, minuteWindowStart), + countOrganizationAutomationUsageSince(automationKey.organizationId, automationKey.type, category, minuteWindowStart) + ]); + + const creditsNeeded = MCP_TOOL_CREDIT_COST[toolName]; + if (creditsUsed + creditsNeeded > limits.monthlyCredits) { + throw new AppError( + 'Monthly automation credits exhausted for this organization', + ErrorCodes.AUTOMATION_CREDIT_LIMIT_EXCEEDED, + 429 + ); + } + + const keyLimit = + category === 'read' + ? limits.rateLimits.perKey.readPerMinute + : category === 'write' + ? limits.rateLimits.perKey.writePerMinute + : limits.rateLimits.perKey.publishPerMinute; + + const orgLimit = + category === 'read' + ? limits.rateLimits.perOrg.readPerMinute + : category === 'write' + ? limits.rateLimits.perOrg.writePerMinute + : limits.rateLimits.perOrg.publishPerMinute; + + if (keyRequestsInWindow >= keyLimit || orgRequestsInWindow >= orgLimit) { + throw new AppError('Automation rate limit exceeded', ErrorCodes.AUTOMATION_RATE_LIMIT_EXCEEDED, 429); + } +} + +export async function recordMcpAutomationUsage( + automationKey: TOrganizationApiKey, + toolName: TMcpToolName, + metadata: Record = {} +): Promise { + await createOrganizationAutomationUsage({ + organizationId: automationKey.organizationId, + organizationApiKeyId: automationKey.id, + type: automationKey.type, + action: toolName, + category: getMcpAutomationCategory(toolName), + creditsConsumed: MCP_TOOL_CREDIT_COST[toolName], + metadata + }); +} diff --git a/apps/api/src/services/tag.ts b/apps/api/src/services/tag.ts index 76feaf02..b714f1d4 100644 --- a/apps/api/src/services/tag.ts +++ b/apps/api/src/services/tag.ts @@ -1,4 +1,6 @@ import type { + TAutomationCourseTagAssignment, + TAutomationDraftTagAssignment, TCourseTagAssignment, TTagCreate, TTagGroupCreate, @@ -8,6 +10,8 @@ import type { import { createTag, createTagGroup, + findTagBySlug, + findTagGroupBySlug, generateUniqueTagGroupSlug, generateUniqueTagSlug, getCourseOrganizationId, @@ -21,6 +25,8 @@ import { updateTagGroup } from '@cio/db/queries/tag'; import { getOrgIdBySiteName, isUserOrgAdmin } from '@cio/db/queries/organization'; +import { TAG_COLOR_OPTIONS, type TTagColor } from '@cio/utils/validation/tag'; +import { slugifyTagValue } from '@cio/db/queries/tag'; import { AppError, ErrorCodes } from '@api/utils/errors'; @@ -34,6 +40,51 @@ function normalizeOptionalText(value: string | undefined): string | null | undef return normalized.length > 0 ? normalized : null; } +function normalizeAutomationTagNames(tagNames: string[]) { + return Array.from(new Map(tagNames.map((name) => [name.trim().toLowerCase(), name.trim()])).values()).filter(Boolean); +} + +function pickTagColor(name: string): TTagColor { + const seed = Array.from(name).reduce((acc, char) => acc + char.charCodeAt(0), 0); + return TAG_COLOR_OPTIONS[seed % TAG_COLOR_OPTIONS.length] as TTagColor; +} + +async function ensureAutomationTagGroup(orgId: string, groupName: string) { + const slug = slugifyTagValue(groupName); + const existing = await findTagGroupBySlug(orgId, slug); + + if (existing) { + return existing; + } + + return createOrganizationTagGroup(orgId, { name: groupName }); +} + +async function ensureAutomationTags(orgId: string, tagNames: string[], groupName: string) { + const normalizedTagNames = normalizeAutomationTagNames(tagNames); + const group = await ensureAutomationTagGroup(orgId, groupName); + const tags = []; + + for (const tagName of normalizedTagNames) { + const slug = slugifyTagValue(tagName); + const existing = await findTagBySlug(orgId, slug); + + if (existing) { + tags.push(existing); + continue; + } + + const created = await createOrganizationTag(orgId, { + groupId: group.id, + name: tagName, + color: pickTagColor(tagName) + }); + tags.push(created); + } + + return tags; +} + export async function getOrganizationTagGroups(orgId: string) { try { return getTagGroupsWithTags(orgId); @@ -301,3 +352,23 @@ export async function replaceCourseTags( ); } } + +export async function assignCourseTagsByName(orgId: string, courseId: string, data: TAutomationDraftTagAssignment) { + const ensuredTags = await ensureAutomationTags(orgId, data.tagNames, data.groupName?.trim() || 'Automation'); + const existingTags = data.mode === 'merge' ? await getCourseTags(orgId, courseId) : []; + const tagIds = Array.from(new Set([...existingTags.map((tag) => tag.id), ...ensuredTags.map((tag) => tag.id)])); + + return replaceCourseTags(orgId, courseId, { tagIds }); +} + +export async function assignTagsToCoursesByName(orgId: string, data: TAutomationCourseTagAssignment) { + const normalizedCourseIds = Array.from(new Set(data.courseIds)); + const results = []; + + for (const courseId of normalizedCourseIds) { + const tags = await assignCourseTagsByName(orgId, courseId, data); + results.push({ courseId, tags }); + } + + return results; +} diff --git a/apps/api/src/utils/errors.ts b/apps/api/src/utils/errors.ts index c6b9db14..79cc0bd3 100644 --- a/apps/api/src/utils/errors.ts +++ b/apps/api/src/utils/errors.ts @@ -67,6 +67,9 @@ export const ErrorCodes = { ORGANIZATION_API_KEY_FETCH_FAILED: 'ORGANIZATION_API_KEY_FETCH_FAILED', ORGANIZATION_API_KEY_REVOKE_FAILED: 'ORGANIZATION_API_KEY_REVOKE_FAILED', ORGANIZATION_API_KEY_ROTATE_FAILED: 'ORGANIZATION_API_KEY_ROTATE_FAILED', + AUTOMATION_KEY_LIMIT_EXCEEDED: 'AUTOMATION_KEY_LIMIT_EXCEEDED', + AUTOMATION_CREDIT_LIMIT_EXCEEDED: 'AUTOMATION_CREDIT_LIMIT_EXCEEDED', + AUTOMATION_RATE_LIMIT_EXCEEDED: 'AUTOMATION_RATE_LIMIT_EXCEEDED', TAG_GROUP_NOT_FOUND: 'TAG_GROUP_NOT_FOUND', TAG_NOT_FOUND: 'TAG_NOT_FOUND', TAG_FETCH_FAILED: 'TAG_FETCH_FAILED', diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 7148e072..7bb01ed4 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -18,14 +18,14 @@ "types": ["node"], "paths": { "@api/*": ["src/*"], - "@db/types": ["./node_modules/@cio/db/dist/types.d.ts"], - "@db/*": ["./node_modules/@cio/db/dist/*"], - "@cio/db/*": ["./node_modules/@cio/db/dist/*"], - "@cio/db/types": ["./node_modules/@cio/db/dist/types.d.ts"], - "@cio/utils": ["./node_modules/@cio/utils/dist/index.ts"], - "@cio/utils/*": ["./node_modules/@cio/utils/dist/*"], - "@cio/email/*": ["./node_modules/@cio/email/dist/*"], - "@cio/email": ["./node_modules/@cio/email/dist/index.d.ts"] + "@db/types": ["../../packages/db/src/types.ts"], + "@db/*": ["../../packages/db/src/*"], + "@cio/db/*": ["../../packages/db/src/*"], + "@cio/db/types": ["../../packages/db/src/types.ts"], + "@cio/utils": ["../../packages/utils/src/index.ts"], + "@cio/utils/*": ["../../packages/utils/src/*"], + "@cio/email/*": ["../../packages/email/src/*"], + "@cio/email": ["../../packages/email/src/index.ts"] } }, "include": ["src/**/*"], diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example index 3b4fec0e..41542c31 100644 --- a/apps/dashboard/.env.example +++ b/apps/dashboard/.env.example @@ -6,12 +6,19 @@ PUBLIC_IS_SELFHOSTED=false # PUBLIC_APP_DESCRIPTION=A flexible LMS for your organization # PUBLIC_OG_IMAGE_URL=https://app.yourdomain.com/og-image.png -# CSP: External domains for script-src, style-src, connect-src, frame-src, font-src. -# When PUBLIC_IS_SELFHOSTED=true: REQUIRED - no defaults, only these env vars are used. -# When PUBLIC_IS_SELFHOSTED=false: Optional - unset uses SaaS defaults. +# CSP: External domains allowed by Content-Security-Policy. +# These are runtime env vars — set them when running the container (no rebuild needed). +# When PUBLIC_IS_SELFHOSTED=true: starts with 'self' only; add your domains here. +# When PUBLIC_IS_SELFHOSTED=false: unset uses SaaS defaults. # Comma-separated, e.g. ALLOWED_EXTERNAL_DOMAINS=assets.cdn.clsrio.com,cdnjs.cloudflare.com,cdn.plyr.io -# Per-directive: CSP_SCRIPT_SRC_DOMAINS, CSP_STYLE_SRC_DOMAINS, CSP_CONNECT_SRC_DOMAINS, CSP_FRAME_SRC_DOMAINS, CSP_FONT_SRC_DOMAINS +# ALLOWED_EXTERNAL_DOMAINS overrides all per-directive vars below. # ALLOWED_EXTERNAL_DOMAINS= +# CSP_SCRIPT_SRC_DOMAINS= +# CSP_STYLE_SRC_DOMAINS= +# CSP_CONNECT_SRC_DOMAINS= +# CSP_FRAME_SRC_DOMAINS= +# CSP_FONT_SRC_DOMAINS= +# CSP_MEDIA_SRC_DOMAINS= (applies to both img-src and media-src) # Unsplash setup - Optional if you want to use Unsplash images UNSPLASH_API_KEY= diff --git a/apps/dashboard/src/app.css b/apps/dashboard/src/app.css index a35eaffc..814842f1 100644 --- a/apps/dashboard/src/app.css +++ b/apps/dashboard/src/app.css @@ -46,22 +46,111 @@ html .dark { border: 2px solid var(--scrollbarBG); } +.prose { + color: var(--foreground); +} + .prose h1, .prose h2, .prose h3, -.prose h4, -.prose h5, -.prose h6 { - font-size: revert !important; - font-weight: revert !important; - margin-top: 0px !important; - margin-bottom: 16px !important; - line-height: 1.25 !important; +.prose h4 { + @apply scroll-m-20 tracking-tight text-balance; + color: var(--foreground); +} + +.prose h1 { + @apply mt-0 text-4xl font-extrabold; +} + +.prose h2 { + @apply mt-10 border-b pb-2 text-3xl font-semibold transition-colors; +} + +.prose h2:first-child { + @apply mt-0; +} + +.prose h3 { + @apply mt-8 text-2xl font-semibold; +} + +.prose h4 { + @apply mt-8 text-xl font-semibold; +} + +.prose p, +.prose li, +.prose blockquote { + @apply leading-7; } .prose p { - margin-top: 0px !important; - margin-bottom: 10px !important; + @apply my-0; +} + +.prose p:not(:first-child) { + @apply mt-6; +} + +.prose a { + @apply font-medium underline underline-offset-4; + color: var(--primary); +} + +.prose blockquote { + @apply mt-6 border-s-2 ps-6 italic; + border-inline-start-color: var(--border); +} + +.prose ul, +.prose ol { + @apply my-6 ms-6 p-0; +} + +.prose ul { + @apply list-disc; +} + +.prose ol { + @apply list-decimal; +} + +.prose li { + @apply mt-2; +} + +.prose table { + @apply my-6 w-full border-collapse; +} + +.prose thead tr, +.prose tbody tr { + @apply m-0 border-t p-0; + border-top-color: var(--border); +} + +.prose tbody tr:nth-child(even) { + background: var(--muted); +} + +.prose th, +.prose td { + @apply border px-4 py-2 text-start; + border-color: var(--border); +} + +.prose th { + @apply font-bold; +} + +.prose th[align='center'], +.prose td[align='center'] { + @apply text-center; +} + +.prose th[align='right'], +.prose td[align='right'] { + @apply text-end; } /* .dark .prose h1, @@ -106,10 +195,6 @@ iframe.iframe { } } -.prose a { - color: var(--main-primary-color); -} - :not([data-slot='button']) > svg.lucide-icon:not(.custom) { color: #666666; } diff --git a/apps/dashboard/src/hooks.server.ts b/apps/dashboard/src/hooks.server.ts index 87b85d41..9551ae3c 100644 --- a/apps/dashboard/src/hooks.server.ts +++ b/apps/dashboard/src/hooks.server.ts @@ -1,5 +1,6 @@ import { getSessionData } from '$lib/utils/services/auth/session'; import { getCioCookieString } from '$lib/utils/functions/cookies'; +import { applyCspExtensions } from '$lib/utils/csp'; import { type Handle, redirect } from '@sveltejs/kit'; import { isPublicApiRoute, isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute'; import { ROUTE } from '$lib/utils/constants/routes'; @@ -12,13 +13,15 @@ export const handle: Handle = async (args) => { event.locals = sessionData; } - // Handle API routes + let response: Response; + if (event.url.pathname.includes('/api')) { - return handleAPIRoutes(args); + response = await handleAPIRoutes(args); + } else { + response = await handlePagesRoutes(args); } - // Handle other routes (ui pages, etc.) - return handlePagesRoutes(args); + return applyCspExtensions(response); }; const handlePagesRoutes: Handle = async ({ event, resolve }) => { diff --git a/apps/dashboard/src/lib/features/auth/utils/auth-sso-store.ts b/apps/dashboard/src/lib/features/auth/utils/auth-sso-store.ts new file mode 100644 index 00000000..e83be35e --- /dev/null +++ b/apps/dashboard/src/lib/features/auth/utils/auth-sso-store.ts @@ -0,0 +1,44 @@ +/** + * Global SSO auth state for the current org. Shared by login and signup + * so we don't re-fetch when navigating between them. + */ + +import { get, writable } from 'svelte/store'; +import { INITIAL_SSO_AUTH_STATE, ssoStateFromOrgInfo, type OrgSsoInfoShape, type SsoAuthState } from './auth-sso'; + +export type AuthSsoStore = { + /** Org we loaded SSO info for; when nav changes org we re-fetch */ + loadedForOrgId: string | null; + /** Org-level SSO state from getOrgSsoInfo */ + ssoState: SsoAuthState; + orgSupportsSso: boolean; +}; + +const initial: AuthSsoStore = { + loadedForOrgId: null, + ssoState: INITIAL_SSO_AUTH_STATE, + orgSupportsSso: false +}; + +export const authSsoStore = writable(initial); + +/** + * Ensures SSO info for the given org is loaded. If we already have data for + * this orgId, does nothing. Otherwise fetches and updates the store. + */ +export async function ensureSsoInfoLoaded( + orgId: string, + fetchOrgSsoInfo: (id: string) => Promise +): Promise { + const current = get(authSsoStore); + if (current.loadedForOrgId === orgId) return; + + const orgInfo = await fetchOrgSsoInfo(orgId); + const ssoState = ssoStateFromOrgInfo(orgInfo); + + authSsoStore.set({ + loadedForOrgId: orgId, + ssoState, + orgSupportsSso: orgInfo?.hasSso ?? false + }); +} diff --git a/apps/dashboard/src/lib/features/auth/utils/auth-sso.ts b/apps/dashboard/src/lib/features/auth/utils/auth-sso.ts new file mode 100644 index 00000000..3b06f58f --- /dev/null +++ b/apps/dashboard/src/lib/features/auth/utils/auth-sso.ts @@ -0,0 +1,101 @@ +/** + * Shared SSO auth state and helpers for login/signup flows. + * Used by (auth)/login and (auth)/signup to avoid duplicating SSO logic. + */ + +export type SsoAuthState = { + checking: boolean; + required: boolean; + available: boolean; + redirectUrl: string | null; + providerName: string | null; + providerId: string | null; +}; + +export const INITIAL_SSO_AUTH_STATE: SsoAuthState = { + checking: false, + required: false, + available: false, + redirectUrl: null, + providerName: null, + providerId: null +}; + +/** Shape returned by getOrgSsoInfo (avoids importing org types here). */ +export type OrgSsoInfoShape = { + hasSso: boolean; + ssoRequired?: boolean; + redirectUrl?: string | null; + providerName?: string | null; + providerId?: string | null; +} | null; + +/** Shape returned by discoverSso (avoids importing org types here). */ +export type SsoDiscoveryResultShape = { + ssoRequired?: boolean; + ssoAvailable?: boolean; + redirectUrl?: string | null; + providerName?: string | null; + providerId?: string | null; +} | null; + +export function ssoStateFromOrgInfo(orgInfo: OrgSsoInfoShape): SsoAuthState { + if (!orgInfo) return INITIAL_SSO_AUTH_STATE; + return { + checking: false, + required: orgInfo.ssoRequired ?? false, + available: orgInfo.hasSso ?? false, + redirectUrl: orgInfo.redirectUrl ?? null, + providerName: orgInfo.providerName ?? null, + providerId: orgInfo.providerId ?? null + }; +} + +export function ssoStateFromDiscovery(result: SsoDiscoveryResultShape): SsoAuthState { + if (!result) return INITIAL_SSO_AUTH_STATE; + return { + checking: false, + required: result.ssoRequired ?? false, + available: result.ssoAvailable ?? false, + redirectUrl: result.redirectUrl ?? null, + providerName: result.providerName ?? null, + providerId: result.providerId ?? null + }; +} + +export function buildSsoRedirectUrl(redirectParam: string | null): string { + const base = typeof window !== 'undefined' ? window.location.origin : ''; + const path = redirectParam && redirectParam.startsWith('/') ? redirectParam : ''; + return `${base}${path}`; +} + +const SSO_EMAIL_DEBOUNCE_MS = 500; + +export function createSsoEmailChecker(options: { + getEmail: () => string; + getOrgSupportsSso: () => boolean; + discoverSso: (email: string) => Promise; + onChecking: () => void; + onResult: (state: SsoAuthState) => void; + onClear: () => void; +}) { + let timeoutId: ReturnType | undefined; + + return function handleEmailChange() { + options.onClear(); + if (!options.getOrgSupportsSso()) return; + + clearTimeout(timeoutId); + const email = options.getEmail(); + if (!email || !email.includes('@')) { + options.onResult(INITIAL_SSO_AUTH_STATE); + return; + } + + options.onChecking(); + timeoutId = setTimeout(async () => { + const result = await options.discoverSso(email); + options.onResult(ssoStateFromDiscovery(result)); + }, SSO_EMAIL_DEBOUNCE_MS); + }; +} diff --git a/apps/dashboard/src/lib/features/automation/api/automation.svelte.ts b/apps/dashboard/src/lib/features/automation/api/automation.svelte.ts index f607025e..8b553d81 100644 --- a/apps/dashboard/src/lib/features/automation/api/automation.svelte.ts +++ b/apps/dashboard/src/lib/features/automation/api/automation.svelte.ts @@ -1,7 +1,9 @@ import type { AutomationKey, AutomationKeyType, + AutomationUsage, CreateAutomationKeyRequest, + GetAutomationUsageRequest, ListAutomationKeysRequest, RevokeAutomationKeyRequest, RotateAutomationKeyRequest @@ -15,6 +17,7 @@ class AutomationApi extends BaseApiWithErrors { generatedSecret = $state(null); isFetched = $state(false); keys = $state([]); + usage = $state(null); async listKeys(type?: AutomationKeyType) { return this.execute({ @@ -30,6 +33,19 @@ class AutomationApi extends BaseApiWithErrors { }); } + async getUsage(type: AutomationKeyType = 'mcp') { + return this.execute({ + requestFn: () => + classroomio.organization.automation.usage.$get({ + query: { type } + }), + logContext: 'fetching automation usage', + onSuccess: (response) => { + this.usage = response.data; + } + }); + } + async createKey(type: AutomationKeyType, label?: string) { return this.execute({ requestFn: () => @@ -43,6 +59,9 @@ class AutomationApi extends BaseApiWithErrors { onSuccess: (response) => { this.generatedSecret = response.data.secret; this.keys = [response.data.key, ...this.keys]; + if (this.usage) { + this.usage = { ...this.usage, activeKeys: this.usage.activeKeys + 1 }; + } snackbar.success('snackbar.automation.created'); } }); @@ -54,6 +73,10 @@ class AutomationApi extends BaseApiWithErrors { logContext: 'revoking automation key', onSuccess: (response) => { this.keys = this.keys.map((key) => (key.id === response.data.id ? response.data : key)); + if (this.usage) { + const activeKeys = this.keys.filter((key) => key.type === 'mcp' && !key.revokedAt).length; + this.usage = { ...this.usage, activeKeys }; + } snackbar.success('snackbar.automation.revoked'); } }); diff --git a/apps/dashboard/src/lib/features/automation/pages/api.svelte b/apps/dashboard/src/lib/features/automation/pages/api.svelte new file mode 100644 index 00000000..dc7fbb70 --- /dev/null +++ b/apps/dashboard/src/lib/features/automation/pages/api.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/apps/dashboard/src/lib/features/automation/pages/automation.svelte b/apps/dashboard/src/lib/features/automation/pages/automation.svelte deleted file mode 100644 index 13d0298f..00000000 --- a/apps/dashboard/src/lib/features/automation/pages/automation.svelte +++ /dev/null @@ -1,269 +0,0 @@ - - - - {#if !$isOrgAdmin} - - {/if} - - - - {$t('automation.tabs.mcp')} - - {$t('automation.tabs.zapier')} - {$t('automation.coming_soon')} - - - {$t('automation.tabs.api')} - {$t('automation.coming_soon')} - - - - - - - - {$t('automation.mcp.keys.title')} - - {$t('automation.mcp.keys.description')} - -
- -
- - {#if automationApi.generatedSecret} -
-

{$t('automation.mcp.keys.secret_once')}

-
- - - -
-
- {/if} - - {#if $isOrgAdmin} - {#if mcpKeys.length > 0} -
- {#each mcpKeys as key (key.id)} - - -
-
- {key.label} - {getMaskedAutomationSecret(key.secretPrefix)} -
- - {key.revokedAt ? $t('automation.keys.revoked') : $t('automation.keys.active')} - -
-
- -

- {$t('automation.keys.created_at_label')} - {new Date(key.createdAt).toLocaleString()} -

-

- {$t('automation.keys.last_used_label')} - {key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleString() : $t('automation.keys.never_used')} -

-

- {$t('automation.keys.scopes_label')} - {key.scopes.join(', ')} -

-
- - - - -
- {/each} -
- {:else} - - {/if} - {/if} -
- - - - - - - {$t('automation.mcp.setup.title')} - - {$t('automation.mcp.setup.description')} - - - - {$t('automation.clients.claude_code')} - {$t('automation.clients.codex')} - {$t('automation.clients.cursor')} - - - - - - {$t('automation.clients.claude_code')} - {$t('automation.mcp.setup.claude_code_description')} - - - - - - - - - - - - - - - - {$t('automation.clients.codex')} - {$t('automation.mcp.setup.codex_description')} - - - - - - - - - - - - - - - - {$t('automation.clients.cursor')} - {$t('automation.mcp.setup.cursor_description')} - - - - - - - - - - - - - -
- - - - - - - - -
-
diff --git a/apps/dashboard/src/lib/features/automation/pages/index.ts b/apps/dashboard/src/lib/features/automation/pages/index.ts index 761fd2d8..59a42c1a 100644 --- a/apps/dashboard/src/lib/features/automation/pages/index.ts +++ b/apps/dashboard/src/lib/features/automation/pages/index.ts @@ -1 +1,3 @@ -export { default as AutomationPage } from './automation.svelte'; +export { default as ApiAutomationPage } from './api.svelte'; +export { default as McpAutomationPage } from './mcp.svelte'; +export { default as ZapierAutomationPage } from './zapier.svelte'; diff --git a/apps/dashboard/src/lib/features/automation/pages/mcp.svelte b/apps/dashboard/src/lib/features/automation/pages/mcp.svelte new file mode 100644 index 00000000..16088a8e --- /dev/null +++ b/apps/dashboard/src/lib/features/automation/pages/mcp.svelte @@ -0,0 +1,239 @@ + + + + {#if !$isOrgAdmin} + + {/if} + + {#if $isOrgAdmin && automationApi.usage} +
+ {#each usageCards as card, i (i)} + + {/each} +
+ {/if} + +
+
+
+ + {$t('automation.mcp.keys.title')} +
+ {#if $isOrgAdmin} + + + + {/if} +
+

{$t('automation.mcp.keys.description')}

+ + {#if generatedSecret} +
+

{$t('automation.mcp.keys.secret_once')}

+
+ + + +
+
+ {/if} + + {#if $isOrgAdmin} + {#if mcpKeys.length > 0} + + + + {$t('automation.keys.table_key')} + {$t('automation.keys.table_status')} + {$t('automation.keys.table_created')} + {$t('automation.keys.table_last_used')} + + + + + {#each mcpKeys as key (key.id)} + + +
+

{key.label}

+

+ {getMaskedAutomationSecret(key.secretPrefix)} +

+
+
+ + + {key.revokedAt ? $t('automation.keys.revoked') : $t('automation.keys.active')} + + + + {new Date(key.createdAt).toLocaleDateString()} + + + {key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleDateString() : $t('automation.keys.never_used')} + + + {#if !key.revokedAt} + + + + + + + + onRotateKey(key.id)}> + {$t('automation.keys.rotate')} + + onRevokeKey(key.id)} class="ui:text-destructive"> + {$t('automation.keys.revoke')} + + + + {/if} + +
+ {/each} +
+
+ {:else} + + {/if} + {/if} +
+ + + + + {$t('automation.mcp.setup.title')} + + {$t('automation.mcp.setup.description')} + + + + {$t('automation.clients.cursor')} + {$t('automation.clients.claude_code')} + {$t('automation.clients.codex')} + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/apps/dashboard/src/lib/features/automation/pages/zapier.svelte b/apps/dashboard/src/lib/features/automation/pages/zapier.svelte new file mode 100644 index 00000000..e074a71d --- /dev/null +++ b/apps/dashboard/src/lib/features/automation/pages/zapier.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/apps/dashboard/src/lib/features/automation/utils/types.ts b/apps/dashboard/src/lib/features/automation/utils/types.ts index 0226f0bf..1833fb72 100644 --- a/apps/dashboard/src/lib/features/automation/utils/types.ts +++ b/apps/dashboard/src/lib/features/automation/utils/types.ts @@ -1,15 +1,18 @@ import { classroomio, type InferResponseType } from '$lib/utils/services/api'; export type ListAutomationKeysRequest = (typeof classroomio.organization)['automation']['keys']['$get']; +export type GetAutomationUsageRequest = (typeof classroomio.organization)['automation']['usage']['$get']; export type CreateAutomationKeyRequest = (typeof classroomio.organization)['automation']['keys']['$post']; export type RevokeAutomationKeyRequest = (typeof classroomio.organization)['automation']['keys'][':keyId']['$delete']; export type RotateAutomationKeyRequest = (typeof classroomio.organization)['automation']['keys'][':keyId']['rotate']['$post']; export type ListAutomationKeysSuccess = Extract, { success: true }>; +export type GetAutomationUsageSuccess = Extract, { success: true }>; export type CreateAutomationKeySuccess = Extract, { success: true }>; export type RevokeAutomationKeySuccess = Extract, { success: true }>; export type RotateAutomationKeySuccess = Extract, { success: true }>; export type AutomationKey = ListAutomationKeysSuccess['data'][number]; export type AutomationKeyType = AutomationKey['type']; +export type AutomationUsage = GetAutomationUsageSuccess['data']; diff --git a/apps/dashboard/src/lib/features/community/components/community-comment-item.svelte b/apps/dashboard/src/lib/features/community/components/community-comment-item.svelte index 9087c3a0..1c80cb78 100644 --- a/apps/dashboard/src/lib/features/community/components/community-comment-item.svelte +++ b/apps/dashboard/src/lib/features/community/components/community-comment-item.svelte @@ -6,7 +6,7 @@ import { shortenName } from '$lib/utils/functions/string'; import { Vote } from '$features/ui'; import { CircleCheckIcon } from '$features/ui/icons'; - import { sanitizeHtml } from '@cio/ui/tools/sanitize'; + import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content'; interface CommentAuthor { id: number | string | null; @@ -65,7 +65,7 @@ {/if}
- {@html sanitizeHtml(comment.body ?? '')} +
diff --git a/apps/dashboard/src/lib/features/community/pages/community-question.svelte b/apps/dashboard/src/lib/features/community/pages/community-question.svelte index 190964b3..ce1b3b4c 100644 --- a/apps/dashboard/src/lib/features/community/pages/community-question.svelte +++ b/apps/dashboard/src/lib/features/community/pages/community-question.svelte @@ -22,7 +22,7 @@ import { CommunityDeleteModal, CommunityCommentItem } from '$features/community/components'; import type { CommunityQuestionSuccess } from '../utils/types'; import { currentCommunityQuestion } from '../utils/store'; - import { sanitizeHtml } from '@cio/ui/tools/sanitize'; + import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content'; interface Props { slug: string; @@ -211,7 +211,7 @@ {:else}
- {@html sanitizeHtml(communityApi.question?.body ?? '')} +
{/if} diff --git a/apps/dashboard/src/lib/features/course/components/card.svelte b/apps/dashboard/src/lib/features/course/components/card.svelte index 360337dc..65fd8f97 100644 --- a/apps/dashboard/src/lib/features/course/components/card.svelte +++ b/apps/dashboard/src/lib/features/course/components/card.svelte @@ -20,6 +20,7 @@ import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter'; import { calcCourseProgress, calcProgressRate } from '$features/course/utils/functions'; import CardDropdown from './card-dropdown.svelte'; + import CoursePublishBadge from './course-publish-badge.svelte'; import type { OrgCourses, UserEnrolledCourses } from '$features/course/types'; import type { OrgPublicCourses } from '$features/org/utils/types'; @@ -196,13 +197,7 @@ {/if} {:else} - - {#if isPublished} - {$t('courses.course_card.published')} - {:else} - {$t('courses.course_card.unpublished')} - {/if} - + {/if} diff --git a/apps/dashboard/src/lib/features/course/components/course-filter-popover.svelte b/apps/dashboard/src/lib/features/course/components/course-filter-popover.svelte index 279369d4..1a9bd0f0 100644 --- a/apps/dashboard/src/lib/features/course/components/course-filter-popover.svelte +++ b/apps/dashboard/src/lib/features/course/components/course-filter-popover.svelte @@ -1,6 +1,7 @@
-

- {courseApi.course?.title || ''} -

+
+

+ {courseApi.course?.title || ''} +

+ + {#if showCoursePublishBadge} + + {/if} +
- - + + + {#snippet children(isHovered)} + + + + {/snippet} +
diff --git a/apps/dashboard/src/lib/features/course/components/course-publish-badge.svelte b/apps/dashboard/src/lib/features/course/components/course-publish-badge.svelte new file mode 100644 index 00000000..3dabe961 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/course-publish-badge.svelte @@ -0,0 +1,18 @@ + + + + {#if isPublished} + {$t('courses.course_card.published')} + {:else} + {$t('courses.course_card.unpublished')} + {/if} + diff --git a/apps/dashboard/src/lib/features/course/components/exercise/update-description.svelte b/apps/dashboard/src/lib/features/course/components/exercise/update-description.svelte index 223f8367..16cc01be 100644 --- a/apps/dashboard/src/lib/features/course/components/exercise/update-description.svelte +++ b/apps/dashboard/src/lib/features/course/components/exercise/update-description.svelte @@ -1,6 +1,6 @@ {#if mode === MODES.edit} @@ -67,7 +63,7 @@ {#if !isHtmlValueEmpty(content)} - {@html sanitizeHtml(content)} + {:else if hasAtLeastOneTranslation}

diff --git a/apps/dashboard/src/lib/features/course/components/list.svelte b/apps/dashboard/src/lib/features/course/components/list.svelte index b42df0ac..526770b9 100644 --- a/apps/dashboard/src/lib/features/course/components/list.svelte +++ b/apps/dashboard/src/lib/features/course/components/list.svelte @@ -1,12 +1,12 @@

@@ -30,17 +38,19 @@
-

+

{activity.title}

-

+

{activity.percentage} {#if !activity.hidePercentage} % {/if}

{#if activity.description} -

+

{activity.description}

{/if} diff --git a/apps/dashboard/src/lib/features/ui/auth-ui.svelte b/apps/dashboard/src/lib/features/ui/auth-ui.svelte index 0b8cfb5e..84f9e3e6 100644 --- a/apps/dashboard/src/lib/features/ui/auth-ui.svelte +++ b/apps/dashboard/src/lib/features/ui/auth-ui.svelte @@ -1,4 +1,5 @@
- {@html sanitizeHtml(code)} +
diff --git a/apps/dashboard/src/lib/features/ui/course-landing-page/components/editor/editor.svelte b/apps/dashboard/src/lib/features/ui/course-landing-page/components/editor/editor.svelte index fd15576f..edc842e8 100644 --- a/apps/dashboard/src/lib/features/ui/course-landing-page/components/editor/editor.svelte +++ b/apps/dashboard/src/lib/features/ui/course-landing-page/components/editor/editor.svelte @@ -32,6 +32,7 @@ import { Button } from '@cio/ui/base/button'; import { courseApi } from '$features/course/api'; import { generateSlug } from '@cio/utils/functions'; + import { openCoursePreview } from '$features/course/utils/course-preview'; import * as Sidebar from '@cio/ui/base/sidebar'; import { useSidebar } from '@cio/ui/base/sidebar'; @@ -164,9 +165,12 @@ syncCourseStore(course); } - async function handlePreview() { - const link = `${$currentOrgDomain}/course/${course.slug}`; - window.open(link, '_blank'); + function handlePreview() { + openCoursePreview({ + courseId, + courseSlug: course.slug, + currentOrgDomain: $currentOrgDomain + }); } function setter(value: any, setterKey: string) { @@ -193,7 +197,7 @@ {#snippet children(isHovered)} - + {/snippet} diff --git a/apps/dashboard/src/lib/features/ui/course-landing-page/components/pricing-section.svelte b/apps/dashboard/src/lib/features/ui/course-landing-page/components/pricing-section.svelte index 45e02bb2..42b331df 100644 --- a/apps/dashboard/src/lib/features/ui/course-landing-page/components/pricing-section.svelte +++ b/apps/dashboard/src/lib/features/ui/course-landing-page/components/pricing-section.svelte @@ -5,7 +5,7 @@ import { isCourseFree, calcCourseDiscount } from '$lib/utils/functions/course'; import { currentOrg } from '$lib/utils/store/org'; import { goto } from '$app/navigation'; - import { sanitizeHtml } from '@cio/ui/tools/sanitize'; + import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content'; import HTMLRender from '$features/ui/html-render.svelte'; import PaymentModal from './payment-modal.svelte'; import type { Course } from '$features/course/utils/types'; @@ -175,7 +175,9 @@ {#if courseData?.metadata?.reward?.show}
- {@html sanitizeHtml(get(courseData, 'metadata.reward.description', '') as string)} +
{/if} diff --git a/apps/dashboard/src/lib/features/ui/course-landing-page/course-landing-page.svelte b/apps/dashboard/src/lib/features/ui/course-landing-page/course-landing-page.svelte index 51ba9e0a..379f03ad 100644 --- a/apps/dashboard/src/lib/features/ui/course-landing-page/course-landing-page.svelte +++ b/apps/dashboard/src/lib/features/ui/course-landing-page/course-landing-page.svelte @@ -4,7 +4,7 @@ import { page } from '$app/state'; import { onMount, onDestroy } from 'svelte'; import { resolve } from '$app/paths'; - import { sanitizeHtml } from '@cio/ui/tools/sanitize'; + import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content'; import PlayIcon from '@lucide/svelte/icons/play'; import { getLectureNo } from '$features/course/utils/functions'; @@ -215,7 +215,7 @@
    - {@html sanitizeHtml(get(courseData, 'metadata.requirements', ''))} +
{/if} @@ -228,7 +228,7 @@ - {@html sanitizeHtml(get(courseData, 'metadata.description', ''))} + {/if} @@ -238,7 +238,7 @@

{$t('course.navItem.landing_page.learn')}

    - {@html sanitizeHtml(get(courseData, 'metadata.goals', ''))} +
{/if} diff --git a/apps/dashboard/src/lib/features/ui/index.ts b/apps/dashboard/src/lib/features/ui/index.ts index 8db45e03..7049b351 100644 --- a/apps/dashboard/src/lib/features/ui/index.ts +++ b/apps/dashboard/src/lib/features/ui/index.ts @@ -17,6 +17,7 @@ export * from './question'; export { default as UploadWidget } from './upload-widget/upload-widget.svelte'; export { default as PageLoadProgress } from './page-load-progress.svelte'; export { default as PoweredBy } from './upgrade-powered-by.svelte'; +export { default as RefreshPageData } from './refresh-page-data.svelte'; export { default as RoleBasedSecurity } from './role-based-security.svelte'; export { default as Search } from './search.svelte'; export { default as SenjaEmbed } from './senja-embed.svelte'; diff --git a/apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts b/apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts index b9cedb0c..b7e5d780 100644 --- a/apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts +++ b/apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts @@ -108,7 +108,36 @@ const baseNavConfig: NavItemConfig[] = [ icon: BracesIcon, requiresAdmin: true, disableWhenNotAdmin: true, - matchPattern: '^/org/[^/]+/automation(/.*)?$' + useHashUrl: true, + matchPattern: '^/org/[^/]+/automation(/.*)?$', + items: [ + { + titleKey: 'automation.tabs.mcp', + path: '/automation/mcp' + }, + { + titleKey: 'automation.tabs.zapier', + path: '/automation/zapier' + }, + { + titleKey: 'automation.tabs.api', + path: '/automation/api' + } + ], + nestedRoutes: [ + { + path: 'mcp', + titleKey: 'automation.tabs.mcp' + }, + { + path: 'zapier', + titleKey: 'automation.tabs.zapier' + }, + { + path: 'api', + titleKey: 'automation.tabs.api' + } + ] }, { titleKey: 'org_navigation.settings', diff --git a/apps/dashboard/src/lib/features/ui/refresh-page-data.svelte b/apps/dashboard/src/lib/features/ui/refresh-page-data.svelte new file mode 100644 index 00000000..04f38c2b --- /dev/null +++ b/apps/dashboard/src/lib/features/ui/refresh-page-data.svelte @@ -0,0 +1,43 @@ + + + + + diff --git a/apps/dashboard/src/lib/utils/csp-domains.js b/apps/dashboard/src/lib/utils/csp-domains.js new file mode 100644 index 00000000..af108526 --- /dev/null +++ b/apps/dashboard/src/lib/utils/csp-domains.js @@ -0,0 +1,71 @@ +// SaaS default CSP domains, baked in at build time. +// Self-hosted (adapter-node) starts with empty lists; runtime domains +// are added via env vars in hooks.server.ts so pre-built Docker images stay configurable. + +const saasDefaults = { + scriptSrc: [ + 'https://assets.cdn.clsrio.com', + 'https://cdnjs.cloudflare.com', + 'https://*.posthog.com', + 'https://*.senja.io', + 'https://umami.hz.oncws.com', + 'https://www.youtube.com', + 'https://youtube.com', + 'https://google.com' + ], + styleSrc: [ + 'https://cdn.plyr.io', + 'https://unpkg.com/katex@0.12.0/dist/katex.min.css', + 'https://assets.cdn.clsrio.com/eqneditor_1.css', + 'https://fonts.googleapis.com' + ], + connectSrc: [ + 'https://*.classroomio.com', + 'https://classroomio.com', + 'https://app.classroomio.com', + 'https://api.classroomio.com', + 'https://pgrest.classroomio.com', + 'https://play.classroomio.com', + 'wss://*.classroomio.com', + 'https://assets.cdn.clsrio.com', + 'https://cdn.plyr.io', + 'https://*.posthog.com', + 'https://umami.hz.oncws.com', + 'https://*.r2.cloudflarestorage.com', + 'https://*.senja.io', + 'https://*.ytimg.com', + 'https://noembed.com' + ], + frameSrc: [ + 'https://www.youtube.com', + 'https://youtube.com', + 'https://www.youtube-nocookie.com', + 'https://www.google.com', + 'https://google.com' + ], + fontSrc: ['https://fonts.gstatic.com', 'https://cdn.plyr.io'], + mediaSrc: ['https:'] +}; + +/** + * @param {boolean} isSelfHosted + * @param {string | undefined} serverUrl - PUBLIC_SERVER_URL, added to connect-src for SaaS builds + */ +export function getCspDomains(isSelfHosted, serverUrl) { + if (isSelfHosted) { + return { + scriptSrc: [], + styleSrc: [], + connectSrc: [], + frameSrc: [], + fontSrc: [], + mediaSrc: [], + apiOrigin: null + }; + } + + return { + ...saasDefaults, + apiOrigin: serverUrl ?? null + }; +} diff --git a/apps/dashboard/src/lib/utils/csp.ts b/apps/dashboard/src/lib/utils/csp.ts new file mode 100644 index 00000000..d41e4dd9 --- /dev/null +++ b/apps/dashboard/src/lib/utils/csp.ts @@ -0,0 +1,75 @@ +function parseCspDomains(value: string | undefined): string[] { + if (!value) return []; + return value + .split(',') + .map((d) => d.trim()) + .filter(Boolean) + .map((d) => (d.startsWith('http://') || d.startsWith('https://') ? d : `https://${d}`)); +} + +function buildCspExtensions(): Record { + const allDomains = parseCspDomains(process.env.ALLOWED_EXTERNAL_DOMAINS); + + const or = (envVar: string) => (allDomains.length ? allDomains : parseCspDomains(process.env[envVar])); + + const scriptSrc = or('CSP_SCRIPT_SRC_DOMAINS'); + const styleSrc = or('CSP_STYLE_SRC_DOMAINS'); + const connectSrcBase = or('CSP_CONNECT_SRC_DOMAINS'); + const frameSrc = or('CSP_FRAME_SRC_DOMAINS'); + const fontSrc = or('CSP_FONT_SRC_DOMAINS'); + const mediaSrc = or('CSP_MEDIA_SRC_DOMAINS'); + + const serverUrl = process.env.PUBLIC_SERVER_URL; + const connectSrc = serverUrl ? [...connectSrcBase, serverUrl] : connectSrcBase; + + const extensions: Record = {}; + if (scriptSrc.length) extensions['script-src'] = scriptSrc; + if (styleSrc.length) { + extensions['style-src'] = styleSrc; + extensions['style-src-elem'] = styleSrc; + } + if (connectSrc.length) extensions['connect-src'] = connectSrc; + if (frameSrc.length) extensions['frame-src'] = frameSrc; + if (fontSrc.length) extensions['font-src'] = fontSrc; + if (mediaSrc.length) { + extensions['img-src'] = mediaSrc; + extensions['media-src'] = mediaSrc; + } + + return extensions; +} + +let cachedExtensions: Record | null = null; + +function getExtensions(): Record { + if (!cachedExtensions) cachedExtensions = buildCspExtensions(); + return cachedExtensions; +} + +function extendHeader(header: string, extensions: Record): string { + return header + .split(';') + .map((d) => d.trim()) + .filter(Boolean) + .map((directive) => { + const spaceIdx = directive.indexOf(' '); + const name = spaceIdx === -1 ? directive : directive.substring(0, spaceIdx); + const extra = extensions[name]; + return extra?.length ? `${directive} ${extra.join(' ')}` : directive; + }) + .join('; '); +} + +export function applyCspExtensions(response: Response): Response { + const extensions = getExtensions(); + if (Object.keys(extensions).length === 0) return response; + + for (const name of ['content-security-policy', 'content-security-policy-report-only']) { + const header = response.headers.get(name); + if (header) { + response.headers.set(name, extendHeader(header, extensions)); + } + } + + return response; +} diff --git a/apps/dashboard/src/lib/utils/functions/toHtml.ts b/apps/dashboard/src/lib/utils/functions/toHtml.ts index e8384112..e3ce171c 100644 --- a/apps/dashboard/src/lib/utils/functions/toHtml.ts +++ b/apps/dashboard/src/lib/utils/functions/toHtml.ts @@ -3,6 +3,8 @@ import { browser } from '$app/environment'; export const isHtmlValueEmpty = (html: string) => { if (!html || typeof html !== 'string') return true; + if (/({ - checking: false, - required: false, - available: false, - redirectUrl: null, - providerName: null, - providerId: null - }); + /** Per-email discovery result; display uses this over org-level when set */ + let discoveryState = $state(null); const redirectUrl = $derived(page.url.searchParams.get('redirect')); const hideGoogleAuth = $derived(!!($globalStore.isOrgSite && $currentOrg.disableGoogleAuth)); - async function initSSOInfo() { - if (isSSOInfoLoaded || !$currentOrg.id) return; - - isSSOInfoLoaded = true; - - const ssoInfo = await ssoApi.getOrgSsoInfo($currentOrg.id); - console.log('ssoInfo', ssoInfo); - - if (ssoInfo) { - orgSupportsSso = ssoInfo.hasSso; - ssoState = { - checking: false, - required: ssoInfo.ssoRequired, - available: ssoInfo.hasSso, - redirectUrl: ssoInfo.redirectUrl, - providerName: ssoInfo.providerName, - providerId: ssoInfo.providerId - }; - } - } + const ssoState = $derived(discoveryState ?? $authSsoStore.ssoState); + const orgSupportsSso = $derived($authSsoStore.orgSupportsSso); $effect(() => { - initSSOInfo(); + const orgId = $currentOrg.id; + if (orgId) ensureSsoInfoLoaded(orgId, (id) => ssoApi.getOrgSsoInfo(id)); }); - let emailCheckTimeout: ReturnType; - function handleEmailChange() { - errors.email = ''; - if (!orgSupportsSso) return; - - clearTimeout(emailCheckTimeout); - - emailCheckTimeout = setTimeout(async () => { - if (!fields.email || !fields.email.includes('@')) { - ssoState = { - checking: false, - required: false, - available: false, - redirectUrl: null, - providerName: null, - providerId: null - }; - return; - } - - ssoState.checking = true; - const result = await ssoApi.discoverSso(fields.email); - ssoState.checking = false; - - if (result) { - ssoState = { - checking: false, - required: result.ssoRequired, - available: result.ssoAvailable, - redirectUrl: result.redirectUrl, - providerName: result.providerName, - providerId: result.providerId - }; - } - }, 500); - } + const handleEmailChange = createSsoEmailChecker({ + getEmail: () => fields.email, + getOrgSupportsSso: () => orgSupportsSso, + discoverSso: (email) => ssoApi.discoverSso(email), + onChecking: () => { + discoveryState = { ...$authSsoStore.ssoState, checking: true }; + }, + onResult: (state) => { + discoveryState = state; + }, + onClear: () => { + errors.email = ''; + discoveryState = null; + } + }); async function handleSsoLogin() { - const url = `${window.location.origin}${ssoState.redirectUrl || ''}`; + const url = buildSsoRedirectUrl(ssoState.redirectUrl); - const res = await authClient.signIn.sso({ + await authClient.signIn.sso({ email: fields.email, providerId: ssoState.providerId || '', callbackURL: url }); - - console.log('res', res); } async function handleSubmit() { @@ -182,7 +127,32 @@ Welcome back to {$currentOrg.name || 'ClassroomIO'} - +{#snippet getPasswordAuthAlternative()} + +
+ {#if ssoState.required} +
+ +
+

+ {$t('settings.auth.login.sso_required')} +

+
+
+ {/if} + +
+{/snippet} + +
{$t('login.email')} @@ -205,44 +175,6 @@ - - {#if ssoState.available} -
-
- -
-

- {$t('settings.auth.login.sso_enabled', { provider: ssoState.providerName || 'SSO' })} -

- {#if ssoState.required} -

- {$t('settings.auth.login.sso_required')} -

- {/if} -
-
- -
- {/if} - - - {#if ssoState.available && !ssoState.required && !($globalStore.isOrgSite && $currentOrg.disableEmailPassword)} -
- -
- - {$t('settings.auth.login.or_continue_with')} - -
-
- {/if} - {#if !ssoState.required && !($globalStore.isOrgSite && $currentOrg.disableEmailPassword)} diff --git a/apps/dashboard/src/routes/(auth)/signup/+page.svelte b/apps/dashboard/src/routes/(auth)/signup/+page.svelte index a758bdd8..752e86f2 100644 --- a/apps/dashboard/src/routes/(auth)/signup/+page.svelte +++ b/apps/dashboard/src/routes/(auth)/signup/+page.svelte @@ -16,6 +16,10 @@ import { onMount } from 'svelte'; import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; + import { ssoApi } from '$features/org/api/sso.svelte'; + import ShieldIcon from '@lucide/svelte/icons/shield'; + import { buildSsoRedirectUrl, createSsoEmailChecker, type SsoAuthState } from '$features/auth/utils/auth-sso'; + import { authSsoStore, ensureSsoInfoLoaded } from '$features/auth/utils/auth-sso-store'; let { data } = $props(); const emailFromUrl = page.url.searchParams.get('email') ?? ''; @@ -29,6 +33,9 @@ } = $state({}); let submitError: string = $state(''); + /** Per-email discovery result; display uses this over org-level when set */ + let discoveryState = $state(null); + const disableSubmit = $derived(getDisableSubmit(fields)); const redirectUrl = $derived(page.url.searchParams.get('redirect')); const inviteToken = $derived(page.url.searchParams.get('invite_token')); @@ -36,6 +43,9 @@ // Use org from store or layout data (layout data ensures org is available on self-hosted before store is set) const org = $derived($currentOrg?.id ? $currentOrg : (data.org ?? $currentOrg)); + const ssoState = $derived(discoveryState ?? $authSsoStore.ssoState); + const orgSupportsSso = $derived($authSsoStore.orgSupportsSso); + // Invite context: allow signup when invite_token present or redirect contains invite info const hasInviteContext = $derived( !!inviteToken || (!!redirectUrl && (redirectUrl.includes('/invite/t/') || redirectUrl.includes('invite_token'))) @@ -54,12 +64,48 @@ } }); + $effect(() => { + const orgId = org?.id; + if (orgId) ensureSsoInfoLoaded(orgId, (id) => ssoApi.getOrgSsoInfo(id)); + }); + + const handleEmailChange = createSsoEmailChecker({ + getEmail: () => fields.email, + getOrgSupportsSso: () => orgSupportsSso, + discoverSso: (email) => ssoApi.discoverSso(email), + onChecking: () => { + discoveryState = { ...$authSsoStore.ssoState, checking: true }; + }, + onResult: (state) => { + discoveryState = state; + }, + onClear: () => { + errors.email = ''; + discoveryState = null; + } + }); + + async function handleSsoLogin() { + const url = buildSsoRedirectUrl(ssoState.redirectUrl); + + await authClient.signIn.sso({ + email: fields.email, + providerId: ssoState.providerId || '', + callbackURL: url + }); + } + async function handleSubmit() { if (signupRestricted) { submitError = t.get('settings.auth.login.signup_disabled_error'); return; } + if (ssoState.required && ssoState.available) { + handleSsoLogin(); + return; + } + const validationRes = authValidation(fields); console.log('validationRes', validationRes); @@ -151,7 +197,31 @@
{:else} - + {#snippet getPasswordAuthAlternative()} +
+ {#if ssoState.required} +
+ +
+

+ {$t('settings.auth.login.sso_required')} +

+
+
+ {/if} + +
+ {/snippet} + +
{$t('login.fields.email')} @@ -160,6 +230,7 @@ id="email" type="email" bind:value={fields.email} + oninput={handleEmailChange} placeholder="you@domain.com" disabled={loading || isEmailPrefilled} readonly={isEmailPrefilled} @@ -173,48 +244,50 @@ - - {$t('login.fields.password')} - - - {#if errors.password} - {$t(errors.password)} - {/if} - {$t('login.fields.password_helper_message')} - - + {#if !ssoState.required} + + {$t('login.fields.password')} + + + {#if errors.password} + {$t(errors.password)} + {/if} + {$t('login.fields.password_helper_message')} + + - - {$t('login.fields.confirm_password')} - - - {#if errors.confirmPassword} - {errors.confirmPassword} - {/if} - - + + {$t('login.fields.confirm_password')} + + + {#if errors.confirmPassword} + {errors.confirmPassword} + {/if} + + - {#if submitError} -

{submitError}

+ {#if submitError} +

{submitError}

+ {/if} + + {/if} - -
{/if} diff --git a/apps/dashboard/src/routes/courses/[id]/+page.svelte b/apps/dashboard/src/routes/courses/[id]/+page.svelte index 88926d43..24d3f763 100644 --- a/apps/dashboard/src/routes/courses/[id]/+page.svelte +++ b/apps/dashboard/src/routes/courses/[id]/+page.svelte @@ -1,9 +1,10 @@ @@ -13,6 +16,9 @@ {$t('course.navItem.attendance.title')} + + courseApi.refreshCourse(data.courseId, $profile.id)} /> + {#snippet child()} diff --git a/apps/dashboard/src/routes/courses/[id]/certificates/+page.svelte b/apps/dashboard/src/routes/courses/[id]/certificates/+page.svelte index bb11a01e..36e5bce5 100644 --- a/apps/dashboard/src/routes/courses/[id]/certificates/+page.svelte +++ b/apps/dashboard/src/routes/courses/[id]/certificates/+page.svelte @@ -1,5 +1,6 @@ @@ -11,6 +12,9 @@ {$t('course.navItem.certificates.title')} + + + {#snippet child()} diff --git a/apps/dashboard/src/routes/courses/[id]/lessons/+page.svelte b/apps/dashboard/src/routes/courses/[id]/lessons/+page.svelte index 29e24dd9..c7693d27 100644 --- a/apps/dashboard/src/routes/courses/[id]/lessons/+page.svelte +++ b/apps/dashboard/src/routes/courses/[id]/lessons/+page.svelte @@ -1,11 +1,12 @@ @@ -42,13 +48,16 @@ - {#if !data.personId} - - - - {/if} +
+ {#if !data.personId} + + + + {/if} + +
diff --git a/apps/dashboard/src/routes/courses/[id]/settings/+page.svelte b/apps/dashboard/src/routes/courses/[id]/settings/+page.svelte index c7e4ca5f..12aeac1e 100644 --- a/apps/dashboard/src/routes/courses/[id]/settings/+page.svelte +++ b/apps/dashboard/src/routes/courses/[id]/settings/+page.svelte @@ -3,9 +3,13 @@ import * as Page from '@cio/ui/base/page'; import { t } from '$lib/utils/functions/translations'; import { Button } from '@cio/ui/base/button'; + import { courseApi } from '$features/course/api'; + import { profile } from '$lib/utils/store/user'; + import { RefreshPageData } from '$features/ui'; let settingsComponent: CourseSettingsPage | null = $state(null); let isSaving = $state(false); + let { data } = $props(); async function handleSave() { isSaving = true; @@ -25,9 +29,12 @@ - +
+ + courseApi.refreshCourse(data.courseId, $profile.id)} /> +
diff --git a/apps/dashboard/src/routes/courses/[id]/submissions/+page.svelte b/apps/dashboard/src/routes/courses/[id]/submissions/+page.svelte index 977024fe..dbc3a190 100644 --- a/apps/dashboard/src/routes/courses/[id]/submissions/+page.svelte +++ b/apps/dashboard/src/routes/courses/[id]/submissions/+page.svelte @@ -1,6 +1,6 @@ - {t.get('automation.title')} - ClassroomIO + {t.get('automation.tabs.api')} - ClassroomIO - {$t('automation.title')} - {$t('automation.subtitle')} + {$t('automation.tabs.api')} + {$t('automation.coming_soon')} {#snippet child()} - + {/snippet} diff --git a/apps/dashboard/src/routes/org/[slug]/automation/mcp/+page.server.ts b/apps/dashboard/src/routes/org/[slug]/automation/mcp/+page.server.ts new file mode 100644 index 00000000..358bb904 --- /dev/null +++ b/apps/dashboard/src/routes/org/[slug]/automation/mcp/+page.server.ts @@ -0,0 +1,23 @@ +import { classroomio, getApiHeaders } from '$lib/utils/services/api'; + +export const load = async ({ parent, cookies }) => { + const { orgId } = await parent(); + + if (!orgId) { + return { keys: [], usage: null }; + } + + const headers = getApiHeaders(cookies, orgId); + + const [keysResponse, usageResponse] = await Promise.all([ + classroomio.organization.automation.keys.$get({ query: { type: 'mcp' } }, headers), + classroomio.organization.automation.usage.$get({ query: { type: 'mcp' } }, headers) + ]); + + const [keysData, usageData] = await Promise.all([keysResponse.json(), usageResponse.json()]); + + return { + keys: keysData.success ? keysData.data : [], + usage: usageData.success ? usageData.data : null + }; +}; diff --git a/apps/dashboard/src/routes/org/[slug]/automation/mcp/+page.svelte b/apps/dashboard/src/routes/org/[slug]/automation/mcp/+page.svelte new file mode 100644 index 00000000..cfecc327 --- /dev/null +++ b/apps/dashboard/src/routes/org/[slug]/automation/mcp/+page.svelte @@ -0,0 +1,34 @@ + + + + {t.get('automation.tabs.mcp')} - ClassroomIO + + + + + + {$t('automation.tabs.mcp')} + {$t('automation.subtitle')} + + + + + {#snippet child()} + + {/snippet} + + diff --git a/apps/dashboard/src/routes/org/[slug]/automation/zapier/+page.svelte b/apps/dashboard/src/routes/org/[slug]/automation/zapier/+page.svelte new file mode 100644 index 00000000..e76803f3 --- /dev/null +++ b/apps/dashboard/src/routes/org/[slug]/automation/zapier/+page.svelte @@ -0,0 +1,22 @@ + + + + {t.get('automation.tabs.zapier')} - ClassroomIO + + + + + {$t('automation.tabs.zapier')} + {$t('automation.coming_soon')} + + + + + {#snippet child()} + + {/snippet} + diff --git a/apps/dashboard/src/routes/org/[slug]/courses/+page.svelte b/apps/dashboard/src/routes/org/[slug]/courses/+page.svelte index fcbd2eff..86616d4f 100644 --- a/apps/dashboard/src/routes/org/[slug]/courses/+page.svelte +++ b/apps/dashboard/src/routes/org/[slug]/courses/+page.svelte @@ -13,11 +13,13 @@ let searchValue = $state(''); let selectedId: string = $state('0'); + let selectedOrder = $state<'asc' | 'desc'>('desc'); let selectedTags = $state([]); let hasInitializedFilters = $state(false); let isFiltering = $state(false); const validSortValues = new Set(COURSE_SORT_OPTIONS.map((option) => option.value)); + const validSortOrders = new Set(['asc', 'desc']); $effect(() => { if (data.courses) { @@ -37,6 +39,27 @@ return '0'; } + function normalizeSortOrder(sortOrder: string | null): 'asc' | 'desc' { + if (sortOrder && validSortOrders.has(sortOrder)) { + return sortOrder as 'asc' | 'desc'; + } + + return 'desc'; + } + + function getLegacySortState(sortValue: string | null) { + switch (sortValue) { + case '3': + return { selectedId: '0', selectedOrder: 'asc' as const }; + case '4': + return { selectedId: '1', selectedOrder: 'asc' as const }; + case '5': + return { selectedId: '2', selectedOrder: 'asc' as const }; + default: + return null; + } + } + function updateFiltersUrl() { if (!browser || !hasInitializedFilters) { return; @@ -56,6 +79,12 @@ nextUrl.searchParams.delete('sort'); } + if (selectedOrder !== 'desc') { + nextUrl.searchParams.set('order', selectedOrder); + } else { + nextUrl.searchParams.delete('order'); + } + const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`; window.history.replaceState(window.history.state, '', nextPath); } @@ -65,7 +94,8 @@ return; } - localStorage.setItem('classroomio_filter_course_key', selectedId); + localStorage.setItem('classroomio_filter_course_sort_key', selectedId); + localStorage.setItem('classroomio_filter_course_order_key', selectedOrder); updateFiltersUrl(); }); @@ -94,6 +124,7 @@ async function clearFilters() { selectedId = '0'; + selectedOrder = 'desc'; if (selectedTags.length === 0) { return; @@ -111,15 +142,27 @@ return false; }); + const sortedCourses = [...filteredCourses]; + if (selectedId === '0') { - return filteredCourses.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + return sortedCourses.sort((a, b) => + selectedOrder === 'asc' + ? new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + : new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); } else if (selectedId === '1') { - return filteredCourses.sort((a, b) => (b.isPublished ? 0 : 1) - (a.isPublished ? 0 : 1)); + return sortedCourses.sort((a, b) => + selectedOrder === 'asc' + ? Number(a.isPublished) - Number(b.isPublished) + : Number(b.isPublished) - Number(a.isPublished) + ); } else if (selectedId === '2') { - return filteredCourses.sort((a, b) => b.lessonCount - a.lessonCount); + return sortedCourses.sort((a, b) => + selectedOrder === 'asc' ? a.lessonCount - b.lessonCount : b.lessonCount - a.lessonCount + ); } - return filteredCourses; + return sortedCourses; }); onMount(() => { @@ -129,9 +172,32 @@ $courseMetaDeta.view = courseView; } - const sortFromUrl = normalizeSortValue(new URLSearchParams(window.location.search).get('sort')); - const sortFromStorage = normalizeSortValue(localStorage.getItem('classroomio_filter_course_key')); - selectedId = sortFromUrl !== '0' ? sortFromUrl : sortFromStorage; + const searchParams = new URLSearchParams(window.location.search); + const legacySortFromUrl = getLegacySortState(searchParams.get('sort')); + const legacySortFromStorage = getLegacySortState(localStorage.getItem('classroomio_filter_course_key')); + const oldSortFromStorage = normalizeSortValue(localStorage.getItem('classroomio_filter_course_key')); + const sortFromUrl = normalizeSortValue(searchParams.get('sort')); + const sortFromStorage = normalizeSortValue(localStorage.getItem('classroomio_filter_course_sort_key')); + const orderFromUrl = normalizeSortOrder(searchParams.get('order')); + const orderFromStorage = normalizeSortOrder(localStorage.getItem('classroomio_filter_course_order_key')); + + if (legacySortFromUrl) { + selectedId = legacySortFromUrl.selectedId; + selectedOrder = legacySortFromUrl.selectedOrder; + } else if (sortFromUrl !== '0' || orderFromUrl !== 'desc') { + selectedId = sortFromUrl; + selectedOrder = orderFromUrl; + } else if (legacySortFromStorage) { + selectedId = legacySortFromStorage.selectedId; + selectedOrder = legacySortFromStorage.selectedOrder; + } else if (sortFromStorage !== '0' || orderFromStorage !== 'desc') { + selectedId = sortFromStorage; + selectedOrder = orderFromStorage; + } else { + selectedId = oldSortFromStorage; + selectedOrder = 'desc'; + } + hasInitializedFilters = true; updateFiltersUrl(); }); @@ -156,6 +222,7 @@ {#snippet filterControls()} d.trim()) - .filter(Boolean) - .map((d) => (d.startsWith('http://') || d.startsWith('https://') ? d : `https://${d}`)); -} - -const ALLOWED_EXTERNAL_DOMAINS = parseDomainsFromEnv('ALLOWED_EXTERNAL_DOMAINS'); -const ALLOWED_SCRIPT_SRC = parseDomainsFromEnv('CSP_SCRIPT_SRC_DOMAINS'); -const ALLOWED_STYLE_SRC = parseDomainsFromEnv('CSP_STYLE_SRC_DOMAINS'); -const ALLOWED_CONNECT_SRC = parseDomainsFromEnv('CSP_CONNECT_SRC_DOMAINS'); -const ALLOWED_FRAME_SRC = parseDomainsFromEnv('CSP_FRAME_SRC_DOMAINS'); -const ALLOWED_FONT_SRC = parseDomainsFromEnv('CSP_FONT_SRC_DOMAINS'); - -const defaultScriptSrcDomains = [ - 'https://assets.cdn.clsrio.com', - 'https://cdnjs.cloudflare.com', - 'https://*.posthog.com', - 'https://*.senja.io', - 'https://umami.hz.oncws.com', - 'https://www.youtube.com', - 'https://youtube.com', - 'https://google.com' -]; -const defaultStyleSrcDomains = [ - 'https://cdn.plyr.io', - 'https://unpkg.com/katex@0.12.0/dist/katex.min.css', - 'https://assets.cdn.clsrio.com/eqneditor_1.css', - 'https://fonts.googleapis.com' -]; -const defaultConnectSrcDomains = [ - 'https://*.classroomio.com', - 'https://classroomio.com', - 'https://app.classroomio.com', - 'https://api.classroomio.com', - 'https://pgrest.classroomio.com', - 'https://play.classroomio.com', - 'wss://*.classroomio.com', - 'https://assets.cdn.clsrio.com', - 'https://cdn.plyr.io', - 'https://*.posthog.com', - 'https://umami.hz.oncws.com', - 'https://*.r2.cloudflarestorage.com', - 'https://*.senja.io', - 'https://*.ytimg.com', - 'https://noembed.com' -]; -const defaultFrameSrcDomains = [ - 'https://www.youtube.com', - 'https://youtube.com', - 'https://www.youtube-nocookie.com', - 'https://www.google.com', - 'https://google.com' -]; -const defaultFontSrcDomains = ['https://fonts.gstatic.com', 'https://cdn.plyr.io']; - -// Self-hosted: use ONLY env vars, no defaults (strict CISO allowlists, data residency). -// SaaS: use defaults when env vars are unset (backward compatible). -const scriptSrcDomains = useNodeAdapter - ? (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_SCRIPT_SRC ?? []) - : (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_SCRIPT_SRC ?? defaultScriptSrcDomains); -const styleSrcDomains = useNodeAdapter - ? (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_STYLE_SRC ?? []) - : (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_STYLE_SRC ?? defaultStyleSrcDomains); -const connectSrcDomains = useNodeAdapter - ? (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_CONNECT_SRC ?? []) - : (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_CONNECT_SRC ?? defaultConnectSrcDomains); -const frameSrcDomains = useNodeAdapter - ? (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_FRAME_SRC ?? []) - : (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_FRAME_SRC ?? defaultFrameSrcDomains); -const fontSrcDomains = useNodeAdapter - ? (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_FONT_SRC ?? []) - : (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_FONT_SRC ?? defaultFontSrcDomains); +const csp = getCspDomains(isSelfHosted, process.env.PUBLIC_SERVER_URL); /** @type {import('@sveltejs/kit').Config} */ const config = { - // Consult https://kit.svelte.dev/docs/integrations#preprocessors - // for more information about preprocessors preprocess: [vitePreprocess({})], kit: { - adapter: IS_CLOUDFLARE ? adapterCloudflare() : useNodeAdapter ? adapterNode() : adapterAuto(), + adapter: IS_CLOUDFLARE ? adapterCloudflare() : isSelfHosted ? adapterNode() : adapterAuto(), alias: { $lib: path.resolve('./src/lib'), $features: path.resolve('./src/lib/features'), @@ -117,20 +35,20 @@ const config = { mode: 'auto', directives: { 'default-src': ['self'], - 'script-src': ['self', ...scriptSrcDomains, 'unsafe-hashes', 'unsafe-eval'], - 'style-src': ['self', 'unsafe-inline', ...styleSrcDomains], - 'style-src-elem': ['self', 'unsafe-inline', ...styleSrcDomains], - 'font-src': ['self', ...fontSrcDomains], - 'img-src': ['self', 'data:', 'https:', 'blob:', 'http://localhost:9000'], - 'media-src': ['self', 'https:', 'data:', 'blob:', 'http://localhost:9000'], - 'frame-src': ['self', ...frameSrcDomains], + 'script-src': ['self', ...csp.scriptSrc, 'unsafe-hashes', 'unsafe-eval'], + 'style-src': ['self', 'unsafe-inline', ...csp.styleSrc], + 'style-src-elem': ['self', 'unsafe-inline', ...csp.styleSrc], + 'font-src': ['self', ...csp.fontSrc], + 'img-src': ['self', 'data:', ...csp.mediaSrc, 'blob:', 'http://localhost:9000'], + 'media-src': ['self', ...csp.mediaSrc, 'data:', 'blob:', 'http://localhost:9000'], + 'frame-src': ['self', ...csp.frameSrc], 'connect-src': [ 'self', 'blob:', 'http://localhost:3002', 'http://localhost:9000', - ...(API_ORIGIN ? [API_ORIGIN] : []), - ...connectSrcDomains + ...(csp.apiOrigin ? [csp.apiOrigin] : []), + ...csp.connectSrc ], 'worker-src': ['self', 'blob:'], 'object-src': ['none'], @@ -141,20 +59,20 @@ const config = { }, reportOnly: { 'default-src': ['self'], - 'script-src': ['self', ...scriptSrcDomains, 'unsafe-hashes', 'unsafe-eval'], - 'style-src': ['self', 'unsafe-inline', ...styleSrcDomains], - 'style-src-elem': ['self', 'unsafe-inline', ...styleSrcDomains], - 'font-src': ['self', ...fontSrcDomains], - 'img-src': ['self', 'data:', 'https:', 'blob:', 'http://localhost:9000'], - 'media-src': ['self', 'https:', 'data:', 'blob:', 'http://localhost:9000'], - 'frame-src': ['self', ...frameSrcDomains], + 'script-src': ['self', ...csp.scriptSrc, 'unsafe-hashes', 'unsafe-eval'], + 'style-src': ['self', 'unsafe-inline', ...csp.styleSrc], + 'style-src-elem': ['self', 'unsafe-inline', ...csp.styleSrc], + 'font-src': ['self', ...csp.fontSrc], + 'img-src': ['self', 'data:', ...csp.mediaSrc, 'blob:', 'http://localhost:9000'], + 'media-src': ['self', ...csp.mediaSrc, 'data:', 'blob:', 'http://localhost:9000'], + 'frame-src': ['self', ...csp.frameSrc], 'connect-src': [ 'self', 'blob:', 'http://localhost:3002', 'http://localhost:9000', - ...(API_ORIGIN ? [API_ORIGIN] : []), - ...connectSrcDomains + ...(csp.apiOrigin ? [csp.apiOrigin] : []), + ...csp.connectSrc ], 'worker-src': ['self', 'blob:'], 'object-src': ['none'], diff --git a/apps/docs/content/docs/mcp.mdx b/apps/docs/content/docs/mcp.mdx index 108a8122..bc0f4770 100644 --- a/apps/docs/content/docs/mcp.mdx +++ b/apps/docs/content/docs/mcp.mdx @@ -132,6 +132,7 @@ Current tools: - `list_org_courses` - `get_course_structure` +- `reorder_course_content` - `update_course_landing_page` - `create_course_draft` - `create_course_draft_from_course` @@ -172,7 +173,42 @@ Drafts support: - safer updates to existing courses - auditability of AI-generated structure -## Create a New Course +Lesson authoring rule: + +- `lessonLanguages[].content` should contain only the lesson body HTML +- do not include the lesson title +- do not use `h1` or `h2` in lesson content +- start headings at `h3` +- ClassroomIO already renders the lesson title in the course UI + +## After Publish + +Once a draft has been published, the live course becomes the safer source of truth. + +Recommended agent rule: + +- if the draft is still `DRAFT`, continue editing that draft +- if the draft is `PUBLISHED`, do not keep reusing it for major follow-up edits +- instead, read the live course and create a fresh draft from that course + +Recommended post-publish sequence: + +1. `get_course_structure` +2. `create_course_draft_from_course` +3. `update_course_draft` +4. `publish_course_draft_to_existing_course` + +Why: + +- teachers may edit the live course in the UI after publish +- another agent session may have changed the course +- reusing an old published draft can overwrite newer live changes + +For exercises on an already-published course, prefer the direct exercise tools instead of creating a whole new draft when the user only wants to add or revise exercises. + +## User Flows + +### Flow 1: Create a new course from a prompt Typical flow: @@ -195,26 +231,20 @@ Publish returns: - use an explicit `bannerImageUrl` - generate a random Unsplash-based banner from the course title or a custom query -## Update an Existing Course +### Flow 2: Update a landing page with AI-generated copy and media -When the course already exists, use the live course as the source of truth. +User says: -Recommended sequence: +```text +Rewrite this course landing page to feel more premium, generate a new cover image, and add three believable reviews. +``` -1. `list_org_courses` if the agent needs to resolve the course ID by name -2. `get_course_structure` -3. `create_course_draft_from_course` -4. `update_course_draft` -5. `publish_course_draft_to_existing_course` +Expected tool sequence: -This avoids applying broad, blind changes directly to the live course. +1. Agent calls `list_org_courses` if it needs to resolve the course ID by name first. +2. Agent calls `update_course_landing_page`. -## Update a Course Landing Page - -Use `update_course_landing_page` when the user wants AI help with sales-page style content on an -already-existing course. - -This tool can update: +What the landing-page tool can update: - title - short description @@ -224,33 +254,167 @@ This tool can update: - pricing fields - instructor section - reviews -- the landing-page image +- landing page image -For images, the tool supports either: +The tool can either: -- `imageUrl` for an explicit cover image -- `generateImage` and `imageQuery` for a random Unsplash-derived image +- use an explicit `imageUrl` +- or generate a random Unsplash-based image with `generateImage` and `imageQuery` -## After Publish +### Flow 3: Create a draft from a PDF -Once a draft has been published, the live course becomes the safer source of truth. +User says: -Recommended agent rule: +```text +I have my course in a PDF. Extract it and turn it into a ClassroomIO course draft. +``` -- if the draft is still `DRAFT`, continue editing that draft -- if the draft is `PUBLISHED`, do not keep reusing it for major follow-up edits -- instead, read the live course and create a fresh draft from that course +Expected tool sequence: -Why: +1. Agent reads the PDF itself. +2. Agent restructures the content into normalized JSON. +3. Agent calls `create_course_draft`. +4. User reviews the draft. +5. Agent calls `update_course_draft` if needed. +6. User approves. +7. Agent calls `publish_course_draft`. -- teachers may edit the live course in the UI after publish -- another agent session may have changed the course -- reusing an old published draft can overwrite newer live changes +Important: -## Exercises on Live Courses +- the PDF does not need to be uploaded to MCP +- the MCP package only receives structured JSON -If the user only wants to add or revise exercises on a published course, prefer the direct -exercise tools instead of creating a whole new draft: +### Flow 4: Tag a draft before publish + +User says: + +```text +Add tags for algebra, beginner, and exponential growth to this draft. +``` + +Expected tool sequence: + +1. Agent calls `tag_course_draft`. +2. User reviews the updated draft tags. +3. Agent later calls `publish_course_draft`. + +Result: + +- the draft stores tag names +- publish ensures the tags exist in ClassroomIO +- publish assigns those tags to the live course + +### Flow 5: Update an existing course safely + +User says: + +```text +Take my existing Algebra course, reorganize it, and rewrite the lesson content for a beginner audience. +``` + +When the course already exists, use the live course as the source of truth. + +Expected tool sequence: + +1. `list_org_courses` if the agent needs to resolve the course ID by name +2. Agent calls `get_course_structure` with the existing `courseId`. +3. Agent optionally calls `create_course_draft_from_course` to persist a seeded draft. +4. Agent updates the draft with the new structure and content using `update_course_draft`. +5. User reviews the changes. +6. Agent calls `publish_course_draft_to_existing_course`. + +What this publish does today: + +- updates the existing course title, description, type, and metadata +- can set a generated course cover from Unsplash +- returns the public `courseUrl` +- updates existing sections when the draft keeps their IDs +- updates existing lessons when the draft keeps their IDs +- creates new sections and lessons for draft items without matching live IDs +- upserts lesson language content +- merges draft tags into the existing course + +### Flow 6: Reorder live course content + +User says: + +```text +Move the quiz before lesson 2 and put the recap lesson into the last section. +``` + +Use `reorder_course_content` when the live course only needs structural moves: + +- reorder sections +- reorder lessons or exercises within a section +- move lessons or exercises between sections + +This is the fast path for live structure edits and avoids creating a draft when lesson content is not changing. + +Expected tool sequence: + +1. Agent calls `reorder_course_content`. + +Use this direct path when the live course only needs section ordering or lesson or exercise moves between sections. +It avoids the full draft round-trip for simple structural changes. + +### Flow 7: Add exercises to a live course + +User says: + +```text +Add a 5-question multiple-choice exercise to lesson 3. +``` + +Expected tool sequence: + +1. Agent calls `list_course_exercises` if it needs current context. +2. Agent calls `create_course_exercise` for a brand new exercise. +3. Agent calls `update_course_exercise` for later revisions. + +Use this direct path when the course is already live and the change is exercise-specific. + +What it does not do today: + +- automatically delete sections or lessons that are absent from the draft +- automatically delete lesson locales that are absent from the draft + +That behavior is intentional. The current update path is non-destructive by default. + +## How Existing-Course Updates Work + +When a draft is seeded from a live course: + +- section `externalId` values are the real section IDs +- lesson `externalId` values are the real lesson IDs + +As long as the agent preserves those IDs while editing the draft, publish-to-existing-course can map draft items back to the live records and update them safely. + +If the agent adds a brand new section or lesson, it should assign a new synthetic `externalId`. ClassroomIO will create that record on publish. + +## Input Contract Notes + +For large updates, the agent should: + +1. call `get_course_structure` +2. keep existing `externalId` values for records it wants to update +3. only generate new `externalId` values for new records +4. avoid deleting records from the draft unless the user explicitly wants omitted content to remain untouched + +Publish input also supports: + +- `bannerImageUrl`: use this exact course cover +- `bannerImageQuery`: search Unsplash with this query +- `generateBannerImage`: fetch a random Unsplash image using the course title or query + +Tagging tools use: + +- `tagNames`: human-readable tag names, not tag IDs +- `mode: "merge" | "replace"` +- `groupName` optional, defaults to `Automation` + +The API creates missing tags automatically when needed. + +Exercise tools use the live course directly: - `list_course_exercises` - `get_course_exercise` @@ -258,8 +422,6 @@ exercise tools instead of creating a whole new draft: - `create_course_exercise_from_template` - `update_course_exercise` -Use a draft only when the agent is restructuring broader course content. - Supported `questionTypeId` values for exercise payloads: - `1` `RADIO` - Single answer @@ -275,29 +437,24 @@ Supported `questionTypeId` values for exercise payloads: - `11` `HOTSPOT` - Hotspot - `12` `LINK` - Links -## Tagging +## Operational Notes -The MCP supports tagging at two levels: +- `create_course_draft_from_course` may add warnings if a live course uses legacy lesson notes instead of localized lesson content +- if a course has ungrouped lessons, the seeded draft may add a synthetic `Ungrouped` section so the structure stays valid +- published drafts are treated as finalized snapshots -- `tag_course_draft` - - set or revise tags before publish -- `tag_courses` - - tag one or more live courses +## Recommended Agent Behavior -The live-course tagging tool supports merge-or-replace behavior. Use merge when the user wants to -add tags without removing existing ones. +For small edits, an agent can still read the course structure and modify only the affected items in the draft. -## PDF-Based Authoring +For broad revisions, prefer this sequence: -If a user wants to turn a PDF into a course: +1. `get_course_structure` +2. `create_course_draft_from_course` +3. `update_course_draft` +4. `publish_course_draft_to_existing_course` -1. the agent reads and extracts the PDF itself -2. the agent converts the content into normalized course JSON -3. the agent calls `create_course_draft` -4. the user reviews the draft -5. the agent publishes it - -The PDF itself is not uploaded to the MCP server. +That keeps major AI-assisted edits reviewable and explicit. Keep published drafts for history and auditability. Do not treat omission from a draft as an diff --git a/apps/docs/content/docs/quickstart/self-hosting.mdx b/apps/docs/content/docs/quickstart/self-hosting.mdx index fa71be73..f1ca3618 100644 --- a/apps/docs/content/docs/quickstart/self-hosting.mdx +++ b/apps/docs/content/docs/quickstart/self-hosting.mdx @@ -31,7 +31,7 @@ cd classroomio cp .env.example .env ``` -Open `.env` and set `PUBLIC_SERVER_URL`, `TRUSTED_ORIGINS`, `BETTER_AUTH_SECRET`, and `ALLOWED_EXTERNAL_DOMAINS` for your domain. For local development the defaults work as-is. +Open `.env` and set `PUBLIC_SERVER_URL`, `TRUSTED_ORIGINS`, and `BETTER_AUTH_SECRET` for your domain. For local development the defaults work as-is. 3. Start the full stack: @@ -73,10 +73,23 @@ Docker self-hosting uses a single root `.env` file. Every `docker compose` comma | `PUBLIC_IS_SELFHOSTED` | Self-hosted mode flag | `true` | | `PRIVATE_SERVER_URL` | Internal API URL for SSR (Docker network) | `http://api:3081` | | `DASHBOARD_ORIGIN` | Dashboard origin for invite links, email links, etc. | `http://localhost:3082` | -| `ALLOWED_EXTERNAL_DOMAINS` | CSP allowed domains (comma-separated) | — | + +#### CSP Domains (Runtime) + +CSP domain variables are **runtime** — set them in your `.env` or `docker-compose.yaml` `environment:` section. No image rebuild is needed when changing them. + +| Variable | Description | +|----------|-------------| +| `ALLOWED_EXTERNAL_DOMAINS` | Overrides all per-directive CSP vars (comma-separated) | +| `CSP_SCRIPT_SRC_DOMAINS` | Domains allowed in `script-src` | +| `CSP_STYLE_SRC_DOMAINS` | Domains allowed in `style-src` | +| `CSP_CONNECT_SRC_DOMAINS` | Domains allowed in `connect-src` | +| `CSP_FRAME_SRC_DOMAINS` | Domains allowed in `frame-src` | +| `CSP_FONT_SRC_DOMAINS` | Domains allowed in `font-src` | +| `CSP_MEDIA_SRC_DOMAINS` | Domains allowed in `img-src` and `media-src` | - When `PUBLIC_IS_SELFHOSTED=true`, the dashboard has **no default** CSP domains. You must set `ALLOWED_EXTERNAL_DOMAINS` with your CDN, API, video, and font domains. + When `PUBLIC_IS_SELFHOSTED=true`, the dashboard starts with only `'self'` allowed. Set `ALLOWED_EXTERNAL_DOMAINS` (or the per-directive vars) with your CDN, API, video, and font domains. Since these are runtime env vars, you can change them by restarting the container — no rebuild required. #### Optional — Email @@ -229,7 +242,7 @@ PRIVATE_SERVER_URL="http://${{api.RAILWAY_PRIVATE_DOMAIN}}:8080" PUBLIC_SERVER_URL="https://api.yourdomain.com" ``` -Optional: add `PUBLIC_APP_TITLE`, `PUBLIC_APP_DESCRIPTION`, `PUBLIC_OG_IMAGE_URL` for custom branding. +Optional: add `PUBLIC_APP_TITLE`, `PUBLIC_APP_DESCRIPTION`, `PUBLIC_OG_IMAGE_URL` for custom branding. You can also use per-directive CSP vars (`CSP_SCRIPT_SRC_DOMAINS`, `CSP_MEDIA_SRC_DOMAINS`, etc.) instead of `ALLOWED_EXTERNAL_DOMAINS` for finer control. `PRIVATE_SERVER_KEY` must be the same value in both API and dashboard. It authenticates server-side dashboard requests to the API. diff --git a/docker/Dockerfile.api b/docker/Dockerfile.api index 4bdf36fa..58b6594a 100644 --- a/docker/Dockerfile.api +++ b/docker/Dockerfile.api @@ -13,9 +13,12 @@ RUN pnpm install --frozen-lockfile --ignore-scripts --shamefully-hoist --filter RUN find . -name 'tsconfig.tsbuildinfo' -delete RUN pnpm -r --filter @cio/api... build +COPY docker/entrypoint-api.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + EXPOSE 3081 ENV NODE_ENV=production \ PORT=3081 -CMD ["pnpm", "--filter", "@cio/api", "start"] +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/docker/Dockerfile.dashboard b/docker/Dockerfile.dashboard index 4ee9889b..eaa44fb1 100644 --- a/docker/Dockerfile.dashboard +++ b/docker/Dockerfile.dashboard @@ -5,13 +5,6 @@ WORKDIR /app ENV CI=true ARG PUBLIC_IS_SELFHOSTED=true -ARG PUBLIC_SERVER_URL -ARG ALLOWED_EXTERNAL_DOMAINS -ARG CSP_SCRIPT_SRC_DOMAINS -ARG CSP_STYLE_SRC_DOMAINS -ARG CSP_CONNECT_SRC_DOMAINS -ARG CSP_FRAME_SRC_DOMAINS -ARG CSP_FONT_SRC_DOMAINS RUN corepack enable && corepack prepare pnpm@10.19.0 --activate @@ -19,17 +12,8 @@ COPY . . RUN pnpm install --frozen-lockfile --ignore-scripts --filter @cio/dashboard... RUN find . -name 'tsconfig.tsbuildinfo' -delete -RUN echo "Dashboard build PUBLIC_IS_SELFHOSTED=${PUBLIC_IS_SELFHOSTED}" && \ - echo "Dashboard build PUBLIC_SERVER_URL=${PUBLIC_SERVER_URL}" && \ - NODE_OPTIONS="--max-old-space-size=3072" \ +RUN NODE_OPTIONS="--max-old-space-size=3072" \ PUBLIC_IS_SELFHOSTED="${PUBLIC_IS_SELFHOSTED}" \ - PUBLIC_SERVER_URL="${PUBLIC_SERVER_URL}" \ - ALLOWED_EXTERNAL_DOMAINS="${ALLOWED_EXTERNAL_DOMAINS}" \ - CSP_SCRIPT_SRC_DOMAINS="${CSP_SCRIPT_SRC_DOMAINS}" \ - CSP_STYLE_SRC_DOMAINS="${CSP_STYLE_SRC_DOMAINS}" \ - CSP_CONNECT_SRC_DOMAINS="${CSP_CONNECT_SRC_DOMAINS}" \ - CSP_FRAME_SRC_DOMAINS="${CSP_FRAME_SRC_DOMAINS}" \ - CSP_FONT_SRC_DOMAINS="${CSP_FONT_SRC_DOMAINS}" \ pnpm -r --filter @cio/dashboard... build RUN test -f apps/dashboard/build/index.js || (echo "ERROR: Dashboard build output not found at apps/dashboard/build/index.js" && exit 1) diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 9acb668f..54f9ff6a 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -72,23 +72,6 @@ services: profiles: - minio - db-init: - build: - context: .. - dockerfile: docker/Dockerfile.api - container_name: cio-db-init - depends_on: - postgres: - condition: service_healthy - environment: - DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-classroomio} - PRIVATE_DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-classroomio} - command: - - sh - - -c - - pnpm --filter @cio/db db:setup - restart: "no" - api: build: context: .. @@ -101,8 +84,6 @@ services: condition: service_healthy redis: condition: service_healthy - db-init: - condition: service_completed_successfully environment: NODE_ENV: production PORT: 3081 @@ -137,6 +118,7 @@ services: LICENSE_KEY: ${LICENSE_KEY:-} UNSPLASH_API_KEY: ${UNSPLASH_API_KEY:-} BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-local-dev-only-secret-change-this} + SKIP_DB_SETUP: ${SKIP_DB_SETUP:-} restart: unless-stopped dashboard: @@ -145,13 +127,6 @@ services: dockerfile: docker/Dockerfile.dashboard args: PUBLIC_IS_SELFHOSTED: ${PUBLIC_IS_SELFHOSTED:-true} - PUBLIC_SERVER_URL: ${PUBLIC_SERVER_URL:-http://localhost:3081} - ALLOWED_EXTERNAL_DOMAINS: ${ALLOWED_EXTERNAL_DOMAINS:-} - CSP_SCRIPT_SRC_DOMAINS: ${CSP_SCRIPT_SRC_DOMAINS:-} - CSP_STYLE_SRC_DOMAINS: ${CSP_STYLE_SRC_DOMAINS:-} - CSP_CONNECT_SRC_DOMAINS: ${CSP_CONNECT_SRC_DOMAINS:-} - CSP_FRAME_SRC_DOMAINS: ${CSP_FRAME_SRC_DOMAINS:-} - CSP_FONT_SRC_DOMAINS: ${CSP_FONT_SRC_DOMAINS:-} container_name: cio-dashboard ports: - "3082:3082" @@ -169,6 +144,13 @@ services: PRIVATE_APP_HOST: ${PRIVATE_APP_HOST:-localhost} PRIVATE_APP_SUBDOMAINS: ${PRIVATE_APP_SUBDOMAINS:-app} AUTH_BEARER_TOKEN: ${AUTH_BEARER_TOKEN:-} + ALLOWED_EXTERNAL_DOMAINS: ${ALLOWED_EXTERNAL_DOMAINS:-} + CSP_SCRIPT_SRC_DOMAINS: ${CSP_SCRIPT_SRC_DOMAINS:-} + CSP_STYLE_SRC_DOMAINS: ${CSP_STYLE_SRC_DOMAINS:-} + CSP_CONNECT_SRC_DOMAINS: ${CSP_CONNECT_SRC_DOMAINS:-} + CSP_FRAME_SRC_DOMAINS: ${CSP_FRAME_SRC_DOMAINS:-} + CSP_FONT_SRC_DOMAINS: ${CSP_FONT_SRC_DOMAINS:-} + CSP_MEDIA_SRC_DOMAINS: ${CSP_MEDIA_SRC_DOMAINS:-} restart: unless-stopped volumes: diff --git a/docker/docker-push.sh b/docker/docker-push.sh index b94c523b..01ca1b2c 100755 --- a/docker/docker-push.sh +++ b/docker/docker-push.sh @@ -42,13 +42,6 @@ echo "Dashboard build arg PUBLIC_IS_SELFHOSTED=${PUBLIC_IS_SELFHOSTED}" docker build \ -f docker/Dockerfile.dashboard \ --build-arg PUBLIC_IS_SELFHOSTED=${PUBLIC_IS_SELFHOSTED} \ - --build-arg PUBLIC_SERVER_URL=${PUBLIC_SERVER_URL:-} \ - --build-arg ALLOWED_EXTERNAL_DOMAINS="${ALLOWED_EXTERNAL_DOMAINS:-}" \ - --build-arg CSP_SCRIPT_SRC_DOMAINS="${CSP_SCRIPT_SRC_DOMAINS:-}" \ - --build-arg CSP_STYLE_SRC_DOMAINS="${CSP_STYLE_SRC_DOMAINS:-}" \ - --build-arg CSP_CONNECT_SRC_DOMAINS="${CSP_CONNECT_SRC_DOMAINS:-}" \ - --build-arg CSP_FRAME_SRC_DOMAINS="${CSP_FRAME_SRC_DOMAINS:-}" \ - --build-arg CSP_FONT_SRC_DOMAINS="${CSP_FONT_SRC_DOMAINS:-}" \ -t ${DOCKERHUB_USERNAME}/dashboard:${VERSION} \ -t ${DOCKERHUB_USERNAME}/dashboard:latest \ . diff --git a/docker/docs/PUBLISHING_IMAGES.md b/docker/docs/PUBLISHING_IMAGES.md index 523c892c..9ae17d3f 100644 --- a/docker/docs/PUBLISHING_IMAGES.md +++ b/docker/docs/PUBLISHING_IMAGES.md @@ -158,7 +158,7 @@ The GitHub Actions workflow builds for both `linux/amd64` and `linux/arm64` plat ### Build failures - Check Dockerfile syntax -- Ensure all build arguments are provided +- The only required build argument is `PUBLIC_IS_SELFHOSTED` (defaults to `true`) - Check available disk space: `docker system df` ### Clean up old images diff --git a/docker/docs/SELF_HOST.md b/docker/docs/SELF_HOST.md index 3740f33f..c9988795 100644 --- a/docker/docs/SELF_HOST.md +++ b/docker/docs/SELF_HOST.md @@ -25,7 +25,8 @@ All Docker services read from a single root `.env` file. See [`.env.example`](.. Key points: -- **Required:** `PUBLIC_SERVER_URL`, `TRUSTED_ORIGINS`, `BETTER_AUTH_SECRET`, `PUBLIC_IS_SELFHOSTED=true`, `ALLOWED_EXTERNAL_DOMAINS`, `DASHBOARD_ORIGIN`. +- **Required:** `PUBLIC_SERVER_URL`, `TRUSTED_ORIGINS`, `BETTER_AUTH_SECRET`, `PUBLIC_IS_SELFHOSTED=true`, `DASHBOARD_ORIGIN`. +- **CSP (runtime):** `ALLOWED_EXTERNAL_DOMAINS` (overrides all) or per-directive: `CSP_SCRIPT_SRC_DOMAINS`, `CSP_STYLE_SRC_DOMAINS`, `CSP_CONNECT_SRC_DOMAINS`, `CSP_FRAME_SRC_DOMAINS`, `CSP_FONT_SRC_DOMAINS`, `CSP_MEDIA_SRC_DOMAINS`. These are read at container startup — no image rebuild needed. - **Auth cookies:** Set `AUTH_COOKIE_DOMAIN` to your root domain (e.g. `.yourdomain.com`) when API and dashboard use different subdomains. Without it, auth cookies may not be set and login will fail. - **Auto-generated:** `AUTH_BEARER_TOKEN`, `PRIVATE_SERVER_KEY` (by `./run-docker-full-stack.sh`). - **Auto-configured:** All `MINIO_*` / `OBJECT_STORAGE_*` vars (by the startup script). diff --git a/docker/entrypoint-api.sh b/docker/entrypoint-api.sh new file mode 100644 index 00000000..7333bc4a --- /dev/null +++ b/docker/entrypoint-api.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -e + +if [ "${SKIP_DB_SETUP}" = "true" ]; then + echo "Skipping database setup (SKIP_DB_SETUP=true)." +else + echo "Running database setup..." + pnpm --filter @cio/db db:setup + echo "Database setup complete." +fi + +echo "Starting API..." +exec pnpm --filter @cio/api start diff --git a/package.json b/package.json index 01ae6fff..14d3b837 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,10 @@ "onlyBuiltDependencies": [ "esbuild", "@tailwindcss/oxide" - ] + ], + "patchedDependencies": { + "layerchart@2.0.0-next.27": "patches/layerchart@2.0.0-next.27.patch" + } }, "packageManager": "pnpm@10.19.0+sha512.c9fc7236e92adf5c8af42fd5bf1612df99c2ceb62f27047032f4720b33f8eacdde311865e91c411f2774f618d82f320808ecb51718bfa82c060c4ba7c76a32b8" } diff --git a/packages/db/src/queries/organization/automation-key.ts b/packages/db/src/queries/organization/automation-key.ts index ec37eedf..a99b6968 100644 --- a/packages/db/src/queries/organization/automation-key.ts +++ b/packages/db/src/queries/organization/automation-key.ts @@ -1,6 +1,6 @@ import * as schema from '@db/schema'; -import { and, desc, eq, isNull } from 'drizzle-orm'; +import { and, count, desc, eq, isNull } from 'drizzle-orm'; import { db } from '@db/drizzle'; import type { TNewOrganizationApiKey, TOrganizationApiKey, TOrganizationApiKeyType } from '@db/types'; @@ -42,6 +42,29 @@ export const listOrganizationApiKeys = async ( } }; +export const countActiveOrganizationApiKeys = async ( + organizationId: string, + type: TOrganizationApiKeyType +): Promise => { + try { + const [row] = await db + .select({ total: count() }) + .from(schema.organizationApiKey) + .where( + and( + eq(schema.organizationApiKey.organizationId, organizationId), + eq(schema.organizationApiKey.type, type), + isNull(schema.organizationApiKey.revokedAt) + ) + ); + + return Number(row?.total ?? 0); + } catch (error) { + console.error('countActiveOrganizationApiKeys error:', error); + throw new Error('Failed to count active organization API keys'); + } +}; + export const getOrganizationApiKeyById = async ( organizationId: string, keyId: string diff --git a/packages/db/src/queries/organization/automation-usage.ts b/packages/db/src/queries/organization/automation-usage.ts new file mode 100644 index 00000000..c092209e --- /dev/null +++ b/packages/db/src/queries/organization/automation-usage.ts @@ -0,0 +1,124 @@ +import * as schema from '@db/schema'; + +import { and, count, desc, eq, gte, sql } from 'drizzle-orm'; + +import { db } from '@db/drizzle'; +import type { TAutomationUsageCategory, TNewOrganizationAutomationUsage, TOrganizationApiKeyType } from '@db/types'; + +export const createOrganizationAutomationUsage = async (data: TNewOrganizationAutomationUsage) => { + try { + const [row] = await db.insert(schema.organizationAutomationUsage).values(data).returning(); + + if (!row) { + throw new Error('Failed to create organization automation usage'); + } + + return row; + } catch (error) { + console.error('createOrganizationAutomationUsage error:', error); + throw new Error('Failed to create organization automation usage'); + } +}; + +export const countOrganizationAutomationUsageSinceByKey = async ( + organizationApiKeyId: string, + category: TAutomationUsageCategory, + since: string +): Promise => { + try { + const [row] = await db + .select({ total: count() }) + .from(schema.organizationAutomationUsage) + .where( + and( + eq(schema.organizationAutomationUsage.organizationApiKeyId, organizationApiKeyId), + eq(schema.organizationAutomationUsage.category, category), + gte(schema.organizationAutomationUsage.createdAt, since) + ) + ); + + return Number(row?.total ?? 0); + } catch (error) { + console.error('countOrganizationAutomationUsageSinceByKey error:', error); + throw new Error('Failed to count organization automation usage by key'); + } +}; + +export const countOrganizationAutomationUsageSince = async ( + organizationId: string, + type: TOrganizationApiKeyType, + category: TAutomationUsageCategory, + since: string +): Promise => { + try { + const [row] = await db + .select({ total: count() }) + .from(schema.organizationAutomationUsage) + .where( + and( + eq(schema.organizationAutomationUsage.organizationId, organizationId), + eq(schema.organizationAutomationUsage.type, type), + eq(schema.organizationAutomationUsage.category, category), + gte(schema.organizationAutomationUsage.createdAt, since) + ) + ); + + return Number(row?.total ?? 0); + } catch (error) { + console.error('countOrganizationAutomationUsageSince error:', error); + throw new Error('Failed to count organization automation usage'); + } +}; + +export const getOrganizationAutomationCreditsUsedSince = async ( + organizationId: string, + type: TOrganizationApiKeyType, + since: string +): Promise => { + try { + const [row] = await db + .select({ + total: sql`COALESCE(SUM(${schema.organizationAutomationUsage.creditsConsumed}), 0)` + }) + .from(schema.organizationAutomationUsage) + .where( + and( + eq(schema.organizationAutomationUsage.organizationId, organizationId), + eq(schema.organizationAutomationUsage.type, type), + gte(schema.organizationAutomationUsage.createdAt, since) + ) + ); + + return Number(row?.total ?? 0); + } catch (error) { + console.error('getOrganizationAutomationCreditsUsedSince error:', error); + throw new Error('Failed to get organization automation credits used'); + } +}; + +export const listRecentOrganizationAutomationUsage = async ( + organizationId: string, + type: TOrganizationApiKeyType, + limit = 10 +) => { + try { + return await db + .select({ + action: schema.organizationAutomationUsage.action, + creditsConsumed: schema.organizationAutomationUsage.creditsConsumed, + createdAt: schema.organizationAutomationUsage.createdAt + }) + .from(schema.organizationAutomationUsage) + .where( + and( + eq(schema.organizationAutomationUsage.organizationId, organizationId), + eq(schema.organizationAutomationUsage.type, type) + ) + ) + .orderBy(desc(schema.organizationAutomationUsage.createdAt)) + .limit(limit); + } catch (error) { + console.error('listRecentOrganizationAutomationUsage error:', error); + throw new Error('Failed to list recent organization automation usage'); + } +}; diff --git a/packages/db/src/queries/organization/index.ts b/packages/db/src/queries/organization/index.ts index 7ad75cc6..5b165e4d 100644 --- a/packages/db/src/queries/organization/index.ts +++ b/packages/db/src/queries/organization/index.ts @@ -2,3 +2,4 @@ export * from './organization'; export * from './quiz'; export * from './invite'; export * from './automation-key'; +export * from './automation-usage'; diff --git a/packages/db/src/queries/organization/organization.ts b/packages/db/src/queries/organization/organization.ts index 72b88216..959e7bfd 100644 --- a/packages/db/src/queries/organization/organization.ts +++ b/packages/db/src/queries/organization/organization.ts @@ -677,3 +677,13 @@ export const getOrganizationPlanStatus = async (orgId: string) => { return result; }; + +export const getActiveOrganizationPlan = async (orgId: string) => { + const [plan] = await db + .select() + .from(schema.organizationPlan) + .where(and(eq(schema.organizationPlan.orgId, orgId), eq(schema.organizationPlan.isActive, true))) + .limit(1); + + return plan ?? null; +}; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 0dfb1941..3be8836a 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -25,9 +25,10 @@ import { sql } from 'drizzle-orm'; export const courseType = pgEnum('COURSE_TYPE', ['SELF_PACED', 'LIVE_CLASS']); export const locale = pgEnum('LOCALE', ['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']); export const plan = pgEnum('PLAN', ['EARLY_ADOPTER', 'ENTERPRISE', 'BASIC']); -export const courseImportSourceType = pgEnum('COURSE_IMPORT_SOURCE_TYPE', ['prompt', 'pdf']); +export const courseImportSourceType = pgEnum('COURSE_IMPORT_SOURCE_TYPE', ['prompt', 'pdf', 'course']); export const courseImportDraftStatus = pgEnum('COURSE_IMPORT_DRAFT_STATUS', ['DRAFT', 'PUBLISHED', 'ARCHIVED']); export const organizationApiKeyType = pgEnum('ORGANIZATION_API_KEY_TYPE', ['mcp', 'api', 'zapier']); +export const automationUsageCategory = pgEnum('AUTOMATION_USAGE_CATEGORY', ['read', 'write', 'publish']); export const courseInviteEventType = pgEnum('COURSE_INVITE_EVENT_TYPE', [ 'CREATED', 'REVOKED', @@ -2035,6 +2036,35 @@ export const organizationApiKey = pgTable( ] ); +export const organizationAutomationUsage = pgTable( + 'organization_automation_usage', + { + id: uuid() + .default(sql`gen_random_uuid()`) + .primaryKey() + .notNull(), + organizationId: uuid('organization_id') + .notNull() + .references(() => organization.id), + organizationApiKeyId: uuid('organization_api_key_id').references(() => organizationApiKey.id, { + onDelete: 'set null' + }), + type: organizationApiKeyType('type').notNull(), + action: varchar('action', { length: 120 }).notNull(), + category: automationUsageCategory('category').notNull(), + creditsConsumed: integer('credits_consumed').default(0).notNull(), + metadata: jsonb('metadata').default({}).$type>().notNull(), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .default(sql`timezone('utc'::text, now())`) + .notNull() + }, + (table) => [ + index('idx_org_automation_usage_org_created_at').on(table.organizationId, table.createdAt), + index('idx_org_automation_usage_key_created_at').on(table.organizationApiKeyId, table.createdAt), + index('idx_org_automation_usage_type_created_at').on(table.type, table.createdAt) + ] +); + export const courseImportDraft = pgTable( 'course_import_draft', { diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 7052328c..df89974b 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -171,6 +171,8 @@ export type TNewOrganizationTokenAuth = typeof schema.organizationTokenAuth.$inf export type TOrganizationApiKey = typeof schema.organizationApiKey.$inferSelect; export type TNewOrganizationApiKey = typeof schema.organizationApiKey.$inferInsert; +export type TOrganizationAutomationUsage = typeof schema.organizationAutomationUsage.$inferSelect; +export type TNewOrganizationAutomationUsage = typeof schema.organizationAutomationUsage.$inferInsert; export type TCourseImportDraft = typeof schema.courseImportDraft.$inferSelect; export type TNewCourseImportDraft = typeof schema.courseImportDraft.$inferInsert; @@ -188,6 +190,7 @@ export type TOrganizationInviteEventType = (typeof schema.organizationInviteEven export type TSsoProvider = (typeof schema.organizationSsoProviderType.enumValues)[number]; export type TOrganizationApiKeyType = (typeof schema.organizationApiKeyType.enumValues)[number]; +export type TAutomationUsageCategory = (typeof schema.automationUsageCategory.enumValues)[number]; export type TCourseImportSourceType = (typeof schema.courseImportSourceType.enumValues)[number]; diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 422016f9..3ddc134a 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -43,6 +43,7 @@ Current tools: - `list_org_courses` - `get_course_structure` +- `reorder_course_content` - `update_course_landing_page` - `create_course_draft` - `create_course_draft_from_course` @@ -167,7 +168,9 @@ Drafts support: Lesson authoring rule: - `lessonLanguages[].content` should contain only the lesson body HTML -- do not include the lesson title or a top-level heading that repeats `lessons[].title` +- do not include the lesson title +- do not use `h1` or `h2` in lesson content +- start headings at `h3` - ClassroomIO already renders the lesson title in the course UI ## After Publish @@ -323,7 +326,22 @@ What this publish does today: - upserts lesson language content - merges draft tags into the existing course -### Flow 6: Add exercises to a live course +### Flow 6: Reorder live course content + +User says: + +```text +Move the quiz before lesson 2 and put the recap lesson into the last section. +``` + +Expected tool sequence: + +1. Agent calls `reorder_course_content`. + +Use this direct path when the live course only needs section ordering or lesson or exercise moves between sections. +It avoids the full draft round-trip for simple structural changes. + +### Flow 7: Add exercises to a live course User says: diff --git a/packages/mcp/src/api-client.ts b/packages/mcp/src/api-client.ts index 217f72f4..7c74bfef 100644 --- a/packages/mcp/src/api-client.ts +++ b/packages/mcp/src/api-client.ts @@ -6,7 +6,7 @@ import type { TCourseImportDraftPublishToCourse, TCourseImportDraftUpdate } from '@cio/utils/validation/course-import'; -import type { TCourseLandingPageUpdate } from '@cio/utils/validation/course'; +import type { TCourseContentReorder, TCourseLandingPageUpdate, TCourseUpdateParam } from '@cio/utils/validation/course'; import type { TExerciseCreate, TExerciseFromTemplate, @@ -78,6 +78,13 @@ export class ClassroomIoApiClient { }); } + async reorderCourseContent(courseId: TCourseUpdateParam['courseId'], payload: TCourseContentReorder) { + return this.request(`/course/${courseId}/content/reorder`, { + method: 'PUT', + body: payload + }); + } + async getCourseDraft(draftId: string) { return this.request(`/organization/course-import/drafts/${draftId}`, { method: 'GET' diff --git a/packages/mcp/src/tools/course-drafts.ts b/packages/mcp/src/tools/course-drafts.ts index 4a7b277d..187b60e4 100644 --- a/packages/mcp/src/tools/course-drafts.ts +++ b/packages/mcp/src/tools/course-drafts.ts @@ -20,7 +20,11 @@ import { ZCourseImportDraftPublishToCourse, ZCourseImportDraftUpdate } from '@cio/utils/validation/course-import'; -import { ZCourseLandingPageUpdate } from '@cio/utils/validation/course'; +import { + ZCourseContentReorder, + ZCourseContentReorderBase, + ZCourseLandingPageUpdate +} from '@cio/utils/validation/course'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { ZodRawShapeCompat } from '@modelcontextprotocol/sdk/server/zod-compat.js'; @@ -61,6 +65,10 @@ export const ZUpdateCourseLandingPageToolInput = ZCourseLandingPageUpdate.extend courseId: ZCourseImportCourseParam.shape.courseId }); +export const ZReorderCourseContentToolInput = ZCourseContentReorderBase.extend({ + courseId: ZCourseImportCourseParam.shape.courseId +}); + export const ZPublishCourseDraftToolInput = ZCourseImportDraftPublish.extend({ draftId: ZCourseImportDraftGetParam.shape.draftId }); @@ -83,6 +91,7 @@ const createCourseExerciseFromTemplateShape = ZCreateCourseExerciseFromTemplateToolInput.shape as unknown as ZodRawShapeCompat; const updateCourseExerciseShape = ZUpdateCourseExerciseToolInput.shape as unknown as ZodRawShapeCompat; const updateCourseLandingPageShape = ZUpdateCourseLandingPageToolInput.shape as unknown as ZodRawShapeCompat; +const reorderCourseContentShape = ZReorderCourseContentToolInput.shape as unknown as ZodRawShapeCompat; const publishCourseDraftShape = ZPublishCourseDraftToolInput.shape as unknown as ZodRawShapeCompat; const publishCourseDraftToExistingCourseShape = ZPublishCourseDraftToExistingCourseToolInput.shape as unknown as ZodRawShapeCompat; @@ -91,7 +100,7 @@ const tagCoursesShape = ZAutomationCourseTagAssignment.shape as unknown as ZodRa const SUPPORTED_QUESTION_TYPES_GUIDE = 'Supported questionTypeId values: RADIO=1 (Single answer), CHECKBOX=2 (Multiple answers), TEXTAREA=3 (Paragraph), TRUE_FALSE=4 (True/False), SHORT_ANSWER=5 (Short answer), NUMERIC=6 (Numeric answer), FILL_BLANK=7 (Fill in the blank), FILE_UPLOAD=8 (File upload), MATCHING=9 (Matching), ORDERING=10 (Ordering), HOTSPOT=11 (Hotspot), LINK=12 (Links). Use the numeric ids in exercise payloads.'; const LESSON_HTML_GUIDE = - 'For draft lesson HTML, put only the lesson body in lessonLanguages[].content. Do not include the lesson title or a top-level heading that repeats lessons[].title because the ClassroomIO UI already renders the lesson title.'; + 'For draft lesson HTML, put only the lesson body in lessonLanguages[].content. Do not include the lesson title. Do not use h1 or h2 anywhere in lesson HTML. Start headings at h3 because that is the highest heading level allowed in lesson content.'; export function registerCourseDraftTools(server: McpServer, apiClient: ClassroomIoApiClient) { server.tool( @@ -127,6 +136,18 @@ export function registerCourseDraftTools(server: McpServer, apiClient: Classroom } ); + server.tool( + 'reorder_course_content', + 'Batch reorder live course sections and lesson or exercise content without creating a draft. Use this for section ordering or moving/reordering lessons and exercises between sections. For items, pass type as LESSON or EXERCISE and use sectionId to move between sections or null for ungrouped content.', + reorderCourseContentShape, + async (args) => { + const { courseId, ...rawPayload } = ZReorderCourseContentToolInput.parse(args); + const payload = ZCourseContentReorder.parse(rawPayload); + const result = await apiClient.reorderCourseContent(courseId, payload); + return jsonContent(result); + } + ); + server.tool( 'create_course_draft', `Create a new unpublished course draft from structured course JSON. Use this before the first publish. ${LESSON_HTML_GUIDE}`, diff --git a/packages/ui/src/custom/exercise-question/renderers/textarea/submission.svelte b/packages/ui/src/custom/exercise-question/renderers/textarea/submission.svelte index 7cdeace0..f6fdb462 100644 --- a/packages/ui/src/custom/exercise-question/renderers/textarea/submission.svelte +++ b/packages/ui/src/custom/exercise-question/renderers/textarea/submission.svelte @@ -1,6 +1,7 @@ + +{#each segments as segment} + {#if segment.type === 'html'} + {@html segment.content} + {:else} + {@const dims = svgDimensions(segment.content)} + + {/if} +{/each} diff --git a/packages/ui/src/output.css b/packages/ui/src/output.css index 55e5f4b8..44062191 100644 --- a/packages/ui/src/output.css +++ b/packages/ui/src/output.css @@ -1,2 +1,2 @@ /*! 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-aspect-video:16 / 9;--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\:aspect-video{aspect-ratio:var(--ui-aspect-video)}.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\:\[\&_\.lc-area-path\]\:opacity-100 .lc-area-path{opacity:1}.ui\:\[\&_\.lc-axis-tick\]\:stroke-0 .lc-axis-tick{stroke-width:0}.ui\:\[\&_\.lc-axis-tick-label\]\:fill-muted-foreground .lc-axis-tick-label{fill:var(--muted-foreground)}.ui\:\[\&_\.lc-axis-tick-label\]\:font-normal .lc-axis-tick-label{--tw-font-weight:var(--ui-font-weight-normal);font-weight:var(--ui-font-weight-normal)}.ui\:\[\&_\.lc-grid-x-radial-circle\]\:stroke-border .lc-grid-x-radial-circle,.ui\:\[\&_\.lc-grid-x-radial-line\]\:stroke-border .lc-grid-x-radial-line,.ui\:\[\&_\.lc-grid-y-radial-circle\]\:stroke-border .lc-grid-y-radial-circle,.ui\:\[\&_\.lc-grid-y-radial-line\]\:stroke-border .lc-grid-y-radial-line{stroke:var(--border)}.ui\:\[\&_\.lc-highlight-line\]\:stroke-0 .lc-highlight-line{stroke-width:0}.ui\:\[\&_\.lc-highlight-line\]\:opacity-100 .lc-highlight-line,.ui\:\[\&_\.lc-highlight-point\]\:opacity-100 .lc-highlight-point{opacity:1}.ui\:\[\&_\.lc-labels-text\:not\(\[fill\]\)\]\:fill-foreground .lc-labels-text:not([fill]){fill:var(--foreground)}.ui\:\[\&_\.lc-layout-svg-g\]\:fill-transparent .lc-layout-svg-g{fill:#0000}.ui\:\[\&_\.lc-legend-swatch\]\:size-2\.5 .lc-legend-swatch{width:calc(var(--ui-spacing) * 2.5);height:calc(var(--ui-spacing) * 2.5)}.ui\:\[\&_\.lc-legend-swatch\]\:rounded-\[2px\] .lc-legend-swatch{border-radius:2px}.ui\:\[\&_\.lc-legend-swatch-button\]\:items-center .lc-legend-swatch-button{align-items:center}.ui\:\[\&_\.lc-legend-swatch-button\]\:gap-1\.5 .lc-legend-swatch-button{gap:calc(var(--ui-spacing) * 1.5)}.ui\:\[\&_\.lc-legend-swatch-group\]\:items-center .lc-legend-swatch-group{align-items:center}.ui\:\[\&_\.lc-legend-swatch-group\]\:gap-4 .lc-legend-swatch-group{gap:calc(var(--ui-spacing) * 4)}.ui\:\[\&_\.lc-line\]\:stroke-border\/50 .lc-line{stroke:var(--border)}@supports (color:color-mix(in lab, red, red)){.ui\:\[\&_\.lc-line\]\:stroke-border\/50 .lc-line{stroke:color-mix(in oklab, var(--border) 50%, transparent)}}.ui\:\[\&_\.lc-root-container\]\:w-full .lc-root-container{width:100%}.ui\:\[\&_\.lc-rule-x-line\:not\(\.lc-grid-x-rule\)\]\:stroke-0 .lc-rule-x-line:not(.lc-grid-x-rule),.ui\:\[\&_\.lc-rule-y-line\:not\(\.lc-grid-y-rule\)\]\:stroke-0 .lc-rule-y-line:not(.lc-grid-y-rule){stroke-width:0}.ui\:\[\&_\.lc-spline-path\]\:opacity-100 .lc-spline-path{opacity:1}.ui\:\[\&_\.lc-text\]\:text-xs .lc-text{font-size:var(--ui-text-xs);line-height:var(--tw-leading,var(--ui-text-xs--line-height))}.ui\:\[\&_\.lc-text-svg\]\:overflow-visible .lc-text-svg{overflow:visible}.ui\:\[\&_\.lc-tooltip-rects-g\]\:fill-transparent .lc-tooltip-rects-g{fill:#0000}.ui\:\[\&_\.stroke-white\]\:stroke-transparent .stroke-white{stroke:#0000}.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\:\[\&_text\]\:stroke-transparent text{stroke:#0000}.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 +@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-aspect-video:16 / 9;--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-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\:aspect-video{aspect-ratio:var(--ui-aspect-video)}.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\:\[\&_\.lc-area-path\]\:opacity-100 .lc-area-path{opacity:1}.ui\:\[\&_\.lc-axis-tick\]\:stroke-0 .lc-axis-tick{stroke-width:0}.ui\:\[\&_\.lc-axis-tick-label\]\:fill-muted-foreground .lc-axis-tick-label{fill:var(--muted-foreground)}.ui\:\[\&_\.lc-axis-tick-label\]\:font-normal .lc-axis-tick-label{--tw-font-weight:var(--ui-font-weight-normal);font-weight:var(--ui-font-weight-normal)}.ui\:\[\&_\.lc-grid-x-radial-circle\]\:stroke-border .lc-grid-x-radial-circle,.ui\:\[\&_\.lc-grid-x-radial-line\]\:stroke-border .lc-grid-x-radial-line,.ui\:\[\&_\.lc-grid-y-radial-circle\]\:stroke-border .lc-grid-y-radial-circle,.ui\:\[\&_\.lc-grid-y-radial-line\]\:stroke-border .lc-grid-y-radial-line{stroke:var(--border)}.ui\:\[\&_\.lc-highlight-line\]\:stroke-0 .lc-highlight-line{stroke-width:0}.ui\:\[\&_\.lc-highlight-line\]\:opacity-100 .lc-highlight-line,.ui\:\[\&_\.lc-highlight-point\]\:opacity-100 .lc-highlight-point{opacity:1}.ui\:\[\&_\.lc-labels-text\:not\(\[fill\]\)\]\:fill-foreground .lc-labels-text:not([fill]){fill:var(--foreground)}.ui\:\[\&_\.lc-layout-svg-g\]\:fill-transparent .lc-layout-svg-g{fill:#0000}.ui\:\[\&_\.lc-legend-swatch\]\:size-2\.5 .lc-legend-swatch{width:calc(var(--ui-spacing) * 2.5);height:calc(var(--ui-spacing) * 2.5)}.ui\:\[\&_\.lc-legend-swatch\]\:rounded-\[2px\] .lc-legend-swatch{border-radius:2px}.ui\:\[\&_\.lc-legend-swatch-button\]\:items-center .lc-legend-swatch-button{align-items:center}.ui\:\[\&_\.lc-legend-swatch-button\]\:gap-1\.5 .lc-legend-swatch-button{gap:calc(var(--ui-spacing) * 1.5)}.ui\:\[\&_\.lc-legend-swatch-group\]\:items-center .lc-legend-swatch-group{align-items:center}.ui\:\[\&_\.lc-legend-swatch-group\]\:gap-4 .lc-legend-swatch-group{gap:calc(var(--ui-spacing) * 4)}.ui\:\[\&_\.lc-line\]\:stroke-border\/50 .lc-line{stroke:var(--border)}@supports (color:color-mix(in lab, red, red)){.ui\:\[\&_\.lc-line\]\:stroke-border\/50 .lc-line{stroke:color-mix(in oklab, var(--border) 50%, transparent)}}.ui\:\[\&_\.lc-root-container\]\:w-full .lc-root-container{width:100%}.ui\:\[\&_\.lc-rule-x-line\:not\(\.lc-grid-x-rule\)\]\:stroke-0 .lc-rule-x-line:not(.lc-grid-x-rule),.ui\:\[\&_\.lc-rule-y-line\:not\(\.lc-grid-y-rule\)\]\:stroke-0 .lc-rule-y-line:not(.lc-grid-y-rule){stroke-width:0}.ui\:\[\&_\.lc-spline-path\]\:opacity-100 .lc-spline-path{opacity:1}.ui\:\[\&_\.lc-text\]\:text-xs .lc-text{font-size:var(--ui-text-xs);line-height:var(--tw-leading,var(--ui-text-xs--line-height))}.ui\:\[\&_\.lc-text-svg\]\:overflow-visible .lc-text-svg{overflow:visible}.ui\:\[\&_\.lc-tooltip-rects-g\]\:fill-transparent .lc-tooltip-rects-g{fill:#0000}.ui\:\[\&_\.stroke-white\]\:stroke-transparent .stroke-white{stroke:#0000}.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\:\[\&_text\]\:stroke-transparent text{stroke:#0000}.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/tools/sanitize.ts b/packages/ui/src/tools/sanitize.ts index 3f8508f4..d29a374e 100644 --- a/packages/ui/src/tools/sanitize.ts +++ b/packages/ui/src/tools/sanitize.ts @@ -150,6 +150,37 @@ function fallbackSanitize(html: string): string { return html; } +export type ContentSegment = { type: 'html'; content: string } | { type: 'svg'; content: string }; + +/** + * Splits HTML into safe-HTML segments and raw SVG segments. + * HTML segments are sanitized; SVG segments are left raw so the caller + * can render them inside a sandboxed iframe (sandbox="") where scripts, + * forms, and cookie access are all disabled by the browser. + */ +export function splitHtmlAndSvg(html: string): ContentSegment[] { + if (typeof html !== 'string' || !html) return []; + + const segments: ContentSegment[] = []; + const svgRegex = /]*>[\s\S]*?<\/svg>/gi; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = svgRegex.exec(html)) !== null) { + if (match.index > lastIndex) { + segments.push({ type: 'html', content: sanitizeHtml(html.slice(lastIndex, match.index)) }); + } + segments.push({ type: 'svg', content: match[0] }); + lastIndex = svgRegex.lastIndex; + } + + if (lastIndex < html.length) { + segments.push({ type: 'html', content: sanitizeHtml(html.slice(lastIndex)) }); + } + + return segments; +} + /** * Strips all HTML tags and returns plain text * Use this when you only need the text content diff --git a/packages/utils/src/plans/automation.ts b/packages/utils/src/plans/automation.ts new file mode 100644 index 00000000..aa9cf240 --- /dev/null +++ b/packages/utils/src/plans/automation.ts @@ -0,0 +1,130 @@ +import { PLAN } from './constants'; + +export const AUTOMATION_TYPE = { + MCP: 'mcp', + API: 'api', + ZAPIER: 'zapier' +} as const; + +export const AUTOMATION_USAGE_CATEGORY = { + READ: 'read', + WRITE: 'write', + PUBLISH: 'publish' +} as const; + +export const MCP_TOOL_CREDIT_COST = { + list_org_courses: 0, + get_course_structure: 0, + get_course_draft: 0, + list_course_exercises: 0, + get_course_exercise: 0, + create_course_draft: 1, + create_course_draft_from_course: 1, + reorder_course_content: 1, + update_course_landing_page: 1, + update_course_draft: 1, + tag_course_draft: 1, + tag_courses: 1, + create_course_exercise: 1, + create_course_exercise_from_template: 1, + update_course_exercise: 1, + publish_course_draft: 5, + publish_course_draft_to_existing_course: 5 +} as const; + +export type TMcpToolName = keyof typeof MCP_TOOL_CREDIT_COST; +export type TAutomationUsageCategory = (typeof AUTOMATION_USAGE_CATEGORY)[keyof typeof AUTOMATION_USAGE_CATEGORY]; + +export type McpAutomationLimits = { + monthlyCredits: number; + maxActiveKeys: number; + rateLimits: { + perKey: { + readPerMinute: number; + writePerMinute: number; + publishPerMinute: number; + }; + perOrg: { + readPerMinute: number; + writePerMinute: number; + publishPerMinute: number; + }; + }; +}; + +export const MCP_AUTOMATION_LIMITS_BY_PLAN: Record = { + [PLAN.BASIC]: { + monthlyCredits: 20, + maxActiveKeys: 1, + rateLimits: { + perKey: { + readPerMinute: 30, + writePerMinute: 10, + publishPerMinute: 1 + }, + perOrg: { + readPerMinute: 60, + writePerMinute: 20, + publishPerMinute: 2 + } + } + }, + [PLAN.EARLY_ADOPTER]: { + monthlyCredits: 500, + maxActiveKeys: 5, + rateLimits: { + perKey: { + readPerMinute: 60, + writePerMinute: 20, + publishPerMinute: 3 + }, + perOrg: { + readPerMinute: 120, + writePerMinute: 40, + publishPerMinute: 5 + } + } + }, + [PLAN.ENTERPRISE]: { + monthlyCredits: 5000, + maxActiveKeys: 25, + rateLimits: { + perKey: { + readPerMinute: 180, + writePerMinute: 60, + publishPerMinute: 10 + }, + perOrg: { + readPerMinute: 360, + writePerMinute: 120, + publishPerMinute: 20 + } + } + } +}; + +export function getMcpAutomationCategory(toolName: TMcpToolName): TAutomationUsageCategory { + if ( + toolName === 'get_course_structure' || + toolName === 'get_course_draft' || + toolName === 'list_org_courses' || + toolName === 'list_course_exercises' || + toolName === 'get_course_exercise' + ) { + return AUTOMATION_USAGE_CATEGORY.READ; + } + + if (toolName === 'publish_course_draft' || toolName === 'publish_course_draft_to_existing_course') { + return AUTOMATION_USAGE_CATEGORY.PUBLISH; + } + + return AUTOMATION_USAGE_CATEGORY.WRITE; +} + +export function getMcpAutomationLimits(planName: string | null | undefined): McpAutomationLimits { + if (!planName) { + return MCP_AUTOMATION_LIMITS_BY_PLAN[PLAN.BASIC]; + } + + return MCP_AUTOMATION_LIMITS_BY_PLAN[planName] ?? MCP_AUTOMATION_LIMITS_BY_PLAN[PLAN.BASIC]; +} diff --git a/packages/utils/src/plans/data.ts b/packages/utils/src/plans/data.ts index 27f6ce8f..745e2163 100644 --- a/packages/utils/src/plans/data.ts +++ b/packages/utils/src/plans/data.ts @@ -13,6 +13,7 @@ export default { 'Unlimited Q/A', '20 Students', 'AI Course Builder (no video upload)', + 'MCP access with 20 automation credits / month', 'ClassroomIO branding' ], CTA: { @@ -37,6 +38,7 @@ export default { 'Unlimited Collaborators', '10K Students', 'Advanced AI Course Builder (Video upload & Certificate)', + 'MCP access with 500 automation credits / month', 'Custom Branding', 'Slack Invite', 'Includes all upcoming features' @@ -60,7 +62,13 @@ export default { YEARLY: 'Request Pricing', IS_PREMIUM: true }, - FEATURES: ['Everything in Early Adopters plus:', 'Unlimited students', 'Custom Domain', '24/7 Support'], + FEATURES: [ + 'Everything in Early Adopters plus:', + 'Unlimited students', + 'Custom Domain', + 'MCP access with 5,000 automation credits / month', + '24/7 Support' + ], CTA: { LABEL: 'Contact Us', LINK: 'https://cal.com/classroomio/enterprise', diff --git a/packages/utils/src/plans/index.ts b/packages/utils/src/plans/index.ts index cdbab41f..88586621 100644 --- a/packages/utils/src/plans/index.ts +++ b/packages/utils/src/plans/index.ts @@ -1,4 +1,5 @@ export * from './constants'; +export * from './automation'; export * from './utils'; import PLANS from './data'; diff --git a/packages/utils/src/validation/course-import/course-import.ts b/packages/utils/src/validation/course-import/course-import.ts index 8ca8d759..93d9cb28 100644 --- a/packages/utils/src/validation/course-import/course-import.ts +++ b/packages/utils/src/validation/course-import/course-import.ts @@ -3,6 +3,8 @@ import * as z from 'zod'; import { ZCourseMetadata } from '../course/course'; const ZSupportedLocale = z.enum(['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']); +const LESSON_BODY_HTML_DESCRIPTION = + 'Lesson body HTML only. Do not include the lesson title. Do not use h1 or h2 anywhere in lessonLanguages[].content. Start headings at h3 because that is the highest heading level allowed in lesson content.'; export const ZCourseImportWarning = z.object({ code: z.string().min(1), @@ -12,7 +14,7 @@ export const ZCourseImportWarning = z.object({ export type TCourseImportWarning = z.infer; export const ZCourseImportSourceReference = z.object({ - type: z.enum(['prompt', 'pdf']), + type: z.enum(['prompt', 'pdf', 'course']), label: z.string().min(1), pageStart: z.number().int().min(1).optional(), pageEnd: z.number().int().min(1).optional() @@ -48,13 +50,14 @@ export type TCourseImportDraftLesson = z.infer; export const ZCourseImportDraftLessonLanguage = z.object({ lessonExternalId: z.string().min(1), locale: ZSupportedLocale, - content: z.string().min(1) + content: z.string().min(1).describe(LESSON_BODY_HTML_DESCRIPTION) }); export type TCourseImportDraftLessonLanguage = z.infer; export const ZCourseImportDraftPayload = z .object({ course: ZCourseImportDraftCourse, + tags: z.array(z.string().trim().min(1).max(80)).max(100).default([]), sections: z.array(ZCourseImportDraftSection).min(1), lessons: z.array(ZCourseImportDraftLesson).min(1), lessonLanguages: z.array(ZCourseImportDraftLessonLanguage).min(1), @@ -63,6 +66,19 @@ export const ZCourseImportDraftPayload = z warnings: z.array(ZCourseImportWarning).default([]) }) .superRefine((value, ctx) => { + const normalizedTags = new Set(); + value.tags.forEach((tag, index) => { + const tagKey = tag.trim().toLowerCase(); + if (normalizedTags.has(tagKey)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['tags', index], + message: 'Draft tags must be unique' + }); + } + normalizedTags.add(tagKey); + }); + const sectionIds = new Set(); value.sections.forEach((section, index) => { if (sectionIds.has(section.externalId)) { @@ -108,7 +124,7 @@ export const ZCourseImportDraftPayload = z export type TCourseImportDraftPayload = z.infer; export const ZCourseImportDraftCreate = z.object({ - sourceType: z.enum(['prompt', 'pdf']), + sourceType: z.enum(['prompt', 'pdf', 'course']), idempotencyKey: z.string().min(1).optional(), summary: z.record(z.string(), z.unknown()).optional(), sourceArtifacts: z.array(z.record(z.string(), z.unknown())).optional(), @@ -116,6 +132,19 @@ export const ZCourseImportDraftCreate = z.object({ }); export type TCourseImportDraftCreate = z.infer; +export const ZCourseImportCourseParam = z.object({ + courseId: z.string().min(1) +}); +export type TCourseImportCourseParam = z.infer; + +export const ZCourseImportDraftCreateFromCourse = z.object({ + courseId: z.string().min(1), + idempotencyKey: z.string().min(1).optional(), + summary: z.record(z.string(), z.unknown()).optional(), + sourceArtifacts: z.array(z.record(z.string(), z.unknown())).optional() +}); +export type TCourseImportDraftCreateFromCourse = z.infer; + export const ZCourseImportDraftGetParam = z.object({ draftId: z.string().uuid() }); @@ -133,6 +162,14 @@ export const ZCourseImportDraftPublish = z.object({ title: z.string().min(1).optional(), description: z.string().min(1).optional(), type: z.enum(['LIVE_CLASS', 'SELF_PACED']).optional(), - metadata: ZCourseMetadata.optional() + metadata: ZCourseMetadata.optional(), + bannerImageUrl: z.string().url().optional(), + bannerImageQuery: z.string().min(1).max(120).optional(), + generateBannerImage: z.boolean().optional() }); export type TCourseImportDraftPublish = z.infer; + +export const ZCourseImportDraftPublishToCourse = ZCourseImportDraftPublish.extend({ + courseId: z.string().min(1) +}); +export type TCourseImportDraftPublishToCourse = z.infer; diff --git a/packages/utils/src/validation/course/course.ts b/packages/utils/src/validation/course/course.ts index cb93d474..fe956b0a 100644 --- a/packages/utils/src/validation/course/course.ts +++ b/packages/utils/src/validation/course/course.ts @@ -114,6 +114,69 @@ export const ZCourseContentUpdate = z.object({ }); export type TCourseContentUpdate = z.infer; +export const ZCourseContentReorderSection = z.object({ + id: z.string().min(1), + order: z.number().int().min(0) +}); +export type TCourseContentReorderSection = z.infer; + +export const ZCourseContentReorderItem = ZCourseContentUpdateItem.omit({ + isUnlocked: true +}); +export type TCourseContentReorderItem = z.infer; + +export const ZCourseContentReorderBase = z.object({ + sections: z.array(ZCourseContentReorderSection).min(1).optional(), + items: z.array(ZCourseContentReorderItem).min(1).optional() +}); +export type TCourseContentReorderBase = z.infer; + +export const ZCourseContentReorder = ZCourseContentReorderBase.superRefine((data, ctx) => { + if (!data.sections?.length && !data.items?.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['sections'], + message: 'Provide sections or items to reorder' + }); + } + + const sectionIds = new Set(); + data.sections?.forEach((section, index) => { + if (sectionIds.has(section.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['sections', index, 'id'], + message: 'Section IDs must be unique' + }); + } + + sectionIds.add(section.id); + }); + + const itemKeys = new Set(); + data.items?.forEach((item, index) => { + if (item.order === undefined && item.sectionId === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['items', index], + message: 'Each item must provide order or sectionId' + }); + } + + const itemKey = `${item.type}:${item.id}`; + if (itemKeys.has(itemKey)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['items', index, 'id'], + message: 'Item IDs must be unique per content type' + }); + } + + itemKeys.add(itemKey); + }); +}); +export type TCourseContentReorder = z.infer; + export const ZCourseContentDeleteItem = z.object({ id: z.string().min(1), type: z.enum(['LESSON', 'EXERCISE']) @@ -139,6 +202,40 @@ export const ZCourseCreate = z.object({ }); export type TCourseCreate = z.infer; +export const ZCourseReward = z.object({ + show: z.boolean(), + description: z.string() +}); + +export const ZCourseInstructor = z.object({ + name: z.string(), + role: z.string(), + coursesNo: z.number(), + description: z.string(), + imgUrl: z.string() +}); + +export const ZCourseCertificate = z.object({ + templateUrl: z.string() +}); + +export const ZCourseReview = 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() +}); + +export const ZCourseLessonTabsOrder = z.array( + z.object({ + id: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]), + name: z.string() + }) +); + // Course metadata schema matching the database structure export const ZCourseMetadata = z.object({ requirements: z.string().optional(), @@ -148,47 +245,11 @@ export const ZCourseMetadata = z.object({ 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(), + reward: ZCourseReward.optional(), + instructor: ZCourseInstructor.optional(), + certificate: ZCourseCertificate.optional(), + reviews: z.array(ZCourseReview).optional(), + lessonTabsOrder: ZCourseLessonTabsOrder.optional(), grading: z.boolean().optional(), lessonDownload: z.boolean().optional(), allowNewStudent: z.boolean(), @@ -197,6 +258,27 @@ export const ZCourseMetadata = z.object({ }); export type TCourseMetadata = z.infer; +export const ZCourseLandingPageMetadataUpdate = 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: ZCourseReward.partial().optional(), + instructor: ZCourseInstructor.partial().optional(), + certificate: ZCourseCertificate.partial().optional(), + reviews: z.array(ZCourseReview).optional(), + lessonTabsOrder: ZCourseLessonTabsOrder.optional(), + grading: z.boolean().optional(), + lessonDownload: z.boolean().optional(), + allowNewStudent: z.boolean().optional(), + sectionDisplay: z.record(z.string(), z.boolean()).optional(), + isContentGroupingEnabled: z.boolean().optional() +}); +export type TCourseLandingPageMetadataUpdate = z.infer; + export const ZCourseUpdate = z.object({ title: z.string().min(1).optional(), description: z.string().min(1).optional(), @@ -206,6 +288,8 @@ export const ZCourseUpdate = z.object({ isPublished: z.boolean().optional(), overview: z.string().optional(), metadata: ZCourseMetadata.optional(), + cost: z.number().int().min(0).optional(), + currency: z.enum(['NGN', 'USD']).optional(), isCertificateDownloadable: z.boolean().optional(), certificateTheme: z.string().optional(), tagIds: z.array(z.uuid()).max(100).optional() @@ -217,6 +301,19 @@ export const ZCourseUpdateParam = z.object({ }); export type TCourseUpdateParam = z.infer; +export const ZCourseLandingPageUpdate = z.object({ + title: z.string().min(1).optional(), + description: z.string().min(1).optional(), + overview: z.string().optional(), + cost: z.number().int().min(0).optional(), + currency: z.enum(['NGN', 'USD']).optional(), + imageUrl: z.url().optional(), + generateImage: z.boolean().optional(), + imageQuery: z.string().min(1).max(120).optional(), + metadata: ZCourseLandingPageMetadataUpdate.optional() +}); +export type TCourseLandingPageUpdate = z.infer; + export const ZCourseDeleteParam = z.object({ courseId: z.string().min(1) }); diff --git a/packages/utils/src/validation/organization/automation-key.ts b/packages/utils/src/validation/organization/automation-key.ts index bfa935d9..8468b7e4 100644 --- a/packages/utils/src/validation/organization/automation-key.ts +++ b/packages/utils/src/validation/organization/automation-key.ts @@ -8,6 +8,11 @@ export const ZOrganizationApiKeyScope = z.enum([ 'course_import:draft:read', 'course_import:draft:update', 'course_import:draft:publish', + 'course:read', + 'course:write', + 'course:tag:write', + 'course:exercise:read', + 'course:exercise:write', 'public_api:*' ]); export type TOrganizationApiKeyScope = z.infer; @@ -17,6 +22,11 @@ export const ZListOrganizationApiKeysQuery = z.object({ }); export type TListOrganizationApiKeysQuery = z.infer; +export const ZOrganizationAutomationUsageQuery = z.object({ + type: ZOrganizationApiKeyType.default('mcp') +}); +export type TOrganizationAutomationUsageQuery = z.infer; + export const ZCreateOrganizationApiKey = z.object({ type: ZOrganizationApiKeyType, label: z.string().min(1).max(120).optional(), diff --git a/packages/utils/src/validation/tag/tag.ts b/packages/utils/src/validation/tag/tag.ts index 2ed35571..4e11e689 100644 --- a/packages/utils/src/validation/tag/tag.ts +++ b/packages/utils/src/validation/tag/tag.ts @@ -67,3 +67,26 @@ export const ZCourseTagAssignment = z.object({ tagIds: z.array(z.uuid()).max(100).default([]) }); export type TCourseTagAssignment = z.infer; + +export const ZAutomationTagMode = z.enum(['merge', 'replace']).default('merge'); +export type TAutomationTagMode = z.infer; + +export const ZAutomationTagName = z.string().trim().min(1).max(80); +export type TAutomationTagName = z.infer; + +export const ZAutomationDraftTagParam = z.object({ + draftId: z.string().uuid() +}); +export type TAutomationDraftTagParam = z.infer; + +export const ZAutomationDraftTagAssignment = z.object({ + tagNames: z.array(ZAutomationTagName).min(1).max(100), + mode: ZAutomationTagMode, + groupName: z.string().trim().min(1).max(80).optional() +}); +export type TAutomationDraftTagAssignment = z.infer; + +export const ZAutomationCourseTagAssignment = ZAutomationDraftTagAssignment.extend({ + courseIds: z.array(z.string().uuid()).min(1).max(100) +}); +export type TAutomationCourseTagAssignment = z.infer; diff --git a/patches/layerchart@2.0.0-next.27.patch b/patches/layerchart@2.0.0-next.27.patch new file mode 100644 index 00000000..51be7801 --- /dev/null +++ b/patches/layerchart@2.0.0-next.27.patch @@ -0,0 +1,23 @@ +diff --git a/dist/components/MonthPath.svelte b/dist/components/MonthPath.svelte +index 3ca6c1ec5bf10f2ebacceb0743d7844860bac057..5e3d4246a5d66b4643839c749eee00c8319c788c 100644 +--- a/dist/components/MonthPath.svelte ++++ b/dist/components/MonthPath.svelte +@@ -30,7 +30,7 @@ + + +