diff --git a/AGENTS.md b/AGENTS.md index f888831b..a2bd02d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,6 +177,23 @@ You can bind form controls directly to a writable store’s properties. Svelte c ``` +### Reactive built-in collections + +In Svelte 5, the built-in `Set` and `Map` classes are **not** reactive. Use `SvelteSet` and `SvelteMap` from `svelte/reactivity` instead. They are already reactive on their own — do **not** wrap them in `$state()`. + +```svelte + +``` + ### Server-Side API Calls Use `.server.ts` files for server-side code to isolate API keys. @@ -205,6 +222,7 @@ Use `.server.ts` files for server-side code to isolate API keys. - Use `.server.ts` for server-side API calls with API keys - Use non-null assertion (`!`) for `user` when `authMiddleware` is present - Put all user-facing copy in translations and reference by key +- Use `SvelteSet`/`SvelteMap` from `svelte/reactivity` for reactive collections (not `new Set`/`new Map`) ### ❌ DON'T - Put business logic in routes or queries @@ -217,6 +235,8 @@ Use `.server.ts` files for server-side code to isolate API keys. - Call RPC client directly in server files (use `.server.ts` classes) - Add unnecessary null checks for `user` when `authMiddleware` is present - Write plain English strings in components +- Use `new Set()`/`new Map()` for mutable reactive state — use `SvelteSet`/`SvelteMap` instead +- Wrap `SvelteSet`/`SvelteMap` in `$state()` — they are already reactive - **Use inline type imports** (e.g. `import('Package').Type` in type positions) — use top-level `import type` instead ## Checklist for New Routes diff --git a/apps/api/src/middlewares/org-member.ts b/apps/api/src/middlewares/org-member.ts index 765ad8f0..c34b1b38 100644 --- a/apps/api/src/middlewares/org-member.ts +++ b/apps/api/src/middlewares/org-member.ts @@ -11,7 +11,6 @@ import { getUserOrgRole } from '@cio/db/queries/organization'; export const orgMemberMiddleware = async (c: Context, next: Next) => { try { const user = c.get('user'); - console.log('orgMemberMiddleware user', user); if (!user) { return c.json( { @@ -24,7 +23,6 @@ export const orgMemberMiddleware = async (c: Context, next: Next) => { } const orgId = c.req.header('cio-org-id'); - console.log('orgMemberMiddleware orgId', orgId); if (!orgId) { return c.json( { @@ -37,7 +35,6 @@ export const orgMemberMiddleware = async (c: Context, next: Next) => { } const roleId = await getUserOrgRole(orgId, user.id); - console.log('orgMemberMiddleware roleId', orgId); if (roleId === null) { return c.json( { diff --git a/apps/api/src/routes/organization/organization.ts b/apps/api/src/routes/organization/organization.ts index 698b4a33..b87bf295 100644 --- a/apps/api/src/routes/organization/organization.ts +++ b/apps/api/src/routes/organization/organization.ts @@ -1,4 +1,6 @@ import { + ZAssignAudienceCourses, + ZAudienceInviteByEmail, ZCancelOrgPlan, ZCreateOrgPlan, ZCreateOrganization, @@ -7,12 +9,20 @@ import { ZGetOrganizationCoursesQuery, ZGetOrganizations, ZGetUserAnalytics, + ZImportAudienceMembers, ZInviteTeamMembers, ZLMSExercisesParam, ZRemoveTeamMember, ZUpdateOrgPlan, ZUpdateOrganization } from '@cio/utils/validation/organization'; +import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage'; +import { + assignAudienceToCourses, + importAudienceMembers, + resendAudienceInvite, + revokeAudiencePendingInvite +} from '@api/services/organization/audience'; import { cancelOrgPlan, createOrg, @@ -33,13 +43,13 @@ import { } from '@api/services/organization'; import { Hono } from '@api/utils/hono'; +import { ROLE } from '@cio/utils/constants'; 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 { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key'; +import { automationRouter } from '@api/routes/organization/automation'; import { courseImportRouter } from '@api/routes/organization/course-import'; import { getLMSExercisesService } from '@api/services/exercise'; import { handleError } from '@api/utils/errors'; @@ -47,9 +57,9 @@ 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 { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member'; 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() @@ -100,7 +110,7 @@ export const organizationRouter = new Hono() * Gets organization team members (non-students) * Requires authentication */ - .get('/team', authMiddleware, orgMemberMiddleware, async (c) => { + .get('/team', authMiddleware, orgTeamMemberMiddleware, async (c) => { try { const orgId = c.req.header('cio-org-id')!; const team = await getOrgTeam(orgId); @@ -168,7 +178,7 @@ export const organizationRouter = new Hono() * Gets organization audience (students) * Requires authentication */ - .get('/audience', authMiddleware, orgMemberMiddleware, async (c) => { + .get('/audience', authMiddleware, orgTeamMemberMiddleware, async (c) => { try { const orgId = c.req.header('cio-org-id')!; const audience = await getOrgAudience(orgId); @@ -184,6 +194,48 @@ export const organizationRouter = new Hono() return handleError(c, error, 'Failed to fetch organization audience'); } }) + /** + * POST /organization/audience/resend-invite + * Resends a student org invite email (new token; optional course metadata from last invite) + */ + .post( + '/audience/resend-invite', + authMiddleware, + orgTeamMemberMiddleware, + zValidator('json', ZAudienceInviteByEmail), + async (c) => { + try { + const orgId = c.req.header('cio-org-id')!; + const user = c.get('user')!; + const data = c.req.valid('json'); + const result = await resendAudienceInvite(orgId, data, user.id); + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to resend audience invite'); + } + } + ) + /** + * POST /organization/audience/revoke-invite + * Revokes an active (non-expired) pending student org invite + */ + .post( + '/audience/revoke-invite', + authMiddleware, + orgTeamMemberMiddleware, + zValidator('json', ZAudienceInviteByEmail), + async (c) => { + try { + const orgId = c.req.header('cio-org-id')!; + const user = c.get('user')!; + const data = c.req.valid('json'); + const result = await revokeAudiencePendingInvite(orgId, data, user.id); + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to revoke audience invite'); + } + } + ) /** * GET /organization/audience/:userId/analytics * Gets user analytics for a specific user in an organization @@ -192,7 +244,7 @@ export const organizationRouter = new Hono() .get( '/audience/:userId/analytics', authMiddleware, - orgMemberMiddleware, + orgTeamMemberMiddleware, zValidator('param', ZGetUserAnalytics), async (c) => { try { @@ -458,7 +510,6 @@ export const organizationRouter = new Hono() */ .put('/', authMiddleware, orgAdminMiddleware, zValidator('json', ZUpdateOrganization), async (c) => { try { - console.log('updateOrg'); console.log(c.req.valid('json')); const orgId = c.req.header('cio-org-id')!; @@ -515,6 +566,65 @@ export const organizationRouter = new Hono() return handleError(c, error, 'Failed to fetch LMS exercises'); } }) + /** + * POST /organization/audience/import + * Imports users into the organization as students with optional course assignment + * Requires authentication and admin role + */ + .post( + '/audience/import', + authMiddleware, + orgTeamMemberMiddleware, + zValidator('json', ZImportAudienceMembers), + async (c) => { + try { + const orgId = c.req.header('cio-org-id')!; + const user = c.get('user')!; + const data = c.req.valid('json'); + + const result = await importAudienceMembers(orgId, data, user.id); + + return c.json( + { + success: true, + data: result + }, + 201 + ); + } catch (error) { + return handleError(c, error, 'Failed to import audience members'); + } + } + ) + /** + * POST /organization/audience/assign-courses + * Assigns existing audience members to one or more courses + * Requires authentication and admin role + */ + .post( + '/audience/assign-courses', + authMiddleware, + orgAdminMiddleware, + zValidator('json', ZAssignAudienceCourses), + async (c) => { + try { + const orgId = c.req.header('cio-org-id')!; + const data = c.req.valid('json'); + + const result = await assignAudienceToCourses(orgId, data); + + return c.json( + { + success: true, + data: result + }, + 200 + ); + } catch (error) { + return handleError(c, error, 'Failed to assign audience to courses'); + } + } + ) .route('/automation', automationRouter) .route('/course-import', courseImportRouter) .route('/tags', tagsRouter) diff --git a/apps/api/src/services/course/invite.ts b/apps/api/src/services/course/invite.ts index 07a3dde8..2566bab2 100644 --- a/apps/api/src/services/course/invite.ts +++ b/apps/api/src/services/course/invite.ts @@ -23,7 +23,7 @@ import { getDashboardBaseUrl } from '@api/config/dashboard-url'; import { getCourseTeachers } from '@cio/db/queries/course/people'; import { getProfileById } from '@cio/db/queries/auth'; import { buildEmailFromName, sendEmail } from '@cio/email'; -import { getProfileByEmail } from '@cio/db/queries/auth'; +import { getProfileByEmail, markUserAndProfileEmailVerified } from '@cio/db/queries/auth'; import { generateSlug } from '@cio/utils/functions'; type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED'; @@ -948,6 +948,8 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex .limit(1); if (existingMember) { + await markUserAndProfileEmailVerified(user.id, tx); + await recordInviteAudit(invite.id, invite.courseId, 'ACCEPTED', { actorProfileId: user.id, targetEmail: normalizedEmail, @@ -1029,6 +1031,8 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex email: normalizedEmail }); + await markUserAndProfileEmailVerified(user.id, tx); + const [consumeInvite] = await tx .update(schema.courseInvite) .set({ diff --git a/apps/api/src/services/dash.ts b/apps/api/src/services/dash.ts index f33e53fa..371bec61 100644 --- a/apps/api/src/services/dash.ts +++ b/apps/api/src/services/dash.ts @@ -2,7 +2,7 @@ import { AppError, ErrorCodes } from '@api/utils/errors'; import { getCourseStats, getDashOrgStats, getRecentEnrollments } from '@cio/db/queries/dash'; import { OrganisationAnalytics } from '@api/types'; -import { getOrgIdBySiteName } from '@db/queries'; +import { getOrgIdBySiteName } from '@cio/db/queries'; export async function getOrganisationAnalytics(orgId?: string, siteName?: string): Promise { const analytics: OrganisationAnalytics = { diff --git a/apps/api/src/services/organization.ts b/apps/api/src/services/organization.ts index 24becb43..7fd37318 100644 --- a/apps/api/src/services/organization.ts +++ b/apps/api/src/services/organization.ts @@ -1,11 +1,12 @@ import { AppError, ErrorCodes } from '@api/utils/errors'; -import type { TNewOrganizationPlan, TOrganization, TOrganizationPlan, TPlan } from '@db/types'; +import type { TNewOrganizationPlan, TOrganization, TOrganizationPlan } from '@db/types'; import { cancelOrganizationPlan, createOrganizationPlan, deleteOrganizationMember, getOrgIdBySiteName, getFirstOrganizationWithPlans, + getLatestOrgInvitesByEmails, getOrganizationAudience, getOrganizationBySiteName, getOrganizationTeam, @@ -32,6 +33,8 @@ import { ROLE } from '@cio/utils/constants'; import { createOrganizationWithOwner } from '@api/services/onboarding'; import { getProfileById } from '@cio/db/queries/auth'; import { inviteTeamMembers as inviteTeamMembersSecure } from './organization/invite'; +import { deriveAudienceMemberStatus } from '@api/utils/audience-member-status'; +import type { OrgAudienceMember } from '@api/types/org'; /** * Creates a new organization with the current user as owner @@ -116,12 +119,26 @@ export async function getOrgTeam(orgId: string) { /** * Gets organization audience (students) * @param orgId - The organization ID - * @returns Array of student profiles + * @returns Audience members with invite status for profile-less rows */ -export async function getOrgAudience(orgId: string) { +export async function getOrgAudience(orgId: string): Promise { try { const audience = await getOrganizationAudience(orgId); - return audience; + + const emailsWithoutProfile = audience.filter((m) => !m.profileId && m.email).map((m) => m.email.toLowerCase()); + + const invites = await getLatestOrgInvitesByEmails(orgId, emailsWithoutProfile); + const inviteByEmail = new Map(invites.map((i) => [i.email.toLowerCase(), i])); + + return audience.map( + (member): OrgAudienceMember => ({ + ...member, + status: deriveAudienceMemberStatus( + member.profileId, + member.email ? inviteByEmail.get(member.email.toLowerCase()) : undefined + ) + }) + ); } catch (error) { throw new AppError( error instanceof Error ? error.message : 'Failed to fetch organization audience', diff --git a/apps/api/src/services/organization/audience.ts b/apps/api/src/services/organization/audience.ts new file mode 100644 index 00000000..ed156bf6 --- /dev/null +++ b/apps/api/src/services/organization/audience.ts @@ -0,0 +1,531 @@ +import { AppError, ErrorCodes } from '@api/utils/errors'; +import type { + TAssignAudienceCourses, + TAudienceInviteByEmail, + TImportAudienceMembers +} from '@cio/utils/validation/organization'; +import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group'; +import { buildEmailFromName, sendEmail } from '@cio/email'; +import { + checkEmailsExistInOrg, + createOrganizationInvite, + createOrganizationInviteAudits, + createOrganizationInvites, + createOrganizationMembers, + getLatestOrganizationInviteRowByOrgAndEmail, + getOrgMembersByProfileIds, + getOrganizationById, + getStudentOrganizationMemberByOrgAndEmail, + hasActiveOrganizationInviteForEmail, + revokeActiveOrganizationInvitesByEmails +} from '@cio/db/queries/organization'; +import { getCourseGroupIds, getOrgCourseGroups, getOrgCourses } from '@cio/db/queries/course'; + +import { ROLE } from '@cio/utils/constants'; +import crypto from 'node:crypto'; +import { getDashboardBaseUrl } from '@api/config/dashboard-url'; +import { getProfilesByEmails } from '@cio/db/queries/auth'; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const ORG_INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; +/** Parallel outbound invite emails; avoids sequential SMTP/API latency per recipient. */ +const EMAIL_SEND_CONCURRENCY = 5; + +async function mapWithConcurrency(items: T[], concurrency: number, fn: (item: T) => Promise): Promise { + if (items.length === 0) { + return []; + } + const results: R[] = new Array(items.length); + let next = 0; + const workerCount = Math.min(concurrency, items.length); + const workers = Array.from({ length: workerCount }, async () => { + while (true) { + const i = next++; + if (i >= items.length) break; + results[i] = await fn(items[i]); + } + }); + await Promise.all(workers); + return results; +} + +function hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +function generateToken(): string { + return crypto.randomBytes(32).toString('base64url'); +} + +function buildInviteLink(token: string): string { + return `${getDashboardBaseUrl()}/invite/${encodeURIComponent(token)}`; +} + +function getExpiryLabel(expiresAtIso: string): string { + return new Date(expiresAtIso).toLocaleString('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + timeZone: 'UTC' + }); +} + +function parseRecipientCsv(recipientCsv: string): string[] { + return recipientCsv + .split(/[\n,;\t ]+/g) + .map((value) => value.trim()) + .filter(Boolean); +} + +function getNormalizedRecipients(recipientCsv: string): { + valid: string[]; + invalid: string[]; + duplicates: string[]; +} { + const raw = parseRecipientCsv(recipientCsv); + const seen = new Set(); + const valid: string[] = []; + const invalid: string[] = []; + const duplicates: string[] = []; + + for (const recipient of raw) { + const normalized = recipient.toLowerCase().trim(); + if (!normalized) continue; + + if (!EMAIL_REGEX.test(normalized)) { + invalid.push(normalized); + continue; + } + + if (seen.has(normalized)) { + duplicates.push(normalized); + continue; + } + + seen.add(normalized); + valid.push(normalized); + } + + return { valid, invalid, duplicates }; +} + +export async function importAudienceMembers(orgId: string, data: TImportAudienceMembers, invitedByProfileId: string) { + const organization = await getOrganizationById(orgId); + if (!organization || !organization.siteName) { + throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404); + } + + const recipients = getNormalizedRecipients(data.recipientCsv); + + if (recipients.invalid.length > 0) { + throw new AppError( + `Invalid emails found: ${recipients.invalid.slice(0, 5).join(', ')}`, + ErrorCodes.VALIDATION_ERROR, + 400, + 'recipientCsv' + ); + } + + if (recipients.valid.length === 0) { + throw new AppError('No valid emails provided', ErrorCodes.VALIDATION_ERROR, 400, 'recipientCsv'); + } + + const existingEmails = await checkEmailsExistInOrg(orgId, recipients.valid); + const emailsToImport = recipients.valid.filter((email) => !existingEmails.includes(email)); + + if (emailsToImport.length === 0) { + return { + imported: 0, + skipped: existingEmails.length, + duplicates: recipients.duplicates.length, + emailsSent: 0, + emailsFailed: 0 + }; + } + + await createOrganizationMembers( + emailsToImport.map((email) => ({ + organizationId: orgId, + email, + roleId: ROLE.STUDENT, + verified: false + })) + ); + + let courseIds: string[] = []; + let courseNames: string[] = []; + + if (data.allCourses) { + const courses = await getOrgCourses({ orgId }); + courseIds = courses.map((c) => c.id); + courseNames = courses.map((c) => c.title).filter(Boolean); + } else if (data.courseIds && data.courseIds.length > 0) { + const courses = await getOrgCourses({ orgId, courseIds: data.courseIds }); + courseIds = courses.map((c) => c.id); + courseNames = courses.map((c) => c.title).filter(Boolean); + } + if (courseIds.length > 0) { + const courseGroupMappings = await getCourseGroupIds(courseIds); + const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[]; + + if (validGroupIds.length > 0) { + const profiles = await getProfilesByEmails(emailsToImport); + if (profiles.length > 0) { + const users = profiles.map((p) => ({ profileId: p.id, email: p.email ?? undefined })); + await enrollUsersInCourseGroups(validGroupIds, users, ROLE.STUDENT); + } + } + } + + await revokeActiveOrganizationInvitesByEmails(orgId, emailsToImport, invitedByProfileId); + + const expiresAt = new Date(Date.now() + ORG_INVITE_EXPIRY_MS).toISOString(); + const courseNamesLabel = courseNames.length > 0 ? courseNames.join(', ') : undefined; + + const inviteInputs = emailsToImport.map((email) => { + const token = generateToken(); + return { + email, + token, + row: { + organizationId: orgId, + roleId: ROLE.STUDENT, + email, + tokenHash: hashToken(token), + createdByProfileId: invitedByProfileId, + expiresAt, + isRevoked: false, + metadata: { + source: 'AUDIENCE_IMPORT', + courseIds: courseIds.length > 0 ? courseIds : undefined + } + } + }; + }); + + const invites = await createOrganizationInvites(inviteInputs.map((i) => i.row)); + const inviteByEmail = new Map(invites.map((inv) => [inv.email.toLowerCase(), inv])); + const tokenByEmail = new Map(inviteInputs.map((i) => [i.email.toLowerCase(), i.token])); + + await createOrganizationInviteAudits( + emailsToImport.map((email) => { + const invite = inviteByEmail.get(email)!; + return { + inviteId: invite.id, + organizationId: orgId, + eventType: 'CREATED' as const, + actorProfileId: invitedByProfileId, + targetEmail: email, + ipAddress: null, + userAgent: null, + metadata: { + roleId: ROLE.STUDENT, + roleName: 'Student', + expiresAt, + courseIds + } + }; + }) + ); + + let emailsSent = 0; + let emailsFailed = 0; + + if (data.sendEmail) { + const emailOutcomes = await mapWithConcurrency(emailsToImport, EMAIL_SEND_CONCURRENCY, async (email) => { + const invite = inviteByEmail.get(email)!; + const token = tokenByEmail.get(email)!; + try { + const inviteLink = buildInviteLink(token); + const responses = await sendEmail('studentOrgInvite', { + to: email, + fields: { + email, + orgName: organization.name, + inviteLink, + expiresAt: getExpiryLabel(expiresAt), + courseNames: courseNamesLabel + }, + from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`) + }); + + const allSuccessful = responses.every((r) => r.success); + if (allSuccessful) { + return { + inviteId: invite.id, + email, + success: true as const, + error: undefined as string | undefined + }; + } + return { + inviteId: invite.id, + email, + success: false as const, + error: responses + .filter((r) => !r.success) + .map((r) => r.error || 'Unknown') + .join('; ') + }; + } catch (emailError) { + const message = emailError instanceof Error ? emailError.message : 'Unknown email error'; + return { inviteId: invite.id, email, success: false as const, error: message }; + } + }); + + emailsSent = emailOutcomes.filter((o) => o.success).length; + emailsFailed = emailOutcomes.length - emailsSent; + + await createOrganizationInviteAudits( + emailOutcomes.map((o) => ({ + inviteId: o.inviteId, + organizationId: orgId, + eventType: o.success ? ('EMAIL_SENT' as const) : ('EMAIL_FAILED' as const), + actorProfileId: invitedByProfileId, + targetEmail: o.email, + ipAddress: null, + userAgent: null, + metadata: o.success ? {} : { error: o.error ?? 'Unknown' } + })) + ); + } + + return { + imported: emailsToImport.length, + skipped: existingEmails.length, + duplicates: recipients.duplicates.length, + emailsSent, + emailsFailed + }; +} + +export async function resendAudienceInvite(orgId: string, data: TAudienceInviteByEmail, invitedByProfileId: string) { + const organization = await getOrganizationById(orgId); + if (!organization || !organization.siteName) { + throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404); + } + + const member = await getStudentOrganizationMemberByOrgAndEmail(orgId, data.email); + if (!member) { + throw new AppError('Audience member not found', ErrorCodes.VALIDATION_ERROR, 404, 'email'); + } + if (member.profileId) { + throw new AppError('Member has already joined', ErrorCodes.VALIDATION_ERROR, 400, 'email'); + } + + const emailToUse = member.email.toLowerCase().trim(); + + const latestInvite = await getLatestOrganizationInviteRowByOrgAndEmail(orgId, emailToUse); + const meta = latestInvite?.metadata as { courseIds?: string[] } | undefined; + const courseIdsFromMetadata = meta?.courseIds?.filter(Boolean) ?? []; + + let courseIds: string[] = []; + let courseNames: string[] = []; + if (courseIdsFromMetadata.length > 0) { + const courses = await getOrgCourses({ orgId, courseIds: courseIdsFromMetadata }); + courseIds = courses.map((c) => c.id); + courseNames = courses.map((c) => c.title).filter(Boolean); + } + + await revokeActiveOrganizationInvitesByEmails(orgId, [emailToUse], invitedByProfileId); + + const expiresAt = new Date(Date.now() + ORG_INVITE_EXPIRY_MS).toISOString(); + const token = generateToken(); + const courseNamesLabel = courseNames.length > 0 ? courseNames.join(', ') : undefined; + + const invite = await createOrganizationInvite({ + organizationId: orgId, + roleId: ROLE.STUDENT, + email: emailToUse, + tokenHash: hashToken(token), + createdByProfileId: invitedByProfileId, + expiresAt, + isRevoked: false, + metadata: { + source: 'AUDIENCE_RESEND', + courseIds: courseIds.length > 0 ? courseIds : undefined + } + }); + + await createOrganizationInviteAudits([ + { + inviteId: invite.id, + organizationId: orgId, + eventType: 'CREATED', + actorProfileId: invitedByProfileId, + targetEmail: emailToUse, + ipAddress: null, + userAgent: null, + metadata: { + roleId: ROLE.STUDENT, + roleName: 'Student', + expiresAt, + courseIds + } + } + ]); + + let emailSent = false; + try { + const inviteLink = buildInviteLink(token); + const responses = await sendEmail('studentOrgInvite', { + to: emailToUse, + fields: { + email: emailToUse, + orgName: organization.name, + inviteLink, + expiresAt: getExpiryLabel(expiresAt), + courseNames: courseNamesLabel + }, + from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`) + }); + const allSuccessful = responses.every((r) => r.success); + emailSent = allSuccessful; + await createOrganizationInviteAudits([ + { + inviteId: invite.id, + organizationId: orgId, + eventType: allSuccessful ? 'EMAIL_SENT' : 'EMAIL_FAILED', + actorProfileId: invitedByProfileId, + targetEmail: emailToUse, + ipAddress: null, + userAgent: null, + metadata: allSuccessful + ? {} + : { + error: responses + .filter((r) => !r.success) + .map((r) => r.error || 'Unknown') + .join('; ') + } + } + ]); + } catch (emailError) { + const message = emailError instanceof Error ? emailError.message : 'Unknown email error'; + await createOrganizationInviteAudits([ + { + inviteId: invite.id, + organizationId: orgId, + eventType: 'EMAIL_FAILED', + actorProfileId: invitedByProfileId, + targetEmail: emailToUse, + ipAddress: null, + userAgent: null, + metadata: { error: message } + } + ]); + } + + return { emailSent }; +} + +export async function revokeAudiencePendingInvite( + orgId: string, + data: TAudienceInviteByEmail, + revokedByProfileId: string +) { + const member = await getStudentOrganizationMemberByOrgAndEmail(orgId, data.email); + if (!member) { + throw new AppError('Audience member not found', ErrorCodes.VALIDATION_ERROR, 404, 'email'); + } + if (member.profileId) { + throw new AppError('Member has already joined', ErrorCodes.VALIDATION_ERROR, 400, 'email'); + } + + const emailToUse = member.email.toLowerCase().trim(); + + const hasActive = await hasActiveOrganizationInviteForEmail(orgId, emailToUse); + if (!hasActive) { + throw new AppError('No active pending invite to revoke', ErrorCodes.VALIDATION_ERROR, 400); + } + + const revoked = await revokeActiveOrganizationInvitesByEmails(orgId, [emailToUse], revokedByProfileId); + if (revoked.length === 0) { + throw new AppError('No invite was revoked', ErrorCodes.VALIDATION_ERROR, 400); + } + + await createOrganizationInviteAudits( + revoked.map((inv) => ({ + inviteId: inv.id, + organizationId: orgId, + eventType: 'REVOKED' as const, + actorProfileId: revokedByProfileId, + targetEmail: emailToUse, + ipAddress: null, + userAgent: null, + metadata: { reason: 'audience_revoked_by_admin' } + })) + ); + + return { revoked: true }; +} + +export async function assignAudienceToCourses(orgId: string, data: TAssignAudienceCourses) { + const organization = await getOrganizationById(orgId); + if (!organization) { + throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404); + } + + const orgMembers = await getOrgMembersByProfileIds(orgId, data.profileIds); + + const validProfileIds = new Set(orgMembers.map((m) => m.profileId).filter(Boolean)); + const profileEmailMap = new Map(orgMembers.filter((m) => m.profileId).map((m) => [m.profileId!, m.email ?? ''])); + + const courseGroups = await getOrgCourseGroups(orgId, data.courseIds); + + if (courseGroups.length === 0) { + throw new AppError('No valid courses found', ErrorCodes.VALIDATION_ERROR, 400, 'courseIds'); + } + + const validGroupIds = courseGroups.map((cg) => cg.groupId).filter(Boolean) as string[]; + const courseTitleByGroupId = new Map(courseGroups.map((cg) => [cg.groupId, cg.courseTitle])); + const validProfiles = data.profileIds.filter((id) => validProfileIds.has(id)); + + const pairs = validProfiles.flatMap((profileId) => validGroupIds.map((groupId) => ({ groupId, profileId }))); + + const existingSet = await getExistingGroupMembers(pairs); + + const toInsert = pairs.filter((p) => !existingSet.has(`${p.groupId}:${p.profileId}`)); + const alreadyEnrolled = pairs.length - toInsert.length; + + if (toInsert.length > 0) { + await addGroupMembers( + toInsert.map((p) => ({ + groupId: p.groupId, + roleId: ROLE.STUDENT, + profileId: p.profileId, + email: profileEmailMap.get(p.profileId) || undefined + })) + ); + } + + let emailsSent = 0; + + if (data.sendEmail && toInsert.length > 0) { + const emailPromises = toInsert + .filter((p) => profileEmailMap.get(p.profileId)) + .map(async (p) => { + const email = profileEmailMap.get(p.profileId)!; + try { + await sendEmail('studentCourseWelcome', { + to: email, + fields: { + orgName: organization.name, + courseName: courseTitleByGroupId.get(p.groupId) || 'Course' + }, + from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`) + }); + emailsSent++; + } catch (emailError) { + console.error(`assignAudienceToCourses email error for ${email}:`, emailError); + } + }); + + await Promise.all(emailPromises); + } + + return { + assigned: toInsert.length, + alreadyEnrolled, + emailsSent + }; +} diff --git a/apps/api/src/services/organization/invite.ts b/apps/api/src/services/organization/invite.ts index 900dce18..c639ccce 100644 --- a/apps/api/src/services/organization/invite.ts +++ b/apps/api/src/services/organization/invite.ts @@ -11,11 +11,14 @@ import { getOrganizationInviteByTokenHash, revokeActiveOrganizationInvitesByEmails } from '@cio/db/queries/organization'; +import { getCourseGroupIds } from '@cio/db/queries/course'; +import { enrollUsersInCourseGroups } from '@cio/db/queries/group'; import { ROLE } from '@cio/utils/constants'; import crypto from 'node:crypto'; import { db } from '@cio/db/drizzle'; import { getDashboardBaseUrl } from '@api/config/dashboard-url'; +import { parseCourseIdsFromInviteMetadata } from '@api/utils/org'; import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile'; import { sendEmail } from '@cio/email'; @@ -46,12 +49,13 @@ function normalizeEmails(emails: string[]): string[] { } function buildInviteLink(token: string): string { - return `${getDashboardBaseUrl()}/invite/t/${encodeURIComponent(token)}`; + return `${getDashboardBaseUrl()}/invite/${encodeURIComponent(token)}`; } function getRoleLabel(roleId: number): string { if (roleId === ROLE.ADMIN) return 'Admin'; if (roleId === ROLE.TUTOR) return 'Tutor'; + if (roleId === ROLE.STUDENT) return 'Student'; return `Role ${roleId}`; } @@ -405,6 +409,7 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c const siteName = result.organization.siteName || ''; const invite = await getOrganizationInviteByTokenHash(tokenHash); + if (invite) { await recordOrganizationInviteAudit(invite.invite.id, invite.invite.organizationId, 'ACCEPTED', { actorProfileId: user.id, @@ -415,10 +420,35 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c }); } + // Auto-enroll in courses if invite metadata contains courseIds (from audience import). + // Run regardless of alreadyAccepted — enrollment is idempotent and the user may + // have accepted the org invite previously without getting course access. + if (invite) { + const metadata = invite.invite.metadata; + const courseIds = parseCourseIdsFromInviteMetadata(metadata); + + if (courseIds.length > 0) { + try { + // Same as importAudienceMembers after course ids are known: resolve groups from course rows only. + const courseGroupMappings = await getCourseGroupIds(courseIds); + const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[]; + await enrollUsersInCourseGroups( + validGroupIds, + [{ profileId: user.id, email: normalizedEmail }], + invite.invite.roleId + ); + } catch (error) { + console.error('acceptOrganizationInvite course enrollment error:', error); + } + } + } + + const redirectTo = result.roleId === ROLE.STUDENT ? '/lms' : siteName ? `/org/${siteName}` : '/org'; + return { organizationId: result.organization.id, roleId: result.roleId, alreadyAccepted: result.alreadyAccepted, - redirectTo: siteName ? `/org/${siteName}` : '/org' + redirectTo }; } diff --git a/apps/api/src/types/index.ts b/apps/api/src/types/index.ts index 96258206..49003fde 100644 --- a/apps/api/src/types/index.ts +++ b/apps/api/src/types/index.ts @@ -1,3 +1,4 @@ // Re-export all types export * from './database'; export * from './dash'; +export * from './org'; diff --git a/apps/api/src/types/org.ts b/apps/api/src/types/org.ts new file mode 100644 index 00000000..5091f2c8 --- /dev/null +++ b/apps/api/src/types/org.ts @@ -0,0 +1,11 @@ +import type { AudienceMemberStatus } from '@api/utils/audience-member-status'; + +export type OrgAudienceMember = { + id: number; + profileId: string | null; + name: string; + email: string; + avatarUrl: string; + createdAt: string; + status: AudienceMemberStatus; +}; diff --git a/apps/api/src/utils/audience-member-status.ts b/apps/api/src/utils/audience-member-status.ts new file mode 100644 index 00000000..d08309b0 --- /dev/null +++ b/apps/api/src/utils/audience-member-status.ts @@ -0,0 +1,23 @@ +export type AudienceMemberStatus = 'active' | 'pending' | 'expired' | 'revoked'; + +export function deriveAudienceMemberStatus( + profileId: string | null, + invite: { acceptedAt: string | null; isRevoked: boolean; expiresAt: string } | undefined +): AudienceMemberStatus { + if (profileId) { + return 'active'; + } + if (invite?.acceptedAt) { + return 'active'; + } + if (!invite) { + return 'pending'; + } + if (invite.isRevoked) { + return 'revoked'; + } + if (new Date(invite.expiresAt) <= new Date()) { + return 'expired'; + } + return 'pending'; +} diff --git a/apps/api/src/utils/org.ts b/apps/api/src/utils/org.ts new file mode 100644 index 00000000..fe8af47e --- /dev/null +++ b/apps/api/src/utils/org.ts @@ -0,0 +1,25 @@ +/** Normalize JSONB organization-invite metadata for course ids (matches importAudienceMembers storage). */ +export function parseCourseIdsFromInviteMetadata(metadata: unknown): string[] { + let obj: Record | null = null; + if (metadata == null) { + return []; + } + if (typeof metadata === 'string') { + try { + const parsed = JSON.parse(metadata) as unknown; + obj = typeof parsed === 'object' && parsed !== null ? (parsed as Record) : null; + } catch { + return []; + } + } else if (typeof metadata === 'object') { + obj = metadata as Record; + } + if (!obj) { + return []; + } + const raw = obj.courseIds ?? obj.course_ids; + if (!Array.isArray(raw)) { + return []; + } + return raw.filter((id): id is string => typeof id === 'string' && id.length > 0); +} diff --git a/apps/dashboard/src/app.css b/apps/dashboard/src/app.css index 814842f1..f337307b 100644 --- a/apps/dashboard/src/app.css +++ b/apps/dashboard/src/app.css @@ -59,11 +59,11 @@ html .dark { } .prose h1 { - @apply mt-0 text-4xl font-extrabold; + @apply mt-0 text-4xl font-normal; } .prose h2 { - @apply mt-10 border-b pb-2 text-3xl font-semibold transition-colors; + @apply mt-0 text-3xl font-normal transition-colors; } .prose h2:first-child { @@ -71,11 +71,11 @@ html .dark { } .prose h3 { - @apply mt-8 text-2xl font-semibold; + @apply mt-0 text-2xl font-normal; } .prose h4 { - @apply mt-8 text-xl font-semibold; + @apply mt-0 text-xl font-normal; } .prose p, diff --git a/apps/dashboard/src/hooks.server.ts b/apps/dashboard/src/hooks.server.ts index 9551ae3c..a72fff62 100644 --- a/apps/dashboard/src/hooks.server.ts +++ b/apps/dashboard/src/hooks.server.ts @@ -1,5 +1,5 @@ import { getSessionData } from '$lib/utils/services/auth/session'; -import { getCioCookieString } from '$lib/utils/functions/cookies'; +import { getHasCioCookies } 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'; @@ -26,7 +26,7 @@ export const handle: Handle = async (args) => { const handlePagesRoutes: Handle = async ({ event, resolve }) => { const { pathname } = event.url; - const hasCioCookie = Boolean(getCioCookieString(event.cookies)); + const hasCioCookie = getHasCioCookies(event.cookies); if (isPublicRoute(pathname)) { return resolve(event); diff --git a/apps/dashboard/src/lib/features/app/init.svelte.ts b/apps/dashboard/src/lib/features/app/init.svelte.ts index c17b2b12..de69ad4e 100644 --- a/apps/dashboard/src/lib/features/app/init.svelte.ts +++ b/apps/dashboard/src/lib/features/app/init.svelte.ts @@ -119,7 +119,8 @@ class AppInitApi extends BaseApi { } // This allows you to be on the landing page of an organization site and not be redirected - if (isOrgSite && isPublicRoute(window.location.pathname)) { + const path = window.location.pathname; + if (isPublicRoute(path) && (path !== '/' || isOrgSite)) { console.log('no redirect is needed'); return; } diff --git a/apps/dashboard/src/lib/features/audience/components/assign-courses-modal.svelte b/apps/dashboard/src/lib/features/audience/components/assign-courses-modal.svelte new file mode 100644 index 00000000..bc5a0f09 --- /dev/null +++ b/apps/dashboard/src/lib/features/audience/components/assign-courses-modal.svelte @@ -0,0 +1,102 @@ + + + !v && handleClose()}> + + + {$t('audience.assign.title')} + {$t('audience.assign.description')} + + + ({ id: c.id, label: c.title || c.id }))} + isSelected={(id) => fields.selectedCourseIds.has(id)} + onToggle={toggleCourse} + namePrefix="assign-course" + /> + + + + + + + + + diff --git a/apps/dashboard/src/lib/features/audience/components/audience-member-row.svelte b/apps/dashboard/src/lib/features/audience/components/audience-member-row.svelte new file mode 100644 index 00000000..d2fa5cd5 --- /dev/null +++ b/apps/dashboard/src/lib/features/audience/components/audience-member-row.svelte @@ -0,0 +1,102 @@ + + + + + + + + {#if memberDetailHref} + + + {#if row.avatarUrl} + + {/if} + + + + + {row.name} + + {:else} + + + {#if row.avatarUrl} + + {/if} + + + + + {row.name} + + {/if} + + {row.email} + + {$t(statusLabelKey(row.status))} + + {row.createdAt} + + {#if showInviteActions} + + e.stopPropagation()} + > + + + + {#if canResendAudienceInvite(row.status)} + onResendInvite(row.email)}> + {$t('audience.invite.resend')} + + {/if} + {#if canRevokeAudienceInvite(row.status)} + onRevokeInvite(row.email)} + > + {$t('audience.invite.revoke')} + + {/if} + + + {/if} + + diff --git a/apps/dashboard/src/lib/features/audience/components/audience-pagination.svelte b/apps/dashboard/src/lib/features/audience/components/audience-pagination.svelte new file mode 100644 index 00000000..adc6001b --- /dev/null +++ b/apps/dashboard/src/lib/features/audience/components/audience-pagination.svelte @@ -0,0 +1,40 @@ + + +
+ + {#snippet children({ pages, currentPage: activePage })} + + + + + {#each pages as pageItem (pageItem.key)} + {#if pageItem.type === 'ellipsis'} + + + + {:else} + + + {pageItem.value} + + + {/if} + {/each} + + + + + {/snippet} + +
diff --git a/apps/dashboard/src/lib/features/audience/components/audience-table-skeleton.svelte b/apps/dashboard/src/lib/features/audience/components/audience-table-skeleton.svelte new file mode 100644 index 00000000..ab759464 --- /dev/null +++ b/apps/dashboard/src/lib/features/audience/components/audience-table-skeleton.svelte @@ -0,0 +1,15 @@ + + +
+ {#each Array(rowCount) as _, i (i)} + + {/each} +
diff --git a/apps/dashboard/src/lib/features/audience/components/audience-table-toolbar.svelte b/apps/dashboard/src/lib/features/audience/components/audience-table-toolbar.svelte new file mode 100644 index 00000000..acab7939 --- /dev/null +++ b/apps/dashboard/src/lib/features/audience/components/audience-table-toolbar.svelte @@ -0,0 +1,30 @@ + + + + {#if hasSelection} +
+ + {$t('audience.selected_count', { count: selectedCount })} + + +
+ {:else} + + {/if} +
diff --git a/apps/dashboard/src/lib/features/audience/components/audience-table.svelte b/apps/dashboard/src/lib/features/audience/components/audience-table.svelte new file mode 100644 index 00000000..13e0e5a3 --- /dev/null +++ b/apps/dashboard/src/lib/features/audience/components/audience-table.svelte @@ -0,0 +1,80 @@ + + +
+ + + + + + + {#each headers as header (header)} + {header.value} + {/each} + + {$t('audience.invite.row_actions_aria')} + + + + + {#each rows as row (String(row.id))} + onToggleRow(String(row.id))} + {inviteActionEmail} + {onResendInvite} + {onRevokeInvite} + /> + {/each} + + +
diff --git a/apps/dashboard/src/lib/features/audience/components/index.ts b/apps/dashboard/src/lib/features/audience/components/index.ts index 27634dc5..b9a6c99e 100644 --- a/apps/dashboard/src/lib/features/audience/components/index.ts +++ b/apps/dashboard/src/lib/features/audience/components/index.ts @@ -1 +1,6 @@ -// Components exports will be added here when needed +export { default as AssignCoursesModal } from './assign-courses-modal.svelte'; +export { default as AudienceMemberRow } from './audience-member-row.svelte'; +export { default as AudiencePagination } from './audience-pagination.svelte'; +export { default as AudienceTable } from './audience-table.svelte'; +export { default as AudienceTableSkeleton } from './audience-table-skeleton.svelte'; +export { default as AudienceTableToolbar } from './audience-table-toolbar.svelte'; diff --git a/apps/dashboard/src/lib/features/audience/pages/audience.svelte b/apps/dashboard/src/lib/features/audience/pages/audience.svelte index 1304d68e..5fae1f5c 100644 --- a/apps/dashboard/src/lib/features/audience/pages/audience.svelte +++ b/apps/dashboard/src/lib/features/audience/pages/audience.svelte @@ -1,41 +1,68 @@ + +
+

{$t('audience.import.description')}

+ + + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + {#if courseAccessMode === 'select'} + ({ id: c.id, label: c.title || c.id }))} + isSelected={(id) => selectedCourseIds.has(id)} + onToggle={toggleCourse} + namePrefix="import-course" + /> + {/if} +
+ + + +
+ +
+
diff --git a/apps/dashboard/src/lib/features/audience/pages/index.ts b/apps/dashboard/src/lib/features/audience/pages/index.ts index d53396db..2b1de9db 100644 --- a/apps/dashboard/src/lib/features/audience/pages/index.ts +++ b/apps/dashboard/src/lib/features/audience/pages/index.ts @@ -1,2 +1,3 @@ export { default as AudiencePage } from './audience.svelte'; +export { default as ImportAudiencePage } from './import.svelte'; export { default as UserAnalyticsPage } from './user-analytics.svelte'; diff --git a/apps/dashboard/src/lib/features/audience/pages/user-analytics.svelte b/apps/dashboard/src/lib/features/audience/pages/user-analytics.svelte index a2915481..953ff5ed 100644 --- a/apps/dashboard/src/lib/features/audience/pages/user-analytics.svelte +++ b/apps/dashboard/src/lib/features/audience/pages/user-analytics.svelte @@ -4,11 +4,11 @@ import UnfoldVerticalIcon from '@lucide/svelte/icons/unfold-vertical'; import BookOpenIcon from '@lucide/svelte/icons/book-open'; - import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left'; import ChartLineIcon from '@lucide/svelte/icons/chart-line'; import { Progress } from '@cio/ui/base/progress'; import { ActivityCard, HeroProfileCard, LoadingPage } from '$features/ui'; + import { BackButton } from '@cio/ui'; import { t } from '$lib/utils/functions/translations'; import { currentOrgPath } from '$lib/utils/store/org'; import type { UserAnalytics } from '$lib/utils/types/analytics'; @@ -61,10 +61,7 @@ {#if userAnalytics}
@@ -116,7 +113,7 @@
- {#each filteredCourses as course, index} + {#each filteredCourses as course (course.id)} {#key index}
import * as Avatar from '@cio/ui/base/avatar'; import TrashIcon from '@lucide/svelte/icons/trash'; + import UserIcon from '@lucide/svelte/icons/user'; import { calDateDiff } from '$lib/utils/functions/date'; import { IconButton } from '@cio/ui/custom/icon-button'; - import { shortenName } from '$lib/utils/functions/string'; import { Vote } from '$features/ui'; import { CircleCheckIcon } from '$features/ui/icons'; import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content'; @@ -40,11 +40,15 @@
- - {shortenName(comment.author?.fullname) || 'U'} + {#if comment.author?.avatarUrl} + + {/if} + + +

{comment.author?.fullname}

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 ce1b3b4c..8975edbb 100644 --- a/apps/dashboard/src/lib/features/community/pages/community-question.svelte +++ b/apps/dashboard/src/lib/features/community/pages/community-question.svelte @@ -5,6 +5,7 @@ import * as Avatar from '@cio/ui/base/avatar'; import { Skeleton } from '@cio/ui/base/skeleton'; import TrashIcon from '@lucide/svelte/icons/trash'; + import UserIcon from '@lucide/svelte/icons/user'; import { t } from '$lib/utils/functions/translations'; import { calDateDiff } from '$lib/utils/functions/date'; @@ -17,19 +18,20 @@ import { Vote } from '$features/ui'; import { IconButton } from '@cio/ui/custom/icon-button'; import { InputField } from '@cio/ui/custom/input-field'; - import { shortenName } from '$lib/utils/functions/string'; import { TextEditor } from '$features/ui'; import { CommunityDeleteModal, CommunityCommentItem } from '$features/community/components'; + import { BackButton } from '@cio/ui'; import type { CommunityQuestionSuccess } from '../utils/types'; import { currentCommunityQuestion } from '../utils/store'; import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content'; interface Props { slug: string; + backHref?: string; question?: CommunityQuestionSuccess['data'] | null; } - let { question }: Props = $props(); + let { question, backHref }: Props = $props(); let commentEditor: TiptapEditor | undefined = $state(); let isValidAnswer = false; // V2 allow admin mark an answer as accepted @@ -100,6 +102,9 @@ + {#if backHref} + + {/if} {#if communityApi.isEditMode}
- - {shortenName(communityApi.question?.authorFullname) || 'U'} + {#if communityApi.question?.authorAvatarUrl} + + {/if} + + +

{communityApi.question?.authorFullname}

diff --git a/apps/dashboard/src/lib/features/community/pages/community.svelte b/apps/dashboard/src/lib/features/community/pages/community.svelte index 8c8d90c2..f98e5768 100644 --- a/apps/dashboard/src/lib/features/community/pages/community.svelte +++ b/apps/dashboard/src/lib/features/community/pages/community.svelte @@ -4,6 +4,8 @@ import { Empty } from '@cio/ui/custom/empty'; import * as Item from '@cio/ui/base/item'; import { Search } from '@cio/ui/custom/search'; + import { Button } from '@cio/ui/base/button'; + import { resolve } from '$app/paths'; import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more'; import MessageCirclePlusIcon from '@lucide/svelte/icons/message-circle-plus'; @@ -62,10 +64,8 @@ {$t('community.all')} - {#each communityApi.courses as course} - {#if course.id} - {course.title} - {/if} + {#each communityApi.courses as course (course.id)} + {course.title} {/each} @@ -77,10 +77,10 @@ {:else} - {#each filteredQuestions as question} + {#each filteredQuestions as question (question.slug)} {#snippet child({ props })} - + @@ -89,11 +89,15 @@ {question?.authorFullname} asked {calDateDiff(question.createdAt || new Date())} - e.stopPropagation()}> +

diff --git a/apps/dashboard/src/lib/features/course/api/course.svelte.ts b/apps/dashboard/src/lib/features/course/api/course.svelte.ts index e8b09bdd..b59a03fd 100644 --- a/apps/dashboard/src/lib/features/course/api/course.svelte.ts +++ b/apps/dashboard/src/lib/features/course/api/course.svelte.ts @@ -24,6 +24,7 @@ import { mapZodErrorsToTranslations } from '$lib/utils/validation'; import { profile } from '$lib/utils/store/user'; import { resolve } from '$app/paths'; import { snackbar } from '$features/ui/snackbar/store'; +import { coursesApi } from './courses.svelte'; import { ROLE } from '@cio/utils/constants'; import { ContentType } from '@cio/utils/constants/content'; import type { CourseMembers } from '../utils/types'; @@ -499,6 +500,7 @@ export class CourseApi extends BaseApiWithErrors { }), logContext: 'deleting course', onSuccess: (response) => { + coursesApi.removeCourseFromLists(courseId); if (response.data) { snackbar.success('Course deleted successfully'); this.success = true; diff --git a/apps/dashboard/src/lib/features/course/api/courses.svelte.ts b/apps/dashboard/src/lib/features/course/api/courses.svelte.ts index 3151722a..09540eda 100644 --- a/apps/dashboard/src/lib/features/course/api/courses.svelte.ts +++ b/apps/dashboard/src/lib/features/course/api/courses.svelte.ts @@ -8,6 +8,8 @@ import type { UserEnrolledCourses } from '$features/course/types'; +import { SvelteSet } from 'svelte/reactivity'; + /** * API class for course operations */ @@ -16,12 +18,19 @@ export class CoursesApi extends BaseApiWithErrors { enrolledCourses = $state([]); recommendedCourses = $state([]); + /** Keeps list UIs in sync after a course is deleted (server data is updated separately on navigation). */ + removeCourseFromLists(courseId: string) { + this.orgCourses = this.orgCourses.filter((c) => c.id !== courseId); + this.enrolledCourses = this.enrolledCourses.filter((c) => c.id !== courseId); + this.recommendedCourses = this.recommendedCourses.filter((c) => c.id !== courseId); + } + /** * Fetches org courses for the current organization * Org ID is automatically added from currentOrg store */ async getOrgCourses(tagSlugs: string[] = []) { - const normalizedTagSlugs = Array.from(new Set(tagSlugs.map((tag) => tag.trim()).filter(Boolean))); + const normalizedTagSlugs = Array.from(new SvelteSet(tagSlugs.map((tag) => tag.trim()).filter(Boolean))); return this.execute({ requestFn: () => @@ -32,7 +41,7 @@ export class CoursesApi extends BaseApiWithErrors { tags: normalizedTagSlugs.join(',') } } - : {} + : { query: {} } ), logContext: 'fetching org courses', onSuccess: (response) => { diff --git a/apps/dashboard/src/lib/features/course/api/lesson.svelte.ts b/apps/dashboard/src/lib/features/course/api/lesson.svelte.ts index 1da98498..4eb9ef33 100644 --- a/apps/dashboard/src/lib/features/course/api/lesson.svelte.ts +++ b/apps/dashboard/src/lib/features/course/api/lesson.svelte.ts @@ -77,7 +77,6 @@ export class LessonApi extends BaseApiWithErrors { translations = $state>>({}); note = $state(''); - private autoSaveTimeoutId: NodeJS.Timeout | null = null; /** * Gets a lesson by ID @@ -841,23 +840,6 @@ export class LessonApi extends BaseApiWithErrors { ) ]); } - - /** - * Starts autosave with debouncing - * @param courseId Course ID - * @param lessonId Lesson ID - */ - startAutoSave(courseId: string, lessonId: string) { - if (!this.isDirty || this.isSaving) return; - if (this.autoSaveTimeoutId) clearTimeout(this.autoSaveTimeoutId); - - this.isSaving = true; - this.autoSaveTimeoutId = setTimeout(async () => { - await this.save(courseId, lessonId); - this.isSaving = false; - this.autoSaveTimeoutId = null; - }, 1500); - } } export const lessonApi = new LessonApi(); diff --git a/apps/dashboard/src/lib/features/course/components/analytics/student-table.svelte b/apps/dashboard/src/lib/features/course/components/analytics/student-table.svelte index bfe6a7d8..4288b9c3 100644 --- a/apps/dashboard/src/lib/features/course/components/analytics/student-table.svelte +++ b/apps/dashboard/src/lib/features/course/components/analytics/student-table.svelte @@ -13,7 +13,6 @@ import EmptyState from './empty-state.svelte'; import * as Avatar from '@cio/ui/base/avatar'; import type { CourseAnalytics } from '$features/course/utils/types'; - import { shortenName } from '$lib/utils/functions/string'; interface Props { students?: CourseAnalytics['students']; @@ -37,7 +36,10 @@ if (!student) return; goto( - resolve(`/courses/${courseApi.course?.id}/people/${student.id}?back=/courses/${courseApi.course?.id}/analytics`) + resolve( + `/courses/${courseApi.course?.id}/people/${student.id}?back=/courses/${courseApi.course?.id}/analytics`, + {} + ) ); }; @@ -79,11 +81,15 @@ >
- - {shortenName(student.profile.fullname) || 'S'} + {#if student.profile.avatar_url} + + {/if} + + +

diff --git a/apps/dashboard/src/lib/features/course/components/card.svelte b/apps/dashboard/src/lib/features/course/components/card.svelte index 65fd8f97..ef162a4c 100644 --- a/apps/dashboard/src/lib/features/course/components/card.svelte +++ b/apps/dashboard/src/lib/features/course/components/card.svelte @@ -21,6 +21,7 @@ import { calcCourseProgress, calcProgressRate } from '$features/course/utils/functions'; import CardDropdown from './card-dropdown.svelte'; import CoursePublishBadge from './course-publish-badge.svelte'; + import CourseTagsOverflow from './course-tags-overflow.svelte'; import type { OrgCourses, UserEnrolledCourses } from '$features/course/types'; import type { OrgPublicCourses } from '$features/org/utils/types'; @@ -57,7 +58,7 @@ title: course.title, type: course.type, description: course.description, - isPublished: course.isPublished, + isPublished: !!course.isPublished, pricingData: { cost: course.cost, discount: course.metadata?.discount || 0, @@ -147,25 +148,14 @@ - + {title} {description} -

- {#if !isLMS && courseTags.length > 0} - {#each courseTags as tag (tag.id)} - - - {tag.name} - - {/each} - {/if} -
+ {#if !isLMS} + + {/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 1a9bd0f0..17bd6020 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,5 +1,11 @@ + +{#if tags.length > 0} +
+ + +
+ {#each tags.slice(0, displayTagCount) as tag (tag.id)} + {#if variant === 'card'} + + + {tag.name} + + {:else} + + + {tag.name} + + {/if} + {/each} + {#if hiddenTags.length > 0} + + + + {#snippet child({ props })} + {#if variant === 'card'} + + +{hiddenTags.length} + + {:else} + + +{hiddenTags.length} + + {/if} + {/snippet} + + + {hiddenTagsLabel} + + + + {/if} +
+
+{/if} diff --git a/apps/dashboard/src/lib/features/course/components/exercise/preview.svelte b/apps/dashboard/src/lib/features/course/components/exercise/preview.svelte index e221487c..5f8ec5c4 100644 --- a/apps/dashboard/src/lib/features/course/components/exercise/preview.svelte +++ b/apps/dashboard/src/lib/features/course/components/exercise/preview.svelte @@ -1,6 +1,7 @@ + +
{#if showMarkComplete && lessonId} + +
+ + + + + + {$t('course.navItem.lessons.prev_shortcut')} + + - -
+ + + + + + {$t('course.navItem.lessons.next_shortcut')} + + +
+
diff --git a/apps/dashboard/src/lib/features/course/components/lesson/note/note.svelte b/apps/dashboard/src/lib/features/course/components/lesson/note/note.svelte index 6171658a..5695abf4 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/note/note.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/note/note.svelte @@ -9,6 +9,7 @@ import type { TLocale } from '@cio/db/types'; import AIButton from '$features/course/components/lesson/ai-button.svelte'; import type { Writable } from 'svelte/store'; + import { saveDraft } from '$features/course/utils/lesson-draft'; interface Props { mode?: (typeof MODES)[keyof typeof MODES]; @@ -33,12 +34,7 @@ } lessonApi.translations[lessonId][lessonApi.currentLocale] = `${content}`; - try { - localStorage.setItem(`lesson-${lessonId}-${lessonApi.currentLocale}`, `${content}`); - } catch (error) { - console.error('Error saving lesson note to localStorage', error); - } - + saveDraft(lessonId, lessonApi.currentLocale, `${content}`); lessonApi.isDirty = true; } diff --git a/apps/dashboard/src/lib/features/course/components/list.svelte b/apps/dashboard/src/lib/features/course/components/list.svelte index 526770b9..03a8a9b1 100644 --- a/apps/dashboard/src/lib/features/course/components/list.svelte +++ b/apps/dashboard/src/lib/features/course/components/list.svelte @@ -1,4 +1,5 @@ - goto(`/courses/${id}`)}> + goto(resolve(`/courses/[id]`, { id }))}>

{title}

{description}

{#if !isMobileStore.current} {type} - + {#if tags.length === 0} - {:else} -
- {#each tags as tag (tag.id)} - - - {tag.name} - - {/each} -
+ {/if}
{totalLessons} diff --git a/apps/dashboard/src/lib/features/course/components/people/bulk-email-section.svelte b/apps/dashboard/src/lib/features/course/components/people/bulk-email-section.svelte index 9bce0a03..a73e7fd7 100644 --- a/apps/dashboard/src/lib/features/course/components/people/bulk-email-section.svelte +++ b/apps/dashboard/src/lib/features/course/components/people/bulk-email-section.svelte @@ -3,10 +3,10 @@ import { TextareaField } from '@cio/ui/custom/textarea-field'; import { CheckboxField } from '@cio/ui/custom/checkbox-field'; import { t } from '$lib/utils/functions/translations'; - import { inviteSettingsStore, studentInviteLinkStore } from './store'; - import { getInvitePayload } from './invite-utils'; import { snackbar } from '$features/ui/snackbar/store'; - import { peopleApi } from '$features/course/api'; + import { orgApi } from '$features/org/api/org.svelte'; + import { courseApi } from '$features/course/api'; + import { profile } from '$lib/utils/store/user'; const INVITE_MODAL = 'course.navItem.people.invite_modal'; @@ -19,72 +19,58 @@ let recipientCsv = $state(''); let sendInviteEmails = $state(true); + let isSubmitting = $state(false); - async function createEmailInvites() { + async function inviteNewStudents() { if (!courseId) return; if (!recipientCsv.trim()) { - snackbar.error(`${INVITE_MODAL}.snackbar_add_one_recipient`); + snackbar.error('audience.import.snackbar_no_emails'); return; } - studentInviteLinkStore.update((s) => ({ ...s, isCreating: true })); + isSubmitting = true; try { - const settings = $inviteSettingsStore; - const payload = getInvitePayload(settings); - const response = await peopleApi.createStudentInvite(courseId, { - ...payload, + const response = await orgApi.importAudienceMembers({ recipientCsv, + courseIds: [courseId], sendEmail: sendInviteEmails }); - const createdCount = response?.invites?.length || 0; - const sent = response?.delivery?.sent || 0; - const failed = response?.delivery?.failed || 0; - const duplicates = response?.duplicatesSkipped?.length || 0; - - snackbar.success( - $t(`${INVITE_MODAL}.snackbar_email_invites_success`, { - count: createdCount, - sent, - failed, - duplicates - }) - ); - - recipientCsv = ''; - onInviteCreated?.(); + if (response) { + recipientCsv = ''; + await courseApi.refreshCourse(courseId, $profile.id); + await onInviteCreated?.(); + } } catch (error) { - console.error('Failed to create email invites', error); - snackbar.error(`${INVITE_MODAL}.snackbar_failed_create_email_invites`); + console.error('Failed to invite new students into organization', error); + snackbar.error('audience.import.snackbar_failed'); } finally { - studentInviteLinkStore.update((s) => ({ ...s, isCreating: false })); + isSubmitting = false; } } - - const isCreating = $derived($studentInviteLinkStore.isCreating);
-

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

+

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

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

-
diff --git a/apps/dashboard/src/lib/features/course/components/people/existing-students-section.svelte b/apps/dashboard/src/lib/features/course/components/people/existing-students-section.svelte new file mode 100644 index 00000000..58b109ba --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/people/existing-students-section.svelte @@ -0,0 +1,149 @@ + + +
+
+

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

+

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

+
+ +
+ + + {#if selectedCount > 0} +

{$t('audience.selected_count', { count: selectedCount })}

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

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

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

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

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

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

+ {:else} + {#each filteredStudents as student (student.profileId)} +
student.profileId && toggleStudent(student.profileId)} + onkeydown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + if (student.profileId) toggleStudent(student.profileId); + } + }} + > + + {student.email} + +
+ {/each} + {/if} +
+ + + +
+ +
+
+
diff --git a/apps/dashboard/src/lib/features/course/components/people/invitation-modal.svelte b/apps/dashboard/src/lib/features/course/components/people/invitation-modal.svelte index 73d6cf8a..4bad8bc2 100644 --- a/apps/dashboard/src/lib/features/course/components/people/invitation-modal.svelte +++ b/apps/dashboard/src/lib/features/course/components/people/invitation-modal.svelte @@ -13,19 +13,11 @@ import { peopleApi } from '$features/course/api'; import { orgApi } from '$features/org/api/org.svelte'; import type { OrgTeamMember } from '$lib/utils/types/org'; - import { - DEFAULT_INVITE_SETTINGS_STATE, - DEFAULT_STUDENT_INVITE_LINK_STATE, - inviteSettingsStore, - studentInviteLinkStore - } from './store'; - import type { Tutor } from './types'; + import type { OrgStudent, Tutor } from './types'; import TutorSelectSection from './tutor-select-section.svelte'; - import InviteSettingsSection from './invite-settings-section.svelte'; + import ExistingStudentsSection from './existing-students-section.svelte'; import BulkEmailSection from './bulk-email-section.svelte'; - import InviteListSection from './invite-list-section.svelte'; - import type { InviteListItem } from './types'; let tutors = $state([]); let selectedIds = $state([]); @@ -36,21 +28,9 @@ const selectedTutors = $derived(tutors.filter((t) => selectedIds.includes(t.id.toString()))); const INVITE_MODAL = 'course.navItem.people.invite_modal'; - let existingInvites = $state([]); - let isLoadingInvites = $state(false); - let activeTab = $state<'tutors' | 'students'>('tutors'); - let activeStudentTab = $state<'email' | 'history'>('email'); - - async function loadInvites() { - if (!courseId) return; - isLoadingInvites = true; - try { - const invites = await peopleApi.listStudentInvites(courseId); - existingInvites = (invites || []) as InviteListItem[]; - } finally { - isLoadingInvites = false; - } - } + let isLoadingStudents = $state(false); + let activeTab = $state<'tutors' | 'students'>('students'); + const availableStudents = $derived.by(() => getAvailableStudents(orgApi.audience, courseApi.group.people)); function getTutors(team: OrgTeamMember[]) { const existingTutors = courseApi?.group?.tutors || []; @@ -65,6 +45,22 @@ })); } + function getAvailableStudents(students: OrgStudent[], courseMembers: typeof courseApi.group.people) { + const enrolledStudentIds = new Set( + courseMembers + .filter((member) => Number(member.roleId) === ROLE.STUDENT) + .map((member) => member.profileId) + .filter((profileId): profileId is string => Boolean(profileId)) + ); + + return students.filter((student) => { + if (!student.profileId) { + return false; + } + return !enrolledStudentIds.has(student.profileId); + }); + } + function setTutors(orgId: string | undefined) { if (!orgId) return; untrack(async () => { @@ -77,6 +73,18 @@ }); } + function loadStudents(orgId: string | undefined) { + if (!orgId) return; + untrack(async () => { + isLoadingStudents = true; + try { + await orgApi.getOrgAudience(orgId); + } finally { + isLoadingStudents = false; + } + }); + } + async function onSubmit() { if (!selectedTutors.length) { goto(resolve(page.url.pathname, {})); @@ -105,11 +113,8 @@ $effect(() => { if (!isOpen || !courseId) return; untrack(() => { - activeTab = 'tutors'; - activeStudentTab = 'email'; - inviteSettingsStore.set({ ...DEFAULT_INVITE_SETTINGS_STATE }); - studentInviteLinkStore.set({ ...DEFAULT_STUDENT_INVITE_LINK_STATE }); - void loadInvites(); + activeTab = 'students'; + void loadStudents($currentOrg.id); }); }); @@ -149,35 +154,8 @@
- - - - - - {$t(`${INVITE_MODAL}.direct_email_bulk`)} - - - {$t(`${INVITE_MODAL}.existing_invites`)} - - - - -
- -
-
- - -
- -
-
-
+ +
diff --git a/apps/dashboard/src/lib/features/course/components/people/types.ts b/apps/dashboard/src/lib/features/course/components/people/types.ts index e84583fb..8ce830c9 100644 --- a/apps/dashboard/src/lib/features/course/components/people/types.ts +++ b/apps/dashboard/src/lib/features/course/components/people/types.ts @@ -1,5 +1,6 @@ import type { StudentInviteListItem, StudentInviteAuditItem } from '../../utils/types'; import type { OrganizationTeamMembers } from '$features/org/utils/types'; +import type { OrganizationAudience } from '$features/org/utils/types'; import type { TCourseInvitePreset } from '@cio/utils/validation/course/invite'; export interface Profile { @@ -30,6 +31,9 @@ export type Tutor = Pick & { text: OrgTeamMemberItem['fullname']; }; +type OrgAudienceMemberItem = OrganizationAudience[number]; +export type OrgStudent = OrgAudienceMemberItem; + /** Re-export API-driven invite types from course utils */ export type InviteListItem = StudentInviteListItem; export type InviteAuditItem = StudentInviteAuditItem; diff --git a/apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar-navigation.svelte b/apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar-navigation.svelte index ec99a611..114c4bad 100644 --- a/apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar-navigation.svelte +++ b/apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar-navigation.svelte @@ -4,8 +4,7 @@ import { resolve } from '$app/paths'; import Plus from '@lucide/svelte/icons/plus'; import * as Sidebar from '@cio/ui/base/sidebar'; - import { Button } from '@cio/ui/base/button'; - import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left'; + import { BackButton } from '@cio/ui'; import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents'; import { @@ -231,10 +230,7 @@ - + {#each navItems as item (item.id)} diff --git a/apps/dashboard/src/lib/features/course/pages/courses.svelte b/apps/dashboard/src/lib/features/course/pages/courses.svelte index 2b183a34..32ea2221 100644 --- a/apps/dashboard/src/lib/features/course/pages/courses.svelte +++ b/apps/dashboard/src/lib/features/course/pages/courses.svelte @@ -21,6 +21,7 @@ import { DeleteModal } from '$features/ui'; import { t } from '$lib/utils/functions/translations'; import { courseApi } from '$features/course/api'; + import { COURSE_SORT_OPTIONS, DEFAULT_COURSE_SORT, type CourseSortBy } from '../utils/constants'; import { deleteCourseModal, deleteCourseModalInitialState, courseMetaDeta } from '../utils/store'; import { browser } from '$app/environment'; import type { OrgCourses, UserEnrolledCourses } from '$features/course/types'; @@ -33,7 +34,7 @@ isExplore?: boolean; isLMS?: boolean; searchValue?: string; - selectedId?: string; + sortKey?: CourseSortBy; isLoading?: boolean; showSortSelect?: boolean; filterControls?: Snippet; @@ -46,22 +47,21 @@ isExplore = false, isLMS = false, searchValue = $bindable(''), - selectedId = $bindable('0'), + sortKey = $bindable(DEFAULT_COURSE_SORT), isLoading = false, showSortSelect = true, filterControls }: Props = $props(); - const filterOptions = $derived([ - { value: '0', label: $t('courses.course_filter.date_created') }, - { value: '1', label: $t('courses.course_filter.published') }, - { value: '2', label: $t('courses.course_filter.lessons') } - ]); - - const selectedLabel = $derived( - filterOptions.find((opt) => opt.value === selectedId)?.label || filterOptions[0].label + const filterOptions = $derived( + COURSE_SORT_OPTIONS.map((option) => ({ + value: option.value, + label: $t(option.label) + })) ); + const selectedLabel = $derived(filterOptions.find((opt) => opt.value === sortKey)?.label || filterOptions[0].label); + const setViewPreference = (preference: 'grid' | 'list') => { $courseMetaDeta.view = preference; if (browser) { @@ -94,12 +94,12 @@ isLoading={$deleteCourseModal.isDeleting} /> - + {#if showSortSelect} - + -

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

+

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

{#each filterOptions as option (option.label)} @@ -142,9 +142,9 @@ {$t('courses.course_card.list_view.title')} - {$t('courses.course_card.list_view.description')} + {$t('courses.course_card.list_view.description')} {$t('courses.course_card.list_view.type')} - {$t('courses.course_card.list_view.tags')} + {$t('courses.course_card.list_view.tags')} {$t('courses.course_card.list_view.lessons')} {$t('courses.course_card.list_view.students')} {$t('courses.course_card.list_view.published')} diff --git a/apps/dashboard/src/lib/features/course/pages/lesson.svelte b/apps/dashboard/src/lib/features/course/pages/lesson.svelte index 1a747fd0..abff1d7c 100644 --- a/apps/dashboard/src/lib/features/course/pages/lesson.svelte +++ b/apps/dashboard/src/lib/features/course/pages/lesson.svelte @@ -18,7 +18,7 @@ import { globalStore } from '$lib/utils/store/app'; import { currentOrg } from '$lib/utils/store/org'; import { snackbar } from '$features/ui/snackbar/store'; - import { RefreshPageData } from '$features/ui'; + import { RefreshPageData, UnsavedChanges } from '$features/ui'; import LessonVersionHistory from '$features/course/components/lesson/lesson-version-history.svelte'; import { courseApi, lessonApi } from '$features/course/api'; import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml'; @@ -50,6 +50,7 @@ import type { TLocale } from '@cio/db/types'; import { orderedTabs, tabs as materialTabs } from '$features/course/components/lesson/constants'; import { getViewModeComponents } from '$features/course/components/lesson/utils'; + import { loadDraft, clearDraft } from '$features/course/utils/lesson-draft'; interface Props { courseId: string; @@ -61,9 +62,14 @@ const mode = $derived($page.url.searchParams.get('mode') === 'edit' ? MODES.edit : MODES.view); let prevModeParam = $state(null); - let isSaving = $state(false); let isDeletingLesson = $state(false); let isVersionDrawerOpen = $state(false); + // eslint-disable-next-line svelte/prefer-writable-derived -- must be writable: cleared before intentional navigations and bound to UnsavedChanges + let hasUnsavedChanges = $state(false); + + $effect(() => { + hasUnsavedChanges = lessonApi.isDirty && mode === MODES.edit; + }); const lessonTitle = $derived(lessonApi.lesson?.title || 'Lesson'); const isLessonUnlocked = $derived(lessonApi.lesson?.isUnlocked ?? false); @@ -80,6 +86,12 @@ } function toggleMode() { + if (mode === MODES.edit && lessonApi.isDirty) { + if (timeoutId) clearTimeout(timeoutId); + timeoutId = undefined; + saveLesson(); + } + hasUnsavedChanges = false; setModeQueryParam(mode === MODES.edit ? MODES.view : MODES.edit); } @@ -139,7 +151,7 @@ const isMaterialsEmpty = $derived(tabs.every((tab) => tab.badgeValue === 0)); - let timeoutId: NodeJS.Timeout; + let timeoutId: NodeJS.Timeout | undefined; let isLoading = writable(false); @@ -184,9 +196,6 @@ async function saveLesson() { if (!lessonApi.lesson) return false; - // Prevent autosave loops: we only set this back to `true` when the user edits again. - lessonApi.isDirty = false; - const [isLessonUpdated] = await Promise.all([ lessonApi.update(courseApi.course?.id || '', lessonId, { title: lessonApi.lesson.title || undefined, @@ -200,6 +209,8 @@ if (isLessonUpdated) { patchLessonListItemLocally(); + clearDraft(lessonId, lessonApi.currentLocale); + lessonApi.isDirty = false; } } @@ -219,6 +230,7 @@ await lessonApi.delete(courseId, lessonId); if (lessonApi.success) { + hasUnsavedChanges = false; await goto(resolve(`/courses/${courseId}/lessons`, {})); } @@ -228,6 +240,8 @@ function handleSave(prevMode: string) { untrack(() => { if (prevMode === MODES.edit) { + if (timeoutId) clearTimeout(timeoutId); + timeoutId = undefined; saveLesson(); } }); @@ -240,11 +254,8 @@ if (timeoutId) clearTimeout(timeoutId); untrack(() => { - isSaving = true; timeoutId = setTimeout(async () => { await saveLesson(); - - isSaving = false; }, 2000); }); } @@ -255,13 +266,41 @@ autoSave(); }; + // Check for an unsaved localStorage draft when the lesson first loads. + let draftCheckedForLesson = ''; + $effect(() => { + if (!browser) return; + if (!lessonApi.lesson || !lessonId) return; + if (draftCheckedForLesson === lessonId) return; + draftCheckedForLesson = lessonId; + + const draft = loadDraft(lessonId, lessonApi.currentLocale); + if (!draft) return; + + const serverContent = lessonApi.translations[lessonId]?.[lessonApi.currentLocale] || ''; + if (draft.content === serverContent) { + clearDraft(lessonId, lessonApi.currentLocale); + return; + } + + const shouldRestore = window.confirm(t.get('course.navItem.lessons.draft_recovery')); + if (shouldRestore) { + if (!lessonApi.translations[lessonId]) { + lessonApi.translations[lessonId] = {} as Record; + } + lessonApi.translations[lessonId][lessonApi.currentLocale] = draft.content; + lessonApi.isDirty = true; + } else { + clearDraft(lessonId, lessonApi.currentLocale); + } + }); + // Only autosave after real edits, not on every reactive update. $effect(() => { if (mode !== MODES.edit) return; if (!lessonApi.lesson || !lessonId) return; if (!lessonApi.isDirty) return; if ($isLoading) return; - console.log('autoSave'); autoSave(); }); @@ -270,7 +309,6 @@ let didHandleExitEdit = false; $effect(() => { if (!lessonId) return; - console.log('didHandleExitEdit'); const currentParam = $page.url.searchParams.get('mode'); const prev = prevModeParam; @@ -338,8 +376,17 @@ {/if} -
- +
+ {#if mode === MODES.edit} + + {#if lessonApi.isSaving} + {$t('course.navItem.lessons.saving')} + {:else if !lessonApi.isDirty} + {$t('course.navItem.lessons.saved')} + {/if} + + {/if} + {#if mode === MODES.edit} {:else} @@ -452,6 +499,8 @@ {/snippet} + + {#if isVersionDrawerOpen && window.innerWidth >= 1024} - - {shortenName(person.profile.fullname) || 'U'} + {#if person.profile.avatar_url} + + {/if} + + +
diff --git a/apps/dashboard/src/lib/features/course/pages/submissions.svelte b/apps/dashboard/src/lib/features/course/pages/submissions.svelte index 6f4b1942..6470a42e 100644 --- a/apps/dashboard/src/lib/features/course/pages/submissions.svelte +++ b/apps/dashboard/src/lib/features/course/pages/submissions.svelte @@ -152,7 +152,7 @@ await submissionApi.updateGrades(courseId, subId, { answers, total, - feedback, + feedback: feedback || undefined, statusId: STATUS.GRADED }); @@ -195,6 +195,8 @@ } }; + handleModalClose(); + isSaving = false; } diff --git a/apps/dashboard/src/lib/features/course/utils/constants.ts b/apps/dashboard/src/lib/features/course/utils/constants.ts index 5043b49c..28210495 100644 --- a/apps/dashboard/src/lib/features/course/utils/constants.ts +++ b/apps/dashboard/src/lib/features/course/utils/constants.ts @@ -16,8 +16,51 @@ export const EXERCISE_TEMPLATE_TAGS = { GIT: 'GIT' }; +/** Named sort keys for course lists (URL `sort`, localStorage, filter UI). */ +export const CourseSortBy = { + DateCreated: 'date_created', + Published: 'published', + Lessons: 'lessons' +} as const; + +export type CourseSortBy = (typeof CourseSortBy)[keyof typeof CourseSortBy]; + +export const DEFAULT_COURSE_SORT: CourseSortBy = CourseSortBy.DateCreated; + +/** URL `order` param and filter toggle (ascending vs descending). */ +export const CourseSortOrder = { + Asc: 'asc', + Desc: 'desc' +} as const; + +export type CourseSortOrder = (typeof CourseSortOrder)[keyof typeof CourseSortOrder]; + +export const DEFAULT_SORT_ORDER: CourseSortOrder = CourseSortOrder.Desc; + +const COURSE_SORT_ORDER_VALUES = new Set(Object.values(CourseSortOrder)); + +export function parseCourseSortOrder(value: string | null | undefined): CourseSortOrder { + if (value != null && COURSE_SORT_ORDER_VALUES.has(value)) { + return value as CourseSortOrder; + } + return DEFAULT_SORT_ORDER; +} + export const COURSE_SORT_OPTIONS = [ - { value: '0', label: 'courses.course_filter.date_created' }, - { value: '1', label: 'courses.course_filter.published' }, - { value: '2', label: 'courses.course_filter.lessons' } + { value: CourseSortBy.DateCreated, label: 'courses.course_filter.date_created' }, + { value: CourseSortBy.Published, label: 'courses.course_filter.published' }, + { value: CourseSortBy.Lessons, label: 'courses.course_filter.lessons' } ] as const; + +const COURSE_SORT_VALUES = new Set(Object.values(CourseSortBy)); + +/** Parses URL `sort` / stored sort strings into {@link CourseSortBy}; unknown values fall back to default. */ +export function parseCourseSortValue(value: string | null | undefined): CourseSortBy { + if (value == null || value === '') { + return DEFAULT_COURSE_SORT; + } + if (COURSE_SORT_VALUES.has(value)) { + return value as CourseSortBy; + } + return DEFAULT_COURSE_SORT; +} diff --git a/apps/dashboard/src/lib/features/course/utils/lesson-draft.ts b/apps/dashboard/src/lib/features/course/utils/lesson-draft.ts new file mode 100644 index 00000000..7a9539f0 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/utils/lesson-draft.ts @@ -0,0 +1,37 @@ +import type { TLocale } from '@cio/db/types'; + +interface LessonDraft { + content: string; + timestamp: number; +} + +function draftKey(lessonId: string, locale: TLocale): string { + return `lesson-draft-${lessonId}-${locale}`; +} + +export function saveDraft(lessonId: string, locale: TLocale, content: string) { + try { + const draft: LessonDraft = { content, timestamp: Date.now() }; + localStorage.setItem(draftKey(lessonId, locale), JSON.stringify(draft)); + } catch { + // localStorage may be full or unavailable + } +} + +export function loadDraft(lessonId: string, locale: TLocale): LessonDraft | null { + try { + const raw = localStorage.getItem(draftKey(lessonId, locale)); + if (!raw) return null; + return JSON.parse(raw) as LessonDraft; + } catch { + return null; + } +} + +export function clearDraft(lessonId: string, locale: TLocale) { + try { + localStorage.removeItem(draftKey(lessonId, locale)); + } catch { + // ignore + } +} diff --git a/apps/dashboard/src/lib/features/lms/pages/explore.svelte b/apps/dashboard/src/lib/features/lms/pages/explore.svelte index 082e9a6c..3362558f 100644 --- a/apps/dashboard/src/lib/features/lms/pages/explore.svelte +++ b/apps/dashboard/src/lib/features/lms/pages/explore.svelte @@ -1,5 +1,6 @@ -
+
{@render children()}
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 379f03ad..95cf875d 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 @@ -6,6 +6,7 @@ import { resolve } from '$app/paths'; import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content'; import PlayIcon from '@lucide/svelte/icons/play'; + import UserIcon from '@lucide/svelte/icons/user'; import { getLectureNo } from '$features/course/utils/functions'; import { currentOrg } from '$lib/utils/store/org'; @@ -31,7 +32,6 @@ import * as Avatar from '@cio/ui/base/avatar'; import PricingSection from './components/pricing-section.svelte'; import NavSection from './components/nav-section.svelte'; - import { shortenName } from '$lib/utils/functions/string'; import SectionsDisplay from './components/sections-display.svelte'; import { observeIntersection } from './components/intersection-observer'; @@ -194,7 +194,7 @@
- + {#each navItems as navItem (navItem.key)} @@ -316,15 +316,14 @@
- {#if review.avatar_url} - - - {shortenName(review.name) || 'AV'} - - {/if} + + {#if review.avatar_url} + + {/if} + + + +
@@ -385,15 +384,14 @@
- {#if review.avatar_url} - - - {shortenName(review.name) || 'AV'} - - {/if} + + {#if review.avatar_url} + + {/if} + + + +
diff --git a/apps/dashboard/src/lib/features/ui/course-landing-page/store.ts b/apps/dashboard/src/lib/features/ui/course-landing-page/store.ts index 1ca9566b..7fe9aace 100644 --- a/apps/dashboard/src/lib/features/ui/course-landing-page/store.ts +++ b/apps/dashboard/src/lib/features/ui/course-landing-page/store.ts @@ -48,7 +48,7 @@ export const landingPageStore = writable({ id: 1, show: true, name: 'Adeyemi Adetunji', - avatar_url: '/logo-192.png', + avatar_url: '', rating: 1, created_at: new Date(), description: 'rice ventore officiis maxime laudantium recusandae sit dolor.' diff --git a/apps/dashboard/src/lib/features/ui/sidebar/footer/menu.svelte b/apps/dashboard/src/lib/features/ui/sidebar/footer/menu.svelte index 635930e7..5d54dc6c 100644 --- a/apps/dashboard/src/lib/features/ui/sidebar/footer/menu.svelte +++ b/apps/dashboard/src/lib/features/ui/sidebar/footer/menu.svelte @@ -1,4 +1,6 @@ Question - ClassroomIO - + diff --git a/apps/dashboard/src/routes/lms/community/ask/+page.svelte b/apps/dashboard/src/routes/lms/community/ask/+page.svelte index 804ac7f2..318772ea 100644 --- a/apps/dashboard/src/routes/lms/community/ask/+page.svelte +++ b/apps/dashboard/src/routes/lms/community/ask/+page.svelte @@ -1,7 +1,12 @@ - @@ -11,6 +16,7 @@ + {$t('community.ask.ask_the')} diff --git a/apps/dashboard/src/routes/org/[slug]/+page.svelte b/apps/dashboard/src/routes/org/[slug]/+page.svelte index 7cffd9e2..8df04be5 100644 --- a/apps/dashboard/src/routes/org/[slug]/+page.svelte +++ b/apps/dashboard/src/routes/org/[slug]/+page.svelte @@ -14,7 +14,6 @@ import { CreateCourseButton } from '$features/course/components'; import * as Avatar from '@cio/ui/base/avatar'; - import { shortenName } from '$lib/utils/functions/string'; import { Progress } from '@cio/ui/base/progress'; import { Button } from '@cio/ui/base/button'; import { WelcomeModal } from '$features/onboarding/components'; @@ -148,11 +147,12 @@
- - {shortenName(enrollment.name) || 'U'} + {#if enrollment.avatarUrl} + + {/if} + + +
diff --git a/apps/dashboard/src/routes/org/[slug]/audience/+page.server.ts b/apps/dashboard/src/routes/org/[slug]/audience/+page.server.ts index 1923e77f..00972280 100644 --- a/apps/dashboard/src/routes/org/[slug]/audience/+page.server.ts +++ b/apps/dashboard/src/routes/org/[slug]/audience/+page.server.ts @@ -5,17 +5,26 @@ export const load = async ({ parent, cookies }) => { if (!orgId) { return { - audience: [] + audience: [], + courses: [] }; } - // Fetch organization audience - const audienceResponse = await classroomio.organization.audience.$get({}, getApiHeaders(cookies, orgId)); + const headers = getApiHeaders(cookies, orgId); + + const [audienceResponse, coursesResponse] = await Promise.all([ + classroomio.organization.audience.$get({}, headers), + classroomio.organization.courses.$get({ query: { tags: undefined } }, headers) + ]); const audienceData = await audienceResponse.json(); const audience = audienceData.success ? audienceData.data : []; + const coursesData = await coursesResponse.json(); + const courses = coursesData.success ? coursesData.data : []; + return { - audience + audience, + courses }; }; diff --git a/apps/dashboard/src/routes/org/[slug]/audience/+page.svelte b/apps/dashboard/src/routes/org/[slug]/audience/+page.svelte index acbbea73..01d735af 100644 --- a/apps/dashboard/src/routes/org/[slug]/audience/+page.svelte +++ b/apps/dashboard/src/routes/org/[slug]/audience/+page.svelte @@ -5,6 +5,8 @@ import { currentOrgPlan, currentOrgMaxAudience } from '$lib/utils/store/org'; import { PLAN } from '@cio/utils/plans'; import * as Page from '@cio/ui/base/page'; + import { page } from '$app/state'; + import { resolve } from '$app/paths'; let { data } = $props(); let isLoading = $state(false); @@ -35,14 +37,17 @@ - + {#snippet child()} - + {/snippet} diff --git a/apps/dashboard/src/routes/org/[slug]/audience/import/+page.server.ts b/apps/dashboard/src/routes/org/[slug]/audience/import/+page.server.ts new file mode 100644 index 00000000..de13477e --- /dev/null +++ b/apps/dashboard/src/routes/org/[slug]/audience/import/+page.server.ts @@ -0,0 +1,19 @@ +import { classroomio, getApiHeaders } from '$lib/utils/services/api'; + +export const load = async ({ parent, cookies }) => { + const { orgId } = await parent(); + + if (!orgId) { + return { + courses: [] + }; + } + + const coursesResponse = await classroomio.organization.courses.$get({}, getApiHeaders(cookies, orgId)); + const coursesData = await coursesResponse.json(); + const courses = coursesData.success ? coursesData.data : []; + + return { + courses + }; +}; diff --git a/apps/dashboard/src/routes/org/[slug]/audience/import/+page.svelte b/apps/dashboard/src/routes/org/[slug]/audience/import/+page.svelte new file mode 100644 index 00000000..35b7250f --- /dev/null +++ b/apps/dashboard/src/routes/org/[slug]/audience/import/+page.svelte @@ -0,0 +1,30 @@ + + + + {$t('audience.import.title')} - ClassroomIO + + + + + + + {$t('audience.import.title')} + + + + {#snippet child()} + + {/snippet} + + diff --git a/apps/dashboard/src/routes/org/[slug]/community/[cslug]/+page.svelte b/apps/dashboard/src/routes/org/[slug]/community/[cslug]/+page.svelte index b6c84537..c46b7cc3 100644 --- a/apps/dashboard/src/routes/org/[slug]/community/[cslug]/+page.svelte +++ b/apps/dashboard/src/routes/org/[slug]/community/[cslug]/+page.svelte @@ -1,11 +1,15 @@ - Question - ClassroomIO - + diff --git a/apps/dashboard/src/routes/org/[slug]/community/ask/+page.svelte b/apps/dashboard/src/routes/org/[slug]/community/ask/+page.svelte index 804ac7f2..318772ea 100644 --- a/apps/dashboard/src/routes/org/[slug]/community/ask/+page.svelte +++ b/apps/dashboard/src/routes/org/[slug]/community/ask/+page.svelte @@ -1,7 +1,12 @@ - @@ -11,6 +16,7 @@ + {$t('community.ask.ask_the')} diff --git a/apps/dashboard/src/routes/org/[slug]/courses/+page.svelte b/apps/dashboard/src/routes/org/[slug]/courses/+page.svelte index 86616d4f..06950c89 100644 --- a/apps/dashboard/src/routes/org/[slug]/courses/+page.svelte +++ b/apps/dashboard/src/routes/org/[slug]/courses/+page.svelte @@ -2,24 +2,33 @@ import { CoursesPage } from '$features/course/pages'; import { CreateCourseButton, CourseFilterPopover } from '$features/course/components'; import { courseMetaDeta } from '$features/course/utils/store'; - import { COURSE_SORT_OPTIONS } from '$features/course/utils/constants'; + import { + CourseSortBy, + CourseSortOrder, + DEFAULT_COURSE_SORT, + DEFAULT_SORT_ORDER, + parseCourseSortOrder, + parseCourseSortValue + } from '$features/course/utils/constants'; import { browser } from '$app/environment'; + import { SvelteSet } from 'svelte/reactivity'; import { t } from '$lib/utils/functions/translations'; import { onMount } from 'svelte'; import * as Page from '@cio/ui/base/page'; import { coursesApi } from '$features/course/api'; let { data } = $props(); + const getInitialSelectedTags = () => data.activeTags ?? []; 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); + let sortKey: CourseSortBy = $state(DEFAULT_COURSE_SORT); + let selectedOrder = $state(DEFAULT_SORT_ORDER); - const validSortValues = new Set(COURSE_SORT_OPTIONS.map((option) => option.value)); - const validSortOrders = new Set(['asc', 'desc']); + let selectedTags = $state(getInitialSelectedTags()); + + let hasInitializedFilters = $state(false); + let hasCompletedInitialUrlSync = false; + let isFiltering = $state(false); $effect(() => { if (data.courses) { @@ -27,39 +36,6 @@ } }); - $effect(() => { - selectedTags = data.activeTags ?? []; - }); - - function normalizeSortValue(sortValue: string | null): string { - if (sortValue && validSortValues.has(sortValue)) { - return sortValue; - } - - 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; @@ -73,13 +49,13 @@ nextUrl.searchParams.delete('tags'); } - if (selectedId !== '0') { - nextUrl.searchParams.set('sort', selectedId); + if (sortKey !== DEFAULT_COURSE_SORT) { + nextUrl.searchParams.set('sort', sortKey); } else { nextUrl.searchParams.delete('sort'); } - if (selectedOrder !== 'desc') { + if (selectedOrder !== DEFAULT_SORT_ORDER) { nextUrl.searchParams.set('order', selectedOrder); } else { nextUrl.searchParams.delete('order'); @@ -94,8 +70,14 @@ return; } - localStorage.setItem('classroomio_filter_course_sort_key', selectedId); + localStorage.setItem('classroomio_filter_course_sort_key', sortKey); localStorage.setItem('classroomio_filter_course_order_key', selectedOrder); + + if (!hasCompletedInitialUrlSync) { + hasCompletedInitialUrlSync = true; + return; + } + updateFiltersUrl(); }); @@ -111,7 +93,7 @@ } function toggleTag(tagSlug: string, checked: boolean) { - const next = new Set(selectedTags); + const next = new SvelteSet(selectedTags); if (checked) { next.add(tagSlug); @@ -123,8 +105,8 @@ } async function clearFilters() { - selectedId = '0'; - selectedOrder = 'desc'; + sortKey = DEFAULT_COURSE_SORT; + selectedOrder = DEFAULT_SORT_ORDER; if (selectedTags.length === 0) { return; @@ -144,21 +126,21 @@ const sortedCourses = [...filteredCourses]; - if (selectedId === '0') { + if (sortKey === CourseSortBy.DateCreated) { 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() + selectedOrder === CourseSortOrder.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') { + } else if (sortKey === CourseSortBy.Published) { return sortedCourses.sort((a, b) => - selectedOrder === 'asc' + selectedOrder === CourseSortOrder.Asc ? Number(a.isPublished) - Number(b.isPublished) : Number(b.isPublished) - Number(a.isPublished) ); - } else if (selectedId === '2') { + } else if (sortKey === CourseSortBy.Lessons) { return sortedCourses.sort((a, b) => - selectedOrder === 'asc' ? a.lessonCount - b.lessonCount : b.lessonCount - a.lessonCount + selectedOrder === CourseSortOrder.Asc ? a.lessonCount - b.lessonCount : b.lessonCount - a.lessonCount ); } @@ -172,34 +154,22 @@ $courseMetaDeta.view = courseView; } - 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')); + const sortFromStorage = parseCourseSortValue(localStorage.getItem('classroomio_filter_course_sort_key')); + const orderFromStorage = parseCourseSortOrder(localStorage.getItem('classroomio_filter_course_order_key')); - if (legacySortFromUrl) { - selectedId = legacySortFromUrl.selectedId; - selectedOrder = legacySortFromUrl.selectedOrder; - } else if (sortFromUrl !== '0' || orderFromUrl !== 'desc') { - selectedId = sortFromUrl; + const searchParams = new URLSearchParams(window.location.search); + const sortFromUrl = parseCourseSortValue(searchParams.get('sort')); + const orderFromUrl = parseCourseSortOrder(searchParams.get('order')); + + if (sortFromUrl !== DEFAULT_COURSE_SORT || orderFromUrl !== DEFAULT_SORT_ORDER) { + sortKey = sortFromUrl; selectedOrder = orderFromUrl; - } else if (legacySortFromStorage) { - selectedId = legacySortFromStorage.selectedId; - selectedOrder = legacySortFromStorage.selectedOrder; - } else if (sortFromStorage !== '0' || orderFromStorage !== 'desc') { - selectedId = sortFromStorage; + } else if (sortFromStorage !== DEFAULT_COURSE_SORT || orderFromStorage !== DEFAULT_SORT_ORDER) { + sortKey = sortFromStorage; selectedOrder = orderFromStorage; - } else { - selectedId = oldSortFromStorage; - selectedOrder = 'desc'; } hasInitializedFilters = true; - updateFiltersUrl(); }); @@ -218,10 +188,10 @@ {#snippet child()} - + {#snippet filterControls()} = betterAuth({ user: { create: { after: async (user) => { + console.log('[auth] databaseHooks.user.create.after: running', { userId: user.id }); await createProfileHook(user); } }, update: { after: async (user) => { - console.log('update user', user); + console.log('[auth] databaseHooks.user.update.after: running', { userId: user.id }); await syncUserWithProfile(user); } } diff --git a/packages/db/src/auth/hooks/create-profile.ts b/packages/db/src/auth/hooks/create-profile.ts index 7b751c9f..f28234ce 100644 --- a/packages/db/src/auth/hooks/create-profile.ts +++ b/packages/db/src/auth/hooks/create-profile.ts @@ -37,7 +37,7 @@ async function isSelfHostedFirstSignup(): Promise { * Self-hosted first signup: auto-verify email (no orgs exist yet, this user will create the only org). */ export const createProfileHook = async (user: User) => { - console.debug('createProfileHook', user); + console.log('[auth] createProfileHook: running', { userId: user.id }); const existingProfile = await db.select().from(schema.profile).where(eq(schema.profile.id, user.id)).limit(1); console.debug('existingProfile', existingProfile); diff --git a/packages/db/src/auth/hooks/index.ts b/packages/db/src/auth/hooks/index.ts index 2daed3b3..bab4bb30 100644 --- a/packages/db/src/auth/hooks/index.ts +++ b/packages/db/src/auth/hooks/index.ts @@ -16,6 +16,7 @@ const afterHooks: Array<(context: AuthMiddlewareContext) => Promise> = * This function will be called by better-auth after each request */ export const combineAfterHooks = async (context: AuthMiddlewareContext) => { + console.log('[auth] combineAfterHooks: running'); for (const hook of afterHooks) { try { await hook(context); diff --git a/packages/db/src/auth/hooks/sso-provisioning.ts b/packages/db/src/auth/hooks/sso-provisioning.ts index 25318b95..60bdbc3f 100644 --- a/packages/db/src/auth/hooks/sso-provisioning.ts +++ b/packages/db/src/auth/hooks/sso-provisioning.ts @@ -1,9 +1,11 @@ import * as schema from '@db/schema'; -import { and, eq } from 'drizzle-orm'; +import { and, eq, isNull } from 'drizzle-orm'; import { User } from 'better-auth'; import { db } from '@db/drizzle'; +import { enrollUsersInCourseGroups } from '@db/queries/group'; +import { getOrgCourseGroups } from '@db/queries/course'; import { markUserAndProfileEmailVerified } from '@db/queries/auth'; const DEFAULT_STUDENT_ROLE_ID = 3; @@ -21,13 +23,25 @@ export async function ensureOrgMembership( ): Promise { const emailLower = email.toLowerCase(); - const [existing] = await db + const [existingByProfile] = await db .select() .from(schema.organizationmember) .where(and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.profileId, userId))) .limit(1); - if (existing) { + if (existingByProfile) { + return; + } + + /** Audience import pre-creates a row with org + email before profile exists; do not insert again. */ + const [existingByEmail] = await db + .select() + .from(schema.organizationmember) + .where(and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.email, emailLower))) + .limit(1); + + if (existingByEmail?.profileId && existingByEmail.profileId !== userId) { + console.error('ensureOrgMembership: org member email already linked to another profile'); return; } @@ -38,20 +52,33 @@ export async function ensureOrgMembership( and( eq(schema.organizationInvite.organizationId, orgId), eq(schema.organizationInvite.email, emailLower), - eq(schema.organizationInvite.isRevoked, false) + eq(schema.organizationInvite.isRevoked, false), + isNull(schema.organizationInvite.acceptedAt) ) ) .limit(1); if (invite) { await db.transaction(async (tx) => { - await tx.insert(schema.organizationmember).values({ - organizationId: orgId, - profileId: userId, - email: emailLower, - roleId: invite.roleId, - verified: true - }); + if (existingByEmail) { + await tx + .update(schema.organizationmember) + .set({ + profileId: userId, + email: emailLower, + roleId: invite.roleId, + verified: true + }) + .where(eq(schema.organizationmember.id, existingByEmail.id)); + } else { + await tx.insert(schema.organizationmember).values({ + organizationId: orgId, + profileId: userId, + email: emailLower, + roleId: invite.roleId, + verified: true + }); + } await tx .update(schema.organizationInvite) @@ -65,10 +92,46 @@ export async function ensureOrgMembership( await markUserAndProfileEmailVerified(userId, tx); }); + const metadata = invite.metadata as Record | null; + const courseIds = Array.isArray(metadata?.courseIds) ? (metadata.courseIds as string[]) : []; + + if (courseIds.length > 0) { + try { + const courseGroups = await getOrgCourseGroups(orgId, courseIds); + const validGroupIds = courseGroups.map((cg) => cg.groupId).filter(Boolean) as string[]; + await enrollUsersInCourseGroups(validGroupIds, [{ profileId: userId, email: emailLower }], invite.roleId); + } catch (error) { + console.error('ensureOrgMembership course enrollment error:', error); + } + } + console.debug('User joined org via invite:', orgId); return; } + if (existingByEmail) { + const [policy] = await db + .select() + .from(schema.organizationAuthPolicy) + .where(eq(schema.organizationAuthPolicy.organizationId, orgId)) + .limit(1); + + const defaultRoleId = policy?.defaultRoleId ?? DEFAULT_STUDENT_ROLE_ID; + const finalRoleId = roleId ?? defaultRoleId; + + await db + .update(schema.organizationmember) + .set({ + profileId: userId, + verified: true, + roleId: finalRoleId + }) + .where(eq(schema.organizationmember.id, existingByEmail.id)); + + console.debug('User linked to existing org membership row:', orgId); + return; + } + const [policy] = await db .select() .from(schema.organizationAuthPolicy) @@ -94,6 +157,7 @@ export async function ensureOrgMembership( * This runs after profile creation and creates organization membership */ export const ssoProvisioningHook = async (user: User) => { + console.log('[auth] ssoProvisioningHook: running', { userId: user.id }); if (!user.email) { console.debug('No email for user, skipping SSO provisioning'); return; diff --git a/packages/db/src/auth/hooks/sync-user.ts b/packages/db/src/auth/hooks/sync-user.ts index 07b2e8c8..d9f9ba59 100644 --- a/packages/db/src/auth/hooks/sync-user.ts +++ b/packages/db/src/auth/hooks/sync-user.ts @@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm'; import { hasVerifiedEmailProvider } from './create-profile'; export const syncUserWithProfile = async (user: User) => { - console.debug('syncUserWithProfile', user); + console.log('[auth] syncUserWithProfile: running', { userId: user?.id }); if (!user?.id) { return; diff --git a/packages/db/src/migrations/0007_rainy_vivisector.sql b/packages/db/src/migrations/0007_rainy_vivisector.sql new file mode 100644 index 00000000..162ac37d --- /dev/null +++ b/packages/db/src/migrations/0007_rainy_vivisector.sql @@ -0,0 +1,184 @@ +CREATE TYPE "public"."AUTOMATION_USAGE_CATEGORY" AS ENUM('read', 'write', 'publish');--> statement-breakpoint +CREATE TYPE "public"."COURSE_IMPORT_DRAFT_STATUS" AS ENUM('DRAFT', 'PUBLISHED', 'ARCHIVED');--> statement-breakpoint +CREATE TYPE "public"."COURSE_IMPORT_SOURCE_TYPE" AS ENUM('prompt', 'pdf', 'course');--> statement-breakpoint +CREATE TYPE "public"."ORGANIZATION_API_KEY_TYPE" AS ENUM('mcp', 'api', 'zapier');--> statement-breakpoint +CREATE TYPE "public"."SSO_PROVIDER" AS ENUM('OKTA', 'GOOGLE_WORKSPACE', 'AUTH0');--> statement-breakpoint +CREATE TABLE "course_import_draft" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "created_by_profile_id" uuid NOT NULL, + "source_type" "COURSE_IMPORT_SOURCE_TYPE" NOT NULL, + "status" "COURSE_IMPORT_DRAFT_STATUS" DEFAULT 'DRAFT' NOT NULL, + "title" varchar NOT NULL, + "locale" "LOCALE" DEFAULT 'en' NOT NULL, + "idempotency_key" varchar, + "summary" jsonb DEFAULT '{}'::jsonb NOT NULL, + "draft" jsonb NOT NULL, + "warnings" jsonb DEFAULT '[]'::jsonb NOT NULL, + "sourceArtifacts" jsonb DEFAULT '[]'::jsonb NOT NULL, + "published_course_id" uuid, + "created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + "updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "organization_api_key" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "created_by_profile_id" uuid NOT NULL, + "type" "ORGANIZATION_API_KEY_TYPE" NOT NULL, + "label" varchar(120) NOT NULL, + "secret_prefix" varchar(32) NOT NULL, + "secret_hash" text NOT NULL, + "scopes" jsonb DEFAULT '[]'::jsonb NOT NULL, + "last_used_at" timestamp with time zone, + "expires_at" timestamp with time zone, + "revoked_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + "updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "organization_auth_policy" ( + "organization_id" uuid PRIMARY KEY NOT NULL, + "force_sso" boolean DEFAULT false NOT NULL, + "auto_join_sso_domains" boolean DEFAULT false NOT NULL, + "break_glass_enabled" boolean DEFAULT true NOT NULL, + "default_role_id" bigint DEFAULT '3' NOT NULL, + "role_mapping" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + "updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "organization_automation_usage" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "organization_api_key_id" uuid, + "type" "ORGANIZATION_API_KEY_TYPE" NOT NULL, + "action" varchar(120) NOT NULL, + "category" "AUTOMATION_USAGE_CATEGORY" NOT NULL, + "credits_consumed" integer DEFAULT 0 NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "organization_sso_config" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "better_auth_provider_id" text NOT NULL, + "provider" "SSO_PROVIDER" NOT NULL, + "display_name" text NOT NULL, + "domain" text NOT NULL, + "is_active" boolean DEFAULT false NOT NULL, + "created_by_profile_id" uuid NOT NULL, + "updated_by_profile_id" uuid, + "created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + "updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + CONSTRAINT "organization_sso_config_organization_id_unique" UNIQUE("organization_id") +); +--> statement-breakpoint +CREATE TABLE "organization_token_auth" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "signing_secret" text NOT NULL, + "is_active" boolean DEFAULT false NOT NULL, + "created_by_profile_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + "updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + CONSTRAINT "organization_token_auth_organization_id_unique" UNIQUE("organization_id") +); +--> statement-breakpoint +CREATE TABLE "sso_provider" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "issuer" text NOT NULL, + "oidc_config" text, + "saml_config" text, + "user_id" uuid NOT NULL, + "provider_id" text NOT NULL, + "organization_id" text, + "domain" text NOT NULL, + CONSTRAINT "sso_provider_provider_id_unique" UNIQUE("provider_id") +); +--> statement-breakpoint +CREATE TABLE "tag" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "group_id" uuid NOT NULL, + "name" varchar NOT NULL, + "slug" text NOT NULL, + "description" text, + "color" varchar NOT NULL, + "created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + "updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + CONSTRAINT "tag_org_slug_key" UNIQUE("organization_id","slug") +); +--> statement-breakpoint +CREATE TABLE "tag_assignment" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "tag_id" uuid NOT NULL, + "course_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + CONSTRAINT "tag_assignment_tag_course_key" UNIQUE("tag_id","course_id") +); +--> statement-breakpoint +CREATE TABLE "tag_group" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "name" varchar NOT NULL, + "slug" text NOT NULL, + "description" text, + "order" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + "updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + CONSTRAINT "tag_group_org_slug_key" UNIQUE("organization_id","slug") +); +--> statement-breakpoint +ALTER TABLE "organization" ALTER COLUMN "theme" SET DEFAULT 'blue';--> statement-breakpoint +ALTER TABLE "option" ADD COLUMN "settings" jsonb DEFAULT '{}'::jsonb;--> statement-breakpoint +ALTER TABLE "organization" ADD COLUMN "disable_signup" boolean DEFAULT false;--> statement-breakpoint +ALTER TABLE "organization" ADD COLUMN "disable_signup_message" text;--> statement-breakpoint +ALTER TABLE "organization" ADD COLUMN "disable_email_password" boolean DEFAULT false;--> statement-breakpoint +ALTER TABLE "organization" ADD COLUMN "disable_google_auth" boolean DEFAULT false;--> statement-breakpoint +ALTER TABLE "question" ADD COLUMN "settings" jsonb DEFAULT '{}'::jsonb;--> statement-breakpoint +ALTER TABLE "question_answer" ADD COLUMN "answer_data" jsonb;--> statement-breakpoint +ALTER TABLE "submission" ADD COLUMN "grading_state" varchar DEFAULT 'queued' NOT NULL;--> statement-breakpoint +ALTER TABLE "submission" ADD COLUMN "overall_status" varchar DEFAULT 'manual_required' NOT NULL;--> statement-breakpoint +ALTER TABLE "course_import_draft" ADD CONSTRAINT "course_import_draft_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "course_import_draft" ADD CONSTRAINT "course_import_draft_created_by_profile_id_profile_id_fk" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "course_import_draft" ADD CONSTRAINT "course_import_draft_published_course_id_course_id_fk" FOREIGN KEY ("published_course_id") REFERENCES "public"."course"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "organization_api_key" ADD CONSTRAINT "organization_api_key_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "organization_api_key" ADD CONSTRAINT "organization_api_key_created_by_profile_id_profile_id_fk" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "organization_auth_policy" ADD CONSTRAINT "organization_auth_policy_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "organization_auth_policy" ADD CONSTRAINT "organization_auth_policy_default_role_id_fkey" FOREIGN KEY ("default_role_id") REFERENCES "public"."role"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "organization_automation_usage" ADD CONSTRAINT "organization_automation_usage_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "organization_automation_usage" ADD CONSTRAINT "organization_automation_usage_organization_api_key_id_organization_api_key_id_fk" FOREIGN KEY ("organization_api_key_id") REFERENCES "public"."organization_api_key"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "organization_sso_config" ADD CONSTRAINT "organization_sso_config_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "organization_sso_config" ADD CONSTRAINT "organization_sso_config_created_by_profile_id_profile_id_fk" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "organization_sso_config" ADD CONSTRAINT "organization_sso_config_updated_by_profile_id_profile_id_fk" FOREIGN KEY ("updated_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "organization_token_auth" ADD CONSTRAINT "organization_token_auth_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "organization_token_auth" ADD CONSTRAINT "organization_token_auth_created_by_profile_id_profile_id_fk" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tag" ADD CONSTRAINT "tag_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tag" ADD CONSTRAINT "tag_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "public"."tag_group"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tag_assignment" ADD CONSTRAINT "tag_assignment_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "public"."tag"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tag_assignment" ADD CONSTRAINT "tag_assignment_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "public"."course"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tag_group" ADD CONSTRAINT "tag_group_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_course_import_draft_org_id" ON "course_import_draft" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "idx_course_import_draft_status" ON "course_import_draft" USING btree ("status");--> statement-breakpoint +CREATE UNIQUE INDEX "course_import_draft_org_idempotency_key" ON "course_import_draft" USING btree ("organization_id","idempotency_key") WHERE "course_import_draft"."idempotency_key" IS NOT NULL;--> statement-breakpoint +CREATE INDEX "idx_organization_api_key_org_id" ON "organization_api_key" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "idx_organization_api_key_type" ON "organization_api_key" USING btree ("type");--> statement-breakpoint +CREATE UNIQUE INDEX "organization_api_key_secret_hash_unique" ON "organization_api_key" USING btree ("secret_hash");--> statement-breakpoint +CREATE INDEX "idx_organization_auth_policy_org_id" ON "organization_auth_policy" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "idx_org_automation_usage_org_created_at" ON "organization_automation_usage" USING btree ("organization_id","created_at");--> statement-breakpoint +CREATE INDEX "idx_org_automation_usage_key_created_at" ON "organization_automation_usage" USING btree ("organization_api_key_id","created_at");--> statement-breakpoint +CREATE INDEX "idx_org_automation_usage_type_created_at" ON "organization_automation_usage" USING btree ("type","created_at");--> statement-breakpoint +CREATE INDEX "idx_organization_sso_config_org_id" ON "organization_sso_config" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "idx_organization_sso_config_domain" ON "organization_sso_config" USING btree ("domain");--> statement-breakpoint +CREATE INDEX "idx_organization_sso_config_provider_id" ON "organization_sso_config" USING btree ("better_auth_provider_id");--> statement-breakpoint +CREATE INDEX "idx_organization_token_auth_org_id" ON "organization_token_auth" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "idx_tag_organization_id" ON "tag" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "idx_tag_group_id" ON "tag" USING btree ("group_id");--> statement-breakpoint +CREATE INDEX "idx_tag_assignment_tag_id" ON "tag_assignment" USING btree ("tag_id");--> statement-breakpoint +CREATE INDEX "idx_tag_assignment_course_id" ON "tag_assignment" USING btree ("course_id");--> statement-breakpoint +CREATE INDEX "idx_tag_group_organization_id" ON "tag_group" USING btree ("organization_id");--> statement-breakpoint +CREATE UNIQUE INDEX "organizationmember_org_profile_unique" ON "organizationmember" USING btree ("organization_id","profile_id") WHERE "organizationmember"."profile_id" IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "organizationmember_org_email_unique" ON "organizationmember" USING btree ("organization_id","email") WHERE "organizationmember"."email" IS NOT NULL; \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0007_snapshot.json b/packages/db/src/migrations/meta/0007_snapshot.json new file mode 100644 index 00000000..403f149b --- /dev/null +++ b/packages/db/src/migrations/meta/0007_snapshot.json @@ -0,0 +1,6539 @@ +{ + "id": "15a07ba2-9a44-49b7-a8d7-db4be40440d8", + "prevId": "4ae36cc4-b800-4a21-be3e-796781690d6f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.analytics_login_events": { + "name": "analytics_login_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "logged_in_at": { + "name": "logged_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_analytics_login_events_logged_in_at": { + "name": "idx_analytics_login_events_logged_in_at", + "columns": [ + { + "expression": "logged_in_at", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_analytics_login_events_user_id": { + "name": "idx_analytics_login_events_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "analytics_login_events_user_id_fkey": { + "name": "analytics_login_events_user_id_fkey", + "tableFrom": "analytics_login_events", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "analytics_login_events_user_id_unique": { + "name": "analytics_login_events_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apps_poll": { + "name": "apps_poll", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorId": { + "name": "authorId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "isPublic": { + "name": "isPublic", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + }, + "expiration": { + "name": "expiration", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "courseId": { + "name": "courseId", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "apps_poll_authorId_fkey": { + "name": "apps_poll_authorId_fkey", + "tableFrom": "apps_poll", + "tableTo": "groupmember", + "columnsFrom": ["authorId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "apps_poll_courseId_fkey": { + "name": "apps_poll_courseId_fkey", + "tableFrom": "apps_poll", + "tableTo": "course", + "columnsFrom": ["courseId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apps_poll_option": { + "name": "apps_poll_option", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "apps_poll_option_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "poll_id": { + "name": "poll_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "apps_poll_option_poll_id_fkey": { + "name": "apps_poll_option_poll_id_fkey", + "tableFrom": "apps_poll_option", + "tableTo": "apps_poll", + "columnsFrom": ["poll_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apps_poll_submission": { + "name": "apps_poll_submission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "apps_poll_submision_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "poll_option_id": { + "name": "poll_option_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "selected_by_id": { + "name": "selected_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "poll_id": { + "name": "poll_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "apps_poll_submission_poll_id_fkey": { + "name": "apps_poll_submission_poll_id_fkey", + "tableFrom": "apps_poll_submission", + "tableTo": "apps_poll", + "columnsFrom": ["poll_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "apps_poll_submission_poll_option_id_fkey": { + "name": "apps_poll_submission_poll_option_id_fkey", + "tableFrom": "apps_poll_submission", + "tableTo": "apps_poll_option", + "columnsFrom": ["poll_option_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "apps_poll_submission_selected_by_id_fkey": { + "name": "apps_poll_submission_selected_by_id_fkey", + "tableFrom": "apps_poll_submission", + "tableTo": "groupmember", + "columnsFrom": ["selected_by_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'video'" + }, + "provider": { + "name": "provider", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'upload'" + }, + "storage_provider": { + "name": "storage_provider", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'s3'" + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "aspect_ratio": { + "name": "aspect_ratio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_external": { + "name": "is_external", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_by_profile_id": { + "name": "created_by_profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_assets_organization_id": { + "name": "idx_assets_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_assets_organization_status": { + "name": "idx_assets_organization_status", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_assets_organization_kind_status": { + "name": "idx_assets_organization_kind_status", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_assets_organization_created_at": { + "name": "idx_assets_organization_created_at", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_assets_organization_byte_size": { + "name": "idx_assets_organization_byte_size", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "byte_size", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_organization_id_fkey": { + "name": "assets_organization_id_fkey", + "tableFrom": "assets", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "assets_created_by_profile_id_fkey": { + "name": "assets_created_by_profile_id_fkey", + "tableFrom": "assets", + "tableTo": "profile", + "columnsFrom": ["created_by_profile_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "assets_org_provider_storage_key_unique": { + "name": "assets_org_provider_storage_key_unique", + "nullsNotDistinct": false, + "columns": ["organization_id", "provider", "storage_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset_usages": { + "name": "asset_usages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slot_type": { + "name": "slot_type", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'lesson_video'" + }, + "slot_key": { + "name": "slot_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_profile_id": { + "name": "created_by_profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_asset_usages_org_id": { + "name": "idx_asset_usages_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_asset_usages_asset_id": { + "name": "idx_asset_usages_asset_id", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_asset_usages_target": { + "name": "idx_asset_usages_target", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_usages_organization_id_fkey": { + "name": "asset_usages_organization_id_fkey", + "tableFrom": "asset_usages", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "asset_usages_asset_id_fkey": { + "name": "asset_usages_asset_id_fkey", + "tableFrom": "asset_usages", + "tableTo": "assets", + "columnsFrom": ["asset_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "asset_usages_created_by_profile_id_fkey": { + "name": "asset_usages_created_by_profile_id_fkey", + "tableFrom": "asset_usages", + "tableTo": "profile", + "columnsFrom": ["created_by_profile_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "asset_usages_asset_target_slot_unique": { + "name": "asset_usages_asset_target_slot_unique", + "nullsNotDistinct": false, + "columns": ["asset_id", "target_type", "target_id", "slot_type", "slot_key", "position"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.community_answer": { + "name": "community_answer", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "question_id": { + "name": "question_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "votes": { + "name": "votes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "author_profile_id": { + "name": "author_profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "community_answer_author_id_fkey": { + "name": "community_answer_author_id_fkey", + "tableFrom": "community_answer", + "tableTo": "organizationmember", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "community_answer_author_profile_id_fkey": { + "name": "community_answer_author_profile_id_fkey", + "tableFrom": "community_answer", + "tableTo": "profile", + "columnsFrom": ["author_profile_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "community_answer_question_id_fkey": { + "name": "community_answer_question_id_fkey", + "tableFrom": "community_answer", + "tableTo": "community_question", + "columnsFrom": ["question_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.community_question": { + "name": "community_question", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "community_question_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "votes": { + "name": "votes", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_profile_id": { + "name": "author_profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "community_question_author_id_fkey": { + "name": "community_question_author_id_fkey", + "tableFrom": "community_question", + "tableTo": "organizationmember", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "community_question_author_profile_id_fkey": { + "name": "community_question_author_profile_id_fkey", + "tableFrom": "community_question", + "tableTo": "profile", + "columnsFrom": ["author_profile_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "community_question_course_id_fkey": { + "name": "community_question_course_id_fkey", + "tableFrom": "community_question", + "tableTo": "course", + "columnsFrom": ["course_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "community_question_organization_id_fkey": { + "name": "community_question_organization_id_fkey", + "tableFrom": "community_question", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.course": { + "name": "course", + "schema": "", + "columns": { + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "overview": { + "name": "overview", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'Welcome to this amazing course 🚀 '" + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_template": { + "name": "is_template", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"goals\":\"\",\"description\":\"\",\"requirements\":\"\"}'::jsonb" + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "currency": { + "name": "currency", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "banner_image": { + "name": "banner_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_certificate_downloadable": { + "name": "is_certificate_downloadable", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "certificate_theme": { + "name": "certificate_theme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ACTIVE'" + }, + "type": { + "name": "type", + "type": "COURSE_TYPE", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'LIVE_CLASS'" + } + }, + "indexes": { + "idx_course_group_id": { + "name": "idx_course_group_id", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "course_group_id_fkey": { + "name": "course_group_id_fkey", + "tableFrom": "course", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "course_slug_key": { + "name": "course_slug_key", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.course_import_draft": { + "name": "course_import_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_profile_id": { + "name": "created_by_profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "COURSE_IMPORT_SOURCE_TYPE", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "COURSE_IMPORT_DRAFT_STATUS", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "LOCALE", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "warnings": { + "name": "warnings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "sourceArtifacts": { + "name": "sourceArtifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "published_course_id": { + "name": "published_course_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "timezone('utc'::text, now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "timezone('utc'::text, now())" + } + }, + "indexes": { + "idx_course_import_draft_org_id": { + "name": "idx_course_import_draft_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_course_import_draft_status": { + "name": "idx_course_import_draft_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "course_import_draft_org_idempotency_key": { + "name": "course_import_draft_org_idempotency_key", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"course_import_draft\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "course_import_draft_organization_id_organization_id_fk": { + "name": "course_import_draft_organization_id_organization_id_fk", + "tableFrom": "course_import_draft", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "course_import_draft_created_by_profile_id_profile_id_fk": { + "name": "course_import_draft_created_by_profile_id_profile_id_fk", + "tableFrom": "course_import_draft", + "tableTo": "profile", + "columnsFrom": ["created_by_profile_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "course_import_draft_published_course_id_course_id_fk": { + "name": "course_import_draft_published_course_id_course_id_fk", + "tableFrom": "course_import_draft", + "tableTo": "course", + "columnsFrom": ["published_course_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.course_invite": { + "name": "course_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "'3'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_profile_id": { + "name": "created_by_profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revoked_by_profile_id": { + "name": "revoked_by_profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "used_count": { + "name": "used_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_revoked": { + "name": "is_revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "allowed_domains": { + "name": "allowed_domains", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "timezone('utc'::text, now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "timezone('utc'::text, now())" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_course_invite_course_id": { + "name": "idx_course_invite_course_id", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_course_invite_expires_at": { + "name": "idx_course_invite_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_course_invite_created_by": { + "name": "idx_course_invite_created_by", + "columns": [ + { + "expression": "created_by_profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_course_invite_revoked_by": { + "name": "idx_course_invite_revoked_by", + "columns": [ + { + "expression": "revoked_by_profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "course_invite_course_id_fkey": { + "name": "course_invite_course_id_fkey", + "tableFrom": "course_invite", + "tableTo": "course", + "columnsFrom": ["course_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "course_invite_created_by_profile_id_fkey": { + "name": "course_invite_created_by_profile_id_fkey", + "tableFrom": "course_invite", + "tableTo": "profile", + "columnsFrom": ["created_by_profile_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "course_invite_revoked_by_profile_id_fkey": { + "name": "course_invite_revoked_by_profile_id_fkey", + "tableFrom": "course_invite", + "tableTo": "profile", + "columnsFrom": ["revoked_by_profile_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "course_invite_role_id_fkey": { + "name": "course_invite_role_id_fkey", + "tableFrom": "course_invite", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "course_invite_token_hash_key": { + "name": "course_invite_token_hash_key", + "nullsNotDistinct": false, + "columns": ["token_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.course_invite_audit": { + "name": "course_invite_audit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_id": { + "name": "invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "COURSE_INVITE_EVENT_TYPE", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_profile_id": { + "name": "actor_profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_email": { + "name": "target_email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "timezone('utc'::text, now())" + } + }, + "indexes": { + "idx_course_invite_audit_invite_id": { + "name": "idx_course_invite_audit_invite_id", + "columns": [ + { + "expression": "invite_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_course_invite_audit_course_id": { + "name": "idx_course_invite_audit_course_id", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_course_invite_audit_event_type": { + "name": "idx_course_invite_audit_event_type", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_course_invite_audit_created_at": { + "name": "idx_course_invite_audit_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "course_invite_audit_invite_id_fkey": { + "name": "course_invite_audit_invite_id_fkey", + "tableFrom": "course_invite_audit", + "tableTo": "course_invite", + "columnsFrom": ["invite_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "course_invite_audit_course_id_fkey": { + "name": "course_invite_audit_course_id_fkey", + "tableFrom": "course_invite_audit", + "tableTo": "course", + "columnsFrom": ["course_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "course_invite_audit_actor_profile_id_fkey": { + "name": "course_invite_audit_actor_profile_id_fkey", + "tableFrom": "course_invite_audit", + "tableTo": "profile", + "columnsFrom": ["actor_profile_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.course_newsfeed": { + "name": "course_newsfeed", + "schema": "", + "columns": { + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reaction": { + "name": "reaction", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"clap\":[],\"smile\":[],\"thumbsup\":[],\"thumbsdown\":[]}'::jsonb" + }, + "is_pinned": { + "name": "is_pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "course_newsfeed_author_id_fkey": { + "name": "course_newsfeed_author_id_fkey", + "tableFrom": "course_newsfeed", + "tableTo": "groupmember", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "course_newsfeed_course_id_fkey": { + "name": "course_newsfeed_course_id_fkey", + "tableFrom": "course_newsfeed", + "tableTo": "course", + "columnsFrom": ["course_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.course_newsfeed_comment": { + "name": "course_newsfeed_comment", + "schema": "", + "columns": { + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "course_newsfeed_comment_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "course_newsfeed_id": { + "name": "course_newsfeed_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "course_newsfeed_comment_author_id_fkey": { + "name": "course_newsfeed_comment_author_id_fkey", + "tableFrom": "course_newsfeed_comment", + "tableTo": "groupmember", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "course_newsfeed_comment_course_newsfeed_id_fkey": { + "name": "course_newsfeed_comment_course_newsfeed_id_fkey", + "tableFrom": "course_newsfeed_comment", + "tableTo": "course_newsfeed", + "columnsFrom": ["course_newsfeed_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.course_section": { + "name": "course_section", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "public_course_section_course_id_fkey": { + "name": "public_course_section_course_id_fkey", + "tableFrom": "course_section", + "tableTo": "course", + "columnsFrom": ["course_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.currency": { + "name": "currency", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "currency_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exercise": { + "name": "exercise", + "schema": "", + "columns": { + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "section_id": { + "name": "section_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "is_unlocked": { + "name": "is_unlocked", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "due_by": { + "name": "due_by", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "exercise_lesson_id_fkey": { + "name": "exercise_lesson_id_fkey", + "tableFrom": "exercise", + "tableTo": "lesson", + "columnsFrom": ["lesson_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "exercise_course_id_fkey": { + "name": "exercise_course_id_fkey", + "tableFrom": "exercise", + "tableTo": "course", + "columnsFrom": ["course_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "exercise_section_id_fkey": { + "name": "exercise_section_id_fkey", + "tableFrom": "exercise", + "tableTo": "course_section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exercise_template": { + "name": "exercise_template", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "exercise_template_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "questionnaire": { + "name": "questionnaire", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group": { + "name": "group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_group_organization_id": { + "name": "idx_group_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_organization_id_fkey": { + "name": "group_organization_id_fkey", + "tableFrom": "group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_attendance": { + "name": "group_attendance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "group_attendance_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_present": { + "name": "is_present", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "group_attendance_course_id_fkey": { + "name": "group_attendance_course_id_fkey", + "tableFrom": "group_attendance", + "tableTo": "course", + "columnsFrom": ["course_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "group_attendance_student_id_fkey": { + "name": "group_attendance_student_id_fkey", + "tableFrom": "group_attendance", + "tableTo": "groupmember", + "columnsFrom": ["student_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.groupmember": { + "name": "groupmember", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "assigned_student_id": { + "name": "assigned_student_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupmember_group_id_fkey": { + "name": "groupmember_group_id_fkey", + "tableFrom": "groupmember", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "groupmember_profile_id_fkey": { + "name": "groupmember_profile_id_fkey", + "tableFrom": "groupmember", + "tableTo": "profile", + "columnsFrom": ["profile_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "groupmember_role_id_fkey": { + "name": "groupmember_role_id_fkey", + "tableFrom": "groupmember", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_entries": { + "name": "unique_entries", + "nullsNotDistinct": false, + "columns": ["group_id", "profile_id", "email"] + }, + "unique_group_email": { + "name": "unique_group_email", + "nullsNotDistinct": false, + "columns": ["group_id", "email"] + }, + "unique_group_profile": { + "name": "unique_group_profile", + "nullsNotDistinct": false, + "columns": ["group_id", "profile_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lesson": { + "name": "lesson", + "schema": "", + "columns": { + "note": { + "name": "note", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "video_url": { + "name": "video_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "slide_url": { + "name": "slide_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "lesson_at": { + "name": "lesson_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "teacher_id": { + "name": "teacher_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_complete": { + "name": "is_complete", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "call_url": { + "name": "call_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "is_unlocked": { + "name": "is_unlocked", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "videos": { + "name": "videos", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "documents": { + "name": "documents", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "section_id": { + "name": "section_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "lesson_course_id_fkey": { + "name": "lesson_course_id_fkey", + "tableFrom": "lesson", + "tableTo": "course", + "columnsFrom": ["course_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "lesson_teacher_id_fkey": { + "name": "lesson_teacher_id_fkey", + "tableFrom": "lesson", + "tableTo": "profile", + "columnsFrom": ["teacher_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "public_course_section_id_fkey": { + "name": "public_course_section_id_fkey", + "tableFrom": "lesson", + "tableTo": "course_section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lesson_comment": { + "name": "lesson_comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "lesson_comment_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "groupmember_id": { + "name": "groupmember_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "lesson_comment_groupmember_id_fkey": { + "name": "lesson_comment_groupmember_id_fkey", + "tableFrom": "lesson_comment", + "tableTo": "groupmember", + "columnsFrom": ["groupmember_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "lesson_comment_lesson_id_fkey": { + "name": "lesson_comment_lesson_id_fkey", + "tableFrom": "lesson_comment", + "tableTo": "lesson", + "columnsFrom": ["lesson_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lesson_completion": { + "name": "lesson_completion", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "lesson_completion_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_complete": { + "name": "is_complete", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "lesson_completion_lesson_id_fkey": { + "name": "lesson_completion_lesson_id_fkey", + "tableFrom": "lesson_completion", + "tableTo": "lesson", + "columnsFrom": ["lesson_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lesson_completion_profile_id_fkey": { + "name": "lesson_completion_profile_id_fkey", + "tableFrom": "lesson_completion", + "tableTo": "profile", + "columnsFrom": ["profile_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_lesson_profile": { + "name": "unique_lesson_profile", + "nullsNotDistinct": false, + "columns": ["lesson_id", "profile_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lesson_language": { + "name": "lesson_language", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "lesson_language_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": false, + "default": "gen_random_uuid()" + }, + "locale": { + "name": "locale", + "type": "LOCALE", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'en'" + } + }, + "indexes": {}, + "foreignKeys": { + "public_lesson_language_lesson_id_fkey": { + "name": "public_lesson_language_lesson_id_fkey", + "tableFrom": "lesson_language", + "tableTo": "lesson", + "columnsFrom": ["lesson_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lesson_language_history": { + "name": "lesson_language_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lesson_language_id": { + "name": "lesson_language_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "old_content": { + "name": "old_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_content": { + "name": "new_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "public_lesson_language_history_lesson_language_id_fkey": { + "name": "public_lesson_language_history_lesson_language_id_fkey", + "tableFrom": "lesson_language_history", + "tableTo": "lesson_language", + "columnsFrom": ["lesson_language_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.option": { + "name": "option", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "option_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "label": { + "name": "label", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_correct": { + "name": "is_correct", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "question_id": { + "name": "question_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "uuid", + "primaryKey": false, + "notNull": false, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "option_question_id_fkey": { + "name": "option_question_id_fkey", + "tableFrom": "option", + "tableTo": "question", + "columnsFrom": ["question_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "siteName": { + "name": "siteName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "landingpage": { + "name": "landingpage", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'blue'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "timezone('utc'::text, now())" + }, + "customization": { + "name": "customization", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"apps\":{\"poll\":true,\"comments\":true},\"course\":{\"grading\":true,\"newsfeed\":true},\"dashboard\":{\"exercise\":true,\"community\":true,\"bannerText\":\"\",\"bannerImage\":\"\"}}'::json" + }, + "is_restricted": { + "name": "is_restricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "customCode": { + "name": "customCode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customDomain": { + "name": "customDomain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isCustomDomainVerified": { + "name": "isCustomDomainVerified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "disable_signup": { + "name": "disable_signup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "disable_signup_message": { + "name": "disable_signup_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disable_email_password": { + "name": "disable_email_password", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "disable_google_auth": { + "name": "disable_google_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_siteName_key": { + "name": "organization_siteName_key", + "nullsNotDistinct": false, + "columns": ["siteName"] + }, + "organization_customDomain_key": { + "name": "organization_customDomain_key", + "nullsNotDistinct": false, + "columns": ["customDomain"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_api_key": { + "name": "organization_api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_profile_id": { + "name": "created_by_profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "ORGANIZATION_API_KEY_TYPE", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "secret_prefix": { + "name": "secret_prefix", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "timezone('utc'::text, now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "timezone('utc'::text, now())" + } + }, + "indexes": { + "idx_organization_api_key_org_id": { + "name": "idx_organization_api_key_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_organization_api_key_type": { + "name": "idx_organization_api_key_type", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_api_key_secret_hash_unique": { + "name": "organization_api_key_secret_hash_unique", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_api_key_organization_id_organization_id_fk": { + "name": "organization_api_key_organization_id_organization_id_fk", + "tableFrom": "organization_api_key", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_api_key_created_by_profile_id_profile_id_fk": { + "name": "organization_api_key_created_by_profile_id_profile_id_fk", + "tableFrom": "organization_api_key", + "tableTo": "profile", + "columnsFrom": ["created_by_profile_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_auth_policy": { + "name": "organization_auth_policy", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "force_sso": { + "name": "force_sso", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_join_sso_domains": { + "name": "auto_join_sso_domains", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "break_glass_enabled": { + "name": "break_glass_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "default_role_id": { + "name": "default_role_id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "'3'" + }, + "role_mapping": { + "name": "role_mapping", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "timezone('utc'::text, now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "timezone('utc'::text, now())" + } + }, + "indexes": { + "idx_organization_auth_policy_org_id": { + "name": "idx_organization_auth_policy_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_auth_policy_organization_id_organization_id_fk": { + "name": "organization_auth_policy_organization_id_organization_id_fk", + "tableFrom": "organization_auth_policy", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_auth_policy_default_role_id_fkey": { + "name": "organization_auth_policy_default_role_id_fkey", + "tableFrom": "organization_auth_policy", + "tableTo": "role", + "columnsFrom": ["default_role_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_automation_usage": { + "name": "organization_automation_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_api_key_id": { + "name": "organization_api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "ORGANIZATION_API_KEY_TYPE", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "AUTOMATION_USAGE_CATEGORY", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "credits_consumed": { + "name": "credits_consumed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "timezone('utc'::text, now())" + } + }, + "indexes": { + "idx_org_automation_usage_org_created_at": { + "name": "idx_org_automation_usage_org_created_at", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_automation_usage_key_created_at": { + "name": "idx_org_automation_usage_key_created_at", + "columns": [ + { + "expression": "organization_api_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_automation_usage_type_created_at": { + "name": "idx_org_automation_usage_type_created_at", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_automation_usage_organization_id_organization_id_fk": { + "name": "organization_automation_usage_organization_id_organization_id_fk", + "tableFrom": "organization_automation_usage", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "organization_automation_usage_organization_api_key_id_organization_api_key_id_fk": { + "name": "organization_automation_usage_organization_api_key_id_organization_api_key_id_fk", + "tableFrom": "organization_automation_usage", + "tableTo": "organization_api_key", + "columnsFrom": ["organization_api_key_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_contacts": { + "name": "organization_contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "organization_contacts_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "organization_contacts_organization_id_fkey": { + "name": "organization_contacts_organization_id_fkey", + "tableFrom": "organization_contacts", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_emaillist": { + "name": "organization_emaillist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "organization_emaillist_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "organization_emaillist_organization_id_fkey": { + "name": "organization_emaillist_organization_id_fkey", + "tableFrom": "organization_emaillist", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_invite": { + "name": "organization_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_profile_id": { + "name": "created_by_profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "accepted_by_profile_id": { + "name": "accepted_by_profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_profile_id": { + "name": "revoked_by_profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "is_revoked": { + "name": "is_revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "timezone('utc'::text, now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "timezone('utc'::text, now())" + } + }, + "indexes": { + "idx_organization_invite_organization_id": { + "name": "idx_organization_invite_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_organization_invite_email_org": { + "name": "idx_organization_invite_email_org", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_organization_invite_expires_at": { + "name": "idx_organization_invite_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_organization_invite_created_by": { + "name": "idx_organization_invite_created_by", + "columns": [ + { + "expression": "created_by_profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_organization_invite_accepted_by": { + "name": "idx_organization_invite_accepted_by", + "columns": [ + { + "expression": "accepted_by_profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_organization_invite_revoked_by": { + "name": "idx_organization_invite_revoked_by", + "columns": [ + { + "expression": "revoked_by_profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_invite_organization_id_fkey": { + "name": "organization_invite_organization_id_fkey", + "tableFrom": "organization_invite", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_invite_role_id_fkey": { + "name": "organization_invite_role_id_fkey", + "tableFrom": "organization_invite", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "organization_invite_created_by_profile_id_fkey": { + "name": "organization_invite_created_by_profile_id_fkey", + "tableFrom": "organization_invite", + "tableTo": "profile", + "columnsFrom": ["created_by_profile_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "organization_invite_accepted_by_profile_id_fkey": { + "name": "organization_invite_accepted_by_profile_id_fkey", + "tableFrom": "organization_invite", + "tableTo": "profile", + "columnsFrom": ["accepted_by_profile_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "organization_invite_revoked_by_profile_id_fkey": { + "name": "organization_invite_revoked_by_profile_id_fkey", + "tableFrom": "organization_invite", + "tableTo": "profile", + "columnsFrom": ["revoked_by_profile_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_invite_token_hash_key": { + "name": "organization_invite_token_hash_key", + "nullsNotDistinct": false, + "columns": ["token_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_invite_audit": { + "name": "organization_invite_audit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_id": { + "name": "invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "ORGANIZATION_INVITE_EVENT_TYPE", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_profile_id": { + "name": "actor_profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_email": { + "name": "target_email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "timezone('utc'::text, now())" + } + }, + "indexes": { + "idx_organization_invite_audit_invite_id": { + "name": "idx_organization_invite_audit_invite_id", + "columns": [ + { + "expression": "invite_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_organization_invite_audit_org_id": { + "name": "idx_organization_invite_audit_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_organization_invite_audit_event_type": { + "name": "idx_organization_invite_audit_event_type", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_organization_invite_audit_created_at": { + "name": "idx_organization_invite_audit_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_invite_audit_invite_id_fkey": { + "name": "organization_invite_audit_invite_id_fkey", + "tableFrom": "organization_invite_audit", + "tableTo": "organization_invite", + "columnsFrom": ["invite_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_invite_audit_organization_id_fkey": { + "name": "organization_invite_audit_organization_id_fkey", + "tableFrom": "organization_invite_audit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_invite_audit_actor_profile_id_fkey": { + "name": "organization_invite_audit_actor_profile_id_fkey", + "tableFrom": "organization_invite_audit", + "tableTo": "profile", + "columnsFrom": ["actor_profile_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_plan": { + "name": "organization_plan", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "organization_plan_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "plan_name": { + "name": "plan_name", + "type": "PLAN", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "deactivated_at": { + "name": "deactivated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "triggered_by": { + "name": "triggered_by", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'lmz'" + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_organization_plan_org_id": { + "name": "idx_organization_plan_org_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_plan_org_id_fkey": { + "name": "organization_plan_org_id_fkey", + "tableFrom": "organization_plan", + "tableTo": "organization", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "organization_plan_triggered_by_fkey": { + "name": "organization_plan_triggered_by_fkey", + "tableFrom": "organization_plan", + "tableTo": "organizationmember", + "columnsFrom": ["triggered_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_plan_subscription_id_key": { + "name": "organization_plan_subscription_id_key", + "nullsNotDistinct": false, + "columns": ["subscription_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_sso_config": { + "name": "organization_sso_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "better_auth_provider_id": { + "name": "better_auth_provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "SSO_PROVIDER", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_profile_id": { + "name": "created_by_profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_by_profile_id": { + "name": "updated_by_profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "timezone('utc'::text, now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "timezone('utc'::text, now())" + } + }, + "indexes": { + "idx_organization_sso_config_org_id": { + "name": "idx_organization_sso_config_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_organization_sso_config_domain": { + "name": "idx_organization_sso_config_domain", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_organization_sso_config_provider_id": { + "name": "idx_organization_sso_config_provider_id", + "columns": [ + { + "expression": "better_auth_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_sso_config_organization_id_organization_id_fk": { + "name": "organization_sso_config_organization_id_organization_id_fk", + "tableFrom": "organization_sso_config", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_sso_config_created_by_profile_id_profile_id_fk": { + "name": "organization_sso_config_created_by_profile_id_profile_id_fk", + "tableFrom": "organization_sso_config", + "tableTo": "profile", + "columnsFrom": ["created_by_profile_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "organization_sso_config_updated_by_profile_id_profile_id_fk": { + "name": "organization_sso_config_updated_by_profile_id_profile_id_fk", + "tableFrom": "organization_sso_config", + "tableTo": "profile", + "columnsFrom": ["updated_by_profile_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_sso_config_organization_id_unique": { + "name": "organization_sso_config_organization_id_unique", + "nullsNotDistinct": false, + "columns": ["organization_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_token_auth": { + "name": "organization_token_auth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "signing_secret": { + "name": "signing_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_profile_id": { + "name": "created_by_profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "timezone('utc'::text, now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "timezone('utc'::text, now())" + } + }, + "indexes": { + "idx_organization_token_auth_org_id": { + "name": "idx_organization_token_auth_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_token_auth_organization_id_organization_id_fk": { + "name": "organization_token_auth_organization_id_organization_id_fk", + "tableFrom": "organization_token_auth", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_token_auth_created_by_profile_id_profile_id_fk": { + "name": "organization_token_auth_created_by_profile_id_profile_id_fk", + "tableFrom": "organization_token_auth", + "tableTo": "profile", + "columnsFrom": ["created_by_profile_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_token_auth_organization_id_unique": { + "name": "organization_token_auth_organization_id_unique", + "nullsNotDistinct": false, + "columns": ["organization_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizationmember": { + "name": "organizationmember", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "organizationmember_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "timezone('utc'::text, now())" + } + }, + "indexes": { + "idx_organizationmember_profile_id": { + "name": "idx_organizationmember_profile_id", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_organizationmember_organization_id": { + "name": "idx_organizationmember_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_organizationmember_profile_org": { + "name": "idx_organizationmember_profile_org", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organizationmember_org_profile_unique": { + "name": "organizationmember_org_profile_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"organizationmember\".\"profile_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "organizationmember_org_email_unique": { + "name": "organizationmember_org_email_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"organizationmember\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organizationmember_organization_id_fkey": { + "name": "organizationmember_organization_id_fkey", + "tableFrom": "organizationmember", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "organizationmember_profile_id_fkey": { + "name": "organizationmember_profile_id_fkey", + "tableFrom": "organizationmember", + "tableTo": "profile", + "columnsFrom": ["profile_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "organizationmember_role_id_fkey": { + "name": "organizationmember_role_id_fkey", + "tableFrom": "organizationmember", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "fullname": { + "name": "fullname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'https://tapaozmyjsuykgerrfkt.supabase.co/storage/v1/object/public/avatars/avatar.png'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "can_add_course": { + "name": "can_add_course", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "goal": { + "name": "goal", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "is_email_verified": { + "name": "is_email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "LOCALE", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'en'" + }, + "is_restricted": { + "name": "is_restricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "idx_profile_id": { + "name": "idx_profile_id", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_profile_email": { + "name": "idx_profile_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "profile_id_fkey": { + "name": "profile_id_fkey", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": ["id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_key": { + "name": "profile_username_key", + "nullsNotDistinct": false, + "columns": ["username"] + }, + "profile_email_key": { + "name": "profile_email_key", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.question": { + "name": "question", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "question_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "question_type_id": { + "name": "question_type_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "exercise_id": { + "name": "exercise_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "uuid", + "primaryKey": false, + "notNull": false, + "default": "gen_random_uuid()" + }, + "points": { + "name": "points", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "order": { + "name": "order", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "question_exercise_id_fkey": { + "name": "question_exercise_id_fkey", + "tableFrom": "question", + "tableTo": "exercise", + "columnsFrom": ["exercise_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "question_question_type_id_fkey": { + "name": "question_question_type_id_fkey", + "tableFrom": "question", + "tableTo": "question_type", + "columnsFrom": ["question_type_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.question_answer": { + "name": "question_answer", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "question_answer_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "answer_data": { + "name": "answer_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "answers": { + "name": "answers", + "type": "varchar[]", + "primaryKey": false, + "notNull": false + }, + "open_answer": { + "name": "open_answer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "question_id": { + "name": "question_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "group_member_id": { + "name": "group_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "submission_id": { + "name": "submission_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "point": { + "name": "point", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "'0'" + } + }, + "indexes": {}, + "foreignKeys": { + "question_answer_group_member_id_fkey": { + "name": "question_answer_group_member_id_fkey", + "tableFrom": "question_answer", + "tableTo": "groupmember", + "columnsFrom": ["group_member_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "question_answer_question_id_fkey": { + "name": "question_answer_question_id_fkey", + "tableFrom": "question_answer", + "tableTo": "question", + "columnsFrom": ["question_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "question_answer_submission_id_fkey": { + "name": "question_answer_submission_id_fkey", + "tableFrom": "question_answer", + "tableTo": "submission", + "columnsFrom": ["submission_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.question_type": { + "name": "question_type", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "question_type_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "label": { + "name": "label", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "typename": { + "name": "typename", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz": { + "name": "quiz", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "questions": { + "name": "questions", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "timelimit": { + "name": "timelimit", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'10s'" + }, + "theme": { + "name": "theme", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'standard'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_organization_id_fkey": { + "name": "quiz_organization_id_fkey", + "tableFrom": "quiz", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_play": { + "name": "quiz_play", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "play_quiz_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "quiz_id": { + "name": "quiz_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "players": { + "name": "players", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'::json" + }, + "started": { + "name": "started", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "currentQuestionId": { + "name": "currentQuestionId", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "showCurrentQuestionAnswer": { + "name": "showCurrentQuestionAnswer", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "isLastQuestion": { + "name": "isLastQuestion", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'CONNECT_TO_PLAY'" + }, + "studentStep": { + "name": "studentStep", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'PIN_SETUP'" + }, + "pin": { + "name": "pin", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_play_quiz_id_fkey": { + "name": "quiz_play_quiz_id_fkey", + "tableFrom": "quiz_play", + "tableTo": "quiz", + "columnsFrom": ["quiz_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "quiz_play_pin_key": { + "name": "quiz_play_pin_key", + "nullsNotDistinct": false, + "columns": ["pin"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role": { + "name": "role", + "schema": "", + "columns": { + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "role_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "nullsNotDistinct": false, + "columns": ["provider_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submission": { + "name": "submission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "reviewer_id": { + "name": "reviewer_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "'1'" + }, + "grading_state": { + "name": "grading_state", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "overall_status": { + "name": "overall_status", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'manual_required'" + }, + "total": { + "name": "total", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "exercise_id": { + "name": "exercise_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "submitted_by": { + "name": "submitted_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "submission_course_id_fkey": { + "name": "submission_course_id_fkey", + "tableFrom": "submission", + "tableTo": "course", + "columnsFrom": ["course_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "submission_exercise_id_fkey": { + "name": "submission_exercise_id_fkey", + "tableFrom": "submission", + "tableTo": "exercise", + "columnsFrom": ["exercise_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "submission_status_id_fkey": { + "name": "submission_status_id_fkey", + "tableFrom": "submission", + "tableTo": "submissionstatus", + "columnsFrom": ["status_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "submission_submitted_by_fkey": { + "name": "submission_submitted_by_fkey", + "tableFrom": "submission", + "tableTo": "groupmember", + "columnsFrom": ["submitted_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submissionstatus": { + "name": "submissionstatus", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "submission_status_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "label": { + "name": "label", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag": { + "name": "tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "timezone('utc'::text, now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "timezone('utc'::text, now())" + } + }, + "indexes": { + "idx_tag_organization_id": { + "name": "idx_tag_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tag_group_id": { + "name": "idx_tag_group_id", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tag_organization_id_fkey": { + "name": "tag_organization_id_fkey", + "tableFrom": "tag", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tag_group_id_fkey": { + "name": "tag_group_id_fkey", + "tableFrom": "tag", + "tableTo": "tag_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tag_org_slug_key": { + "name": "tag_org_slug_key", + "nullsNotDistinct": false, + "columns": ["organization_id", "slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag_assignment": { + "name": "tag_assignment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "timezone('utc'::text, now())" + } + }, + "indexes": { + "idx_tag_assignment_tag_id": { + "name": "idx_tag_assignment_tag_id", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tag_assignment_course_id": { + "name": "idx_tag_assignment_course_id", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tag_assignment_tag_id_fkey": { + "name": "tag_assignment_tag_id_fkey", + "tableFrom": "tag_assignment", + "tableTo": "tag", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tag_assignment_course_id_fkey": { + "name": "tag_assignment_course_id_fkey", + "tableFrom": "tag_assignment", + "tableTo": "course", + "columnsFrom": ["course_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tag_assignment_tag_course_key": { + "name": "tag_assignment_tag_course_key", + "nullsNotDistinct": false, + "columns": ["tag_id", "course_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag_group": { + "name": "tag_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "timezone('utc'::text, now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "timezone('utc'::text, now())" + } + }, + "indexes": { + "idx_tag_group_organization_id": { + "name": "idx_tag_group_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tag_group_organization_id_fkey": { + "name": "tag_group_organization_id_fkey", + "tableFrom": "tag_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tag_group_org_slug_key": { + "name": "tag_group_org_slug_key", + "nullsNotDistinct": false, + "columns": ["organization_id", "slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.test_tenant": { + "name": "test_tenant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.video_transcripts": { + "name": "video_transcripts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "video_transcripts_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "muse_svid": { + "name": "muse_svid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcript": { + "name": "transcript", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "downloaded": { + "name": "downloaded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitinglist": { + "name": "waitinglist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "waitinglist_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "constraint_name": { + "name": "constraint_name", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.AUTOMATION_USAGE_CATEGORY": { + "name": "AUTOMATION_USAGE_CATEGORY", + "schema": "public", + "values": ["read", "write", "publish"] + }, + "public.COURSE_IMPORT_DRAFT_STATUS": { + "name": "COURSE_IMPORT_DRAFT_STATUS", + "schema": "public", + "values": ["DRAFT", "PUBLISHED", "ARCHIVED"] + }, + "public.COURSE_IMPORT_SOURCE_TYPE": { + "name": "COURSE_IMPORT_SOURCE_TYPE", + "schema": "public", + "values": ["prompt", "pdf", "course"] + }, + "public.COURSE_INVITE_EVENT_TYPE": { + "name": "COURSE_INVITE_EVENT_TYPE", + "schema": "public", + "values": ["CREATED", "REVOKED", "PREVIEWED", "ACCEPTED", "EMAIL_SENT", "EMAIL_FAILED", "ABUSE_BLOCKED"] + }, + "public.COURSE_TYPE": { + "name": "COURSE_TYPE", + "schema": "public", + "values": ["SELF_PACED", "LIVE_CLASS"] + }, + "public.LOCALE": { + "name": "LOCALE", + "schema": "public", + "values": ["en", "hi", "fr", "pt", "de", "vi", "ru", "es", "pl", "da"] + }, + "public.ORGANIZATION_API_KEY_TYPE": { + "name": "ORGANIZATION_API_KEY_TYPE", + "schema": "public", + "values": ["mcp", "api", "zapier"] + }, + "public.ORGANIZATION_INVITE_EVENT_TYPE": { + "name": "ORGANIZATION_INVITE_EVENT_TYPE", + "schema": "public", + "values": ["CREATED", "REVOKED", "PREVIEWED", "ACCEPTED", "EMAIL_SENT", "EMAIL_FAILED", "ABUSE_BLOCKED"] + }, + "public.SSO_PROVIDER": { + "name": "SSO_PROVIDER", + "schema": "public", + "values": ["OKTA", "GOOGLE_WORKSPACE", "AUTH0"] + }, + "public.PLAN": { + "name": "PLAN", + "schema": "public", + "values": ["EARLY_ADOPTER", "ENTERPRISE", "BASIC"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.dash_org_stats": { + "columns": { + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "no_of_courses": { + "name": "no_of_courses", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "enrolled_students": { + "name": "enrolled_students", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "definition": "SELECT gp.organization_id AS org_id, count(DISTINCT course.id) AS no_of_courses, count(DISTINCT gm.profile_id) AS enrolled_students FROM course JOIN \"group\" gp ON gp.id = course.group_id LEFT JOIN groupmember gm ON gm.group_id = gp.id AND gm.role_id = 3 WHERE course.status = 'ACTIVE'::text GROUP BY gp.organization_id", + "name": "dash_org_stats", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.lesson_versions": { + "columns": { + "old_content": { + "name": "old_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_content": { + "name": "new_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "LOCALE", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "definition": "SELECT llh.old_content, llh.new_content, llh.\"timestamp\", ll.locale, ll.lesson_id FROM lesson_language_history llh JOIN lesson_language ll ON ll.id = llh.lesson_language_id", + "name": "lesson_versions", + "schema": "public", + "isExisting": false, + "materialized": false + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index a12249fa..b43e8277 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1771176910627, "tag": "0006_modern_the_initiative", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1774154467188, + "tag": "0007_rainy_vivisector", + "breakpoints": true } ] } diff --git a/packages/db/src/queries/auth/profile.ts b/packages/db/src/queries/auth/profile.ts index 651d1d3f..471b980e 100644 --- a/packages/db/src/queries/auth/profile.ts +++ b/packages/db/src/queries/auth/profile.ts @@ -2,7 +2,7 @@ import * as schema from '@db/schema'; import type { TProfile } from '@db/types'; import { db, type DbOrTxClient } from '@db/drizzle'; -import { eq } from 'drizzle-orm'; +import { eq, inArray } from 'drizzle-orm'; export const getProfileById = async (id: string) => { const [profile] = await db.select().from(schema.profile).where(eq(schema.profile.id, id)).limit(1); @@ -16,6 +16,12 @@ export const getProfileByEmail = async (email: string) => { return profile; }; +export async function getProfilesByEmails(emails: string[]) { + if (emails.length === 0) return []; + + return db.select().from(schema.profile).where(inArray(schema.profile.email, emails)); +} + export const updateProfile = async (id: string, data: Partial>) => { const [updatedProfile] = await db.update(schema.profile).set(data).where(eq(schema.profile.id, id)).returning(); diff --git a/packages/db/src/queries/course/course.ts b/packages/db/src/queries/course/course.ts index f4c988ed..ab691e67 100644 --- a/packages/db/src/queries/course/course.ts +++ b/packages/db/src/queries/course/course.ts @@ -879,3 +879,47 @@ export async function deleteCourseSection(sectionId: string, dbClient: DbOrTxCli ); } } + +/** + * Gets group IDs for multiple courses at once + * @param courseIds Array of course IDs + * @returns Array of { courseId, groupId } mappings + */ +export async function getCourseGroupIds(courseIds: string[]) { + try { + if (courseIds.length === 0) return []; + + return db + .select({ courseId: schema.course.id, groupId: schema.course.groupId }) + .from(schema.course) + .where(inArray(schema.course.id, courseIds)); + } catch (error) { + console.error('getCourseGroupIds error:', error); + throw new Error(`Failed to get course group IDs: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Gets course group mappings for an organization, including course title + * @param orgId Organization ID + * @param courseIds Array of course IDs to filter + * @returns Array of { courseId, courseTitle, groupId } + */ +export async function getOrgCourseGroups(orgId: string, courseIds: string[]) { + try { + if (courseIds.length === 0) return []; + + return db + .select({ + courseId: schema.course.id, + courseTitle: schema.course.title, + groupId: schema.course.groupId + }) + .from(schema.course) + .innerJoin(schema.group, eq(schema.course.groupId, schema.group.id)) + .where(and(eq(schema.group.organizationId, orgId), inArray(schema.course.id, courseIds))); + } catch (error) { + console.error('getOrgCourseGroups error:', error); + throw new Error(`Failed to get org course groups: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} diff --git a/packages/db/src/queries/group/group.ts b/packages/db/src/queries/group/group.ts index aa4f3d50..370f7851 100644 --- a/packages/db/src/queries/group/group.ts +++ b/packages/db/src/queries/group/group.ts @@ -1,7 +1,7 @@ import * as schema from '@db/schema'; import { TNewGroup, TNewGroupmember } from '@db/types'; -import { and, eq, isNotNull, or } from 'drizzle-orm'; +import { and, eq, inArray, isNotNull, or } from 'drizzle-orm'; import { ROLE } from '@cio/utils/constants'; import { db } from '@db/drizzle'; @@ -24,6 +24,91 @@ export async function addGroupMember(values: TNewGroupmember) { } } +export async function addGroupMembers(values: TNewGroupmember[]) { + if (values.length === 0) return []; + try { + return db.insert(schema.groupmember).values(values).returning(); + } catch (error) { + console.error('addGroupMembers error:', error); + throw new Error(`Failed to bulk add group members: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +export async function getExistingGroupMembers( + pairs: Array<{ groupId: string; profileId: string }> +): Promise> { + if (pairs.length === 0) return new Set(); + + try { + const groupIds = [...new Set(pairs.map((p) => p.groupId))]; + const profileIds = [...new Set(pairs.map((p) => p.profileId))]; + + const rows = await db + .select({ groupId: schema.groupmember.groupId, profileId: schema.groupmember.profileId }) + .from(schema.groupmember) + .where(and(inArray(schema.groupmember.groupId, groupIds), inArray(schema.groupmember.profileId, profileIds))); + + return new Set(rows.map((r) => `${r.groupId}:${r.profileId}`)); + } catch (error) { + console.error('getExistingGroupMembers error:', error); + throw new Error( + `Failed to check existing group members: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Enroll users in course groups, skipping any existing memberships. + * Returns the count of newly inserted memberships. + */ +export async function enrollUsersInCourseGroups( + groupIds: string[], + users: Array<{ profileId: string; email?: string }>, + roleId: number +): Promise { + if (groupIds.length === 0 || users.length === 0) return 0; + + const pairs = users.flatMap((u) => groupIds.map((groupId) => ({ groupId, profileId: u.profileId }))); + const existingSet = await getExistingGroupMembers(pairs); + const emailByProfile = new Map(users.map((u) => [u.profileId, u.email])); + + const toInsert = pairs + .filter((p) => !existingSet.has(`${p.groupId}:${p.profileId}`)) + .map((p) => ({ + groupId: p.groupId, + roleId, + profileId: p.profileId, + email: emailByProfile.get(p.profileId) ?? undefined + })); + + if (toInsert.length > 0) { + await addGroupMembers(toInsert); + } + + return toInsert.length; +} + +/** + * Checks if a profile is already a member of a specific group + * @param groupId Group ID + * @param profileId Profile ID + * @returns true if the user is already a member + */ +export async function isGroupMember(groupId: string, profileId: string): Promise { + try { + const [existing] = await db + .select({ id: schema.groupmember.id }) + .from(schema.groupmember) + .where(and(eq(schema.groupmember.groupId, groupId), eq(schema.groupmember.profileId, profileId))) + .limit(1); + + return !!existing; + } catch (error) { + console.error('isGroupMember error:', error); + throw new Error(`Failed to check group membership: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + /** * Checks if a user is a member of a course's group (any role) * @param courseId Course ID diff --git a/packages/db/src/queries/organization/invite.ts b/packages/db/src/queries/organization/invite.ts index 257f064f..ceebad08 100644 --- a/packages/db/src/queries/organization/invite.ts +++ b/packages/db/src/queries/organization/invite.ts @@ -6,7 +6,7 @@ import type { } from '@db/types'; import * as schema from '@db/schema'; -import { and, eq, gt, inArray, isNull, sql } from 'drizzle-orm'; +import { and, desc, eq, gt, inArray, isNull, sql } from 'drizzle-orm'; import { db } from '@db/drizzle'; export async function createOrganizationInvite(values: TNewOrganizationInvite): Promise { @@ -26,6 +26,21 @@ export async function createOrganizationInvite(values: TNewOrganizationInvite): } } +export async function createOrganizationInvites(values: TNewOrganizationInvite[]): Promise { + if (values.length === 0) { + return []; + } + + try { + return await db.insert(schema.organizationInvite).values(values).returning(); + } catch (error) { + console.error('createOrganizationInvites error:', error); + throw new Error( + `Failed to create organization invites: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + export async function createOrganizationInviteAudit( values: TNewOrganizationInviteAudit ): Promise { @@ -45,6 +60,23 @@ export async function createOrganizationInviteAudit( } } +export async function createOrganizationInviteAudits( + values: TNewOrganizationInviteAudit[] +): Promise { + if (values.length === 0) { + return []; + } + + try { + return await db.insert(schema.organizationInviteAudit).values(values).returning(); + } catch (error) { + console.error('createOrganizationInviteAudits error:', error); + throw new Error( + `Failed to create organization invite audit events: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + export async function revokeActiveOrganizationInvitesByEmails( organizationId: string, emails: string[], @@ -180,3 +212,96 @@ export async function hasActiveOrganizationInviteForEmail(organizationId: string return false; } } + +export type TLatestOrgInviteByEmail = { + email: string; + acceptedAt: string | null; + isRevoked: boolean; + expiresAt: string; + createdAt: string; +}; + +/** + * Returns the latest invite per email (by created_at) for the given org and email list. + */ +export async function getLatestOrgInvitesByEmails( + organizationId: string, + emails: string[] +): Promise { + if (emails.length === 0) { + return []; + } + + const normalized = [...new Set(emails.map((e) => e.toLowerCase().trim()))].filter(Boolean); + if (normalized.length === 0) { + return []; + } + + try { + const rows = await db + .select({ + email: schema.organizationInvite.email, + acceptedAt: schema.organizationInvite.acceptedAt, + isRevoked: schema.organizationInvite.isRevoked, + expiresAt: schema.organizationInvite.expiresAt, + createdAt: schema.organizationInvite.createdAt + }) + .from(schema.organizationInvite) + .where( + and( + eq(schema.organizationInvite.organizationId, organizationId), + inArray(schema.organizationInvite.email, normalized) + ) + ); + + const latestByEmail = new Map(); + for (const row of rows) { + const key = row.email.toLowerCase(); + const existing = latestByEmail.get(key); + if (!existing || new Date(row.createdAt) > new Date(existing.createdAt)) { + latestByEmail.set(key, row); + } + } + + return Array.from(latestByEmail.values()); + } catch (error) { + console.error('getLatestOrgInvitesByEmails error:', error); + throw new Error( + `Failed to get latest organization invites by emails: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Latest organization_invite row for an org + email (by created_at), for metadata when resending. + */ +export async function getLatestOrganizationInviteRowByOrgAndEmail( + organizationId: string, + email: string +): Promise { + const normalized = email.toLowerCase().trim(); + if (!normalized) { + return null; + } + + try { + const [row] = await db + .select() + .from(schema.organizationInvite) + .where( + and( + eq(schema.organizationInvite.organizationId, organizationId), + eq(schema.organizationInvite.email, normalized) + ) + ) + .orderBy(desc(schema.organizationInvite.createdAt)) + .limit(1); + + return row ?? null; + } catch (error) { + console.error('getLatestOrganizationInviteRowByOrgAndEmail error:', error); + throw new Error( + `Failed to get latest organization invite: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} diff --git a/packages/db/src/queries/organization/organization.ts b/packages/db/src/queries/organization/organization.ts index 959e7bfd..0939b6b6 100644 --- a/packages/db/src/queries/organization/organization.ts +++ b/packages/db/src/queries/organization/organization.ts @@ -100,7 +100,7 @@ export const createOrganizationMember = async (data: TNewOrganizationmember, dbC * @returns Array of created members */ export const createOrganizationMembers = async (data: TNewOrganizationmember[]) => { - const members = await db.insert(schema.organizationmember).values(data).returning(); + const members = await db.insert(schema.organizationmember).values(data).onConflictDoNothing().returning(); return members; }; @@ -345,34 +345,93 @@ export const getOrganizationTeam = async (orgId: string) => { }; /** - * Gets organization audience (all organization members with student role) - * @param orgId Organization ID - * @returns Array of student profiles + * Gets organization audience (all organization members with student role). + * Includes invited members without a profile (LEFT JOIN profile). + * Row id is organizationmember.id; use profileId for profile-backed actions when present. */ export const getOrganizationAudience = async (orgId: string) => { const result = await db .select({ - id: schema.profile.id, + memberId: schema.organizationmember.id, + profileId: schema.profile.id, fullname: schema.profile.fullname, - email: schema.profile.email, + email: sql`COALESCE(${schema.profile.email}, ${schema.organizationmember.email})`.as('email'), avatarUrl: schema.profile.avatarUrl, - createdAt: schema.profile.createdAt + profileCreatedAt: schema.profile.createdAt, + memberCreatedAt: schema.organizationmember.createdAt }) .from(schema.organizationmember) - .innerJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id)) + .leftJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id)) .where( and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.roleId, ROLE.STUDENT)) ); - return result.map((profile) => ({ - id: profile.id, - name: profile.fullname, - email: profile.email || '', - avatarUrl: profile.avatarUrl || '', - createdAt: profile.createdAt ? new Date(profile.createdAt).toDateString() : '' - })); + return result.map((row) => { + const email = row.email?.trim() ?? ''; + const name = row.fullname?.trim() || (email.includes('@') ? email.split('@')[0] : email) || ''; + const createdAtRaw = row.profileId ? row.profileCreatedAt : row.memberCreatedAt; + const createdAt = createdAtRaw ? new Date(createdAtRaw).toDateString() : ''; + + return { + id: row.memberId, + profileId: row.profileId ?? null, + name, + email, + avatarUrl: row.avatarUrl || '', + createdAt + }; + }); }; +/** + * Student org member matched by profile email or organizationmember.email (for audience invite actions). + */ +export async function getStudentOrganizationMemberByOrgAndEmail( + orgId: string, + email: string +): Promise<{ id: number; profileId: string | null; email: string } | null> { + const normalized = email.toLowerCase().trim(); + if (!normalized) { + return null; + } + + try { + const [row] = await db + .select({ + id: schema.organizationmember.id, + profileId: schema.organizationmember.profileId, + displayEmail: sql`COALESCE(${schema.profile.email}, ${schema.organizationmember.email})`.as( + 'displayEmail' + ) + }) + .from(schema.organizationmember) + .leftJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id)) + .where( + and( + eq(schema.organizationmember.organizationId, orgId), + eq(schema.organizationmember.roleId, ROLE.STUDENT), + or(eq(schema.organizationmember.email, normalized), eq(schema.profile.email, normalized)) + ) + ) + .limit(1); + + if (!row) { + return null; + } + + return { + id: row.id, + profileId: row.profileId, + email: (row.displayEmail ?? normalized).trim() + }; + } catch (error) { + console.error('getStudentOrganizationMemberByOrgAndEmail error:', error); + throw new Error( + `Failed to get organization student member: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + /** * Gets organizations with optional filters * @param filters Filter options (siteName, customDomain, isCustomDomainVerified) @@ -687,3 +746,33 @@ export const getActiveOrganizationPlan = async (orgId: string) => { return plan ?? null; }; + +/** + * Gets organization members by profile IDs + * @param orgId Organization ID + * @param profileIds Array of profile IDs to look up + * @returns Array of { profileId, email } for matching members + */ +export async function getOrgMembersByProfileIds(orgId: string, profileIds: string[]) { + try { + if (profileIds.length === 0) return []; + + return db + .select({ + profileId: schema.organizationmember.profileId, + email: schema.organizationmember.email + }) + .from(schema.organizationmember) + .where( + and( + eq(schema.organizationmember.organizationId, orgId), + inArray(schema.organizationmember.profileId, profileIds) + ) + ); + } catch (error) { + console.error('getOrgMembersByProfileIds error:', error); + throw new Error( + `Failed to get org members by profile IDs: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 3be8836a..21babdd1 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1475,7 +1475,10 @@ export const organizationmember = pgTable( index('idx_organizationmember_profile_org').on(table.profileId, table.organizationId), uniqueIndex('organizationmember_org_profile_unique') .on(table.organizationId, table.profileId) - .where(sql`${table.profileId} IS NOT NULL`) + .where(sql`${table.profileId} IS NOT NULL`), + uniqueIndex('organizationmember_org_email_unique') + .on(table.organizationId, table.email) + .where(sql`${table.email} IS NOT NULL`) ] ); diff --git a/packages/email/src/emails/index.ts b/packages/email/src/emails/index.ts index d555681f..f0e17668 100644 --- a/packages/email/src/emails/index.ts +++ b/packages/email/src/emails/index.ts @@ -18,6 +18,7 @@ export * from './teacher-student-joined'; export * from './teacher-student-buy-request'; export * from './student-prove-payment'; export * from './teacher-course-welcome'; +export * from './student-org-invite'; import type { newsfeedCommentEmail, newsfeedPostEmail } from './newsfeed'; @@ -28,6 +29,7 @@ import type { onPasswordResetEmail } from './on-password-reset'; import type { studentCourseInviteEmail } from './student-course-invite'; import type { studentCourseWelcomeEmail } from './student-course-welcome'; import type { studentProvePaymentEmail } from './student-prove-payment'; +import type { studentOrgInviteEmail } from './student-org-invite'; import type { teacherCourseWelcomeEmail } from './teacher-course-welcome'; import type { teacherStudentBuyRequestEmail } from './teacher-student-buy-request'; import type { teacherStudentJoinedEmail } from './teacher-student-joined'; @@ -54,4 +56,5 @@ export type EmailSchemas = { teacherStudentBuyRequest: typeof teacherStudentBuyRequestEmail.template.schema; studentProvePayment: typeof studentProvePaymentEmail.template.schema; teacherCourseWelcome: typeof teacherCourseWelcomeEmail.template.schema; + studentOrgInvite: typeof studentOrgInviteEmail.template.schema; }; diff --git a/packages/email/src/emails/student-org-invite.ts b/packages/email/src/emails/student-org-invite.ts new file mode 100644 index 00000000..ddd84d0d --- /dev/null +++ b/packages/email/src/emails/student-org-invite.ts @@ -0,0 +1,33 @@ +import * as z from 'zod'; + +import { defineEmail } from '../send'; +import { getDefaultTemplate } from '../templates'; + +export const studentOrgInviteEmail = defineEmail({ + id: 'studentOrgInvite', + subject: 'You have been invited to join as a student', + schema: z.object({ + email: z.string().email(), + orgName: z.string().min(1), + inviteLink: z.url(), + expiresAt: z.string().min(1), + courseNames: z.string().optional() + }), + render: (fields) => { + const courseLine = fields.courseNames + ? `

You have been given access to: ${fields.courseNames}.

` + : ''; + + const content = ` +

Hi there,

+

You have been invited to join ${fields.orgName} as a student.

+ ${courseLine} +

This invite expires on ${fields.expiresAt} (UTC).

+ + `; + + return getDefaultTemplate(content); + } +}); diff --git a/packages/email/src/utils/constants.ts b/packages/email/src/utils/constants.ts index b39149ef..8b53a82c 100644 --- a/packages/email/src/utils/constants.ts +++ b/packages/email/src/utils/constants.ts @@ -2,18 +2,19 @@ import { env } from '../config/env'; export const EMAIL_IDS = [ 'forgotPassword', - 'welcome', - 'verifyEmail', - 'onPasswordReset', 'inviteTeacher', - 'newsfeedPost', 'newsfeedComment', - 'studentCourseWelcome', + 'newsfeedPost', + 'onPasswordReset', 'studentCourseInvite', - 'teacherStudentJoined', - 'teacherStudentBuyRequest', + 'studentCourseWelcome', + 'studentOrgInvite', 'studentProvePayment', - 'teacherCourseWelcome' + 'teacherCourseWelcome', + 'teacherStudentBuyRequest', + 'teacherStudentJoined', + 'verifyEmail', + 'welcome' ] as const; const DEFAULT_EMAIL_FROM = '"Best from ClassroomIO" '; diff --git a/packages/question-types/src/exercise-types.ts b/packages/question-types/src/exercise-types.ts index 1e6727d5..3d7780e2 100644 --- a/packages/question-types/src/exercise-types.ts +++ b/packages/question-types/src/exercise-types.ts @@ -3,13 +3,14 @@ import type { AnswerData } from './answer-data'; export type ExerciseQuestionTypeKey = QuestionTypeKey; -export type ExerciseRendererMode = 'edit' | 'take' | 'preview' | 'view' | 'submission'; +export type ExerciseRendererMode = 'edit' | 'take' | 'preview' | 'review' | 'view' | 'submission'; export type ExerciseQuestionLabelKey = | 'common.option_prefix' | 'common.remove' | 'common.add_option' | 'common.correct_badge' + | 'common.incorrect_badge' | 'common.not_set' | 'navigation.previous' | 'navigation.next' @@ -51,6 +52,8 @@ export type ExerciseQuestionLabelKey = | 'short_answer.edit.instructions_placeholder' | 'short_answer.take.placeholder' | 'short_answer.preview.helper' + | 'short_answer.review.your_answer_label' + | 'short_answer.review.accepted_label' | 'numeric.edit.correct_value_label' | 'numeric.edit.correct_value_info' | 'numeric.edit.tolerance_label' @@ -60,9 +63,13 @@ export type ExerciseQuestionLabelKey = | 'numeric.take.placeholder' | 'numeric.preview.correct_value_label' | 'numeric.preview.tolerance_label' + | 'numeric.review.your_answer_label' + | 'numeric.review.in_range' + | 'numeric.review.out_of_range' | 'fill_blank.edit.accepted_answers_placeholder' | 'fill_blank.take.placeholder' | 'fill_blank.preview.accepted_answers_label' + | 'fill_blank.review.your_answer_label' | 'file_upload.edit.accepted_types_placeholder' | 'file_upload.edit.max_size_placeholder' | 'file_upload.take.selected_file_label' @@ -164,6 +171,8 @@ export interface ExerciseRendererDefinition { take: TRenderer; preview: TRenderer; submission: TRenderer; + /** Read-only submission replay with correctness; defaults to `take` when omitted. */ + review?: TRenderer; } export interface ExerciseQuestionRendererProps { diff --git a/packages/question-types/src/renderer-registry.ts b/packages/question-types/src/renderer-registry.ts index e55ae502..8a48269d 100644 --- a/packages/question-types/src/renderer-registry.ts +++ b/packages/question-types/src/renderer-registry.ts @@ -1,4 +1,4 @@ -import type { ExerciseRendererDefinition, ExerciseRendererMode, ExerciseQuestionTypeKey } from './exercise-types'; +import type { ExerciseQuestionTypeKey, ExerciseRendererDefinition, ExerciseRendererMode } from './exercise-types'; export type ExerciseRendererRegistry = Partial< Record> @@ -18,6 +18,10 @@ export function getRendererForMode( mode: ExerciseRendererMode, fallback: ExerciseRendererDefinition ): TRenderer { + const rendererDefinition = getRendererDefinition(registry, questionType, fallback); const resolvedMode = mode === 'view' ? 'take' : mode; - return getRendererDefinition(registry, questionType, fallback)[resolvedMode]; + if (resolvedMode === 'review') { + return (rendererDefinition.review ?? rendererDefinition.take) as TRenderer; + } + return rendererDefinition[resolvedMode] as TRenderer; } diff --git a/packages/question-types/tests/renderer-registry.test.ts b/packages/question-types/tests/renderer-registry.test.ts index 9d18a517..58b8c626 100644 --- a/packages/question-types/tests/renderer-registry.test.ts +++ b/packages/question-types/tests/renderer-registry.test.ts @@ -55,6 +55,21 @@ describe('renderer registry helpers', () => { expect(getRendererForMode(REGISTRY, QUESTION_TYPE_KEY.ORDERING, 'view', FALLBACK_RENDERER)).toBe('ordering-take'); }); + it("resolves 'review' mode to 'take' when review is omitted", () => { + expect(getRendererForMode(REGISTRY, QUESTION_TYPE_KEY.RADIO, 'review', FALLBACK_RENDERER)).toBe('radio-take'); + }); + + it("resolves 'review' mode using optional review slot when provided", () => { + const withReview: typeof REGISTRY = { + ...REGISTRY, + [QUESTION_TYPE_KEY.RADIO]: { + ...REGISTRY[QUESTION_TYPE_KEY.RADIO]!, + review: 'radio-review' + } + }; + expect(getRendererForMode(withReview, QUESTION_TYPE_KEY.RADIO, 'review', FALLBACK_RENDERER)).toBe('radio-review'); + }); + it('returns fallback renderer mode when type is unregistered', () => { expect(getRendererForMode(REGISTRY, QUESTION_TYPE_KEY.LINK, 'edit', FALLBACK_RENDERER)).toBe('fallback-edit'); expect(getRendererForMode(REGISTRY, QUESTION_TYPE_KEY.LINK, 'take', FALLBACK_RENDERER)).toBe('fallback-take'); diff --git a/packages/storybook/src/molecules/exercise-question/exercise-question.stories.svelte b/packages/storybook/src/molecules/exercise-question/exercise-question.stories.svelte index f3149060..59e32063 100644 --- a/packages/storybook/src/molecules/exercise-question/exercise-question.stories.svelte +++ b/packages/storybook/src/molecules/exercise-question/exercise-question.stories.svelte @@ -29,19 +29,31 @@ {#snippet template()} - + {/snippet} {#snippet template()} - + {/snippet} {#snippet template()} - + {/snippet} @@ -62,13 +74,21 @@ > Hidden – still in development. Not available in the exercise editor.
- + {/snippet} {#snippet template()} - + {/snippet} @@ -89,42 +109,70 @@ > Hidden – still in development. Not available in the exercise editor.
- + {/snippet} {#snippet template()} - + {/snippet} {#snippet template()} - + {/snippet} {#snippet template()} - + {/snippet} {#snippet template()} - + {/snippet} {#snippet template()} - + {/snippet} {#snippet template()} - + {/snippet} diff --git a/packages/storybook/src/molecules/exercise-question/question-fixtures.ts b/packages/storybook/src/molecules/exercise-question/question-fixtures.ts index 51ba249b..2a71a37f 100644 --- a/packages/storybook/src/molecules/exercise-question/question-fixtures.ts +++ b/packages/storybook/src/molecules/exercise-question/question-fixtures.ts @@ -1,6 +1,9 @@ export interface QuestionStoryFixture { question: Record; + /** Correct answer (`AnswerData` or legacy shapes supported by renderers). */ answer?: unknown; + /** Incorrect answer for Review Mode demos in Storybook (`AnswerData`). */ + wrongAnswer?: unknown; } const OPTION_IMAGE_A = 'https://brand.cdn.clsrio.com/classroomio-lms-courses.png'; @@ -33,7 +36,8 @@ export const RADIO_FIXTURE: QuestionStoryFixture = { { id: 1003, label: 'Ruby', value: 'ruby' } ] }, - answer: 1001 + answer: { type: 'RADIO', optionId: 1001 }, + wrongAnswer: { type: 'RADIO', optionId: 1002 } }; export const CHECKBOX_FIXTURE: QuestionStoryFixture = { @@ -63,7 +67,8 @@ export const CHECKBOX_FIXTURE: QuestionStoryFixture = { } ] }, - answer: ['2001', '2003'] + answer: { type: 'CHECKBOX', optionIds: [2001, 2003] }, + wrongAnswer: { type: 'CHECKBOX', optionIds: [2002] } }; export const TEXTAREA_FIXTURE: QuestionStoryFixture = { @@ -93,7 +98,8 @@ export const TRUE_FALSE_FIXTURE: QuestionStoryFixture = { correctValue: true } }, - answer: true + answer: { type: 'TRUE_FALSE', value: true }, + wrongAnswer: { type: 'TRUE_FALSE', value: false } }; export const SHORT_ANSWER_FIXTURE: QuestionStoryFixture = { @@ -103,10 +109,12 @@ export const SHORT_ANSWER_FIXTURE: QuestionStoryFixture = { title: 'Name one strategy for reducing flaky frontend tests. [SHORT_ANSWER]', questionType: 'SHORT_ANSWER', settings: { - instructions: 'Answer in one sentence.' + instructions: 'Answer in one sentence.', + acceptedAnswers: 'use deterministic test fixtures' } }, - answer: 'Use deterministic test fixtures' + answer: { type: 'SHORT_ANSWER', text: 'Use deterministic test fixtures' }, + wrongAnswer: { type: 'SHORT_ANSWER', text: 'Guess randomly every time' } }; export const NUMERIC_FIXTURE: QuestionStoryFixture = { @@ -117,7 +125,8 @@ export const NUMERIC_FIXTURE: QuestionStoryFixture = { questionType: 'NUMERIC', settings: { correctValue: 90, tolerance: 0 } }, - answer: 90 + answer: { type: 'NUMERIC', value: 90 }, + wrongAnswer: { type: 'NUMERIC', value: 42 } }; export const FILL_BLANK_FIXTURE: QuestionStoryFixture = { @@ -128,7 +137,8 @@ export const FILL_BLANK_FIXTURE: QuestionStoryFixture = { questionType: 'FILL_BLANK', settings: { acceptedAnswers: 'API,endpoint' } }, - answer: ['API'] + answer: { type: 'FILL_BLANK', values: ['API'] }, + wrongAnswer: { type: 'FILL_BLANK', values: ['SOAP'] } }; export const FILE_UPLOAD_FIXTURE: QuestionStoryFixture = { @@ -180,7 +190,8 @@ export const ORDERING_FIXTURE: QuestionStoryFixture = { items: ['Design schema', 'Implement renderer', 'Ship to production'] } }, - answer: ['order-1', 'order-2', 'order-3'] + answer: { type: 'ORDERING', orderedValues: ['order-1', 'order-2', 'order-3'] }, + wrongAnswer: { type: 'ORDERING', orderedValues: ['order-3', 'order-1', 'order-2'] } }; export const HOTSPOT_FIXTURE: QuestionStoryFixture = { diff --git a/packages/storybook/src/molecules/exercise-question/question-labels.ts b/packages/storybook/src/molecules/exercise-question/question-labels.ts index 27f8bf7d..036aba46 100644 --- a/packages/storybook/src/molecules/exercise-question/question-labels.ts +++ b/packages/storybook/src/molecules/exercise-question/question-labels.ts @@ -9,7 +9,8 @@ export const QUESTION_LABELS = { 'common.option_prefix': 'Option', 'common.remove': 'Remove', 'common.add_option': 'Add option', - 'common.correct_badge': '(Correct)', + 'common.correct_badge': 'Correct', + 'common.incorrect_badge': 'Incorrect', 'common.not_set': 'Not set', 'navigation.previous': 'Previous', 'navigation.next': 'Next', @@ -51,6 +52,8 @@ export const QUESTION_LABELS = { 'short_answer.edit.instructions_placeholder': 'Optional instructions', 'short_answer.take.placeholder': 'Type your answer', 'short_answer.preview.helper': 'Free-text answer question.', + 'short_answer.review.your_answer_label': 'Submitted:', + 'short_answer.review.accepted_label': 'Accepted answers', 'numeric.edit.correct_value_label': 'Correct value', 'numeric.edit.correct_value_info': 'Expected numeric answer to mark as correct.', 'numeric.edit.tolerance_label': 'Tolerance', @@ -60,9 +63,13 @@ export const QUESTION_LABELS = { 'numeric.take.placeholder': 'Enter a number', 'numeric.preview.correct_value_label': 'Correct value', 'numeric.preview.tolerance_label': 'Tolerance', + 'numeric.review.your_answer_label': 'Submitted:', + 'numeric.review.in_range': 'Within tolerance', + 'numeric.review.out_of_range': 'Outside tolerance', 'fill_blank.edit.accepted_answers_placeholder': 'Accepted answers (comma separated)', 'fill_blank.take.placeholder': 'Enter answers separated by commas', 'fill_blank.preview.accepted_answers_label': 'Accepted answers', + 'fill_blank.review.your_answer_label': 'Submitted:', 'file_upload.edit.accepted_types_placeholder': 'Select accepted file formats', 'file_upload.edit.max_size_placeholder': 'Max size (MB)', 'file_upload.take.selected_file_label': 'Selected file', diff --git a/packages/storybook/src/molecules/exercise-question/question-type-modes.svelte b/packages/storybook/src/molecules/exercise-question/question-type-modes.svelte index faa8cf46..5dd17a18 100644 --- a/packages/storybook/src/molecules/exercise-question/question-type-modes.svelte +++ b/packages/storybook/src/molecules/exercise-question/question-type-modes.svelte @@ -5,6 +5,8 @@ interface Props { question: Record; answer?: unknown; + /** When set, renders an additional Review block with this incorrect `AnswerData`. */ + wrongAnswer?: unknown; } type StoryQuestion = { @@ -15,7 +17,7 @@ settings?: Record; }; - let { question, answer = null }: Props = $props(); + let { question, answer = null, wrongAnswer = undefined }: Props = $props(); function cloneQuestion(source: Record) { return JSON.parse(JSON.stringify(source)); @@ -178,6 +180,42 @@ />
+
+

Review Mode

+

+ Read-only submission replay with correctness (same as grading / finished exercise in the app). +

+ +
+ + {#if wrongAnswer !== undefined} +
+

Review Mode (incorrect)

+

+ Same review UI when the submitted answer does not match the correct solution. +

+ +
+ {/if} +

Submission Mode (12 respondents)

+ import { defineMeta } from '@storybook/addon-svelte-csf'; + import { MultiSelectList } from '@cio/ui'; + import { SvelteSet } from 'svelte/reactivity'; + import { FIELDS } from './fields'; + + const { Story } = defineMeta({ + title: 'Molecules/MultiSelectList', + component: MultiSelectList, + parameters: { + layout: 'centered', + controls: { + include: FIELDS + } + }, + tags: ['autodocs'] + }); + + const sampleItems = [ + { id: 'a', label: 'Introduction' }, + { id: 'b', label: 'Core concepts' }, + { id: 'c', label: 'Practice set' } + ]; + + + + + + {#snippet template()} +
+ selected.has(id)} + onToggle={toggle} + namePrefix="story-ms" + /> +

Selected: {[...selected].join(', ') || 'none'}

+
+ {/snippet} +
+ + + {#snippet template()} +
+ false} + onToggle={() => {}} + /> +
+ {/snippet} +
diff --git a/packages/ui/README.md b/packages/ui/README.md index 17e7f5a0..7450a155 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -64,6 +64,16 @@ Components in the `custom` directory come from various sources or are built on t - `custom/image-cropper/` - Custom image cropping component - `custom/checkbox-field/` - Field component built on top of base Checkbox +### Exercise question (`src/custom/exercise-question/`) + +Learner and author UIs for exercise questions (take, preview, review, submission, edit per question type). + +- **`renderers/shared/`** — Cross-type presentation pieces reused by multiple question families (for example `labeled-value-row.svelte`, `mcq-preview-option-row.svelte`, `submission-response-pie-chart.svelte`). Mode entry files stay thin; add new shared pieces here when two or more types need the same markup. +- **`renderers//`** — One folder per type (`radio`, `numeric`, …) containing `take.svelte`, `preview.svelte`, and optional `review.svelte`, `submission.svelte`, `edit.svelte`, plus type-specific fragments (for example `numeric-expected-fields.svelte`, `ordering-display.svelte`). +- **`renderers/option-image.svelte`**, **`renderers/submission-utils.ts`** — Existing shared helpers at the `renderers/` root; keep importing from there unless consolidating in a later pass. + +**Types with heavier or distinct UIs** (textarea editor, file upload, matching/hotspot authoring, link lists) intentionally keep separate `take` / `preview` implementations until a second consumer (for example a dedicated `review` mode) justifies extracting more shared fragments. + ### Hooks (`src/hooks/`) Reusable Svelte hooks are located in the `src/hooks/` directory. These are Svelte 5 runes-based utilities that can be used across components. diff --git a/packages/ui/src/base/page/page-header.svelte b/packages/ui/src/base/page/page-header.svelte index 3039e047..58613be1 100644 --- a/packages/ui/src/base/page/page-header.svelte +++ b/packages/ui/src/base/page/page-header.svelte @@ -19,7 +19,7 @@ class={cn('ui:flex ui:my-4 ui:py-2 ui:flex-col', isSticky && 'ui:sticky ui:top-13 ui:z-10 ui:bg-white', className)} {...restProps} > -
+
{@render children?.()}
diff --git a/packages/ui/src/custom/back-button/back-button.svelte b/packages/ui/src/custom/back-button/back-button.svelte new file mode 100644 index 00000000..a251d0d1 --- /dev/null +++ b/packages/ui/src/custom/back-button/back-button.svelte @@ -0,0 +1,20 @@ + + + diff --git a/packages/ui/src/custom/back-button/index.ts b/packages/ui/src/custom/back-button/index.ts new file mode 100644 index 00000000..a43f0aa0 --- /dev/null +++ b/packages/ui/src/custom/back-button/index.ts @@ -0,0 +1,4 @@ +import BackButton from './back-button.svelte'; + +export { BackButton }; +export { BackButton as default }; diff --git a/packages/ui/src/custom/checkbox-field/checkbox-field.svelte b/packages/ui/src/custom/checkbox-field/checkbox-field.svelte index 237ce14c..3b7acb04 100644 --- a/packages/ui/src/custom/checkbox-field/checkbox-field.svelte +++ b/packages/ui/src/custom/checkbox-field/checkbox-field.svelte @@ -12,6 +12,8 @@ disabled?: boolean; className?: string; onchange?: (e: Event) => void; + /** When set, the whole row handles the click (e.g. multi-select). Avoids nesting a native button inside the checkbox control. */ + onclick?: () => void; children?: import('svelte').Snippet; } @@ -24,25 +26,70 @@ disabled = false, className = '', onchange = () => {}, + onclick, children }: Props = $props(); + + const rowClass = $derived( + cn( + 'group ui:inline-flex ui:w-full ui:items-center ui:hover:bg-muted ui:rounded-md ui:p-2 ui:text-left', + disabled ? 'cursor-not-allowed' : 'cursor-pointer', + className + ) + ); + + function handleRowClick() { + if (disabled || onclick === undefined) return; + onclick(); + } + + function handleRowKeydown(event: KeyboardEvent) { + if (disabled || onclick === undefined) return; + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onclick(); + } + } -
- - {#if isEditable} +{#if onclick !== undefined} +
+ + {#if isEditable} + + + +
e.stopPropagation()}> + +
+ {:else} + {label} + {/if} + + {@render children?.()} +
+{:else if isEditable} +
+
- {:else} - {label} - {/if} - {@render children?.()} -
+ {@render children?.()} +
+{:else} + +{/if} diff --git a/packages/ui/src/custom/exercise-question/index.ts b/packages/ui/src/custom/exercise-question/index.ts index e6f30468..93960b43 100644 --- a/packages/ui/src/custom/exercise-question/index.ts +++ b/packages/ui/src/custom/exercise-question/index.ts @@ -40,6 +40,7 @@ export { default as CheckboxPreview } from './renderers/checkbox/preview.svelte' export { default as TextareaPreview } from './renderers/textarea/preview.svelte'; export { default as MatchingPreview } from './renderers/matching/preview.svelte'; export { default as OrderingPreview } from './renderers/ordering/preview.svelte'; +export { default as OrderingDisplay } from './renderers/ordering/ordering-display.svelte'; export { default as HotspotPreview } from './renderers/hotspot/preview.svelte'; export { default as TrueFalseSubmission } from './renderers/true-false/submission.svelte'; diff --git a/packages/ui/src/custom/exercise-question/renderer-contract.ts b/packages/ui/src/custom/exercise-question/renderer-contract.ts index fc50d30a..67d97148 100644 --- a/packages/ui/src/custom/exercise-question/renderer-contract.ts +++ b/packages/ui/src/custom/exercise-question/renderer-contract.ts @@ -61,6 +61,14 @@ import { default as ShortAnswerSubmission } from './renderers/short-answer/submi import { default as TextareaSubmission } from './renderers/textarea/submission.svelte'; import { default as TrueFalseSubmission } from './renderers/true-false/submission.svelte'; +import { default as RadioReview } from './renderers/radio/review.svelte'; +import { default as CheckboxReview } from './renderers/checkbox/review.svelte'; +import { default as NumericReview } from './renderers/numeric/review.svelte'; +import { default as ShortAnswerReview } from './renderers/short-answer/review.svelte'; +import { default as FillBlankReview } from './renderers/fill-blank/review.svelte'; +import { default as OrderingReview } from './renderers/ordering/review.svelte'; +import { default as TrueFalseReview } from './renderers/true-false/review.svelte'; + type SharedRendererComponent = Component; export const EXERCISE_QUESTION_RENDERER_CONTRACT: ExerciseRendererRegistry = { @@ -68,13 +76,15 @@ export const EXERCISE_QUESTION_RENDERER_CONTRACT: ExerciseRendererRegistry - import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types'; - import OptionImage from '../option-image.svelte'; + import type { ExerciseQuestionRendererProps } from '@cio/question-types'; import { getOptionImageUrl, hasOptionImages } from '../option-image-utils'; - import { cn } from '../../../../tools'; + import McqPreviewOptionRow from '../shared/mcq-preview-option-row.svelte'; let { question, labels }: ExerciseQuestionRendererProps = $props(); - const label = (key: Parameters[1], fallback = '') => - getExerciseQuestionLabel(labels, key, fallback); const optionsHaveImages = $derived(hasOptionImages(question.options)); @@ -14,22 +11,7 @@
    {#each question.options ?? [] as option, index} - {@const optionImageUrl = getOptionImageUrl(option)} -
  • - - -
    - {option.label || option.value || [label('common.option_prefix'), String(index + 1)].filter(Boolean).join(' ')} - {#if option.isCorrect} - {label('common.correct_badge')} - {/if} -
    -
  • + {/each}
diff --git a/packages/ui/src/custom/exercise-question/renderers/checkbox/review.svelte b/packages/ui/src/custom/exercise-question/renderers/checkbox/review.svelte new file mode 100644 index 00000000..57a63ad1 --- /dev/null +++ b/packages/ui/src/custom/exercise-question/renderers/checkbox/review.svelte @@ -0,0 +1,59 @@ + + +
+
+ {#each question.options ?? [] as option, index} + {@const optionId = `checkbox-review-${question.id ?? 'q'}-${index}`} + {@const optionImageUrl = getOptionImageUrl(option)} + {@const oid = getOptionNumericId(option)} + {@const isSelected = oid !== null && selectedIds.includes(oid)} + {@const isCorrect = option.isCorrect === true} + +
+ + +
+ + +
+
+ {/each} +
+
diff --git a/packages/ui/src/custom/exercise-question/renderers/fill-blank/review.svelte b/packages/ui/src/custom/exercise-question/renderers/fill-blank/review.svelte new file mode 100644 index 00000000..9b33510a --- /dev/null +++ b/packages/ui/src/custom/exercise-question/renderers/fill-blank/review.svelte @@ -0,0 +1,29 @@ + + +
+ +

+ {label('fill_blank.preview.accepted_answers_label')}: {acceptedRaw || label('common.not_set')} +

+
diff --git a/packages/ui/src/custom/exercise-question/renderers/numeric/numeric-expected-fields.svelte b/packages/ui/src/custom/exercise-question/renderers/numeric/numeric-expected-fields.svelte new file mode 100644 index 00000000..2040d046 --- /dev/null +++ b/packages/ui/src/custom/exercise-question/renderers/numeric/numeric-expected-fields.svelte @@ -0,0 +1,18 @@ + + +
+

+ {label('numeric.preview.correct_value_label')}: {correctValue ?? label('common.not_set')} +

+

{label('numeric.preview.tolerance_label')}: {tolerance ?? 0}

+
diff --git a/packages/ui/src/custom/exercise-question/renderers/numeric/preview.svelte b/packages/ui/src/custom/exercise-question/renderers/numeric/preview.svelte index ba667a7a..590d3a53 100644 --- a/packages/ui/src/custom/exercise-question/renderers/numeric/preview.svelte +++ b/packages/ui/src/custom/exercise-question/renderers/numeric/preview.svelte @@ -1,17 +1,8 @@ -
-

- {label('numeric.preview.correct_value_label')}: {correctValue ?? label('common.not_set')} -

-

{label('numeric.preview.tolerance_label')}: {tolerance ?? 0}

-
+ diff --git a/packages/ui/src/custom/exercise-question/renderers/numeric/review.svelte b/packages/ui/src/custom/exercise-question/renderers/numeric/review.svelte new file mode 100644 index 00000000..b3124e44 --- /dev/null +++ b/packages/ui/src/custom/exercise-question/renderers/numeric/review.svelte @@ -0,0 +1,39 @@ + + +
+ + {#if studentVal !== null && correct !== undefined && inRange !== null} +

+ {inRange ? label('numeric.review.in_range') : label('numeric.review.out_of_range')} +

+ {/if} + +
diff --git a/packages/ui/src/custom/exercise-question/renderers/option-review-utils.ts b/packages/ui/src/custom/exercise-question/renderers/option-review-utils.ts new file mode 100644 index 00000000..75319e69 --- /dev/null +++ b/packages/ui/src/custom/exercise-question/renderers/option-review-utils.ts @@ -0,0 +1,22 @@ +/** + * Shared helpers for MCQ-style review rows (radio, checkbox). + */ + +export function getOptionNumericId(option: { id?: number | string }): number | null { + if (option.id === undefined || option.id === null) return null; + const n = Number(option.id); + return Number.isNaN(n) ? null : n; +} + +export function getMcqReviewRowBorderClass(isSelected: boolean, isCorrect: boolean): string { + if (isSelected && isCorrect) { + return 'ui:border-emerald-500 ui:bg-emerald-50/40 dark:ui:bg-emerald-950/30'; + } + if (isSelected && !isCorrect) { + return 'ui:border-red-500 ui:bg-red-50/40 dark:ui:bg-red-950/30'; + } + if (!isSelected && isCorrect) { + return 'ui:border-emerald-300 ui:bg-emerald-50/20 dark:ui:bg-emerald-950/20'; + } + return 'ui:border-muted'; +} diff --git a/packages/ui/src/custom/exercise-question/renderers/ordering/ordering-display.svelte b/packages/ui/src/custom/exercise-question/renderers/ordering/ordering-display.svelte new file mode 100644 index 00000000..4690fe08 --- /dev/null +++ b/packages/ui/src/custom/exercise-question/renderers/ordering/ordering-display.svelte @@ -0,0 +1,53 @@ + + +
+ {#if hasSubmittedAnswer} +
+

{label('ordering.preview.submitted_label')}

+ {#each submittedOrder as item, index (item.id)} +
+ {index + 1} + {item.label} +
+ {/each} +
+ +

+ {isSubmittedOrderCorrect ? label('ordering.preview.matches') : label('ordering.preview.differs')} +

+ {/if} + +
+

{label('ordering.preview.expected_label')}

+ {#each expectedOrder as item, index (item.id)} +
+ {index + 1} + {item.label} +
+ {/each} +
+
diff --git a/packages/ui/src/custom/exercise-question/renderers/ordering/preview.svelte b/packages/ui/src/custom/exercise-question/renderers/ordering/preview.svelte index 4690fe08..e28bb4ca 100644 --- a/packages/ui/src/custom/exercise-question/renderers/ordering/preview.svelte +++ b/packages/ui/src/custom/exercise-question/renderers/ordering/preview.svelte @@ -1,53 +1,8 @@ -
- {#if hasSubmittedAnswer} -
-

{label('ordering.preview.submitted_label')}

- {#each submittedOrder as item, index (item.id)} -
- {index + 1} - {item.label} -
- {/each} -
- -

- {isSubmittedOrderCorrect ? label('ordering.preview.matches') : label('ordering.preview.differs')} -

- {/if} - -
-

{label('ordering.preview.expected_label')}

- {#each expectedOrder as item, index (item.id)} -
- {index + 1} - {item.label} -
- {/each} -
-
+ diff --git a/packages/ui/src/custom/exercise-question/renderers/ordering/review.svelte b/packages/ui/src/custom/exercise-question/renderers/ordering/review.svelte new file mode 100644 index 00000000..f44b3378 --- /dev/null +++ b/packages/ui/src/custom/exercise-question/renderers/ordering/review.svelte @@ -0,0 +1,8 @@ + + + diff --git a/packages/ui/src/custom/exercise-question/renderers/radio/preview.svelte b/packages/ui/src/custom/exercise-question/renderers/radio/preview.svelte index 3cb2b04b..574b25db 100644 --- a/packages/ui/src/custom/exercise-question/renderers/radio/preview.svelte +++ b/packages/ui/src/custom/exercise-question/renderers/radio/preview.svelte @@ -1,12 +1,9 @@ @@ -14,22 +11,7 @@
    {#each question.options ?? [] as option, index} - {@const optionImageUrl = getOptionImageUrl(option)} -
  • - - -
    - {option.label || option.value || [label('common.option_prefix'), String(index + 1)].filter(Boolean).join(' ')} - {#if option.isCorrect} - {label('common.correct_badge')} - {/if} -
    -
  • + {/each}
diff --git a/packages/ui/src/custom/exercise-question/renderers/radio/review.svelte b/packages/ui/src/custom/exercise-question/renderers/radio/review.svelte new file mode 100644 index 00000000..0de85bec --- /dev/null +++ b/packages/ui/src/custom/exercise-question/renderers/radio/review.svelte @@ -0,0 +1,60 @@ + + +
+
+ {#each question.options ?? [] as option, index} + {@const optionImageUrl = getOptionImageUrl(option)} + {@const oid = getOptionNumericId(option)} + {@const isSelected = oid !== null && selectedOptionId !== null && selectedOptionId === oid} + {@const isCorrect = option.isCorrect === true} + +
+ + +
+ +
+ {option.label || + option.value || + [label('common.option_prefix'), String(index + 1)].filter(Boolean).join(' ')} + +
+
+
+ {/each} +
+
diff --git a/packages/ui/src/custom/exercise-question/renderers/radio/submission.svelte b/packages/ui/src/custom/exercise-question/renderers/radio/submission.svelte index f5619494..e7975cdc 100644 --- a/packages/ui/src/custom/exercise-question/renderers/radio/submission.svelte +++ b/packages/ui/src/custom/exercise-question/renderers/radio/submission.svelte @@ -1,7 +1,8 @@ -{#if chartData.length > 0} - - - ({ label: d.label, color: d.color, value: d.responses }))} /> - -{:else} -

- {getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')} -

-{/if} + diff --git a/packages/ui/src/custom/exercise-question/renderers/shared/labeled-value-row.svelte b/packages/ui/src/custom/exercise-question/renderers/shared/labeled-value-row.svelte new file mode 100644 index 00000000..34413f1f --- /dev/null +++ b/packages/ui/src/custom/exercise-question/renderers/shared/labeled-value-row.svelte @@ -0,0 +1,20 @@ + + +

+ {label} + {' '} + {value} +

diff --git a/packages/ui/src/custom/exercise-question/renderers/shared/mcq-preview-option-row.svelte b/packages/ui/src/custom/exercise-question/renderers/shared/mcq-preview-option-row.svelte new file mode 100644 index 00000000..18dfbf37 --- /dev/null +++ b/packages/ui/src/custom/exercise-question/renderers/shared/mcq-preview-option-row.svelte @@ -0,0 +1,33 @@ + + +
  • + + +
    + {option.label || option.value || [label('common.option_prefix'), String(index + 1)].filter(Boolean).join(' ')} + +
    +
  • diff --git a/packages/ui/src/custom/exercise-question/renderers/shared/option-correctness-badges.svelte b/packages/ui/src/custom/exercise-question/renderers/shared/option-correctness-badges.svelte new file mode 100644 index 00000000..ff746e76 --- /dev/null +++ b/packages/ui/src/custom/exercise-question/renderers/shared/option-correctness-badges.svelte @@ -0,0 +1,22 @@ + + +{#if showCorrect} + {label('common.correct_badge')} +{/if} +{#if showIncorrect} + {label('common.incorrect_badge')} +{/if} diff --git a/packages/ui/src/custom/exercise-question/renderers/shared/submission-response-pie-chart.svelte b/packages/ui/src/custom/exercise-question/renderers/shared/submission-response-pie-chart.svelte new file mode 100644 index 00000000..c889626b --- /dev/null +++ b/packages/ui/src/custom/exercise-question/renderers/shared/submission-response-pie-chart.svelte @@ -0,0 +1,36 @@ + + +{#if chartData.length > 0} + + + ({ + label: d.label, + color: d.color ?? CHART_COLORS[0], + value: d.responses + }))} + /> + +{:else} +

    {emptyLabel}

    +{/if} diff --git a/packages/ui/src/custom/exercise-question/renderers/short-answer/review.svelte b/packages/ui/src/custom/exercise-question/renderers/short-answer/review.svelte new file mode 100644 index 00000000..03bc3563 --- /dev/null +++ b/packages/ui/src/custom/exercise-question/renderers/short-answer/review.svelte @@ -0,0 +1,45 @@ + + +
    + + {#if acceptedList.length > 0} +

    + {label('short_answer.review.accepted_label')}: {acceptedList.join(', ')} +

    + {:else} +

    {label('short_answer.preview.helper')}

    + {/if} +
    diff --git a/packages/ui/src/custom/exercise-question/renderers/true-false/preview.svelte b/packages/ui/src/custom/exercise-question/renderers/true-false/preview.svelte index f4645015..19d8340a 100644 --- a/packages/ui/src/custom/exercise-question/renderers/true-false/preview.svelte +++ b/packages/ui/src/custom/exercise-question/renderers/true-false/preview.svelte @@ -1,14 +1,15 @@ diff --git a/packages/ui/src/custom/exercise-question/renderers/true-false/review.svelte b/packages/ui/src/custom/exercise-question/renderers/true-false/review.svelte new file mode 100644 index 00000000..fb797c0a --- /dev/null +++ b/packages/ui/src/custom/exercise-question/renderers/true-false/review.svelte @@ -0,0 +1,21 @@ + + + diff --git a/packages/ui/src/custom/exercise-question/renderers/true-false/true-false-correct.ts b/packages/ui/src/custom/exercise-question/renderers/true-false/true-false-correct.ts new file mode 100644 index 00000000..72f289ad --- /dev/null +++ b/packages/ui/src/custom/exercise-question/renderers/true-false/true-false-correct.ts @@ -0,0 +1,6 @@ +/** + * Matches preview/review: undefined `correctValue` defaults to true. + */ +export function getTrueFalseCorrectIsTrue(settings: Record | undefined): boolean { + return (settings?.correctValue as boolean | undefined) !== false; +} diff --git a/packages/ui/src/custom/exercise-question/renderers/true-false/true-false-review-toggle.svelte b/packages/ui/src/custom/exercise-question/renderers/true-false/true-false-review-toggle.svelte new file mode 100644 index 00000000..14519364 --- /dev/null +++ b/packages/ui/src/custom/exercise-question/renderers/true-false/true-false-review-toggle.svelte @@ -0,0 +1,44 @@ + + +
    + + + {label('true_false.true_label')} + + + + {label('true_false.false_label')} + + + +
    diff --git a/packages/ui/src/custom/multi-select-list/index.ts b/packages/ui/src/custom/multi-select-list/index.ts new file mode 100644 index 00000000..1a24500a --- /dev/null +++ b/packages/ui/src/custom/multi-select-list/index.ts @@ -0,0 +1,5 @@ +import MultiSelectList from './multi-select-list.svelte'; + +export { MultiSelectList }; +export { MultiSelectList as default }; +export type { MultiSelectListItem } from './types'; diff --git a/packages/ui/src/custom/multi-select-list/multi-select-list.svelte b/packages/ui/src/custom/multi-select-list/multi-select-list.svelte new file mode 100644 index 00000000..29a20a48 --- /dev/null +++ b/packages/ui/src/custom/multi-select-list/multi-select-list.svelte @@ -0,0 +1,49 @@ + + +
    +

    + {heading} ({items.length}) +

    + {#if items.length === 0} +

    {emptyMessage}

    + {:else} +
    + {#each items as item (item.id)} + onToggle(item.id)} + /> + {/each} +
    + {/if} +
    diff --git a/packages/ui/src/custom/multi-select-list/types.ts b/packages/ui/src/custom/multi-select-list/types.ts new file mode 100644 index 00000000..184f56fa --- /dev/null +++ b/packages/ui/src/custom/multi-select-list/types.ts @@ -0,0 +1 @@ +export type MultiSelectListItem = { id: string; label: string }; diff --git a/packages/ui/src/custom/simple-logo-nav/simple-logo-nav.svelte b/packages/ui/src/custom/simple-logo-nav/simple-logo-nav.svelte index 9dd1a7fd..e18bbac0 100644 --- a/packages/ui/src/custom/simple-logo-nav/simple-logo-nav.svelte +++ b/packages/ui/src/custom/simple-logo-nav/simple-logo-nav.svelte @@ -7,7 +7,7 @@ rel="noopener noreferrer" class="ui:flex ui:items-center ui:justify-center ui:gap-2" > - ClassroomIO Logo + ClassroomIO Logo ClassroomIO diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 1af0ae84..7b206647 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -66,4 +66,6 @@ export { RadioOptionCard, RadioOptionCardGroup, type RadioOptionCardOption } fro export { DocumentCard } from './custom/document-card'; export * as ExerciseQuestion from './custom/exercise-question'; export * as MediaPlayer from './custom/media-player'; +export { MultiSelectList, type MultiSelectListItem } from './custom/multi-select-list'; export * as YoutubeLinkForm from './custom/youtube-link-form'; +export { BackButton } from './custom/back-button'; diff --git a/packages/ui/src/output.css b/packages/ui/src/output.css index 44062191..c9de6f98 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-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-50:oklch(97.1% .013 17.38);--ui-color-red-500:oklch(63.7% .237 25.331);--ui-color-red-600:oklch(57.7% .245 27.325);--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-emerald-50:oklch(97.9% .021 166.113);--ui-color-emerald-300:oklch(84.5% .143 164.978);--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-fit\!{height:fit-content!important}.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-start\!{justify-content:flex-start!important}.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-300{border-color:var(--ui-color-emerald-300)}.ui\:border-emerald-400{border-color:var(--ui-color-emerald-400)}.ui\:border-emerald-500{border-color:var(--ui-color-emerald-500)}.ui\:border-input{border-color:var(--input)}.ui\:border-muted{border-color:var(--muted)}.ui\:border-muted-foreground{border-color:var(--muted-foreground)}.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\/20{background-color:var(--ui-color-emerald-50)}@supports (color:color-mix(in lab, red, red)){.ui\:bg-emerald-50\/20{background-color:color-mix(in oklab, var(--ui-color-emerald-50) 20%, transparent)}}.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-50\/40{background-color:var(--ui-color-emerald-50)}@supports (color:color-mix(in lab, red, red)){.ui\:bg-emerald-50\/40{background-color:color-mix(in oklab, var(--ui-color-emerald-50) 40%, 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-50\/40{background-color:var(--ui-color-red-50)}@supports (color:color-mix(in lab, red, red)){.ui\:bg-red-50\/40{background-color:color-mix(in oklab, var(--ui-color-red-50) 40%, 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-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-600{color:var(--ui-color-red-600)}.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\:flex-row{flex-direction:row}.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/utils/src/validation/organization/audience.ts b/packages/utils/src/validation/organization/audience.ts new file mode 100644 index 00000000..afc3bc0b --- /dev/null +++ b/packages/utils/src/validation/organization/audience.ts @@ -0,0 +1,24 @@ +import * as z from 'zod'; + +export const ZImportAudienceMembers = z.object({ + recipientCsv: z.string().max(25000), + courseIds: z.array(z.string().uuid()).optional(), + allCourses: z.boolean().optional().default(false), + sendEmail: z.boolean().default(true) +}); + +export type TImportAudienceMembers = z.infer; + +export const ZAssignAudienceCourses = z.object({ + profileIds: z.array(z.uuid()).min(1).max(500), + courseIds: z.array(z.uuid()).min(1), + sendEmail: z.boolean().default(true) +}); + +export type TAssignAudienceCourses = z.infer; + +export const ZAudienceInviteByEmail = z.object({ + email: z.email() +}); + +export type TAudienceInviteByEmail = z.infer; diff --git a/packages/utils/src/validation/organization/index.ts b/packages/utils/src/validation/organization/index.ts index 4cebf65e..bddb489d 100644 --- a/packages/utils/src/validation/organization/index.ts +++ b/packages/utils/src/validation/organization/index.ts @@ -1,3 +1,4 @@ +export * from './audience'; export * from './organization'; export * from './quiz'; export * from './exercise'; diff --git a/prd/forms-product/README.md b/prd/forms-product/README.md new file mode 100644 index 00000000..252217a9 --- /dev/null +++ b/prd/forms-product/README.md @@ -0,0 +1,593 @@ +# Forms Product PRD + +## Status + +- Draft + +## Date + +- March 22, 2026 + +## Implementation Plan + +- See [implementation-plan.md](./implementation-plan.md) for phased delivery, ticket breakdown, and recommended sprint sequencing. + +## Purpose + +Build a standalone Forms product for schools and education teams to collect structured responses such as student feedback, course reviews, enrollment requests, parent intake, event registrations, and internal staff forms, while reusing as much of the existing exercise question engine as possible. + +## Executive Summary + +ClassroomIO already has a strong reusable question engine: + +- `@cio/question-types` already centralizes question contracts, answer serialization, renderer contracts, and form-state helpers, and explicitly calls out reuse for future products such as forms/assessments. +- `@cio/ui/custom/exercise-question` already supports question rendering in `edit`, `take`, `preview`, `review`, and `submission` modes across 12 question types. +- The current exercise stack already handles templates, file upload answers, submission storage, and submission summary visualizations. + +The main constraint is that the current product is still deeply shaped around courses, lessons, grading, and enrolled students. That makes it a poor fit for Google Forms / Typeform style workflows if we try to ship Forms by directly reusing the `exercise` and `submission` entities as-is. + +### Recommendation + +Build Forms as a new top-level product domain, but reuse the existing question engine and renderer stack aggressively. + +### Reuse Estimate + +- **High reuse (60-70%)**: question types, renderer contracts, answer payloads, file upload answer model, template structure, summary visualization patterns. +- **Medium reuse (20-30%)**: builder UI shell, preview flow, submission list components, analytics presentation patterns. +- **Low reuse (0-15%)**: database entities, API routes, auth model, response ownership, publishing/sharing model, grading workflow. + +## Problem Statement + +Today, exercises can technically ask many of the same questions that a form would ask, but they are optimized for assessment, not response collection. + +That creates hard product limits for schools and organizations that want to collect: + +1. Student feedback after a course or session. +2. School review forms and satisfaction surveys. +3. Enrollment and application forms. +4. Parent intake and consent forms. +5. Staff requests and internal administrative forms. +6. Event registration forms. + +The current exercise product does not map cleanly to these workflows because: + +1. Exercises are attached to a `course`, often also a `lesson` and `section`. +2. Exercise access is enforced through course membership middleware. +3. Submissions are tied to `groupmember` and carry grading state, total score, reviewer, and feedback fields. +4. The UX assumes students, instructors, grading, due dates, and completion rather than respondents, publish/share, response settings, and collection workflows. +5. There is no public share-link, embeddable form, thank-you page, response export, response caps, or form lifecycle model. + +## Product Thesis + +If ClassroomIO turns the current exercise question engine into a standalone Forms product, the platform gets a second major content primitive: + +- `Learning content` for teaching and assessment. +- `Response collection` for school operations and feedback. + +This expands ClassroomIO from LMS-only workflows into school operations workflows without throwing away the investment already made in the exercise system. + +## Current-State Audit + +| Area | Current State | Reuse Value | Forms Gap | +| --- | --- | --- | --- | +| `@cio/question-types` | Shared package for question keys, answer payloads, render contracts, serializer, form state | High | Naming is exercise-centric, but architecture is already reusable | +| `@cio/ui/custom/exercise-question` | Full renderers for `edit`, `take`, `preview`, `review`, `submission` | High | Needs forms shell, branding, page layout, respondent UX | +| Question type coverage | 12 types: radio, checkbox, textarea, true/false, short answer, numeric, fill blank, file upload, matching, ordering, hotspot, link | High | Some types are assessment-first; forms also need presentational blocks and contact fields | +| Exercise builder UI | Existing authoring flow with question reorder, media, templates, preview | Medium | Assumes course exercise editing and points/correctness | +| Submission summary UI | Already shows aggregated response charts and individual response replay | Medium | Needs forms response inbox, filters, export, respondent metadata | +| `exercise` table | Has `courseId`, `lessonId`, `sectionId`, `isUnlocked`, `dueBy` | Low | Needs standalone `form` ownership and publish lifecycle | +| `question` table | Bound to `exerciseId` only | Low | Needs form-owned question model or generalized questionnaire ownership | +| `submission` table | Has `exerciseId`, `courseId`, `submittedBy`, grading fields, feedback | Low | Needs form response model without grading assumptions | +| `question_answer` table | Stores `answerData` JSON and file-upload compatible payloads | Medium | Tied to `groupMemberId`; needs respondent abstraction | +| Course exercise routes | Course-member-only access and course-scoped RPC surface | Low | Needs public/authenticated share flows and org-level management | +| Exercise templates | Existing template metadata and questionnaire payload | Medium | Needs form template gallery and form-specific defaults | + +## Data Sources Checked + +- `packages/question-types/README.md` +- `packages/question-types/src/exercise-types.ts` +- `packages/question-types/src/render-contract.ts` +- `packages/question-types/src/form-state.ts` +- `packages/question-types/src/question-type-capabilities.ts` +- `packages/question-types/tests/form-and-render-contract.test.ts` +- `packages/ui/src/custom/exercise-question/question-renderer.svelte` +- `packages/ui/src/custom/exercise-question/question-list.svelte` +- `packages/ui/src/custom/exercise-question/question-form-root.svelte` +- `apps/api/src/routes/course/exercise.ts` +- `apps/api/src/routes/course/submission.ts` +- `apps/api/src/services/exercise/exercise.ts` +- `apps/api/src/services/submission/submission.ts` +- `packages/utils/src/validation/exercise/exercise.ts` +- `packages/utils/src/validation/submission/submission.ts` +- `packages/db/src/schema.ts` +- `apps/dashboard/src/lib/features/course/api/exercise.svelte.ts` +- `apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte` +- `apps/dashboard/src/lib/features/course/components/exercise/submissions/summary.svelte` +- `apps/dashboard/src/lib/features/course/components/exercise/submissions/individual.svelte` + +## Primary Users + +- **School operators** collecting enrollment, consent, and intake data. +- **Teachers and tutors** collecting student feedback and course reviews. +- **Administrators** running internal requests, surveys, and registration forms. +- **Parents and students** responding through public or authenticated form flows. + +## Goals + +1. Launch a standalone Forms product without rebuilding the question engine from scratch. +2. Reuse the existing question type system and UI renderer stack as the shared questionnaire layer. +3. Support the highest-value school use cases first: feedback, review, enrollment, registration, and intake forms. +4. Support both internal authenticated forms and public shareable forms. +5. Ship response collection, response review, and export as first-class workflows. +6. Keep the exercise product working unchanged. + +## Non-Goals (v1) + +- Full Typeform-style branching at question level. +- Payments inside forms. +- Complex computed fields or spreadsheet formulas. +- Workflow automation builder for multi-step approvals. +- E-signatures with legal compliance guarantees. +- PDF-to-form conversion. +- External app integrations beyond CSV export and email notifications. + +## Confirmed Product Decisions + +1. Forms will be a **new top-level product**, not a hidden exercise mode inside courses. +2. The shared question engine remains the foundation for both Exercises and Forms. +3. Forms v1 will support **draft**, **published**, **closed**, and **archived** lifecycle states. +4. Forms responses are **never graded**. Points, correctness, grading state, and reviewer workflow do not belong in the forms domain. +5. Forms v1 will support **one-page** and **multi-step/page** presentation modes. +6. Public forms are required for enrollment and feedback use cases, but must include abuse protection. +7. Authenticated internal forms are also required for staff and student-only workflows. +8. File upload is supported in Forms, but public file upload must be guarded with rate limits and storage controls. +9. Existing exercise question types are reused first; new form-only block types can be added later. +10. The first implementation prioritizes reuse over perfect naming cleanup. Renaming `exercise-question` to a broader package is optional follow-up, not a blocker. + +## Core Use Cases + +### UC-1: Student Feedback Form + +- A school admin creates a feedback form. +- Shares a link after a course/session. +- Students submit ratings, free text, and optional anonymous feedback. +- Admin reviews aggregate charts and individual comments. + +### UC-2: Enrollment Form + +- A school publishes an enrollment form publicly. +- Parent or student fills out personal data, preferences, and file uploads. +- Staff exports responses or reviews them inside the dashboard. + +### UC-3: Internal Review Form + +- A school shares a private authenticated form with teachers or staff. +- Respondents can submit once or multiple times depending on settings. +- Admin sees response metadata, completion times, and exports data. + +### UC-4: Event Registration + +- A team creates a registration form with attendance preferences and contact details. +- Form can close automatically at end date or after a response limit. +- Staff monitors response counts in real time. + +## Functional Requirements + +### FR-1: Form Lifecycle and Management + +Admins can: + +1. Create a new form from scratch or from a template. +2. Save a form as draft. +3. Publish a form and generate a shareable URL. +4. Close a form manually or automatically by schedule/limit. +5. Archive a form without deleting responses. +6. Duplicate an existing form. + +Each form must support: + +- Title +- Description +- Organization ownership +- Status: `DRAFT | PUBLISHED | CLOSED | ARCHIVED` +- Slug/share URL +- Optional start date +- Optional end date +- Optional response limit +- Optional success/thank-you message + +### FR-2: Form Builder + +Reuse the existing exercise question builder patterns for: + +1. Add question +2. Edit question +3. Reorder questions +4. Delete questions +5. Add images and videos to questions +6. Preview form +7. Save changes + +Forms-specific builder additions: + +1. Mark question as required/optional. +2. Toggle anonymous collection. +3. Choose response mode: +- one page +- one question/page +4. Configure respondent fields: +- collect name +- collect email +- authenticated respondent only +- anonymous allowed +5. Configure submission rules: +- one response per respondent +- multiple responses allowed +- save and continue later (optional) + +### FR-3: Sharing and Access + +Forms v1 must support two access models: + +1. **Public link** +- Anyone with the link can respond. +- Optional CAPTCHA / anti-bot protection. + +2. **Authenticated organization form** +- User must sign in. +- Access may be limited to organization members. + +Phase 2 access model: + +3. **Targeted private form** +- User must sign in and be explicitly assigned or invited. +- Best fit for internal staff/student workflows. + +### FR-4: Respondent Experience + +Respondents can: + +1. Open the form through a share link or internal dashboard link. +2. See form title, description, branding, and instructions. +3. Complete questions across supported question types. +4. Upload files when allowed. +5. Submit the form. +6. See a thank-you screen after successful submission. + +Optional v1 capabilities: + +- Save draft and resume later for authenticated respondents. +- Edit own response when form setting allows it. + +### FR-5: Response Management + +Admins can: + +1. View all responses for a form. +2. Filter responses by date, completion status, and respondent metadata. +3. Open a single response and inspect answers question-by-question. +4. Export responses to CSV. +5. Delete a response when policy allows it. +6. Close the form to stop new responses while preserving existing ones. + +### FR-6: Response Analytics + +Reuse and extend the current submission summary patterns to provide: + +1. Total responses +2. Response completion rate +3. Response trend over time +4. Per-question aggregates for structured questions +5. Individual response replay for qualitative review + +Out of scope for v1: + +- AI summarization of comments +- Sentiment analysis +- Cross-form benchmarking + +### FR-7: Templates + +Forms can be created from templates for common education use cases: + +- student feedback +- course review +- enrollment +- parent intake +- event RSVP +- staff request + +The existing template machinery should be reused where practical, but forms need their own template taxonomy and defaults. + +## Recommended Architecture + +### Principle + +Reuse the question engine, not the course-bound entities. + +### Why not reuse `exercise` and `submission` directly? + +Because the current schema and route model are tightly coupled to: + +- `courseId` +- `lessonId` +- `sectionId` +- `submittedBy -> groupmember` +- grading state +- score totals +- instructor review workflow +- course membership authorization + +Trying to bend those entities into a general forms product will create permanent product and technical debt. + +## Technical Design + +### 1. Shared Layer to Reuse + +These assets should remain shared across Exercises and Forms: + +1. `@cio/question-types` +2. `@cio/ui/custom/exercise-question` +3. Answer payload serialization and codecs +4. File-upload answer payload model +5. Question renderer registry +6. Template questionnaire JSON shape +7. Submission summary chart primitives + +### 2. New Domain Model + +#### 2.1 New `form` table + +```sql +CREATE TABLE form ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organization(id) ON DELETE CASCADE, + title VARCHAR NOT NULL, + description TEXT, + status VARCHAR NOT NULL DEFAULT 'DRAFT', + slug VARCHAR NOT NULL, + access_mode VARCHAR NOT NULL DEFAULT 'PUBLIC', + response_mode VARCHAR NOT NULL DEFAULT 'ONE_PAGE', + collect_name BOOLEAN NOT NULL DEFAULT false, + collect_email BOOLEAN NOT NULL DEFAULT false, + allow_anonymous BOOLEAN NOT NULL DEFAULT true, + allow_multiple_responses BOOLEAN NOT NULL DEFAULT true, + allow_edit_response BOOLEAN NOT NULL DEFAULT false, + allow_save_draft BOOLEAN NOT NULL DEFAULT false, + response_limit INTEGER, + starts_at TIMESTAMPTZ, + ends_at TIMESTAMPTZ, + success_message TEXT, + settings JSONB NOT NULL DEFAULT '{}', + created_by_profile_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (organization_id, slug) +); +``` + +#### 2.2 Form questions and options + +Recommended v1 approach: + +- Create `form_question` and `form_question_option` tables mirroring the current exercise question structure. +- Keep the payload shape aligned with the shared question contracts. +- Do **not** attempt a risky generic cross-product table migration in v1. + +Reason: + +- It ships faster. +- It avoids destabilizing the existing exercise product. +- It still preserves maximum code reuse at the question-engine layer. + +Longer-term option: + +- Introduce a generalized `questionnaire`, `questionnaire_question`, and `questionnaire_response` model that both Exercises and Forms can use. + +#### 2.3 New `form_response` table + +```sql +CREATE TABLE form_response ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + form_id UUID NOT NULL REFERENCES form(id) ON DELETE CASCADE, + organization_id UUID NOT NULL REFERENCES organization(id) ON DELETE CASCADE, + respondent_profile_id UUID, + respondent_email VARCHAR, + respondent_name VARCHAR, + status VARCHAR NOT NULL DEFAULT 'SUBMITTED', + source VARCHAR DEFAULT 'link', + submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + metadata JSONB NOT NULL DEFAULT '{}' +); +``` + +#### 2.4 New `form_response_answer` table + +```sql +CREATE TABLE form_response_answer ( + id BIGSERIAL PRIMARY KEY, + form_response_id UUID NOT NULL REFERENCES form_response(id) ON DELETE CASCADE, + form_question_id BIGINT NOT NULL REFERENCES form_question(id) ON DELETE CASCADE, + answer_data JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +### 3. API Surface + +New org-level routes should be introduced rather than extending course exercise routes: + +- `GET /organization/:orgId/forms` +- `POST /organization/:orgId/forms` +- `GET /organization/:orgId/forms/:formId` +- `PUT /organization/:orgId/forms/:formId` +- `POST /organization/:orgId/forms/:formId/publish` +- `POST /organization/:orgId/forms/:formId/close` +- `GET /organization/:orgId/forms/:formId/responses` +- `GET /organization/:orgId/forms/:formId/responses/:responseId` +- `GET /forms/:slug` +- `POST /forms/:slug/respond` + +Optional authenticated draft routes: + +- `POST /forms/:slug/draft` +- `PUT /forms/:slug/draft/:draftId` + +### 4. Dashboard Experience + +#### 4.1 Admin UI + +New dashboard areas: + +1. Forms list page +2. Form builder page +3. Form response inbox +4. Form analytics page +5. Form settings/share page + +#### 4.2 Respondent UI + +New public/internal form runner: + +1. Load form by slug or secure token +2. Render via shared question components in `take` mode +3. Submit to forms response endpoint +4. Show thank-you state + +### 5. Security and Abuse Controls + +Public forms introduce risks the exercise system does not currently have. + +Forms v1 must include: + +1. Rate limiting on public submit endpoints. +2. Bot protection on public forms. +3. File upload limits by size/type/count. +4. Optional authenticated-only setting. +5. Slug collision prevention. +6. Safe export permissions restricted to authorized org users. + +### 6. Reporting and Export + +CSV export must include: + +- response id +- submitted at +- respondent metadata +- one column per question +- file upload URLs or file identifiers + +The dashboard must also support: + +- total responses +- incomplete vs submitted +- per-question breakdown +- individual response view + +## Migration and Reuse Strategy + +### Phase 0: Shared Abstractions Cleanup + +Low-risk cleanup before or during build: + +1. Confirm shared packages remain exercise-agnostic in behavior. +2. Remove assumptions that all respondents are students. +3. Allow shared labels to accept forms-specific copy. +4. Extract any builder logic that currently depends on course page state. + +### Phase 1: Forms MVP + +Ship: + +1. Form CRUD +2. Draft/publish/close lifecycle +3. Public and authenticated forms +4. Shared question renderer reuse +5. Response collection +6. Response inbox +7. CSV export +8. Basic analytics + +### Phase 2: Education Workflow Expansion + +Add: + +1. Templates for common school forms +2. Save-and-resume drafts +3. Edit submitted response +4. Section/page builder +5. Invite-only forms +6. Better branding and embed support + +### Phase 3: Advanced Forms + +Add: + +1. Conditional logic +2. Hidden fields and prefill +3. Webhooks/integrations +4. AI summaries +5. Approval workflows + +## Delivery Estimate + +### Team Assumption + +- 2 full-stack engineers +- 1 product designer shared part-time +- QA support during stabilization + +### Rough Timeline + +1. **Foundation and schema**: 1-2 weeks +2. **Builder and shared renderer integration**: 2-3 weeks +3. **Public/internal form runner and submission flow**: 2 weeks +4. **Response inbox, analytics, export**: 2 weeks +5. **Polish, abuse controls, QA, rollout**: 1-2 weeks + +### Total + +- **8-11 weeks** for a strong MVP +- **12-16 weeks** if v1 also includes save/resume, response editing, sections, and invite-only targeting + +## Key Risks + +1. **Direct schema reuse temptation** +- Reusing `exercise` and `submission` directly will look faster initially but create major long-term coupling and UX compromises. + +2. **Public file upload abuse** +- This needs careful limits and monitoring from day one. + +3. **Shared package naming drift** +- The code can stay shared even if names remain exercise-centric for now, but this will become awkward if Forms grows quickly. + +4. **Builder assumptions** +- Some existing builder state is currently tied to course/exercise pages and will need extraction. + +5. **Reporting expectations** +- Users comparing the product to Google Forms or Typeform will expect polished analytics and exports even in early versions. + +## Open Questions + +1. Should public forms support anonymous file uploads in v1, or should file upload require authentication first? +2. Do schools need one-response-per-email enforcement for public forms? +3. Should forms live only under organizations, or also under public site/subdomain contexts? +4. Is section/page support mandatory for MVP, or acceptable in phase 2? +5. Do we need respondent notification emails on submit in v1? +6. Do we need staff assignment/routing after submission, or is inbox + export enough for MVP? + +## Success Metrics + +1. At least 3 high-value school workflows can be completed without abusing exercises as forms. +2. Form creation to publish time is under 10 minutes for common templates. +3. Public form submission success rate stays above 98%. +4. At least 70% of question rendering code is shared with the exercise product. +5. Zero regressions in exercise creation, submission, and grading flows. + +## Bottom Line + +It is realistic to build a strong Forms product on top of the current exercise implementation, but only if the reuse happens at the **question engine layer**, not by forcing form workflows into the existing course exercise and grading model. + +The existing investment already removed the hardest part, which is rich question rendering and answer modeling. What remains is the product work that makes something feel like Google Forms or Typeform: publishing, sharing, respondent identity, response management, analytics, and public-safe submission infrastructure. diff --git a/prd/forms-product/implementation-plan.md b/prd/forms-product/implementation-plan.md new file mode 100644 index 00000000..ec5ba4ec --- /dev/null +++ b/prd/forms-product/implementation-plan.md @@ -0,0 +1,162 @@ +# Forms Product Implementation Plan + +## Scope Lock + +1. Forms is a new top-level product domain and must not be implemented by repurposing course exercise routes or submission entities directly. +2. Shared question rendering and answer modeling must be reused from `@cio/question-types` and `@cio/ui/custom/exercise-question`. +3. Exercise behavior must remain backward compatible throughout delivery. +4. MVP includes public forms and authenticated organization forms. +5. Invite-only / explicitly targeted forms are phase 2, not MVP. +6. MVP includes draft, published, closed, and archived lifecycle states. +7. MVP includes response inbox, per-question aggregates, and CSV export. +8. MVP excludes grading, points, reviewer workflow, and correctness semantics. +9. MVP may ship with form-specific question tables rather than a generalized cross-product questionnaire schema. +10. Public forms must ship with abuse controls before general rollout. + +## Delivery Phases + +1. Phase A: Shared foundations and schema. +2. Phase B: API validation, services, and public/internal response flow. +3. Phase C: Dashboard builder and management UI. +4. Phase D: Response inbox, analytics, and export. +5. Phase E: Hardening, abuse controls, rollout, and follow-up platform extraction. + +## Ticket Breakdown + +| ID | Phase | Area | Task | Key Files | Depends On | Done When | +| --- | --- | --- | --- | --- | --- | --- | +| FORM-A1 | A | Shared Contracts | Audit `@cio/question-types` and `@cio/ui/custom/exercise-question` for exercise-only assumptions and document the locked reusable surface | `packages/question-types/*`, `packages/ui/src/custom/exercise-question/*`, `prd/forms-product/README.md` | None | Reuse boundaries are explicit and no hidden coupling remains undiscovered | +| FORM-A2 | A | Shared Labels | Extend shared render contracts so host apps can inject forms-specific labels and submit copy without introducing hardcoded strings | `packages/question-types/src/exercise-types.ts`, `packages/question-types/src/render-contract.ts`, `packages/question-types/src/index.ts` | FORM-A1 | Forms runner can use shared components with product-appropriate copy | +| FORM-A3 | A | DB Schema | Add `form` table with lifecycle, access, response settings, timing, slug, and org ownership | `packages/db/src/schema.ts` | None | Schema compiles and form entity supports draft/publish/close flows | +| FORM-A4 | A | DB Schema | Add `formQuestion` and `formQuestionOption` tables aligned to shared question contracts | `packages/db/src/schema.ts` | FORM-A3 | Form questions/options persist independently of exercises | +| FORM-A5 | A | DB Schema | Add `formResponse` and `formResponseAnswer` tables for respondent identity and answer storage | `packages/db/src/schema.ts` | FORM-A3, FORM-A4 | Responses can be stored without grading or groupmember coupling | +| FORM-A6 | A | DB Relations/Types | Add DB relations and exported inferred types for forms entities | `packages/db/src/relations.ts`, `packages/db/src/types.ts` | FORM-A3, FORM-A4, FORM-A5 | Query layer can consume typed forms entities cleanly | +| FORM-A7 | A | DB Migration | Generate migration SQL and metadata for all new forms tables and constraints | `packages/db/src/migrations/*` | FORM-A3, FORM-A4, FORM-A5 | Migration applies cleanly against local DB | +| FORM-A8 | A | Validation | Add form CRUD schemas, publish/close schemas, public response schemas, and response query schemas | `packages/utils/src/validation/form/form.ts`, `packages/utils/src/validation/form/index.ts` | FORM-A3, FORM-A4, FORM-A5 | API can validate form authoring and response payloads consistently | +| FORM-A9 | A | Query Layer | Add CRUD queries for forms, form questions/options, and response storage/read paths | `packages/db/src/queries/form/*` | FORM-A6 | Query layer supports form create/read/update/publish/respond flows | +| FORM-B1 | B | API Errors | Add forms-specific error codes for unavailable form, closed form, invalid responder, response limit reached, and duplicate response | `apps/api/src/utils/errors.ts` | None | Forms routes can return precise failure codes | +| FORM-B2 | B | API Service | Add org-side forms service for create, get, list, update, duplicate, archive, publish, and close | `apps/api/src/services/form/form.ts`, `apps/api/src/services/form/index.ts` | FORM-A8, FORM-A9 | Admin lifecycle actions work through a single service layer | +| FORM-B3 | B | API Service | Add public/internal form runner service to resolve form by slug and compute access rules | `apps/api/src/services/form/runner.ts` | FORM-A8, FORM-A9 | Public and authenticated forms load through one normalized response model | +| FORM-B4 | B | API Service | Add response submission service with typed answer serialization using shared question contracts | `apps/api/src/services/form/response.ts` | FORM-A8, FORM-A9 | Shared renderer payloads can be submitted and persisted end-to-end | +| FORM-B5 | B | API Service | Implement one-response-per-user/email and response-limit enforcement | `apps/api/src/services/form/response.ts` | FORM-B4 | Duplicate/over-limit submissions are blocked server-side | +| FORM-B6 | B | API Service | Add authenticated draft save/resume support only if retained in MVP; otherwise defer and mark explicitly | `apps/api/src/services/form/response.ts`, `apps/api/src/services/form/runner.ts` | FORM-B4 | Scope decision is resolved and API shape is stable | +| FORM-B7 | B | API Routes | Add organization management routes for forms CRUD and lifecycle actions | `apps/api/src/routes/organization/form.ts`, route exports, `apps/api/src/app.ts` | FORM-B2 | Org admins can manage forms over typed RPC routes | +| FORM-B8 | B | API Routes | Add public routes for `GET /forms/:slug` and `POST /forms/:slug/respond` | `apps/api/src/routes/public/form.ts`, `apps/api/src/app.ts` | FORM-B3, FORM-B4, FORM-B5 | Public runner is reachable without course context | +| FORM-B9 | B | API Routes | Add org response routes for list/detail/export preparation | `apps/api/src/routes/organization/form.ts` | FORM-B2, FORM-B4 | Dashboard can load responses and analytics data | +| FORM-B10 | B | File Upload | Reuse upload answer payload model for forms file upload and add size/type restrictions by access mode | `apps/api/src/services/form/response.ts`, shared upload helpers, form routes | FORM-B4 | File-upload questions work safely for forms | +| FORM-C1 | C | Dashboard Types | Add forms feature request/response types inferred from API contracts | `apps/dashboard/src/lib/features/forms/utils/types.ts` | FORM-B7, FORM-B8, FORM-B9 | Dashboard code uses inferred API types only | +| FORM-C2 | C | Dashboard API | Add forms API classes for management, runner, and responses | `apps/dashboard/src/lib/features/forms/api/*.svelte.ts`, `.server.ts` where needed | FORM-C1 | Components can call form APIs through feature-local classes | +| FORM-C3 | C | Navigation | Add forms entry point and routing in dashboard navigation | `apps/dashboard/src/lib/features/ui/navigation/*`, new routes under `apps/dashboard/src/routes/forms/*` | FORM-C2 | Admin can discover and enter forms product from dashboard | +| FORM-C4 | C | Builder State | Create forms builder store/state using shared question contracts without course/exercise dependencies | `apps/dashboard/src/lib/features/forms/store/*`, util files | FORM-C2 | Builder state supports question add/edit/reorder/preview | +| FORM-C5 | C | Builder UI | Build forms editor page reusing shared question renderer/edit mode patterns | `apps/dashboard/src/lib/features/forms/components/builder/*`, routes | FORM-C4 | Admin can author a form end-to-end | +| FORM-C6 | C | Settings UI | Build form settings panel for lifecycle, access mode, identity collection, limits, and success screen copy | `apps/dashboard/src/lib/features/forms/components/settings/*` | FORM-C5 | Admin can configure MVP settings without raw JSON | +| FORM-C7 | C | Public Runner UI | Build form runner page for public and authenticated response flows using shared question renderer in `take` mode | `apps/dashboard/src/routes/forms/respond/[slug]/+page.svelte` or public site equivalent, feature components | FORM-C2, FORM-B8 | Respondents can complete and submit forms | +| FORM-C8 | C | Preview UI | Add builder preview mode matching shared runner behavior | `apps/dashboard/src/lib/features/forms/components/preview/*` | FORM-C5, FORM-C7 | Admin preview matches live runner closely | +| FORM-C9 | C | Templates UI | Add starter templates for feedback, review, enrollment, intake, RSVP, and staff request | `apps/dashboard/src/lib/features/forms/components/templates/*`, template seed/query source as needed | FORM-C5 | Admin can create common forms quickly | +| FORM-C10 | C | Translations | Add forms translation keys and remove hardcoded UI copy from forms feature | `apps/dashboard/src/lib/utils/translations/en.json` and peer locales as needed | FORM-C5, FORM-C6, FORM-C7 | Forms UI follows translation conventions | +| FORM-D1 | D | Response Inbox | Build responses list with filters, counts, completion metadata, and detail drill-in | `apps/dashboard/src/lib/features/forms/components/responses/*`, routes | FORM-B9, FORM-C2 | Admin can inspect submissions without export | +| FORM-D2 | D | Response Detail | Build single-response review view using shared question replay patterns | `apps/dashboard/src/lib/features/forms/components/responses/response-detail.svelte` | FORM-D1 | Admin can inspect each answer question-by-question | +| FORM-D3 | D | Analytics | Build per-question aggregate analytics using submission summary/chart patterns already used in exercises | `apps/dashboard/src/lib/features/forms/components/analytics/*` | FORM-D1 | Admin can see distribution charts and basic trends | +| FORM-D4 | D | Export | Implement CSV export service and dashboard trigger | `apps/api/src/services/form/export.ts`, form routes, dashboard responses UI | FORM-B9, FORM-D1 | Export produces one row per response with respondent metadata and question columns | +| FORM-D5 | D | Permissions | Ensure only authorized org users can view responses and exports | forms services/routes, auth middleware wiring | FORM-B7, FORM-B9 | Response data is not exposed to unauthorized users | +| FORM-E1 | E | Abuse Controls | Add rate limiting and bot protection for public response endpoints | public form routes, middleware/config | FORM-B8 | Public forms are protected against basic spam/abuse | +| FORM-E2 | E | Monitoring | Add logging and monitoring hooks for submit failures, file-upload abuse, and export usage | forms services/routes | FORM-B8, FORM-D4, FORM-E1 | Operational visibility exists before rollout | +| FORM-E3 | E | QA/Tests | Add validation, query, API, and dashboard tests for form create/publish/respond/export flows | tests across `packages/utils`, `packages/db`, `apps/api`, `apps/dashboard` | FORM-D4 | Core flows have regression coverage | +| FORM-E4 | E | Rollout | Add feature flag, rollout checklist, and production enablement notes | forms config/docs, PRD references | FORM-E1, FORM-E2, FORM-E3 | Safe staged rollout is documented and executable | +| FORM-E5 | E | Follow-up Platforming | Evaluate whether to generalize forms/exercises into a shared questionnaire persistence layer after MVP stability | follow-up PRD/docs | FORM-E4 | Decision is made with real usage data instead of pre-optimizing | + +## Route Targets + +### Organization Management Routes + +1. `GET /organization/:orgId/forms` +2. `POST /organization/:orgId/forms` +3. `GET /organization/:orgId/forms/:formId` +4. `PUT /organization/:orgId/forms/:formId` +5. `POST /organization/:orgId/forms/:formId/publish` +6. `POST /organization/:orgId/forms/:formId/close` +7. `GET /organization/:orgId/forms/:formId/responses` +8. `GET /organization/:orgId/forms/:formId/responses/:responseId` +9. `GET /organization/:orgId/forms/:formId/export` + +### Public / Runner Routes + +1. `GET /forms/:slug` +2. `POST /forms/:slug/respond` + +### Optional Authenticated Draft Routes + +Only if MVP keeps save/resume: + +1. `POST /forms/:slug/draft` +2. `PUT /forms/:slug/draft/:draftId` + +## Acceptance Criteria by Phase + +### Phase A + +1. New forms schema exists without altering exercise semantics. +2. Shared question/render contracts are usable by forms without hardcoded exercise wording. +3. Query layer can create and read forms, questions, and responses independently from courses. +4. Build passes for `@cio/db`, `@cio/utils`, and `@cio/question-types`. + +### Phase B + +1. Org admins can create, update, publish, close, and fetch forms through API routes. +2. Public and authenticated forms can be loaded by slug without course membership. +3. Response submission persists typed answers and enforces lifecycle and limit rules. +4. File upload questions work with safe restrictions. + +### Phase C + +1. Dashboard has a discoverable Forms area. +2. Admin can author a form, configure settings, preview it, and publish it. +3. Respondent-facing runner works for public and authenticated forms. +4. No hardcoded user-facing copy is introduced in dashboard forms UI. + +### Phase D + +1. Admin can list responses, open a single response, and inspect answers. +2. Admin can see basic aggregate analytics for supported structured question types. +3. CSV export includes response metadata and one column per question. + +### Phase E + +1. Public endpoints have rate limiting and bot mitigation. +2. Submit, export, and failure paths are monitored. +3. Automated tests cover create/publish/respond/export paths. +4. Rollout can be staged safely behind a feature flag. + +## Verification Commands + +1. `pnpm --filter @cio/question-types build` +2. `pnpm --filter @cio/db build` +3. `pnpm --filter @cio/utils build` +4. `pnpm --filter @cio/api build` +5. `pnpm --filter @cio/dashboard build` +6. Run targeted tests for forms validation, API routes, and dashboard feature flows. + +## Suggested First Sprint Slice + +1. `FORM-A1` to `FORM-A9` +2. `FORM-B1` to `FORM-B5` +3. `FORM-B7`, `FORM-B8` +4. `FORM-C1`, `FORM-C2` + +This gets the core platform decision made early, lands the schema/query/API foundation, and proves the highest-risk assumption: that the shared question engine can power a new standalone product without breaking exercises. + +## Suggested Second Sprint Slice + +1. `FORM-C3` to `FORM-C8` +2. `FORM-D1`, `FORM-D2` +3. `FORM-E1` + +This delivers an MVP loop: create form, publish form, submit response, inspect response, and keep public endpoints safe enough for controlled rollout. + +## Post-MVP Follow-Up Candidates + +1. Invite-only / explicitly targeted forms. +2. Save and resume drafts if not shipped in MVP. +3. Edit submitted response. +4. Multi-section/page builder if one-page MVP ships first. +5. Shared questionnaire persistence model for exercises and forms. diff --git a/prd/ielts-self-paced-platform/README.md b/prd/ielts-self-paced-platform/README.md new file mode 100644 index 00000000..dccb9759 --- /dev/null +++ b/prd/ielts-self-paced-platform/README.md @@ -0,0 +1,671 @@ +# Self-Paced IELTS Preparation Platform PRD + +## Status +- Draft + +## Date +- March 20, 2026 + +## Purpose +Define the product requirements needed for ClassroomIO to support a serious self-paced IELTS preparation experience that can realistically help learners improve enough to pass the exam and reach target band scores. + +--- + +## 1. Executive Summary + +### The Problem +Most online IELTS products fail in one of two ways: +- They provide content without enough exam-like practice. +- They provide practice without enough feedback to explain why learners are stuck. + +For a self-paced IELTS course to work, the platform must do more than host lessons. It must support: +- exam-version selection between `Academic` and `General Training` +- timed practice across all four papers +- feedback aligned to official IELTS scoring criteria +- progress tracking by skill, question type, and score blocker +- deliberate practice loops that turn mistakes into improvement + +Without these features, learners may complete many lessons and still remain underprepared for the actual test. + +### Product Vision +ClassroomIO becomes a serious IELTS preparation platform where a learner can: +1. Diagnose their current level +2. Follow a structured study path +3. Practise each paper under realistic conditions +4. Receive useful feedback and performance analytics +5. Build enough consistency to reach a target band in a fully self-paced format + +### Product Outcome +The platform should be capable of supporting a complete self-paced IELTS program, including: +- study content +- drills +- scored exercises +- mock tests +- skill analytics +- Writing and Speaking feedback workflows +- adaptive study planning + +--- + +## 2. Background and Context + +IELTS is not only a language test. It is a performance test with fixed task types, strict timing, and band-based scoring. A learner may have decent English and still underperform because of: +- poor task understanding +- weak timing control +- weak familiarity with question types +- poor Writing structure +- weak Speaking development +- lack of accurate review after practice + +Official IELTS preparation resources emphasize: +- test format familiarity +- sample questions +- writing scoring criteria +- speaking band descriptors +- realistic trial-test experience + +This means a self-paced platform must not be built like a generic course catalog. It must be built like a guided test-preparation system. + +### Official Reference Alignment +This PRD is aligned with official IELTS preparation and format resources checked on March 20, 2026: +- IELTS test types and format +- sample test resources +- writing preparation resources +- speaking band descriptors + +Sources: +- https://ielts.org/en-us/for-test-takers/how-to-prepare +- https://ielts.org/take-a-test/preparation-resources/sample-test-questions/general-training-test +- https://ielts.org/take-a-test/preparation-resources/writing-test-resources +- https://ielts.org/cdn/ielts-guides/ielts-speaking-band-descriptors.pdf + +--- + +## 3. Goals and Non-Goals + +### Goals +- Support a full self-paced IELTS preparation journey for `Academic` and `General Training`. +- Enable learners to practise every major IELTS task type in a platform-native way. +- Provide enough structured feedback for learners to understand why they are losing marks. +- Support full mock-test experiences with meaningful review. +- Track learner progress by band target, skill, question type, and recurring weakness. +- Make the platform usable for both first-time candidates and resit candidates. + +### Non-Goals +- Replacing the official IELTS exam registration flow +- Delivering official IELTS scores +- Supporting unrelated English certifications in v1 +- Building a live tutoring marketplace in v1 +- Building a full proctoring system in v1 + +--- + +## 4. Target Users + +### Primary Users +- IELTS candidates preparing independently +- Resit candidates who missed their target band previously +- Learners targeting immigration, university admission, or professional registration + +### Secondary Users +- IELTS teachers authoring prep courses +- Course admins managing mock tests, exercise banks, and learner analytics + +### Key Learner Segments + +| Segment | Need | +|---------|------| +| First-time learner | Needs structure, exam understanding, and guided practice | +| Resit candidate | Needs diagnosis, score-blocker analysis, and targeted repair | +| Academic candidate | Needs stronger support for academic Reading and Writing Task 1 visuals | +| General Training candidate | Needs stronger support for practical Reading and letter writing | +| Band 5.5 to 6.5 learner | Needs controlled improvement, stronger basics, and repeated routines | +| Band 6.5 to 7.5 learner | Needs consistency, tighter accuracy, and high-quality feedback | + +--- + +## 5. Core Jobs To Be Done + +### Learner Jobs +- Help me choose the correct IELTS version and understand the format. +- Help me know my current level honestly. +- Help me focus on the mistakes that are actually keeping my band low. +- Let me practise in a way that feels close to the real exam. +- Show me exactly why my answers are wrong. +- Help me improve Writing and Speaking, not just objective questions. +- Give me a realistic study plan based on my target score and exam date. + +### Teacher/Admin Jobs +- Create structured IELTS learning paths. +- Add drills, scored exercises, and mock tests throughout the course. +- Review Writing and Speaking performance if human feedback is enabled. +- Track student performance at paper level and skill level. + +--- + +## 6. Product Principles + +- Practice must be exam-aligned, not generic. +- Feedback must explain failure, not just announce it. +- Timed performance matters as much as content coverage. +- Self-paced does not mean unguided. +- Weakness-driven learning is more important than content volume. +- Writing and Speaking need explicit support, not token placeholders. + +--- + +## 7. Functional Requirements + +## 7.1 Learner Onboarding and Target Setup + +### Purpose +Start each learner with the correct test version, target band, and study path. + +### Requirements +- Learner selects `Academic` or `General Training` +- Learner enters: + - target overall band + - minimum section bands if required + - planned exam date + - prior IELTS history + - confidence by skill +- Platform runs or recommends a diagnostic assessment +- Platform creates an initial learner profile: + - strongest paper + - weakest paper + - likely score blocker + - recommended study path + +### Why It Matters +IELTS prep fails when all learners receive the same sequence regardless of target, timeline, and weakness. + +--- + +## 7.2 Course Structure and Learning Path + +### Purpose +Support a full IELTS curriculum as a structured self-paced journey. + +### Requirements +- Courses support modular sections such as: + - exam foundations + - Listening + - Reading + - Writing Task 1 + - Writing Task 2 + - Speaking + - mock tests + - review workshops +- Courses support branching or filtered paths for: + - Academic-only lessons + - General Training-only lessons +- Lessons support: + - notes + - downloadable materials + - embedded media + - linked exercises + - recommended study sequence + +### Why It Matters +IELTS learners need both structured progression and version-specific content where the exam differs. + +--- + +## 7.3 Exercise Engine + +### Purpose +Support platform-native practice activities instead of relying only on lesson notes. + +### Requirements +- Exercises can be attached to lessons or sections +- Exercise question types must support at minimum: + - `RADIO` + - `CHECKBOX` + - `TRUE_FALSE` + - `SHORT_ANSWER` + - `TEXTAREA` + - `FILL_BLANK` + - `MATCHING` + - `ORDERING` +- Exercises support: + - question-level points + - answer explanations + - pass/fail thresholds + - attempt tracking + - unlocked/locked status +- Exercises support lesson-level usage patterns such as: + - quick check + - skill drill + - end-of-lesson quiz + - section review + +### IELTS-Specific Requirement +The exercise layer must support explicit practice for: +- Listening form completion +- Listening note completion +- Listening multiple choice +- Listening maps and plans +- Reading True/False/Not Given +- Reading Yes/No/Not Given +- Reading matching headings +- Reading matching information +- Reading summary completion +- Reading sentence completion +- vocabulary and paraphrase drills +- grammar correction drills + +### Why It Matters +Learners need repeated, interactive practice on recurring IELTS task types, not only prose explanations. + +--- + +## 7.4 Timed Practice Engine + +### Purpose +Recreate the pressure and pacing of IELTS. + +### Requirements +- Timer support at: + - exercise level + - lesson review level + - paper level + - full mock-test level +- Support timed sets for: + - Listening sections + - Reading passages + - Writing Task 1 + - Writing Task 2 + - full Writing paper + - Speaking simulation prompts +- Support auto-submit or time expiry handling +- Preserve attempt metadata: + - start time + - end time + - time spent + - paused state if allowed + +### Why It Matters +Many learners know the material but collapse under timing pressure. + +--- + +## 7.5 Listening Practice Experience + +### Purpose +Deliver IELTS-style Listening in a realistic way. + +### Requirements +- Audio playback support for Listening sections +- Audio should be restricted to the intended attempt flow for exam simulation mode +- Question order must match recording order +- Support answer entry formats such as: + - one word only + - no more than two words + - numbers + - names +- Post-submission review must show: + - correct answer + - transcript or relevant transcript segment + - explanation of distractor or missed signal + +### Advanced Requirements +- Track spelling errors separately from comprehension errors +- Track distractor-driven mistakes +- Track performance by Listening question type + +--- + +## 7.6 Reading Practice Experience + +### Purpose +Support IELTS Reading with enough fidelity to train strategy and accuracy. + +### Requirements +- Reading passages support: + - section title + - passage body + - question groups + - question-type labels +- Support Academic and General Training passage banks separately +- Question groups support major IELTS Reading patterns +- Post-submission review must show: + - correct answer + - evidence location or explanation + - likely error reason + +### Advanced Requirements +- Passage annotation or highlighting in review mode +- Track performance by question type +- Track time per passage or question group + +--- + +## 7.7 Writing Practice and Evaluation + +### Purpose +Support meaningful Writing improvement instead of only collecting essays. + +### Requirements +- Writing tasks must support: + - Academic Task 1 + - General Training Task 1 + - Task 2 essays +- Writing editor must support: + - timer + - word count + - draft autosave + - prompt display + - planning notes area or planning guidance +- Feedback must map to official Writing criteria: + - `Task Achievement/Task Response` + - `Coherence and Cohesion` + - `Lexical Resource` + - `Grammatical Range and Accuracy` +- Support both: + - AI-assisted feedback + - human-reviewed feedback when enabled + +### Additional Requirements +- Model answers at different quality levels or band levels +- rewrite workflow after feedback +- self-assessment checklist before submission +- recurring grammar error tracking + +### Why It Matters +Writing is one of the most common score blockers and cannot be solved by multiple-choice practice. + +--- + +## 7.8 Speaking Practice and Evaluation + +### Purpose +Support realistic Speaking preparation in a self-paced format. + +### Requirements +- Speaking tasks must support: + - Part 1 short-answer prompts + - Part 2 cue cards + - Part 3 follow-up prompts +- Part 2 must support a one-minute preparation stage +- Learner can record responses +- Learner can replay their own recordings +- Feedback must map to official Speaking criteria: + - `Fluency and Coherence` + - `Lexical Resource` + - `Grammatical Range and Accuracy` + - `Pronunciation` + +### Additional Requirements +- prompt banks by common IELTS topic families +- follow-up question generation or curated prompt trees +- repair-strategy training for hesitation and reformulation +- pronunciation notes or clarity-focused feedback + +### Why It Matters +Speaking in a self-paced system requires structured prompt handling and performance review, not only reading sample answers. + +--- + +## 7.9 Full Mock Tests + +### Purpose +Give learners a realistic test rehearsal and readiness signal. + +### Requirements +- Full mock tests support: + - Listening + - Reading + - Writing + - Speaking simulation +- Mock tests can be taken as: + - full exam flow + - paper-only mode + - section-only mode +- Result screen must show: + - overall performance summary + - paper-by-paper breakdown + - question-type breakdown + - Writing and Speaking feedback if available + - recommended next actions + +### Why It Matters +Mock tests are where learners discover whether their training holds under pressure. + +--- + +## 7.10 Review, Error Log, and Weakness Tracking + +### Purpose +Turn practice into improvement. + +### Requirements +- Every attempt should generate review data +- Platform should track recurring mistakes by: + - skill + - paper + - question type + - rubric criterion + - grammar pattern + - vocabulary weakness +- Learner should have a personal error log +- Platform should recommend targeted next practice from error patterns + +### Example Error Categories +- distractor trap +- poor paraphrase recognition +- timing issue +- spelling issue +- off-task writing +- weak paragraph development +- unclear speaking expansion + +### Why It Matters +Without structured review, learners repeat the same mistakes and only gain false confidence. + +--- + +## 7.11 Study Planning and Adaptive Prep + +### Purpose +Help learners stay on a realistic path to their band goal. + +### Requirements +- Learner can choose a prep timeline such as: + - `4 weeks` + - `8 weeks` + - `12 weeks` +- Platform recommends weekly workload based on: + - target band + - current level + - exam date + - weak papers +- Plan updates based on performance +- Study planner should include: + - drills + - full practice + - review blocks + - mock tests + - recovery days or lighter review days + +--- + +## 7.12 Progress Analytics + +### Purpose +Give learners and course admins meaningful visibility into readiness. + +### Learner Analytics +- current estimated level +- trend by paper +- trend by question type +- Writing criterion history +- Speaking criterion history +- error-log trends +- study consistency metrics + +### Admin Analytics +- completion rates +- exercise difficulty +- common failure patterns across learners +- most-failed question types +- most-used and least-used practice assets + +### Why It Matters +A serious prep platform must show whether learners are actually getting closer to target. + +--- + +## 7.13 Content Authoring for IELTS Instructors + +### Purpose +Make it practical to build and maintain high-quality IELTS prep content. + +### Requirements +- Create lesson-linked exercises without manual database work +- Tag exercises by: + - paper + - test version + - question type + - band range + - topic + - skill focus +- Create mock-test bundles from existing question banks +- Add model answers and explanation blocks +- Reuse exercise templates across courses + +### Why It Matters +The IELTS domain depends heavily on repeatable task patterns. Good author tooling reduces friction and improves consistency. + +--- + +## 8. User Flows + +## 8.1 Learner First-Run Flow +1. Learner enrolls in IELTS prep course +2. Learner selects `Academic` or `General Training` +3. Learner enters target band and exam date +4. Platform runs diagnostic or assigns diagnostic set +5. Platform generates study path and first-week plan + +## 8.2 Lesson-to-Practice Flow +1. Learner studies lesson note +2. Learner completes attached exercise +3. Learner receives immediate results +4. Platform logs mistakes +5. Platform recommends next drill or review task + +## 8.3 Writing Improvement Flow +1. Learner attempts timed writing task +2. Platform scores or annotates response +3. Learner reviews criterion-level feedback +4. Learner rewrites one paragraph or full task +5. Platform tracks revision quality over time + +## 8.4 Mock-Test Flow +1. Learner starts full mock +2. Learner completes papers under timing rules +3. Platform returns score and breakdown +4. Platform highlights weakest score blockers +5. Learner receives targeted follow-up plan + +--- + +## 9. MVP Scope + +### Must Have +- learner onboarding with target-band setup +- Academic vs General Training branching +- lesson-linked exercises +- timed Listening and Reading practice +- timed Writing task submissions +- Speaking prompt and recording support +- full mock-test capability +- criterion-based Writing and Speaking feedback +- learner error log +- progress dashboard + +### Should Have +- model answers by band range +- adaptive study plans +- exercise tagging by question type and band level +- attempt-level analytics for admins + +### Later +- richer Speaking AI feedback +- automated prompt generation +- peer review workflows +- mobile-native speaking rehearsal features + +--- + +## 10. Success Metrics + +### Learner Success Metrics +- % of learners completing diagnostic within first 3 days +- % of active learners completing at least one full mock test +- % of learners with measurable improvement across 4 weeks +- Writing resubmission rate after feedback +- Speaking practice completion rate + +### Product Success Metrics +- average weekly practice attempts per active IELTS learner +- mock-test completion rate +- review completion rate after wrong answers +- exercise completion rate by question type +- retention from onboarding to first mock test + +### Outcome Metrics +- self-reported learner confidence increase +- % of learners reporting target-band attainment +- % of resit learners reporting improvement from prior attempt + +--- + +## 11. Risks and Failure Modes + +- Too much passive content, not enough active practice +- Weak Writing and Speaking feedback quality +- Poor exam realism in timed flows +- Lack of separation between Academic and General Training content +- Generic analytics that do not show score blockers +- Exercise banks that are large but poorly tagged +- Publish path that creates lessons but omits exercises + +--- + +## 12. Technical/Product Notes for ClassroomIO + +### Current Platform Implications +- Lesson notes alone are not enough for IELTS prep. +- Exercise support is essential at the course-content level. +- Course authoring must make it easy to attach real exercises to lessons and sections. +- Draft publishing should eventually support course exercises directly if course generation is intended to be complete at publish time. + +### Immediate Product Gaps to Close +- robust IELTS exercise library support +- mock-test packaging and scheduling +- Writing and Speaking scoring workflows +- richer learner analytics tied to band improvement + +--- + +## 13. Open Questions + +- Should Writing feedback be AI-only, human-only, or hybrid by pricing tier? +- Should Speaking practice support audio only in v1, or audio plus transcript plus scoring? +- Should mock tests be locked by schedule or always open? +- Should estimated band prediction be shown directly to learners, or only criterion-level trends? +- Should draft publishing create exercises automatically as part of full AI-authored course generation? + +--- + +## 14. Recommendation + +The platform should treat IELTS as a practice-and-feedback product, not a content-delivery product. The strongest investment areas are: +- real exercises +- timed practice +- Writing and Speaking feedback +- mock tests +- weakness-driven analytics + +If these areas are implemented well, ClassroomIO can support a self-paced IELTS course that is materially more useful than a traditional LMS course with notes and quizzes.