diff --git a/.claude/skills/frontend-design/SKILL.md b/.claude/skills/frontend-design/SKILL.md new file mode 100644 index 00000000..1f3a37c4 --- /dev/null +++ b/.claude/skills/frontend-design/SKILL.md @@ -0,0 +1,60 @@ +--- +name: frontend-design +description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics. +license: Complete terms in LICENSE.txt +--- + +This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices. + +The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints. + +## Design Thinking + +Before coding, understand the context and commit to a BOLD aesthetic direction: +- **Purpose**: What problem does this interface solve? Who uses it? +- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction. +- **Constraints**: Technical requirements (framework, performance, accessibility). +- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember? + +**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity. + +Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is: +- Production-grade and functional +- Visually striking and memorable +- Cohesive with a clear aesthetic point-of-view +- Meticulously refined in every detail + +## Internal Component Library + +**ALWAYS prioritise components from `packages/ui` before reaching for raw HTML elements or third-party UI libraries.** + +Import using the `@cio/ui` package name. Examples: +- `import { Button } from '@cio/ui/base/button'` +- `import { Chip } from '@cio/ui/custom/chip'` +- `import { cn } from '@cio/ui/tools'` +- `import { BackButton } from '@cio/ui'` (re-exported root for some components) + +Available base components (covers the majority of common UI needs): +`accordion`, `alert`, `avatar`, `badge`, `breadcrumb`, `button`, `button-group`, `card`, `chart`, `checkbox`, `collapsible`, `command`, `copy-button`, `dark-mode`, `dialog`, `drawer`, `dropdown-menu`, `empty`, `field`, `hover-card`, `input`, `input-group`, `item`, `kbd`, `label`, `menubar`, `navigation-menu`, `page`, `pagination`, `password`, `popover`, `progress`, `radio-group`, `select`, `separator`, `sheet`, `sidebar`, `skeleton`, `sonner`, `spinner`, `switch`, `table`, `tabs`, `textarea`, `toggle`, `toggle-group`, `tooltip` + +Available custom components (domain-specific, higher-level): +`animation`, `back-button`, `chat-textarea`, `checkbox-field`, `checkbox-option-card`, `chip`, `code`, `course-card`, `document-card`, `domain-input`, `editor`, `empty`, `exercise-question`, `file-drop-zone`, `icon-button`, `image-cropper`, `input-field`, `media-player`, `mention-popover`, `moving-icons`, `multi-select-list`, `newsfeed-reactions`, `org-landing-page`, `password`, `pricing-card`, `pricing-toggle`, `question-type-picker`, `radio-item`, `radio-option-card`, `resource-list-row`, `safe-html-content`, `search`, `simple-logo-nav`, `textarea-field`, `underline-tabs`, `user-avatar`, `widget-layout-preview`, `youtube-link-form` + +Only build a component from scratch when none of the above is a reasonable fit. When extending an existing component, prefer composition over modification of the source file. + +## Frontend Aesthetics Guidelines + +Focus on: +- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font. +- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. +- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise. +- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density. +- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays. + +NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character. + +Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations. + +**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well. + +Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision. diff --git a/.env.example b/.env.example index 2cb4f7da..718b2963 100644 --- a/.env.example +++ b/.env.example @@ -107,6 +107,13 @@ PRIVATE_APP_SUBDOMAINS=app # Unsplash image search UNSPLASH_API_KEY= +# AI Assistant — set at least one provider key to enable the in-course chat. +# The dashboard model picker exposes Gemini 3 Flash (Google) and GPT-4o (OpenAI). +# Anthropic is supported in code but not surfaced in the picker UI. +OPENAI_API_KEY= +GOOGLE_API_KEY= +ANTHROPIC_API_KEY= + # Enterprise license (SSO, token-auth, no-tracking) LICENSE_KEY= diff --git a/.prettierignore b/.prettierignore index df4c5098..e36b2dae 100644 --- a/.prettierignore +++ b/.prettierignore @@ -15,6 +15,7 @@ apps/api/static pnpm-lock.yaml **/*.md +**/*.html packages/ui/src/output.css diff --git a/.vscode/settings.json b/.vscode/settings.json index fe4dd125..750b2255 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -46,5 +46,11 @@ "**/.turbo": true, "**/coverage": true // "pnpm-lock.yaml": true + }, + "chat.tools.terminal.autoApprove": { + "/^python3 /tmp/write_editor\\.py$/": { + "approve": true, + "matchCommandLine": true + } } } diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/PRD-TRACKER.md b/PRD-TRACKER.md index f1d21f04..05f985e9 100644 --- a/PRD-TRACKER.md +++ b/PRD-TRACKER.md @@ -19,7 +19,7 @@ Use this file to keep a single prioritized view of all PRDs. | - | 1 | `prd/ai-course-assistant` | todo | | - | 2 | `prd/compliance-training-platform` | todo | | - | 4 | `prd/course-content-search` | todo | -| - | 6 | `prd/course-widget-embed` | todo | +| - | 6 | `prd/course-widget-embed` | in-progress (core API, dashboard, public runtime, and embed build flow done; iframe preview parity, Shadow DOM, tests, and rollout hardening left) | | - | 9 | `prd/onboarding-ai-bootstrap` | todo | | - | 10 | `prd/pricing-improvements` | todo | | - | 11 | `prd/public-api` | todo | diff --git a/README.md b/README.md index 7b78a680..5aa2e382 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,23 @@ The repository also contains shared packages under `packages/` (for example `pac To learn more about how to login with a dummy account, [go here.](https://classroomio.com/docs/contributor-guides/demo-accounts) +### Enabling the AI Course Assistant + +The in-course AI chat (course authoring, plan generation, lesson edits) is **disabled by default**. Enable it by setting at least one provider API key in `apps/api/.env` (or the root `.env` for the Docker stack): + +```bash +# Pick one or more — the dashboard model picker exposes Gemini 2.5 Flash and GPT-4o. +OPENAI_API_KEY=sk-... # enables GPT-4o +GOOGLE_API_KEY=AIza... # enables Gemini 2.5 Flash (default model in the picker) +ANTHROPIC_API_KEY=sk-ant-... # supported in code; not currently in the picker UI +``` + +Notes: +- The `GET /agent/status` endpoint flips to `enabled: true` as soon as any of those keys is set, which is what the dashboard checks before showing the AI button on a course. +- Each chat request sends the user-selected `model` (persisted in `localStorage` as `classroomio-ai-chat-model`). The API resolves the provider for that model (`packages/utils/src/agent-models`) and returns 503 `AI_NOT_CONFIGURED` if that provider's key is missing. +- Optional Tinybird observability: set `TINYBIRD_TOKEN` (and optionally `TINYBIRD_BASE_URL`) in `apps/api/.env`. Events are silently skipped when the token is absent. +- More detail on architecture, tools, and routes lives in [`prd/ai-course-assistant/README.md`](prd/ai-course-assistant/README.md). + ### Docker Compose (Full Stack) ```bash diff --git a/apps/api/.env.example b/apps/api/.env.example index ebecb7a3..256d9c23 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -40,4 +40,16 @@ LICENSE_KEY="" # SSO (Optional) # For enterprise SSO with SAML (future support) -# SSO_ENCRYPTION_KEY="your-32-character-secret-key" \ No newline at end of file +# SSO_ENCRYPTION_KEY="your-32-character-secret-key" + +# AI Assistant (Optional) +# Per-provider API keys. The dashboard model picker exposes Gemini 3 Flash +# (Google) and GPT-4o (OpenAI). Anthropic is supported in code but not in the +# picker UI. Set at least one key to enable the assistant. +OPENAI_API_KEY="" +GOOGLE_API_KEY="" +ANTHROPIC_API_KEY="" + +# Tinybird event tracking for AI observability (optional — silently skipped when not set) +# TINYBIRD_TOKEN="" +# TINYBIRD_BASE_URL="https://api.tinybird.co" \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index d5c9eba3..b519e75d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -40,6 +40,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.388.0", "@aws-sdk/s3-request-presigner": "^3.675.0", + "@cio/ai-assistant": "workspace:*", "@cio/db": "workspace:*", "@cio/email": "workspace:*", "@cio/question-types": "workspace:*", @@ -49,6 +50,7 @@ "@hono/zod-validator": "^0.7.6", "@scalar/hono-api-reference": "^0.9.18", "@sentry/profiling-node": "^7.110.1", + "ai": "^6.0.158", "axios": "^1.4.0", "dotenv": "^16.3.1", "drizzle-orm": "^0.44.7", @@ -56,9 +58,12 @@ "hono-openapi": "^1.0.8", "hono-rate-limiter": "^0.4.2", "isomorphic-dompurify": "^3.7.1", + "jszip": "^3.10.1", "katex": "^0.16.10", + "mammoth": "^1.9.0", "marked": "^7.0.3", "nanoid": "^5.1.5", + "pdf-parse": "^1.1.1", "redis": "^4.7.0", "zod": "^4.1.8", "zod-openapi": "^5.4.1" @@ -66,6 +71,7 @@ "devDependencies": { "@types/katex": "^0.16.7", "@types/node": "^20.19.0", + "@types/pdf-parse": "^1.1.5", "@typescript-eslint/eslint-plugin": "7.0.1", "@typescript-eslint/parser": "7.0.1", "@vitest/coverage-v8": "^1.2.2", diff --git a/apps/api/src/__tests__/agent-lesson-content.test.ts b/apps/api/src/__tests__/agent-lesson-content.test.ts new file mode 100644 index 00000000..15c0629a --- /dev/null +++ b/apps/api/src/__tests__/agent-lesson-content.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeAgentLessonContent } from '../services/agent/lesson-content'; + +describe('normalizeAgentLessonContent', () => { + it('removes a leading heading that duplicates the lesson title', () => { + const normalized = normalizeAgentLessonContent( + '

Introduction to Functions

Functions map inputs to outputs.

', + 'Introduction to Functions' + ); + + expect(normalized).toBe('

Functions map inputs to outputs.

'); + }); + + it('removes a leading bold paragraph that duplicates the lesson title', () => { + const normalized = normalizeAgentLessonContent( + '

Introduction to Functions

Functions map inputs to outputs.

', + 'Introduction to Functions' + ); + + expect(normalized).toBe('

Functions map inputs to outputs.

'); + }); + + it('rewrites h1 and h2 tags to h3', () => { + const normalized = normalizeAgentLessonContent( + '

Overview

Key Ideas

Body

', + 'Different Lesson Title' + ); + + expect(normalized).toBe('

Overview

Key Ideas

Body

'); + }); +}); diff --git a/apps/api/src/__tests__/course-go-live-readiness.test.ts b/apps/api/src/__tests__/course-go-live-readiness.test.ts new file mode 100644 index 00000000..b3777431 --- /dev/null +++ b/apps/api/src/__tests__/course-go-live-readiness.test.ts @@ -0,0 +1,232 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ContentType } from '@cio/utils/constants'; +import { getCourseById } from '@cio/db/queries/course'; +import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content'; +import { getCourseOrganizationId } from '@cio/db/queries/tag'; +import { getOrganizationById } from '@cio/db/queries/organization'; +import { updateCourse } from '../services/course/course'; +import { ensureCourseSlug, generateUniqueCourseSlug } from '../services/course/landing-page'; +import { + evaluateCourseGoLiveReadiness, + getCourseGoLiveReadiness, + publishCourseWhenReady +} from '../services/course/go-live-readiness'; + +vi.mock('@cio/db/queries/course', () => ({ + getCourseById: vi.fn() +})); + +vi.mock('@cio/db/queries/course/content', () => ({ + getCourseContentItems: vi.fn() +})); + +vi.mock('@cio/db/queries/tag', () => ({ + getCourseOrganizationId: vi.fn() +})); + +vi.mock('@cio/db/queries/organization', () => ({ + getOrganizationById: vi.fn() +})); + +vi.mock('../services/course/course', () => ({ + updateCourse: vi.fn() +})); + +vi.mock('../services/course/landing-page', () => ({ + buildCourseBaseUrl: vi.fn(() => 'https://school.example.com'), + ensureCourseSlug: vi.fn(), + generateUniqueCourseSlug: vi.fn() +})); + +const mockedGetCourseById = vi.mocked(getCourseById); +const mockedGetCourseContentItems = vi.mocked(getCourseContentItems); +const mockedGetCourseOrganizationId = vi.mocked(getCourseOrganizationId); +const mockedGetOrganizationById = vi.mocked(getOrganizationById); +const mockedUpdateCourse = vi.mocked(updateCourse); +const mockedEnsureCourseSlug = vi.mocked(ensureCourseSlug); +const mockedGenerateUniqueCourseSlug = vi.mocked(generateUniqueCourseSlug); + +function buildCourse(overrides: Record = {}) { + return { + id: 'course-1', + title: 'Workplace Safety Essentials', + description: 'A practical course for safer day-to-day workplace decisions.', + overview: 'Learn the policies, habits, and reporting steps that keep teams safe.', + slug: 'workplace-safety-essentials', + logo: 'https://example.com/banner.jpg', + bannerImage: null, + metadata: { + description: 'A public-facing overview of the safety course.', + goals: 'Identify hazards, report incidents, and follow safe operating procedures.', + requirements: 'No prior training required.', + instructor: { + name: 'Ada Instructor', + role: 'Safety Lead', + coursesNo: 4, + description: 'Experienced workplace safety instructor.', + imgUrl: '' + }, + reviews: [ + { + id: 1, + hide: false, + name: 'Learner', + avatar_url: '', + rating: 5, + created_at: 1, + description: 'Clear and useful.' + } + ], + allowNewStudent: true + }, + type: 'SELF_PACED', + cost: 0, + ...overrides + }; +} + +function buildContentItem(overrides: Partial = {}): CourseContentItemRow { + return { + id: 'lesson-1', + type: ContentType.Lesson, + title: 'Introduction', + order: 0, + createdAt: null, + sectionId: null, + isUnlocked: true, + isComplete: false, + lessonAt: null, + callUrl: null, + hasNoteContent: true, + hasSlideContent: false, + videosCount: 0, + documentsCount: 0, + questionCount: null, + dueBy: null, + ...overrides + }; +} + +describe('evaluateCourseGoLiveReadiness', () => { + it('returns blockers for missing course, landing page, and content requirements', () => { + const readiness = evaluateCourseGoLiveReadiness({ + course: buildCourse({ + title: 'Untitled course', + description: '', + overview: 'Welcome to this amazing course', + slug: null, + logo: '', + metadata: { allowNewStudent: true } + }) as never, + contentItems: [], + organization: null + }); + + expect(readiness.ready).toBe(false); + expect(readiness.blockers.map((blocker) => blocker.code)).toEqual( + expect.arrayContaining([ + 'COURSE_TITLE_MISSING', + 'COURSE_DESCRIPTION_MISSING', + 'COURSE_SLUG_MISSING', + 'LANDING_OVERVIEW_MISSING', + 'LANDING_DESCRIPTION_MISSING', + 'LANDING_GOALS_MISSING', + 'LANDING_REQUIREMENTS_MISSING', + 'LANDING_IMAGE_MISSING', + 'COURSE_CONTENT_MISSING' + ]) + ); + }); + + it('flags empty lessons and exercises without questions', () => { + const readiness = evaluateCourseGoLiveReadiness({ + course: buildCourse() as never, + contentItems: [ + buildContentItem({ hasNoteContent: false, hasSlideContent: false }), + buildContentItem({ + id: 'exercise-1', + type: ContentType.Exercise, + title: 'Final quiz', + questionCount: 0 + }) + ], + organization: null + }); + + expect(readiness.ready).toBe(false); + expect(readiness.blockers.map((blocker) => blocker.code)).toEqual( + expect.arrayContaining(['LESSON_CONTENT_EMPTY', 'EXERCISE_QUESTIONS_MISSING']) + ); + }); + + it('returns ready when required fields and content are complete', () => { + const readiness = evaluateCourseGoLiveReadiness({ + course: buildCourse() as never, + contentItems: [buildContentItem()], + organization: { + customDomain: 'school.example.com', + isCustomDomainVerified: true, + siteName: 'school' + } as never + }); + + expect(readiness.ready).toBe(true); + expect(readiness.blockers).toEqual([]); + expect(readiness.courseUrl).toBe('https://school.example.com/course/workplace-safety-essentials'); + }); +}); + +describe('course go-live service', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedGetCourseOrganizationId.mockResolvedValue('org-1'); + mockedGetOrganizationById.mockResolvedValue({ + customDomain: 'school.example.com', + isCustomDomainVerified: true, + siteName: 'school' + } as never); + mockedGenerateUniqueCourseSlug.mockResolvedValue('workplace-safety-essentials'); + }); + + it('suggests a unique slug when a ready course has no slug', async () => { + mockedGetCourseById.mockResolvedValue([buildCourse({ slug: null })] as never); + mockedGetCourseContentItems.mockResolvedValue([buildContentItem()] as never); + + const readiness = await getCourseGoLiveReadiness('course-1'); + + expect(readiness.ready).toBe(true); + expect(readiness.suggestedFixes.slug).toBe('workplace-safety-essentials'); + expect(readiness.courseUrl).toBe('https://school.example.com/course/workplace-safety-essentials'); + expect(mockedGenerateUniqueCourseSlug).toHaveBeenCalledWith('workplace-safety-essentials'); + }); + + it('does not publish when readiness blockers remain', async () => { + mockedGetCourseById.mockResolvedValue([buildCourse({ description: '' })] as never); + mockedGetCourseContentItems.mockResolvedValue([buildContentItem()] as never); + + await expect(publishCourseWhenReady('course-1')).rejects.toThrow('Course is not ready to go live'); + expect(mockedUpdateCourse).not.toHaveBeenCalled(); + }); + + it('generates a slug and publishes when the course is ready', async () => { + const course = buildCourse({ slug: null }); + mockedGetCourseById.mockResolvedValue([course] as never); + mockedGetCourseContentItems.mockResolvedValue([buildContentItem()] as never); + mockedEnsureCourseSlug.mockResolvedValue('workplace-safety-essentials'); + mockedUpdateCourse.mockResolvedValue({ + ...course, + slug: 'workplace-safety-essentials', + isPublished: true + } as never); + + const result = await publishCourseWhenReady('course-1'); + + expect(mockedEnsureCourseSlug).toHaveBeenCalledWith('course-1', course.title); + expect(mockedUpdateCourse).toHaveBeenCalledWith('course-1', { + slug: 'workplace-safety-essentials', + isPublished: true + }); + expect(result.course.isPublished).toBe(true); + expect(result.readiness.ready).toBe(true); + }); +}); diff --git a/apps/api/src/__tests__/question-update.test.ts b/apps/api/src/__tests__/question-update.test.ts new file mode 100644 index 00000000..c9517f52 --- /dev/null +++ b/apps/api/src/__tests__/question-update.test.ts @@ -0,0 +1,409 @@ +import { describe, expect, it } from 'vitest'; +import { + buildUpdatedQuestions, + type CurrentQuestionForPatch, + type QuestionPatch +} from '@api/services/agent/question-update'; +import { QUESTION_TYPE } from '@cio/utils/validation/constants'; + +const EXERCISE_ID = 'ex-1'; + +function makeQuestion(overrides: Partial): CurrentQuestionForPatch { + return { + id: 1, + title: 'Existing question', + questionTypeId: QUESTION_TYPE.RADIO, + points: 1, + order: 0, + settings: {}, + options: [], + ...overrides + }; +} + +describe('buildUpdatedQuestions', () => { + describe('option-based question types (correct answer on options[].isCorrect)', () => { + it('RADIO: flips the correct option without losing siblings', () => { + const current = makeQuestion({ + questionTypeId: QUESTION_TYPE.RADIO, + options: [ + { id: 10, label: 'A', isCorrect: true }, + { id: 11, label: 'B', isCorrect: false }, + { id: 12, label: 'C', isCorrect: false } + ] + }); + const patch: QuestionPatch = { + id: 1, + options: [ + { id: 10, isCorrect: false }, + { id: 11, isCorrect: true }, + { id: 12, isCorrect: false } + ] + }; + + const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID); + + expect(result.options).toEqual([ + { id: 10, label: 'A', isCorrect: false, settings: undefined }, + { id: 11, label: 'B', isCorrect: true, settings: undefined }, + { id: 12, label: 'C', isCorrect: false, settings: undefined } + ]); + }); + + it('CHECKBOX: can mark multiple options correct in a single patch', () => { + const current = makeQuestion({ + questionTypeId: QUESTION_TYPE.CHECKBOX, + options: [ + { id: 20, label: 'X', isCorrect: false }, + { id: 21, label: 'Y', isCorrect: false }, + { id: 22, label: 'Z', isCorrect: false } + ] + }); + const patch: QuestionPatch = { + id: 1, + options: [ + { id: 20, isCorrect: true }, + { id: 21, isCorrect: true }, + { id: 22, isCorrect: false } + ] + }; + + const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID); + + expect(result.options.filter((o) => o.isCorrect).map((o) => o.id)).toEqual([20, 21]); + }); + + it('TRUE_FALSE: switches the correct side', () => { + const current = makeQuestion({ + questionTypeId: QUESTION_TYPE.TRUE_FALSE, + options: [ + { id: 30, label: 'True', isCorrect: true }, + { id: 31, label: 'False', isCorrect: false } + ] + }); + const patch: QuestionPatch = { + id: 1, + options: [ + { id: 30, isCorrect: false }, + { id: 31, isCorrect: true } + ] + }; + + const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID); + + const correct = result.options.find((o) => o.isCorrect); + expect(correct?.label).toBe('False'); + }); + + it('appends a new option when id is omitted (RADIO)', () => { + const current = makeQuestion({ + questionTypeId: QUESTION_TYPE.RADIO, + options: [ + { id: 40, label: 'A', isCorrect: true }, + { id: 41, label: 'B', isCorrect: false } + ] + }); + const patch: QuestionPatch = { + id: 1, + options: [ + { id: 40, isCorrect: true }, + { id: 41, isCorrect: false }, + { label: 'C', isCorrect: false } + ] + }; + + const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID); + + expect(result.options).toHaveLength(3); + expect(result.options[2]).toEqual({ label: 'C', isCorrect: false, settings: undefined }); + expect(result.options[2].id).toBeUndefined(); + }); + }); + + describe('settings-based question types (correct answer on question.settings)', () => { + it('NUMERIC: writes settings.correctValue and preserves existing tolerance', () => { + const current = makeQuestion({ + questionTypeId: QUESTION_TYPE.NUMERIC, + settings: { tolerance: 0.1, hint: 'a ray' } + }); + const patch: QuestionPatch = { + id: 1, + settings: { correctValue: 1 } + }; + + const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID); + + expect(result.settings).toEqual({ tolerance: 0.1, hint: 'a ray', correctValue: 1 }); + expect(result.options).toEqual([]); + }); + + it('NUMERIC: patching tolerance does not clobber correctValue', () => { + const current = makeQuestion({ + questionTypeId: QUESTION_TYPE.NUMERIC, + settings: { correctValue: 42, tolerance: 0 } + }); + const patch: QuestionPatch = { + id: 1, + settings: { tolerance: 0.5 } + }; + + const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID); + + expect(result.settings).toEqual({ correctValue: 42, tolerance: 0.5 }); + }); + + it('STAR: writes settings.correctValue', () => { + const current = makeQuestion({ + questionTypeId: QUESTION_TYPE.STAR, + settings: { maxStars: 5 } + }); + const patch: QuestionPatch = { + id: 1, + settings: { correctValue: 4 } + }; + + const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID); + + expect(result.settings).toEqual({ maxStars: 5, correctValue: 4 }); + }); + + it('WORD_BANK: writes correctAnswers and template together', () => { + const current = makeQuestion({ + questionTypeId: QUESTION_TYPE.WORD_BANK, + settings: { template: 'A ___ has no end.', correctAnswers: ['line'] } + }); + const patch: QuestionPatch = { + id: 1, + settings: { + template: 'A ___ extends in one direction; a ___ extends in both.', + correctAnswers: ['ray', 'line'] + } + }; + + const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID); + + expect(result.settings).toEqual({ + template: 'A ___ extends in one direction; a ___ extends in both.', + correctAnswers: ['ray', 'line'] + }); + }); + + it('FILL_BLANK: settings shallow-merge preserves untouched keys', () => { + const current = makeQuestion({ + questionTypeId: QUESTION_TYPE.FILL_BLANK, + settings: { caseSensitive: false, correctAnswers: ['old'] } + }); + const patch: QuestionPatch = { + id: 1, + settings: { correctAnswers: ['new'] } + }; + + const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID); + + expect(result.settings).toEqual({ caseSensitive: false, correctAnswers: ['new'] }); + }); + + it('MATCHING: settings shallow-merge preserves pairs not in patch', () => { + const current = makeQuestion({ + questionTypeId: QUESTION_TYPE.MATCHING, + settings: { pairs: [{ left: 'A', right: '1' }] } + }); + const patch: QuestionPatch = { + id: 1, + settings: { + pairs: [ + { left: 'A', right: '1' }, + { left: 'B', right: '2' } + ] + } + }; + + const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID); + + expect(result.settings).toEqual({ + pairs: [ + { left: 'A', right: '1' }, + { left: 'B', right: '2' } + ] + }); + }); + + it('ORDERING: writes settings.correctOrder', () => { + const current = makeQuestion({ + questionTypeId: QUESTION_TYPE.ORDERING, + settings: { items: ['x', 'y', 'z'] } + }); + const patch: QuestionPatch = { + id: 1, + settings: { correctOrder: ['z', 'y', 'x'] } + }; + + const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID); + + expect(result.settings).toEqual({ items: ['x', 'y', 'z'], correctOrder: ['z', 'y', 'x'] }); + }); + + it('HOTSPOT: writes settings.correctRegion', () => { + const current = makeQuestion({ + questionTypeId: QUESTION_TYPE.HOTSPOT, + settings: { imageUrl: 'https://example.com/img.png' } + }); + const patch: QuestionPatch = { + id: 1, + settings: { correctRegion: { x: 10, y: 20, w: 30, h: 30 } } + }; + + const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID); + + expect(result.settings).toEqual({ + imageUrl: 'https://example.com/img.png', + correctRegion: { x: 10, y: 20, w: 30, h: 30 } + }); + }); + }); + + describe('manually-graded / answer-free types', () => { + it.each([ + ['TEXTAREA', QUESTION_TYPE.TEXTAREA], + ['SHORT_ANSWER', QUESTION_TYPE.SHORT_ANSWER], + ['FILE_UPLOAD', QUESTION_TYPE.FILE_UPLOAD], + ['LINK', QUESTION_TYPE.LINK] + ])('%s: updates points and prompt without touching options', (_, questionTypeId) => { + const current = makeQuestion({ + questionTypeId, + title: 'Old prompt', + points: 1, + settings: { rubric: 'be specific' } + }); + const patch: QuestionPatch = { + id: 1, + question: 'New prompt', + points: 5 + }; + + const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID); + + expect(result.question).toBe('New prompt'); + expect(result.points).toBe(5); + expect(result.settings).toEqual({ rubric: 'be specific' }); + expect(result.options).toEqual([]); + }); + }); + + describe('safety guarantees', () => { + it('throws when the patched id is not in the exercise', () => { + const current = makeQuestion({ id: 1 }); + expect(() => buildUpdatedQuestions([current], [{ id: 999, points: 5 }], EXERCISE_ID)).toThrowError( + /does not belong to exercise/ + ); + }); + + it('omitting options preserves existing options (avoids the diff-deletes-all trap)', () => { + const current = makeQuestion({ + questionTypeId: QUESTION_TYPE.RADIO, + options: [ + { id: 50, label: 'A', isCorrect: true }, + { id: 51, label: 'B', isCorrect: false } + ] + }); + const patch: QuestionPatch = { id: 1, points: 3 }; + + const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID); + + expect(result.options).toEqual([ + { id: 50, label: 'A', isCorrect: true, settings: undefined }, + { id: 51, label: 'B', isCorrect: false, settings: undefined } + ]); + }); + + it('passing options drops any existing option missing from the array (downstream diff will delete it)', () => { + const current = makeQuestion({ + questionTypeId: QUESTION_TYPE.RADIO, + options: [ + { id: 60, label: 'A', isCorrect: true }, + { id: 61, label: 'B', isCorrect: false }, + { id: 62, label: 'C', isCorrect: false } + ] + }); + const patch: QuestionPatch = { + id: 1, + options: [ + { id: 60, isCorrect: true }, + { id: 61, isCorrect: false } + ] + }; + + const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID); + + expect(result.options.map((o) => o.id)).toEqual([60, 61]); + }); + + it('omitting question/points/order/typeId backfills from current question', () => { + const current = makeQuestion({ + title: 'Original', + questionTypeId: QUESTION_TYPE.NUMERIC, + points: 7, + order: 3, + settings: { correctValue: 5 } + }); + const patch: QuestionPatch = { id: 1, settings: { correctValue: 9 } }; + + const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID); + + expect(result.question).toBe('Original'); + expect(result.questionTypeId).toBe(QUESTION_TYPE.NUMERIC); + expect(result.points).toBe(7); + expect(result.order).toBe(3); + expect(result.settings).toEqual({ correctValue: 9 }); + }); + + it('per-option settings shallow-merge logic: patch overrides; existing label/isCorrect preserved when omitted', () => { + const current = makeQuestion({ + questionTypeId: QUESTION_TYPE.RADIO, + options: [{ id: 70, label: 'Keep label', isCorrect: true, settings: { color: 'red' } }] + }); + const patch: QuestionPatch = { + id: 1, + options: [{ id: 70, settings: { color: 'blue' } }] + }; + + const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID); + + expect(result.options[0]).toEqual({ + id: 70, + label: 'Keep label', + isCorrect: true, + settings: { color: 'blue' } + }); + }); + + it('handles multiple patches in one call independently', () => { + const current: CurrentQuestionForPatch[] = [ + makeQuestion({ id: 1, questionTypeId: QUESTION_TYPE.NUMERIC, settings: { correctValue: 1 } }), + makeQuestion({ + id: 2, + questionTypeId: QUESTION_TYPE.RADIO, + options: [ + { id: 80, label: 'A', isCorrect: false }, + { id: 81, label: 'B', isCorrect: true } + ] + }) + ]; + const patches: QuestionPatch[] = [ + { id: 1, settings: { correctValue: 42 } }, + { + id: 2, + options: [ + { id: 80, isCorrect: true }, + { id: 81, isCorrect: false } + ] + } + ]; + + const result = buildUpdatedQuestions(current, patches, EXERCISE_ID); + + expect(result[0].settings).toEqual({ correctValue: 42 }); + expect(result[1].options.find((o) => o.isCorrect)?.id).toBe(80); + }); + }); +}); diff --git a/apps/api/src/__tests__/widget-google-fonts.test.ts b/apps/api/src/__tests__/widget-google-fonts.test.ts new file mode 100644 index 00000000..6615915d --- /dev/null +++ b/apps/api/src/__tests__/widget-google-fonts.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { + buildWidgetGoogleFontsStylesheetHref, + collectWidgetGoogleFontFamilies, + getDefaultWidgetConfig, + getWidgetThemeSerifStack, + WIDGET_THEME_PRESET_VALUES +} from '@cio/utils/validation/widget'; + +describe('Widget Google Fonts', () => { + it('loads theme-specific families for each preset', () => { + for (const theme of WIDGET_THEME_PRESET_VALUES) { + const config = { ...getDefaultWidgetConfig(), themePreset: theme }; + const families = collectWidgetGoogleFontFamilies(config); + expect(families.length).toBeGreaterThan(0); + const href = buildWidgetGoogleFontsStylesheetHref(config); + expect(href.startsWith('https://fonts.googleapis.com/css2?')).toBe(true); + expect(href).toContain('display=swap'); + } + }); + + it('includes IBM Plex families for graphite', () => { + const config = { ...getDefaultWidgetConfig(), themePreset: 'graphite' as const }; + const families = collectWidgetGoogleFontFamilies(config); + expect(families).toContain('IBM Plex Sans'); + expect(families).toContain('IBM Plex Serif'); + }); + + it('merges quoted custom Google fonts from typography.fontFamily', () => { + const config = { + ...getDefaultWidgetConfig(), + themePreset: 'classroomio' as const, + typography: { + ...getDefaultWidgetConfig().typography, + fontFamily: "'Manrope', ui-sans-serif, sans-serif" + } + }; + expect(collectWidgetGoogleFontFamilies(config)).toContain('Manrope'); + }); + + it('exposes serif stacks per theme', () => { + expect(getWidgetThemeSerifStack('classroomio')).toContain('Lora'); + expect(getWidgetThemeSerifStack('spruce')).toContain('Fraunces'); + }); +}); diff --git a/apps/api/src/__tests__/widget-payload.test.ts b/apps/api/src/__tests__/widget-payload.test.ts new file mode 100644 index 00000000..a3b97b6e --- /dev/null +++ b/apps/api/src/__tests__/widget-payload.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; + +const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + +/** Mirrors the key generation logic from widget-payload.ts for isolated testing. */ +function encodeBase58(bytes: Uint8Array): string { + let value = 0n; + for (const byte of bytes) { + value = (value << 8n) + BigInt(byte); + } + + if (value === 0n) { + return BASE58_ALPHABET[0]; + } + + let encoded = ''; + while (value > 0n) { + const remainder = Number(value % 58n); + encoded = BASE58_ALPHABET[remainder] + encoded; + value /= 58n; + } + + return encoded; +} + +function generateWidgetPublicKey(): string { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + return `wgt_${encodeBase58(bytes).slice(0, 16)}`; +} + +function getWidgetEmbedCode(publicKey: string): string { + const scriptUrl = 'https://assets.cdn.clsrio.com/embeds/course-widget/course-widget.js'; + return `
\n`; +} + +describe('generateWidgetPublicKey', () => { + it('should start with wgt_ prefix', () => { + const key = generateWidgetPublicKey(); + expect(key.startsWith('wgt_')).toBe(true); + }); + + it('should have expected length', () => { + const key = generateWidgetPublicKey(); + expect(key.length).toBe(20); // 'wgt_' + 16 chars + }); + + it('should generate unique keys', () => { + const keys = new Set(Array.from({ length: 50 }, () => generateWidgetPublicKey())); + expect(keys.size).toBe(50); + }); + + it('should only contain base58 characters after prefix', () => { + const key = generateWidgetPublicKey(); + const suffix = key.slice(4); + const base58Regex = /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/; + expect(base58Regex.test(suffix)).toBe(true); + }); +}); + +describe('encodeBase58', () => { + it('should encode zero bytes to 1', () => { + expect(encodeBase58(new Uint8Array([0]))).toBe('1'); + }); + + it('should produce deterministic output', () => { + const bytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); + const resultA = encodeBase58(bytes); + const resultB = encodeBase58(bytes); + expect(resultA).toBe(resultB); + }); + + it('should not contain ambiguous characters (0, O, I, l)', () => { + const encoded = BASE58_ALPHABET; + expect(encoded).not.toContain('0'); + expect(encoded).not.toContain('O'); + expect(encoded).not.toContain('I'); + expect(encoded).not.toContain('l'); + }); +}); + +describe('getWidgetEmbedCode', () => { + it('should contain the public key in data attribute', () => { + const embedCode = getWidgetEmbedCode('wgt_testkey123456'); + expect(embedCode).toContain('data-widget-key="wgt_testkey123456"'); + }); + + it('should contain the widget type data attribute', () => { + const embedCode = getWidgetEmbedCode('wgt_testkey123456'); + expect(embedCode).toContain('data-cio-widget="course-widget"'); + }); + + it('should contain an async module script tag', () => { + const embedCode = getWidgetEmbedCode('wgt_testkey123456'); + expect(embedCode).toContain('`; +} + +export async function buildWidgetPayload( + widget: TWidget, + overrideSelectedCourseIds?: string[] +): Promise { + const organization = await getOrganizationById(widget.organizationId); + if (!organization) { + throw new Error('Organization not found for widget'); + } + + const activePlan = await getActiveOrganizationPlan(widget.organizationId); + const normalizedConfig = normalizeWidgetConfig(widget.config as Record, activePlan?.planName); + const selectedCourseIds = + overrideSelectedCourseIds ?? (await listWidgetCourses(widget.id)).map((course) => course.courseId); + const allCourses = await getOrgCourses({ orgId: widget.organizationId, limit: 200 }); + const allPublishedCourses = allCourses.items.filter((course) => course.isPublished); + const manualOrderLookup = new Map(selectedCourseIds.map((courseId, index) => [courseId, index])); + + let resolvedCourses = + widget.selectionMode === 'manual' + ? allCourses.items.filter((course) => manualOrderLookup.has(course.id)) + : allPublishedCourses; + + const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization( + widget.organizationId, + resolvedCourses.map((course) => course.id) + ); + + if (normalizedConfig.filters.includeTagIds.length > 0 || normalizedConfig.filters.excludeTagIds.length > 0) { + resolvedCourses = resolvedCourses.filter((course) => { + const courseTags = tagsByCourseId[course.id] ?? []; + const courseTagIds = new Set(courseTags.map((tag) => tag.id)); + const includesRequired = + normalizedConfig.filters.includeTagIds.length === 0 || + normalizedConfig.filters.includeTagIds.some((tagId) => courseTagIds.has(tagId)); + const excludesBlocked = normalizedConfig.filters.excludeTagIds.every((tagId) => !courseTagIds.has(tagId)); + + return includesRequired && excludesBlocked; + }); + } + + if (widget.selectionMode === 'manual' || normalizedConfig.sortBy === 'manual') { + resolvedCourses = [...resolvedCourses].sort( + (courseA, courseB) => + (manualOrderLookup.get(courseA.id) ?? Number.MAX_SAFE_INTEGER) - + (manualOrderLookup.get(courseB.id) ?? Number.MAX_SAFE_INTEGER) + ); + } else if (normalizedConfig.sortBy === 'title') { + resolvedCourses = [...resolvedCourses].sort((courseA, courseB) => courseA.title.localeCompare(courseB.title)); + } else { + resolvedCourses = [...resolvedCourses].sort( + (courseA, courseB) => new Date(courseB.createdAt ?? '').getTime() - new Date(courseA.createdAt ?? '').getTime() + ); + } + + const layoutType = widget.layoutType as TWidgetLayoutType; + const layoutOptions = normalizedConfig.layoutOptions; + + // Hoist the configured "main" course to the front for layouts that have a hero slot, + // so the rest of the slice/limit logic can treat slot 0 as the featured course. + if (layoutType === 'primary_course' && layoutOptions.primaryCourse.featuredCourseId) { + const featuredId = layoutOptions.primaryCourse.featuredCourseId; + const featuredIndex = resolvedCourses.findIndex((course) => course.id === featuredId); + if (featuredIndex > 0) { + const [featuredCourse] = resolvedCourses.splice(featuredIndex, 1); + resolvedCourses = [featuredCourse, ...resolvedCourses]; + } + } else if (layoutType === 'editorial_spotlight' && layoutOptions.editorialSpotlight.mainCourseId) { + const mainId = layoutOptions.editorialSpotlight.mainCourseId; + const mainIndex = resolvedCourses.findIndex((course) => course.id === mainId); + if (mainIndex > 0) { + const [mainCourse] = resolvedCourses.splice(mainIndex, 1); + resolvedCourses = [mainCourse, ...resolvedCourses]; + } + } + + // Cap the course list according to the active layout's limit. Category shelf + // builds its own grouped slices below from the already-filtered universe. + if (layoutType !== 'category_shelf') { + const layoutLimit = getLayoutCourseLimit(layoutType, layoutOptions); + resolvedCourses = resolvedCourses.slice(0, Math.max(layoutLimit, 1)); + } else { + resolvedCourses = resolvedCourses.slice(0, getLayoutCourseLimit(layoutType, layoutOptions)); + } + + // Build payload-specific aggregates. + const heroCourseId = + layoutType === 'primary_course' + ? (layoutOptions.primaryCourse.featuredCourseId ?? resolvedCourses[0]?.id ?? null) + : layoutType === 'editorial_spotlight' + ? (layoutOptions.editorialSpotlight.mainCourseId ?? resolvedCourses[0]?.id ?? null) + : null; + + const tagPool = buildTagPool(layoutType, normalizedConfig, resolvedCourses, tagsByCourseId); + const categories = buildCategoriesFromShelf(layoutType, normalizedConfig, resolvedCourses, tagsByCourseId); + + const planGatedFields = resolveWidgetPlanGatedFields(activePlan?.planName, normalizedConfig.themePreset); + const sanitizedCustomCss = sanitizeCustomCssValue(normalizedConfig.advanced.customCss, activePlan?.planName); + const orgBaseUrl = getCourseBaseUrl(organization.siteName ?? '', organization.customDomain); + + return ZWidgetPayload.parse({ + version: 'v1', + widgetId: widget.id, + publicKey: widget.publicKey, + organization: { + id: organization.id, + name: organization.name, + siteName: organization.siteName, + customDomain: organization.customDomain + }, + layoutType, + selectionMode: widget.selectionMode, + design: { + ...normalizedConfig, + advanced: { + ...normalizedConfig.advanced, + customCss: sanitizedCustomCss + } + }, + planGatedFields, + labels: { + loadMoreLabel: normalizedConfig.content.loadMoreLabel, + poweredByLabel: 'Powered by Classroomio' + }, + courses: resolvedCourses.map((course) => ({ + id: course.id, + slug: course.slug ?? course.id, + title: course.title, + description: course.description, + imageUrl: course.bannerImage || course.logo || null, + price: course.cost ? `${course.currency} ${course.cost}` : 'Free', + lessonCount: course.lessonCount, + exerciseCount: course.exerciseCount ?? 0, + rating: null, + ratingCount: 0, + courseType: course.type, + createdAt: course.createdAt ?? new Date().toISOString(), + tags: tagsByCourseId[course.id] ?? [], + featured: heroCourseId ? course.id === heroCourseId : false, + url: `${orgBaseUrl}/course/${course.slug ?? course.id}` + })), + tagPool, + categories, + timestamp: Date.now() + }); +} + +function buildTagPool( + layoutType: TWidgetLayoutType, + config: ReturnType, + courses: Array<{ id: string }>, + tagsByCourseId: Record +): TWidgetPayloadCourseTag[] { + if (layoutType !== 'tag_filter') { + return []; + } + + const allTagsFromCourses = new Map(); + for (const course of courses) { + for (const tag of tagsByCourseId[course.id] ?? []) { + if (!allTagsFromCourses.has(tag.id)) { + allTagsFromCourses.set(tag.id, tag); + } + } + } + + if (config.layoutOptions.tagFilter.tagSource === 'manual') { + const allowedIds = new Set(config.layoutOptions.tagFilter.manualTagIds); + return [...allTagsFromCourses.values()].filter((tag) => allowedIds.has(tag.id)); + } + + return [...allTagsFromCourses.values()]; +} + +function buildCategoriesFromShelf( + layoutType: TWidgetLayoutType, + config: ReturnType, + courses: Array<{ id: string }>, + tagsByCourseId: Record +): TWidgetPayloadCategory[] { + if (layoutType !== 'category_shelf') { + return []; + } + + const orderedCategoryIds = config.layoutOptions.categoryShelf.categoryTagIds; + if (orderedCategoryIds.length === 0) { + return []; + } + + // Reverse-lookup tag info from the tags-by-course map. + const tagsById = new Map(); + for (const tags of Object.values(tagsByCourseId)) { + for (const tag of tags) { + tagsById.set(tag.id, tag); + } + } + + const maxPerCategory = config.layoutOptions.categoryShelf.maxPerCategory; + + return orderedCategoryIds + .map((tagId) => { + const tagInfo = tagsById.get(tagId); + if (!tagInfo) { + return null; + } + + const matchingCourses = courses.filter((course) => + (tagsByCourseId[course.id] ?? []).some((tag) => tag.id === tagId) + ); + + return { + tagId, + name: tagInfo.name, + slug: tagInfo.slug, + color: tagInfo.color, + courseIds: matchingCourses.slice(0, maxPerCategory).map((course) => course.id) + } satisfies TWidgetPayloadCategory; + }) + .filter((entry): entry is TWidgetPayloadCategory => entry !== null); +} + +export async function listWidgetAvailableEditorData(orgId: string) { + const [coursesResult, tagGroups, activePlan] = await Promise.all([ + getOrgCourses({ orgId, limit: 200 }), + getTagGroupsWithTags(orgId), + getActiveOrganizationPlan(orgId) + ]); + const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization( + orgId, + coursesResult.items.map((course) => course.id) + ); + + return { + availableCourses: coursesResult.items.map((course) => ({ + id: course.id, + slug: course.slug ?? course.id, + title: course.title, + description: course.description, + imageUrl: course.bannerImage || course.logo || null, + isPublished: course.isPublished, + price: course.cost ? `${course.currency} ${course.cost}` : 'Free', + lessonCount: course.lessonCount, + exerciseCount: course.exerciseCount ?? 0, + courseType: course.type, + createdAt: course.createdAt ?? new Date().toISOString(), + tags: tagsByCourseId[course.id] ?? [] + })), + availableTags: tagGroups.flatMap((group) => + group.tags.map((tag) => ({ + id: tag.id, + name: tag.name, + slug: tag.slug, + color: tag.color, + groupId: group.id + })) + ), + planGatedFields: resolveWidgetPlanGatedFields(activePlan?.planName, 'classroomio') + }; +} diff --git a/apps/api/src/services/widget.ts b/apps/api/src/services/widget.ts new file mode 100644 index 00000000..6a1f71f8 --- /dev/null +++ b/apps/api/src/services/widget.ts @@ -0,0 +1,335 @@ +import { AppError, ErrorCodes } from '@api/utils/errors'; +import { + archiveWidget, + createWidget, + createWidgetVersion, + getWidgetListItemById, + getNextWidgetVersion, + getPublishedWidgetPayloadByPublicKey, + getWidgetById, + getWidgetVersionById, + listWidgetCourses, + listWidgetVersions, + listWidgetsByOrganization, + replaceWidgetCourses, + updateWidget +} from '@cio/db/queries/widget'; +import { + ZWidgetPayload, + getDefaultWidgetConfig, + normalizeWidgetConfig, + type TCreateWidget, + type TPreviewWidget, + type TRollbackWidget, + type TUpdateWidget +} from '@cio/utils/validation/widget'; + +import { + buildWidgetPayload, + generateWidgetPublicKey, + getCourseWidgetScriptUrl, + getWidgetEmbedCode, + listWidgetAvailableEditorData +} from './widget-payload'; + +export async function listOrganizationWidgets(orgId: string) { + try { + return await listWidgetsByOrganization(orgId); + } catch (error) { + throw new AppError( + error instanceof Error ? error.message : 'Failed to list widgets', + ErrorCodes.WIDGET_FETCH_FAILED, + 500 + ); + } +} + +export async function createOrganizationWidget(orgId: string, userId: string, data: TCreateWidget) { + try { + const widget = await createWidget({ + organizationId: orgId, + name: data.name, + layoutType: data.layoutType, + selectionMode: data.selectionMode, + publicKey: generateWidgetPublicKey(), + config: normalizeWidgetConfig(data.config ?? getDefaultWidgetConfig()), + createdByUserId: userId, + updatedByUserId: userId + }); + + await replaceWidgetCourses(widget.id, data.selectedCourseIds); + + const listItem = await getWidgetListItemById(orgId, widget.id); + + if (!listItem) { + throw new AppError('Widget not found after creation', ErrorCodes.WIDGET_NOT_FOUND, 404); + } + + return listItem; + } catch (error) { + throw new AppError( + error instanceof Error ? error.message : 'Failed to create widget', + ErrorCodes.WIDGET_CREATE_FAILED, + 500 + ); + } +} + +export async function getOrganizationWidgetDetail(orgId: string, widgetId: string) { + try { + const widget = await getWidgetById(orgId, widgetId); + if (!widget) { + throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404); + } + + const [selectedCourses, versions, editorData, previewPayload] = await Promise.all([ + listWidgetCourses(widget.id), + listWidgetVersions(orgId, widget.id), + listWidgetAvailableEditorData(orgId), + buildWidgetPayload(widget) + ]); + + return { + widget: { + ...widget, + config: normalizeWidgetConfig(widget.config as Record), + selectedCourseIds: selectedCourses.map((course) => course.courseId), + embedCode: getWidgetEmbedCode(widget.publicKey), + publicScriptUrl: getCourseWidgetScriptUrl() + }, + availableCourses: editorData.availableCourses, + availableTags: editorData.availableTags, + previewPayload, + versions, + planGatedFields: editorData.planGatedFields + }; + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + throw new AppError( + error instanceof Error ? error.message : 'Failed to get widget detail', + ErrorCodes.WIDGET_FETCH_FAILED, + 500 + ); + } +} + +export async function updateOrganizationWidget(orgId: string, widgetId: string, userId: string, data: TUpdateWidget) { + try { + const existingWidget = await getWidgetById(orgId, widgetId); + if (!existingWidget) { + throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404); + } + + const updatedWidget = await updateWidget(orgId, widgetId, { + name: data.name ?? existingWidget.name, + layoutType: data.layoutType ?? existingWidget.layoutType, + selectionMode: data.selectionMode ?? existingWidget.selectionMode, + config: normalizeWidgetConfig((data.config ?? existingWidget.config) as Record), + hasUnpublishedChanges: true, + updatedByUserId: userId + }); + + if (!updatedWidget) { + throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404); + } + + if (data.selectedCourseIds) { + await replaceWidgetCourses(widgetId, data.selectedCourseIds); + } + + const listItem = await getWidgetListItemById(orgId, widgetId); + + if (!listItem) { + throw new AppError('Widget not found after update', ErrorCodes.WIDGET_NOT_FOUND, 404); + } + + return listItem; + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + throw new AppError( + error instanceof Error ? error.message : 'Failed to update widget', + ErrorCodes.WIDGET_UPDATE_FAILED, + 500 + ); + } +} + +export async function deleteOrganizationWidget(orgId: string, widgetId: string, userId: string) { + try { + const archivedWidget = await archiveWidget(orgId, widgetId, userId); + if (!archivedWidget) { + throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404); + } + + return archivedWidget; + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + throw new AppError( + error instanceof Error ? error.message : 'Failed to delete widget', + ErrorCodes.WIDGET_DELETE_FAILED, + 500 + ); + } +} + +export async function publishOrganizationWidget(orgId: string, widgetId: string, userId: string) { + try { + const widget = await getWidgetById(orgId, widgetId); + if (!widget) { + throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404); + } + + const payload = await buildWidgetPayload(widget); + const nextVersion = await getNextWidgetVersion(widget.id); + const version = await createWidgetVersion({ + widgetId: widget.id, + version: nextVersion, + configSnapshot: normalizeWidgetConfig(widget.config as Record), + payloadSnapshot: payload, + runtimeManifest: { + version: 'v1', + release: 'current', + entryUrl: getCourseWidgetScriptUrl() + }, + publishedByUserId: userId + }); + + const updatedWidget = await updateWidget(orgId, widget.id, { + status: 'PUBLISHED', + hasUnpublishedChanges: false, + latestPublishedVersionId: version.id, + updatedByUserId: userId + }); + + return { + widget: updatedWidget, + version, + payload, + embedCode: getWidgetEmbedCode(widget.publicKey), + publicScriptUrl: getCourseWidgetScriptUrl() + }; + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + throw new AppError( + error instanceof Error ? error.message : 'Failed to publish widget', + ErrorCodes.WIDGET_PUBLISH_FAILED, + 500 + ); + } +} + +export async function rollbackOrganizationWidget( + orgId: string, + widgetId: string, + userId: string, + data: TRollbackWidget +) { + try { + const [widget, version] = await Promise.all([ + getWidgetById(orgId, widgetId), + getWidgetVersionById(orgId, widgetId, data.versionId) + ]); + + if (!widget) { + throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404); + } + + if (!version) { + throw new AppError('Widget version not found', ErrorCodes.WIDGET_NOT_FOUND, 404); + } + + const nextVersion = await getNextWidgetVersion(widgetId); + const rollbackVersion = await createWidgetVersion({ + widgetId: widget.id, + version: nextVersion, + configSnapshot: version.configSnapshot, + payloadSnapshot: version.payloadSnapshot, + runtimeManifest: version.runtimeManifest, + rolledBackFromVersionId: version.id, + publishedByUserId: userId + }); + + const updatedWidget = await updateWidget(orgId, widgetId, { + status: 'PUBLISHED', + hasUnpublishedChanges: false, + latestPublishedVersionId: rollbackVersion.id, + updatedByUserId: userId + }); + + return { + widget: updatedWidget, + version: rollbackVersion + }; + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + throw new AppError( + error instanceof Error ? error.message : 'Failed to rollback widget', + ErrorCodes.WIDGET_ROLLBACK_FAILED, + 500 + ); + } +} + +export async function previewOrganizationWidget(orgId: string, widgetId: string, data: TPreviewWidget) { + try { + const widget = await getWidgetById(orgId, widgetId); + if (!widget) { + throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404); + } + + const overriddenWidget = { + ...widget, + layoutType: data.layoutType ?? widget.layoutType, + selectionMode: data.selectionMode ?? widget.selectionMode, + config: data.config ?? widget.config + }; + + return buildWidgetPayload(overriddenWidget, data.selectedCourseIds); + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + throw new AppError( + error instanceof Error ? error.message : 'Failed to build widget preview', + ErrorCodes.WIDGET_FETCH_FAILED, + 500 + ); + } +} + +export async function getPublishedWidgetPayload(publicKey: string) { + try { + const payload = await getPublishedWidgetPayloadByPublicKey(publicKey); + if (!payload) { + throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404); + } + + return ZWidgetPayload.parse(payload); + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + throw new AppError( + error instanceof Error ? error.message : 'Failed to fetch widget payload', + ErrorCodes.WIDGET_PAYLOAD_FETCH_FAILED, + 500 + ); + } +} diff --git a/apps/api/src/types/auth.ts b/apps/api/src/types/auth.ts index 2fd1974e..8270767e 100644 --- a/apps/api/src/types/auth.ts +++ b/apps/api/src/types/auth.ts @@ -6,6 +6,7 @@ export type AuthSession = { actorId: string | null; automationKey: TOrganizationApiKey | null; orgId: string | null; + orgRoles: Record; session: typeof auth.$Infer.Session.session | null; user: typeof auth.$Infer.Session.user | null; userRole: number | null; diff --git a/apps/api/src/types/css-tree.d.ts b/apps/api/src/types/css-tree.d.ts new file mode 100644 index 00000000..2bfee7a4 --- /dev/null +++ b/apps/api/src/types/css-tree.d.ts @@ -0,0 +1 @@ +declare module 'css-tree'; diff --git a/apps/api/src/utils/redis/key-generators.ts b/apps/api/src/utils/redis/key-generators.ts index 77102351..deae1766 100644 --- a/apps/api/src/utils/redis/key-generators.ts +++ b/apps/api/src/utils/redis/key-generators.ts @@ -96,3 +96,39 @@ export const endpointKeyGenerator = const baseKey = userKeyGenerator(c); return `${baseKey}:${endpoint}`; }; + +// ─── Agent Document Keys ───────────────────────────────────────────────────── + +/** + * Redis key for storing extracted document text from AI assistant uploads. + * TTL: 3600 seconds (1 hour). + */ +export const agentDocumentKey = (documentId: string): string => { + return `agent:document:${documentId}`; +}; + +/** + * Rate limit key for agent chat endpoint (per-user). + */ +export const agentChatKeyGenerator = (c: Context): string => { + const baseKey = userKeyGenerator(c); + return `${baseKey}:agent:chat`; +}; + +// ─── Dashboard / analytics cache ───────────────────────────────────────────── + +/** + * Redis key for `getStudentLoginActivity` (day-of-week chart per org and window). + * Value: JSON array `{ day, count }[]`. TTL: 24h. + */ +export function dashLoginActivityKey(orgId: string, days: number): string { + return `dash:login-activity:${orgId}:${days}`; +} + +/** + * Rate limit key for agent upload endpoint (per-user). + */ +export const agentUploadKeyGenerator = (c: Context): string => { + const baseKey = userKeyGenerator(c); + return `${baseKey}:agent:upload`; +}; diff --git a/apps/api/src/utils/tinybird.ts b/apps/api/src/utils/tinybird.ts new file mode 100644 index 00000000..a5d27164 --- /dev/null +++ b/apps/api/src/utils/tinybird.ts @@ -0,0 +1,40 @@ +/** + * Tinybird event tracking for AI assistant observability. + * Fire-and-forget: never blocks the agent, silently skipped when not configured. + */ + +const TINYBIRD_TOKEN = process.env.TINYBIRD_TOKEN; +const TINYBIRD_BASE_URL = process.env.TINYBIRD_BASE_URL || 'https://api.tinybird.co'; + +/** + * Send an event to Tinybird for analytics and debugging. + * Safe to call without awaiting — errors are swallowed. + */ +export function trackAgentEvent(eventName: string, data: Record): void { + if (!TINYBIRD_TOKEN) return; + + fetch(`${TINYBIRD_BASE_URL}/v0/events?name=${eventName}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${TINYBIRD_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ ...data, timestamp: new Date().toISOString() }) + }).catch(() => { + // Intentionally swallowed — observability must never break the agent + }); +} + +// ─── Event Names ───────────────────────────────────────────────────────────── + +export const AgentEvent = { + CHAT_STARTED: 'agent.chat.started', + CHAT_COMPLETED: 'agent.chat.completed', + CHAT_ERROR: 'agent.chat.error', + TOOL_CALLED: 'agent.tool.called', + TOOL_COMPLETED: 'agent.tool.completed', + TOOL_FAILED: 'agent.tool.failed', + DOCUMENT_UPLOADED: 'agent.document.uploaded', + PLAN_GENERATED: 'agent.plan.generated', + PLAN_EXECUTED: 'agent.plan.executed' +} as const; diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index f29f8b2a..f6e9202f 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -1,6 +1,12 @@ +import path from 'node:path'; import { defineConfig } from 'vitest/config'; export default defineConfig({ + resolve: { + alias: { + '@api': path.resolve(__dirname, 'src') + } + }, test: { globals: true, environment: 'node', diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example index 75c4920e..152fbb10 100644 --- a/apps/dashboard/.env.example +++ b/apps/dashboard/.env.example @@ -19,6 +19,8 @@ PUBLIC_IS_SELFHOSTED=false # CSP_FRAME_SRC_DOMAINS= # CSP_FONT_SRC_DOMAINS= # CSP_MEDIA_SRC_DOMAINS= (applies to both img-src and media-src) +# CSP_FRAME_ANCESTORS_DOMAINS= (extra parents allowed to iframe this app; appended after 'self'. Overridden by ALLOWED_EXTERNAL_DOMAINS when set.) +# Example: CSP_FRAME_ANCESTORS_DOMAINS=http://localhost:5180 # Google Drive video picker (lesson editor). OAuth Web client ID + Browser API key from Google Cloud Console (Picker API + Drive API enabled). # PUBLIC_GOOGLE_PICKER_CLIENT_ID= @@ -27,9 +29,6 @@ PUBLIC_IS_SELFHOSTED=false # Unsplash setup - Optional if you want to use Unsplash images UNSPLASH_API_KEY= -# OpenAI setup - Optional if you want to use the AI features -OPENAI_API_KEY= - # Sever URL - Optional if you want to use video upload and pdf downloads # This is the deployment URL of apps/api PUBLIC_SERVER_URL= @@ -41,21 +40,3 @@ PRIVATE_SERVER_URL= # The final domain is app.classroomio.com where `app` is PRIVATE_APP_SUBDOMAINS and `classroomio.com` is PRIVATE_APP_HOST PRIVATE_APP_HOST=classroomio.com PRIVATE_APP_SUBDOMAINS=app - - -# ============ CLOUD VERSION SPECIFIC ============ -LEMON_SQUEEZY_API_KEY= -LEMON_SQUEEZY_STORE_ID= -LEMON_SQUEEZY_WEBHOOK_SECRET= - -VITE_SENTRY_AUTH_TOKEN= -VITE_SENTRY_ORG_NAME= -VITE_SENTRY_PROJECT_NAME= - -# Configure https locally https://stackoverflow.com/a/76525335 -USE_HTTPS_ON_LOCALHOST=false - -## Custom Domain -TEAM_ID_VERCEL= -PROJECT_ID_VERCEL= -AUTH_BEARER_TOKEN= diff --git a/apps/dashboard/ROUTE-SPLITTING.md b/apps/dashboard/ROUTE-SPLITTING.md new file mode 100644 index 00000000..9d1862b4 --- /dev/null +++ b/apps/dashboard/ROUTE-SPLITTING.md @@ -0,0 +1,219 @@ +# Route Group Splitting — Optimization for Org Landing Pages + +## Problem + +When a user opens an organization via its subdomain (e.g. `myorg.classroomio.com`), the entire dashboard application was loaded before the landing page could render. This includes heavy dependencies like `UpgradeModal`, `PageLoadProgress`, `appInitApi`, `authClient`, and all theme components — none of which are needed to display a public landing page. + +The result: slow initial load for org landing pages, which should be lightweight and fast. + +## Solution: SvelteKit Route Groups + +Instead of deploying a separate app (which conflicts with the single-domain self-hosted architecture), we split routes into **route groups** with separate layout chains. This leverages SvelteKit's built-in code splitting — each group only loads the JS it needs. + +### Route Group Structure + +``` +routes/ +├── +layout.svelte # Ultra-thin: global CSS, MetaTags, ModeWatcher, Snackbar +├── +layout.server.ts # Root server layout: org detection (getOrgSiteInfo) +├── +page.svelte # Root page: branches between org landing page and dashboard entry +├── _dashboard-entry.svelte # Dynamically imported: auth + setupApp for non-org root visits +│ +├── (org-site)/ # Public org pages — lightweight layout +│ ├── +layout.svelte # Org store setup + optional auth (no login redirect) +│ ├── +layout.ts # Passthrough load +│ ├── courses/ # /courses — public course listing +│ └── course/[slug]/ # /course/[slug] — individual course page + enrollment +│ +├── (app)/ # Authenticated dashboard — full layout +│ ├── +layout.svelte # Auth session, appInitApi.setupApp(), UpgradeModal, etc. +│ ├── +layout.ts # Passthrough load +│ ├── courses/ # Course management +│ ├── org/ # Org settings +│ ├── lms/ # Student LMS +│ ├── programs/ # Programs +│ └── home/ # Dashboard home +│ +├── (auth)/ # Auth pages (login, signup, etc.) +├── invite/ # Invite handling +└── api/ # API routes +``` + +## Key Architecture Decisions + +### 1. Root Layout is Ultra-Thin + +`+layout.svelte` only contains: + +- Global CSS import +- `MetaTags` (with merge from page data) +- `ModeWatcher` (dark mode) +- `Snackbar` + +No auth logic, no org-specific logic, no heavy component imports. This ensures the shared root bundle is minimal for all routes. + +### 2. Root Page (`+page.svelte`) Branches by Context + +The `/` route handles two completely different experiences: + +- **Org site** (`data.isOrgSite === true`): SSR-fetches public courses in `+page.server.ts`, then dynamically imports the theme component via `importThemeComponent()`. Sets up org stores (`currentOrg`, `globalStore`). +- **Non-org site** (`data.isOrgSite === false`): Dynamically imports `_dashboard-entry.svelte`, which handles auth session checking and `appInitApi.setupApp()` for redirect logic. + +The dynamic imports are critical — they prevent the dashboard's auth dependencies from being bundled with the org landing page, and vice versa. + +### 3. `(org-site)` Layout — Lightweight with Optional Auth + +`(org-site)/+layout.svelte` responsibilities: + +- Set up org stores from server data (`currentOrg`, `globalStore.isOrgSite`, theme) +- Set user store from `data.locals` if available +- Check auth session via `authClient.useSession()` +- When session is ready, call `appInitApi.setupApp()` to load full account details +- **Does NOT redirect to `/login`** — these pages are public + +The auth handling is needed so that logged-in users see the correct state (e.g. "Go to LMS" vs "Login" button in the nav). `setupApp` loads the user's organizations and roles, which populates `isOrgStudent` and `basePath`. + +`routeUserToNextPage` inside `setupApp` already handles org-site public routes — it skips redirects when `isPublicRoute(path) && (path !== '/' || isOrgSite)`. + +### 4. `(app)` Layout — Full Auth + App Initialization + +`(app)/+layout.svelte` responsibilities: + +- `authClient.useSession()` with redirect to `/login` for unauthenticated users +- `appInitApi.setupApp()` for full account loading +- `UpgradeModal`, `PageLoadProgress`, `PageRestricted` +- Org store setup from server data + +This layout carries all the heavy dashboard dependencies, which are only loaded for authenticated routes. + +### 5. Dynamic Theme Imports + +Previously, all three landing page themes (bold, classic, minimal) were statically imported, meaning all theme code was bundled even when only one was used. + +Now `importThemeComponent()` in `$features/org/utils/landing-page.ts` uses dynamic imports: + +```typescript +export function importThemeComponent(theme: LandingPageThemeKey) { + switch (theme) { + case 'bold': + return import('@cio/ui/custom/org-landing-page/bold.svelte'); + case 'classic': + return import('@cio/ui/custom/org-landing-page/classic.svelte'); + default: + return import('@cio/ui/custom/org-landing-page/minimal.svelte'); + } +} +``` + +Static imports of all themes are preserved in `$features/org/utils/landing-page-components.ts` — this file is only imported by the dashboard's landing page editor where all themes are needed for preview. + +### 6. SSR for Public Course Data + +`+page.server.ts` fetches public courses server-side for org sites, so the landing page renders with data immediately instead of showing a loading state while fetching client-side. + +## Store Refactoring: `isOrgStudent` + +### Before + +- `isOrgStudent` lived in `$lib/utils/store/org.ts` +- `globalStore` had an `isStudent` field +- Three separate `$effect` blocks in `+page.svelte`, `(org-site)/+layout.svelte`, and `(app)/+layout.svelte` manually synced `isOrgStudent` → `globalStore.isStudent` +- `isStudentExperience` derived from `globalStore.isStudent` + +This was fragile — if any sync `$effect` was removed (as happened during refactoring), `isStudentExperience` and `basePath` would break. + +### After + +- `isOrgStudent` moved to `$lib/utils/store/app.ts`, derived directly from `currentOrg.roleId` +- `globalStore.isStudent` removed entirely +- `isStudentExperience` now derives from `[globalStore, isOrgStudent]` directly — no manual sync needed +- All `$effect` sync blocks removed from layouts and root page +- All component reads of `$globalStore.isStudent` replaced with `$isOrgStudent` + +One source of truth, no manual sync, no fragile `$effect` chains. + +## Auth Flow — Centralized in Root Layout + +Auth session checking and `appInitApi.setupApp()` live in the **root layout** (`+layout.svelte`). This is the single solution that handles auth for all routes — no per-group duplication, no route audits. + +### Why centralized? + +An earlier iteration put auth in each group layout (`(app)`, `(org-site)`, `_dashboard-entry`). This broke when new route groups (like `(auth)`) didn't have auth handling — authenticated users on `/login` or `/signup` would see infinite spinners instead of being redirected. Centralizing eliminates that class of bugs entirely. + +### What the root layout does + +1. `authClient.useSession()` — checks for an active session on every route +2. When session is ready, calls `appInitApi.setupApp()` — loads full account (profile, orgs, roles) +3. `setupApp` → `routeUserToNextPage()` handles redirects based on context: + - On `/login`, `/signup`, `/onboarding`, or `/`: redirects to `/lms` or `/org/[slug]` based on role + - On public org-site routes (`/courses`, `/course/[slug]`): skips redirect (user stays on page with correct login state) + - On other public routes: skips redirect +4. Sets up user store, org stores, and theme from server data +5. No redirect to `/login` — that's the `(app)` layout's job (protected routes only) + +### What each group layout does + +- **`(app)/+layout.svelte`**: Redirects to `/login` if unauthenticated (protected routes). Renders `UpgradeModal`, `PageLoadProgress`, `PageRestricted`. +- **`(org-site)/+layout.svelte`**: `PageRestricted` gating only. No auth logic — root handles it. +- **`(auth)` group**: No layout file needed. Root layout handles session → `setupApp` → redirect away if already authenticated. + +### Flow: login on org site + +1. User logs in at `/login` → redirected to `/` +2. Root layout detects session, calls `setupApp` → loads account details +3. `routeUserToNextPage` sees `isPublicRoute('/') && isOrgSite` → skips redirect +4. User sees landing page with correct login state ("Go to LMS" button) + +### Flow: login on non-org site + +1. User visits `/` → root layout calls `setupApp` +2. `routeUserToNextPage` redirects to `/lms`, `/org/[slug]`, or `/onboarding` based on role +3. `+page.svelte` shows spinner while redirect happens + +### Flow: authenticated user hits /login + +1. Root layout detects session, calls `setupApp` +2. `routeUserToNextPage` sees `shouldRedirectOnAuth('/login')` → true +3. Redirects to `/lms` or `/org/[slug]` + +## Files Changed + +### Store layer + +- `apps/dashboard/src/lib/utils/store/app.ts` — added `isOrgStudent`, removed `globalStore.isStudent`, rewrote `isStudentExperience` +- `apps/dashboard/src/lib/utils/store/org.ts` — removed `isOrgStudent` export +- `apps/dashboard/src/lib/features/app/init.svelte.ts` — updated `isOrgStudent` import path + +### Layout layer + +- `apps/dashboard/src/routes/+layout.svelte` — stripped to minimal +- `apps/dashboard/src/routes/+page.svelte` — branching logic for org vs non-org +- `apps/dashboard/src/routes/_dashboard-entry.svelte` — new, handles non-org auth +- `apps/dashboard/src/routes/(org-site)/+layout.svelte` — org store setup + optional auth +- `apps/dashboard/src/routes/(app)/+layout.svelte` — full auth + app init + +### Component layer (isOrgStudent migration) + +- `features/course/pages/exercise.svelte` +- `features/course/pages/lesson.svelte` +- `features/course/pages/attendance.svelte` +- `features/course/components/exercise/view-mode.svelte` +- `features/course/components/lesson/content-list.svelte` +- `features/course/components/lesson/content-navigation-actions.svelte` +- `features/course/components/lesson/content-section-list.svelte` +- `features/course/components/lesson/lesson-page-edit-header.svelte` +- `features/course/components/sidebar/course-sidebar.svelte` +- `routes/(app)/courses/[id]/exercises/[exerciseId]/+page.svelte` + +### Landing page optimization + +- `features/org/utils/landing-page.ts` — dynamic `importThemeComponent()` +- `features/org/utils/landing-page-components.ts` — new, static imports for editor only +- `routes/+page.server.ts` — SSR course data for org sites + +## Remaining Work / Known Issues + +- **Compliance utils (`isSelfPacedLikeCourse`)**: Several components (`exercise.svelte`, `view-mode.svelte`, `content-list.svelte`, `content-section-list.svelte`) had `isSelfPacedLikeCourse` from `compliance-utils.ts` mixed into the `isOrgStudent` commit. These have been reverted to inline `=== 'SELF_PACED'` checks and should be re-introduced in a dedicated compliance commit. +- **`$inspect` and `console.log` cleanup**: `(app)/+layout.svelte` contains `$inspect('session', $session)` and env-logging `console.log` calls that should be removed before production. +- **Bundle size verification**: Run `pnpm --filter @cio/dashboard build` and compare chunk sizes for org-site vs app routes to confirm the code splitting is effective. +- **`(org-site)` course pages**: `courses/+page.svelte` and `course/[slug]/+page.svelte` still statically import nav/hero components from `@cio/ui/custom/org-landing-page`. These could be dynamically imported for further optimization. diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index f4d6de1b..40169299 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -19,6 +19,7 @@ "translate": "node ./scripts/translate.cjs" }, "dependencies": { + "@ai-sdk/svelte": "^4.0.158", "@better-auth/sso": "1.4.18", "@cio/api": "workspace:*", "@cio/db": "workspace:*", @@ -35,7 +36,7 @@ "@tailwindcss/forms": "^0.5.4", "@tailwindcss/postcss": "^4.1.17", "@types/pluralize": "0.0.30", - "ai": "^2.1.31", + "ai": "^6.0.158", "axios": "^1.8.4", "better-auth": "^1.4.18", "bits-ui": "^2.14.4", @@ -58,6 +59,7 @@ "jspdf-autotable": "^3.8.2", "ky": "^1.10.0", "lodash": "^4.17.21", + "marked": "^7.0.5", "openai-edge": "^1.2.2", "papaparse": "^5.4.1", "pluralize": "^8.0.0", diff --git a/apps/dashboard/src/lib/features/ai-assistant/ai-course-chat.svelte b/apps/dashboard/src/lib/features/ai-assistant/ai-course-chat.svelte new file mode 100644 index 00000000..1751137e --- /dev/null +++ b/apps/dashboard/src/lib/features/ai-assistant/ai-course-chat.svelte @@ -0,0 +1,586 @@ + + +{#snippet chatPanelContent()} + + + + + +{/snippet} + +{#if $isAiAssistantOpen} +
+ + +
+ + + {@render chatPanelContent()} +
+
+{/if} + + diff --git a/apps/dashboard/src/lib/features/ai-assistant/api/ai-assistant.svelte.ts b/apps/dashboard/src/lib/features/ai-assistant/api/ai-assistant.svelte.ts new file mode 100644 index 00000000..0b570a2e --- /dev/null +++ b/apps/dashboard/src/lib/features/ai-assistant/api/ai-assistant.svelte.ts @@ -0,0 +1,171 @@ +import { BaseApiWithErrors, classroomio, apiClient, getRequestBaseUrl } from '$lib/utils/services/api'; +import type { + AgentConversation, + AgentConversationCreateData, + AgentConversationSummary, + AgentStatusData, + AiAssistantMessage +} from '../utils/types'; + +class AiAssistantApi extends BaseApiWithErrors { + status: AgentStatusData | null = $state(null); + conversations: AgentConversationSummary[] = $state([]); + currentConversation: AgentConversation | null = $state(null); + + async fetchStatus(courseId: string) { + await this.execute({ + requestFn: () => + classroomio.agent.status.$get({ + query: { courseId } + }), + logContext: 'fetching agent status', + onSuccess: (result) => { + this.status = result.data; + } + }); + } + + async listConversations(courseId: string) { + await this.execute({ + requestFn: () => + classroomio.agent.history.$get({ + query: { courseId } + }), + logContext: 'listing conversations', + onSuccess: (result) => { + this.conversations = result.data as AgentConversationSummary[]; + } + }); + } + + async loadConversation(conversationId: string) { + await this.execute<(typeof classroomio.agent.history)[':conversationId']['$get']>({ + requestFn: () => + classroomio.agent.history[':conversationId'].$get({ + param: { conversationId } + }), + logContext: 'loading conversation', + onSuccess: (result) => { + this.currentConversation = { + ...(result.data as AgentConversation), + messages: ((result.data as AgentConversation).messages ?? []) as AiAssistantMessage[] + }; + } + }); + } + + async createConversation(courseId: string, title?: string): Promise<{ id: string } | null> { + let created: { id: string } | null = null; + + await this.execute({ + requestFn: () => + classroomio.agent.history.$post({ + json: { courseId, title } + }), + logContext: 'creating conversation', + onSuccess: (result) => { + const newConversation = result.data as AgentConversationCreateData; + created = newConversation; + + this.conversations = [ + { ...newConversation, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, + ...this.conversations + ]; + } + }); + + return created; + } + + async saveMessages(conversationId: string, messages: AiAssistantMessage[], title?: string) { + await this.execute<(typeof classroomio.agent.history)[':conversationId']['$put']>({ + requestFn: () => + classroomio.agent.history[':conversationId'].$put({ + param: { conversationId }, + json: { messages, title } + }), + logContext: 'saving messages' + }); + } + + async deleteConversation(conversationId: string) { + await this.execute<(typeof classroomio.agent.history)[':conversationId']['$delete']>({ + requestFn: () => + classroomio.agent.history[':conversationId'].$delete({ + param: { conversationId } + }), + logContext: 'deleting conversation', + onSuccess: () => { + this.conversations = this.conversations.filter((c) => c.id !== conversationId); + + if (this.currentConversation?.id === conversationId) { + this.currentConversation = null; + } + } + }); + } + + async uploadDocument( + file: File, + courseId: string, + conversationId: string + ): Promise<{ documentId: string; fileName: string; wordCount: number; truncated: boolean } | null> { + const formData = new FormData(); + formData.append('file', file); + + try { + const url = + `${getRequestBaseUrl()}/agent/upload` + + `?courseId=${encodeURIComponent(courseId)}` + + `&conversationId=${encodeURIComponent(conversationId)}`; + + const response = await apiClient.request(url, { + method: 'POST', + body: formData, + credentials: 'include' + }); + const result = (await response.json()) as { + success: boolean; + data?: { documentId: string; fileName: string; wordCount: number; truncated: boolean }; + }; + + if (result.success && result.data) { + return result.data; + } + } catch (error) { + console.error('Error uploading document:', error); + this.error = 'Failed to upload document'; + } + + return null; + } + + async generateTitle(conversationId: string, firstMessageText: string): Promise { + let generatedTitle: string | null = null; + + await this.execute<(typeof classroomio.agent.history)[':conversationId']['generate-title']['$post']>({ + requestFn: () => + classroomio.agent.history[':conversationId']['generate-title'].$post({ + param: { conversationId }, + json: { firstMessageText } + }), + logContext: 'generating title', + onSuccess: (result) => { + generatedTitle = (result.data as { title: string }).title; + + // Update the title in the local conversations list + this.conversations = this.conversations.map((c) => + c.id === conversationId ? { ...c, title: generatedTitle } : c + ); + + if (this.currentConversation?.id === conversationId) { + this.currentConversation = { ...this.currentConversation, title: generatedTitle }; + } + } + }); + + return generatedTitle; + } +} + +export const aiAssistantApi = new AiAssistantApi(); diff --git a/apps/dashboard/src/lib/features/ai-assistant/chat-header.svelte b/apps/dashboard/src/lib/features/ai-assistant/chat-header.svelte new file mode 100644 index 00000000..6c5c9cc2 --- /dev/null +++ b/apps/dashboard/src/lib/features/ai-assistant/chat-header.svelte @@ -0,0 +1,119 @@ + + +
+
+
+ +
+

{$t('course.navItems.nav_ai_assistant')}

+ {#if conversationTitle && conversationTitle !== 'New conversation'} +

{conversationTitle}

+ {/if} +
+
+
+ + + + + + + + + + + + + + + + + + +
+
+ {#if tokenUsage && tokenUsage.allowance > 0} + {@const usagePercent = Math.min(100, Math.round((tokenUsage.used / tokenUsage.allowance) * 100))} +
+
+ {tokenUsage.used.toLocaleString()} / {tokenUsage.allowance.toLocaleString()} + {$t('ai_assistant.tokens_label')} + {usagePercent}% +
+
+
+
+
+ {/if} +
diff --git a/apps/dashboard/src/lib/features/ai-assistant/chat-history-popover.svelte b/apps/dashboard/src/lib/features/ai-assistant/chat-history-popover.svelte new file mode 100644 index 00000000..69380169 --- /dev/null +++ b/apps/dashboard/src/lib/features/ai-assistant/chat-history-popover.svelte @@ -0,0 +1,54 @@ + + +
+

{$t('ai_assistant.chat_history')}

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

{$t('ai_assistant.no_conversations')}

+ {:else} + {#each conversations as conversation (conversation.id)} +
+ + +
+ {/each} + {/if} +
diff --git a/apps/dashboard/src/lib/features/ai-assistant/chat-input.svelte b/apps/dashboard/src/lib/features/ai-assistant/chat-input.svelte new file mode 100644 index 00000000..448f48d6 --- /dev/null +++ b/apps/dashboard/src/lib/features/ai-assistant/chat-input.svelte @@ -0,0 +1,190 @@ + + + + +
+ {#if isExhausted} +
+ {$t('ai_assistant.tokens_exhausted')} +
+ {:else} + {#if error} +
+ {error.message} +
+ {/if} + + {#if isUploading} +
+ + {$t('ai_assistant.uploading_document')} +
+ {:else if uploadedDocument} +
+ + {uploadedDocument.name} + +
+ {/if} + +
+ + {#snippet icon({ item })} + {#if item.type === 'EXERCISE'} + + {:else if item.type === 'SECTION'} + + {:else} + + {/if} + {/snippet} + + {#snippet actions()} + + +
+ + {#if isStreaming} + + {:else} + + {/if} + {/snippet} +
+
+ {/if} +
diff --git a/apps/dashboard/src/lib/features/ai-assistant/chat-message-list.svelte b/apps/dashboard/src/lib/features/ai-assistant/chat-message-list.svelte new file mode 100644 index 00000000..b96e0e29 --- /dev/null +++ b/apps/dashboard/src/lib/features/ai-assistant/chat-message-list.svelte @@ -0,0 +1,143 @@ + + +
+ {#if isEmpty} + +
+
+ +

+ {$t('ai_assistant.empty_state')} +

+
+ +
+ {#each quickActions as action (action)} + + {/each} +
+
+ {:else} + +
+ {#each messages as message (message.id)} + + {/each} + + {#if status === 'submitted' && isStreaming} +
+
+ +
+
+ {/if} + + {#if planExecutionState} +
+ + {#if planExecutionState.isStopped} + + {/if} +
+ {/if} +
+ {/if} +
diff --git a/apps/dashboard/src/lib/features/ai-assistant/index.ts b/apps/dashboard/src/lib/features/ai-assistant/index.ts new file mode 100644 index 00000000..76a02b52 --- /dev/null +++ b/apps/dashboard/src/lib/features/ai-assistant/index.ts @@ -0,0 +1,4 @@ +export { default as AiCourseChat } from './ai-course-chat.svelte'; +export { default as PlanView } from './plan-view.svelte'; +export { default as ProgressCard } from './progress-card.svelte'; +export { isAiAssistantOpen, openAiAssistant, closeAiAssistant, toggleAiAssistant } from './utils/store'; diff --git a/apps/dashboard/src/lib/features/ai-assistant/mention-popover.svelte b/apps/dashboard/src/lib/features/ai-assistant/mention-popover.svelte new file mode 100644 index 00000000..d8172aff --- /dev/null +++ b/apps/dashboard/src/lib/features/ai-assistant/mention-popover.svelte @@ -0,0 +1,53 @@ + + + + {#snippet icon({ item })} + {#if item.type === 'EXERCISE'} + + {:else} + + {/if} + {/snippet} + diff --git a/apps/dashboard/src/lib/features/ai-assistant/message-bubble.svelte b/apps/dashboard/src/lib/features/ai-assistant/message-bubble.svelte new file mode 100644 index 00000000..d7079438 --- /dev/null +++ b/apps/dashboard/src/lib/features/ai-assistant/message-bubble.svelte @@ -0,0 +1,205 @@ + + +
+ +
+ {#if messageAttachment} +
+ + {messageAttachment.name} +
+ {/if} + + {#each inlineParts as part, partIndex (partIndex)} + {#if part.type === 'text'} +
+ + {@html renderMentions(renderMarkdown(part.text as string), courseId)} +
+ {:else if isAgentToolPart(part)} + {@const toolName = getAgentToolName(part)} + {@const toolResult = getAgentToolResult(part)} + {@const toolStatus = getAgentToolStatus(part)} + {@const errorText = getPartErrorText(part)} + {#if toolName === 'generate_course_plan' && toolStatus === 'completed'} + + {:else} +
+ {#if toolStatus === 'completed' && toolName} + + {getToolResultLabel(toolName, toolResult)} + {:else if toolStatus === 'failed' && toolName} + + + {errorText ? truncateErrorText(errorText) : `${getToolLabel(toolName)} failed`} + + {#if errorText} + + {/if} + {:else if toolStatus === 'in_progress' && toolName} + + {getToolLabel(toolName)} + {:else} + + {toolName ? getToolLabel(toolName) : 'Working'} + {/if} +
+ {/if} + {/if} + {/each} + + {#each deferredPlanParts as part, partIndex (partIndex)} + {@const toolName = getAgentToolName(part)} + {@const toolResult = getAgentToolResult(part)} + {@const toolStatus = getAgentToolStatus(part)} + {@const errorText = getPartErrorText(part)} + {#if toolName === 'generate_course_plan' && toolStatus === 'completed'} +
+ +
+ {:else} +
+ {#if toolStatus === 'completed' && toolName} + + {getToolResultLabel(toolName, toolResult)} + {:else if toolStatus === 'failed' && toolName} + + + {errorText ? truncateErrorText(errorText) : `${getToolLabel(toolName)} failed`} + + {#if errorText} + + {/if} + {:else if toolStatus === 'in_progress' && toolName} + + {getToolLabel(toolName)} + {:else} + + {toolName ? getToolLabel(toolName) : 'Working'} + {/if} +
+ {/if} + {/each} +
+
+ + diff --git a/apps/dashboard/src/lib/features/ai-assistant/model-picker.svelte b/apps/dashboard/src/lib/features/ai-assistant/model-picker.svelte new file mode 100644 index 00000000..b1b25347 --- /dev/null +++ b/apps/dashboard/src/lib/features/ai-assistant/model-picker.svelte @@ -0,0 +1,49 @@ + + + + + {AGENT_MODELS[value].label} + + + + +
+ {#each UI_PICKER_MODEL_IDS as id (id)} + {@const isSelected = id === value} + + {/each} +
+
+
diff --git a/apps/dashboard/src/lib/features/ai-assistant/plan-view.svelte b/apps/dashboard/src/lib/features/ai-assistant/plan-view.svelte new file mode 100644 index 00000000..bdae91d3 --- /dev/null +++ b/apps/dashboard/src/lib/features/ai-assistant/plan-view.svelte @@ -0,0 +1,263 @@ + + +
+ +
+ {#if editingField === 'plan-title'} + + {:else} + + {/if} +

+ {editablePlan.sections.length} sections, {totalLessons} lessons{totalExercises > 0 + ? `, ${totalExercises} exercises` + : ''} +

+
+ + +
+ {#each editablePlan.sections as section, sectionIndex (sectionIndex)} +
+
+ + + {#if editingField === `section-${sectionIndex}-title`} + + {:else} + + {/if} +
+ + {#if expandedSections.has(sectionIndex)} +
+ {#each section.items as item, itemIndex (item.order)} +
+ {#if item.type === 'exercise'} + + {:else} + + {/if} +
+ {#if editingField === `item-${sectionIndex}-${itemIndex}-title`} + + {:else} + + {/if} + + {#if editingField === `item-${sectionIndex}-${itemIndex}-desc`} + + {:else} + + {/if} + + {#if item.type === 'lesson' && item.hasExercise} +
+ + Exercise included +
+ {/if} +
+
+ {/each} +
+ {/if} +
+ {/each} +
+ + +
+ {#if estimatedTokens > 0} +

+ Estimated cost: ~{estimatedTokens.toLocaleString()} tokens + {#if costPercentage !== null} + ({costPercentage}% of remaining) + {/if} +

+ {/if} + +
+ + +
+
+
diff --git a/apps/dashboard/src/lib/features/ai-assistant/progress-card.svelte b/apps/dashboard/src/lib/features/ai-assistant/progress-card.svelte new file mode 100644 index 00000000..dff9ceb1 --- /dev/null +++ b/apps/dashboard/src/lib/features/ai-assistant/progress-card.svelte @@ -0,0 +1,78 @@ + + +
+
+

{title}

+

+ {completedCount} / {totalCount} steps completed +

+
+ +
+ {#each steps as step, i (`${i}-${step.label}`)} +
+ {#if step.status === 'completed'} + + {:else if step.status === 'in_progress'} + + {:else if step.status === 'failed'} + + {:else} + + {/if} + + {step.label} + +
+ {/each} +
+ +
+ {#if error} +

{error}

+ {/if} + + {#if currentAction && !isStopped} +

{currentAction}

+ {/if} + + {#if isStopped} +

{$t('ai_assistant.stopped_content_kept')}

+ {/if} + + {#if onStop && !isStopped && completedCount < totalCount} + + {/if} +
+
diff --git a/apps/dashboard/src/lib/features/ai-assistant/tool-invocation-badge.svelte b/apps/dashboard/src/lib/features/ai-assistant/tool-invocation-badge.svelte new file mode 100644 index 00000000..4f892b47 --- /dev/null +++ b/apps/dashboard/src/lib/features/ai-assistant/tool-invocation-badge.svelte @@ -0,0 +1,27 @@ + + +
+ {#if state === 'call' || state === 'partial-call'} + + + {getToolLabel(toolName)}... + + {:else if state === 'result'} + + + {getToolResultLabel(toolName, result)} + + {/if} +
diff --git a/apps/dashboard/src/lib/features/ai-assistant/utils/constants.ts b/apps/dashboard/src/lib/features/ai-assistant/utils/constants.ts new file mode 100644 index 00000000..b5927876 --- /dev/null +++ b/apps/dashboard/src/lib/features/ai-assistant/utils/constants.ts @@ -0,0 +1,5 @@ +export const AI_COURSE_CHAT_DEFAULT_WIDTH = 360; +export const AI_COURSE_CHAT_MIN_WIDTH = 280; +export const AI_COURSE_CHAT_MAX_WIDTH = 520; +export const AI_COURSE_CHAT_STORAGE_KEY = 'classroomio-ai-course-chat-width'; +export const AI_CHAT_MODEL_STORAGE_KEY = 'classroomio-ai-chat-model'; diff --git a/apps/dashboard/src/lib/features/ai-assistant/utils/markdown.ts b/apps/dashboard/src/lib/features/ai-assistant/utils/markdown.ts new file mode 100644 index 00000000..5dbc60af --- /dev/null +++ b/apps/dashboard/src/lib/features/ai-assistant/utils/markdown.ts @@ -0,0 +1,17 @@ +import { marked } from 'marked'; + +// Configure marked for safe, minimal output +marked.setOptions({ + breaks: true, + gfm: true +}); + +/** + * Convert markdown text to sanitized HTML for rendering in the chat. + * Agent responses come as markdown — this makes them render properly. + */ +export function renderMarkdown(text: string): string { + if (!text) return ''; + + return marked.parse(text, { async: false }) as string; +} diff --git a/apps/dashboard/src/lib/features/ai-assistant/utils/mentions.ts b/apps/dashboard/src/lib/features/ai-assistant/utils/mentions.ts new file mode 100644 index 00000000..48ec84c3 --- /dev/null +++ b/apps/dashboard/src/lib/features/ai-assistant/utils/mentions.ts @@ -0,0 +1,65 @@ +type MentionType = 'lesson' | 'exercise' | 'section'; + +/** + * Mention format: @[Title](type:id) + * Where type is "lesson", "exercise", or "section" and id is the content item ID. + */ +const RAW_MENTION_REGEX = /@\[([^\]]+)\]\((lesson|exercise|section):([a-zA-Z0-9_-]+)\)/gi; +const HTML_LINK_MENTION_REGEX = /@?]*href=(["'])(lesson|exercise|section):([a-zA-Z0-9_-]+)\1[^>]*>(.*?)<\/a>/gi; + +function normalizeMentionType(type: string): MentionType | null { + const normalizedType = type.toLowerCase(); + + if (normalizedType === 'lesson' || normalizedType === 'exercise' || normalizedType === 'section') { + return normalizedType; + } + + return null; +} + +function getMentionRoute(courseId: string, type: MentionType, id: string) { + if (type === 'exercise') return `/courses/${courseId}/exercises/${id}`; + if (type === 'section') return `/courses/${courseId}/lessons#section-${id}`; + + return `/courses/${courseId}/lessons/${id}`; +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function buildMentionLink(route: string, label: string, title: string) { + return `${label}`; +} + +/** + * Replace AI lesson/exercise mentions with real in-app links. + * Handles both raw mention syntax and markdown-generated anchor tags. + */ +export function renderMentions(html: string, courseId: string): string { + const linksWithRoutes = html.replace( + HTML_LINK_MENTION_REGEX, + (match, _quote: string, type: string, id: string, label: string) => { + const mentionType = normalizeMentionType(type); + + if (!mentionType) return match; + + const route = getMentionRoute(courseId, mentionType, id); + return buildMentionLink(route, label, `${mentionType}: ${id}`); + } + ); + + return linksWithRoutes.replace(RAW_MENTION_REGEX, (match, title: string, type: string, id: string) => { + const mentionType = normalizeMentionType(type); + + if (!mentionType) return match; + + const route = getMentionRoute(courseId, mentionType, id); + return buildMentionLink(route, escapeHtml(title), `${mentionType}: ${title}`); + }); +} diff --git a/apps/dashboard/src/lib/features/ai-assistant/utils/store.ts b/apps/dashboard/src/lib/features/ai-assistant/utils/store.ts new file mode 100644 index 00000000..d238501d --- /dev/null +++ b/apps/dashboard/src/lib/features/ai-assistant/utils/store.ts @@ -0,0 +1,16 @@ +import { writable } from 'svelte/store'; + +/** Controls whether the AI assistant right sidebar Sheet is open. */ +export const isAiAssistantOpen = writable(false); + +export function openAiAssistant() { + isAiAssistantOpen.set(true); +} + +export function closeAiAssistant() { + isAiAssistantOpen.set(false); +} + +export function toggleAiAssistant() { + isAiAssistantOpen.update((open) => !open); +} diff --git a/apps/dashboard/src/lib/features/ai-assistant/utils/tool-labels.ts b/apps/dashboard/src/lib/features/ai-assistant/utils/tool-labels.ts new file mode 100644 index 00000000..51b570f6 --- /dev/null +++ b/apps/dashboard/src/lib/features/ai-assistant/utils/tool-labels.ts @@ -0,0 +1,93 @@ +const TOOL_LABELS: Record = { + get_course_structure: 'Reading course structure', + get_lesson_content: 'Reading lesson content', + get_exercise_details: 'Reading exercise', + create_section: 'Creating section', + update_section: 'Updating section', + create_lesson: 'Creating lesson', + update_lesson: 'Updating lesson', + update_lesson_content: 'Writing lesson content', + create_exercise: 'Creating exercise', + update_exercise: 'Updating exercise', + add_questions: 'Adding questions', + update_questions: 'Updating questions', + reorder_content: 'Reordering content', + update_course_landing_page: 'Updating landing page', + check_course_go_live_readiness: 'Checking go-live readiness', + go_live_course: 'Publishing course', + generate_course_plan: 'Generating course plan' +}; + +export function getToolLabel(toolName: string): string { + return TOOL_LABELS[toolName] || toolName; +} + +export function getToolResultLabel(toolName: string, result: unknown): string { + const r = result as Record; + + switch (toolName) { + case 'create_section': + return `Created section: ${r.title}`; + case 'update_section': + return `Updated section: ${r.title}`; + case 'create_lesson': + return `Created lesson: ${r.title}`; + case 'update_lesson': + return `Updated lesson: ${r.title}`; + case 'update_lesson_content': + return `Wrote ${r.contentLength} chars`; + case 'create_exercise': + return `Created exercise with ${r.questionCount} questions`; + case 'update_exercise': + return `Updated exercise: ${r.title}`; + case 'add_questions': + return `Added ${r.addedCount} questions`; + case 'update_questions': + return `Updated ${r.updatedCount} questions`; + case 'get_course_structure': + return 'Course structure loaded'; + case 'get_lesson_content': + return 'Lesson content loaded'; + case 'get_exercise_details': + return 'Exercise loaded'; + case 'reorder_content': + return 'Content reordered'; + case 'update_course_landing_page': + return `Updated landing page: ${r.title}`; + case 'check_course_go_live_readiness': { + const blockerCount = Array.isArray(r.blockers) ? r.blockers.length : 0; + const warningCount = Array.isArray(r.warnings) ? r.warnings.length : 0; + + return blockerCount === 0 + ? `Ready to go live${warningCount > 0 ? ` with ${warningCount} warnings` : ''}` + : `${blockerCount} blockers before go-live`; + } + case 'go_live_course': + return r.isPublished ? `Published course: ${r.title}` : 'Course was not published'; + case 'generate_course_plan': + return 'Course plan generated'; + default: + return 'Done'; + } +} + +export const MUTATION_TOOLS = [ + 'create_section', + 'update_section', + 'create_lesson', + 'update_lesson', + 'update_lesson_content', + 'create_exercise', + 'update_exercise', + 'add_questions', + 'update_questions', + 'reorder_content', + 'update_course_landing_page', + 'go_live_course' +]; + +export interface ProgressStep { + label: string; + status: 'pending' | 'in_progress' | 'completed' | 'failed'; + indent?: boolean; +} diff --git a/apps/dashboard/src/lib/features/ai-assistant/utils/tool-parts.ts b/apps/dashboard/src/lib/features/ai-assistant/utils/tool-parts.ts new file mode 100644 index 00000000..992ea5f7 --- /dev/null +++ b/apps/dashboard/src/lib/features/ai-assistant/utils/tool-parts.ts @@ -0,0 +1,62 @@ +import type { ProgressStep } from './tool-labels'; + +export interface AgentToolPart { + type: string; + state?: string; + input?: unknown; + output?: unknown; + errorText?: string; + toolInvocation?: { + toolName?: string; + result?: unknown; + }; +} + +export function isAgentToolPart(part: Record): part is AgentToolPart { + if (part.type === 'tool-invocation') { + return true; + } + + return typeof part.type === 'string' && part.type.startsWith('tool-'); +} + +export function getAgentToolName(part: AgentToolPart): string | null { + if (part.type === 'tool-invocation') { + return part.toolInvocation?.toolName ?? null; + } + + if (!part.type.startsWith('tool-')) { + return null; + } + + return part.type.slice(5); +} + +export function getAgentToolResult(part: AgentToolPart): unknown { + if (part.type === 'tool-invocation') { + return part.toolInvocation?.result; + } + + return part.output; +} + +export function getAgentToolStatus(part: AgentToolPart): ProgressStep['status'] { + if (part.state === 'result' || part.state === 'output-available') { + return 'completed'; + } + + if (part.state === 'output-error') { + return 'failed'; + } + + if ( + part.state === 'call' || + part.state === 'partial-call' || + part.state === 'input-streaming' || + part.state === 'input-available' + ) { + return 'in_progress'; + } + + return 'pending'; +} diff --git a/apps/dashboard/src/lib/features/ai-assistant/utils/types.ts b/apps/dashboard/src/lib/features/ai-assistant/utils/types.ts new file mode 100644 index 00000000..35039a8d --- /dev/null +++ b/apps/dashboard/src/lib/features/ai-assistant/utils/types.ts @@ -0,0 +1,49 @@ +import type { UIMessage } from 'ai'; +import { classroomio, type InferResponseType } from '$lib/utils/services/api'; + +export interface UploadedDocument { + id: string; + name: string; +} + +export interface AiAssistantMessageAttachment { + documentId: string; + name: string; +} + +export interface AiAssistantMessageMetadata { + attachment?: AiAssistantMessageAttachment; +} + +export type AiAssistantMessage = UIMessage; + +export type AgentStatusRequest = typeof classroomio.agent.status.$get; +export type AgentStatusSuccess = Extract, { success: true }>; +export type AgentStatusData = AgentStatusSuccess['data']; + +export type AgentUsageRequest = typeof classroomio.agent.usage.$get; +export type AgentUsageSuccess = Extract, { success: true }>; +export type AgentUsageData = AgentUsageSuccess['data']; + +export type AgentHistoryGetRequest = typeof classroomio.agent.history.$get; +export type AgentHistoryGetSuccess = Extract, { success: true }>; +export type AgentHistoryData = AgentHistoryGetSuccess['data']; + +export type AgentConversationSummary = AgentHistoryData[number]; + +export type AgentConversationRequest = (typeof classroomio.agent.history)[':conversationId']['$get']; +export type AgentConversationSuccess = Extract, { success: true }>; +export type AgentConversation = Omit & { + messages: AiAssistantMessage[]; +}; + +export type AgentConversationCreateRequest = typeof classroomio.agent.history.$post; +export type AgentConversationCreateSuccess = Extract< + InferResponseType, + { success: true } +>; +export type AgentConversationCreateData = AgentConversationCreateSuccess['data']; + +export type AgentHistorySaveRequest = (typeof classroomio.agent.history)[':conversationId']['$put']; + +export type AgentHistoryDeleteRequest = (typeof classroomio.agent.history)[':conversationId']['$delete']; diff --git a/apps/dashboard/src/lib/features/audience/pages/import.svelte b/apps/dashboard/src/lib/features/audience/pages/import.svelte index 9934e48c..b9cf0662 100644 --- a/apps/dashboard/src/lib/features/audience/pages/import.svelte +++ b/apps/dashboard/src/lib/features/audience/pages/import.svelte @@ -127,38 +127,36 @@ {/if} - {#if programs.length > 0} -
- - -
- - -
-
- - -
-
- - -
-
+
+ + +
+ + +
+
+ + +
+
+ + +
+
- {#if programAccessMode === 'select'} - ({ id: p.id, label: p.name || p.id }))} - isSelected={(id) => selectedProgramIds.has(id)} - onToggle={toggleProgram} - namePrefix="import-program" - /> - {/if} -
- {/if} + {#if programAccessMode === 'select'} + ({ id: p.id, label: p.name || p.id }))} + isSelected={(id) => selectedProgramIds.has(id)} + onToggle={toggleProgram} + namePrefix="import-program" + /> + {/if} +
diff --git a/apps/dashboard/src/lib/features/course/api/compliance.svelte.ts b/apps/dashboard/src/lib/features/course/api/compliance.svelte.ts new file mode 100644 index 00000000..aeaf3284 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/api/compliance.svelte.ts @@ -0,0 +1,218 @@ +import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api'; +import type { + CourseComplianceOverview, + ExtendCourseComplianceRequest, + GetCourseComplianceOverviewRequest, + GetLearnerComplianceHistoryRequest, + LearnerComplianceHistory, + ResetCourseComplianceRequest, + WaiveCourseComplianceRequest +} from '../utils/types'; +import { snackbar } from '$features/ui/snackbar/store'; +import { + ZCourseComplianceExtend, + ZCourseComplianceReset, + ZCourseComplianceWaive, + type TCourseComplianceExtend, + type TCourseComplianceReset, + type TCourseComplianceWaive +} from '@cio/utils/validation/course'; +import { mapZodErrorsToTranslations } from '$lib/utils/validation'; + +export class ComplianceApi extends BaseApiWithErrors { + overview = $state(null); + learnerHistory = $state(null); + + private overviewKey = $state(null); + private learnerHistoryKey = $state(null); + + async getOverview(courseId: string) { + const result = await this.execute({ + requestFn: () => + classroomio.course[':courseId'].compliance.$get({ + param: { courseId } + }), + logContext: 'fetching course compliance overview', + onSuccess: (response) => { + this.overview = response.data; + this.overviewKey = courseId; + this.success = true; + this.errors = {}; + }, + onError: (result) => { + if (typeof result === 'string') { + snackbar.error('snackbar.course.compliance.overview_failed'); + } + } + }); + + return result?.data; + } + + async ensureOverview(courseId: string) { + if (this.overviewKey === courseId && this.overview) { + return this.overview; + } + + return this.getOverview(courseId); + } + + async getLearnerHistory(courseId: string, profileId: string) { + const result = await this.execute({ + requestFn: () => + classroomio.course[':courseId'].compliance.learners[':profileId'].$get({ + param: { courseId, profileId } + }), + logContext: 'fetching learner compliance history', + onSuccess: (response) => { + this.learnerHistory = response.data; + this.learnerHistoryKey = `${courseId}:${profileId}`; + this.success = true; + this.errors = {}; + }, + onError: (result) => { + if (typeof result === 'string') { + snackbar.error('snackbar.course.compliance.history_failed'); + } + } + }); + + return result?.data; + } + + async ensureLearnerHistory(courseId: string, profileId: string) { + const requestKey = `${courseId}:${profileId}`; + if (this.learnerHistoryKey === requestKey && this.learnerHistory) { + return this.learnerHistory; + } + + return this.getLearnerHistory(courseId, profileId); + } + + async resetCourseCompliance(courseId: string, fields: TCourseComplianceReset) { + const result = ZCourseComplianceReset.safeParse(fields); + if (!result.success) { + this.errors = mapZodErrorsToTranslations(result.error, 'course'); + return; + } + + const response = await this.execute({ + requestFn: () => + classroomio.course[':courseId'].compliance.reset.$post({ + param: { courseId }, + json: result.data + }), + logContext: 'resetting course compliance', + onSuccess: () => { + snackbar.success('snackbar.course.compliance.reset_success'); + this.success = true; + this.errors = {}; + }, + onError: (result) => { + if (typeof result === 'string') { + snackbar.error('snackbar.course.compliance.reset_failed'); + return; + } + + if ('error' in result && 'field' in result && result.field) { + this.errors[result.field] = result.error; + } + } + }); + + if (response?.data) { + this.overviewKey = null; + await this.getOverview(courseId); + } + + return response?.data; + } + + async extendCourseCompliance(courseId: string, fields: TCourseComplianceExtend) { + const result = ZCourseComplianceExtend.safeParse(fields); + if (!result.success) { + this.errors = mapZodErrorsToTranslations(result.error, 'course'); + return; + } + + const response = await this.execute({ + requestFn: () => + classroomio.course[':courseId'].compliance.extend.$post({ + param: { courseId }, + json: result.data + }), + logContext: 'extending course compliance', + onSuccess: () => { + snackbar.success('snackbar.course.compliance.extend_success'); + this.success = true; + this.errors = {}; + }, + onError: (result) => { + if (typeof result === 'string') { + snackbar.error('snackbar.course.compliance.extend_failed'); + return; + } + + if ('error' in result && 'field' in result && result.field) { + this.errors[result.field] = result.error; + } + } + }); + + if (response?.data) { + this.overviewKey = null; + await this.getOverview(courseId); + } + + return response?.data; + } + + async waiveCourseCompliance(courseId: string, fields: TCourseComplianceWaive) { + const result = ZCourseComplianceWaive.safeParse(fields); + if (!result.success) { + this.errors = mapZodErrorsToTranslations(result.error, 'course'); + return; + } + + const response = await this.execute({ + requestFn: () => + classroomio.course[':courseId'].compliance.waive.$post({ + param: { courseId }, + json: result.data + }), + logContext: 'waiving course compliance', + onSuccess: () => { + snackbar.success('snackbar.course.compliance.waive_success'); + this.success = true; + this.errors = {}; + }, + onError: (result) => { + if (typeof result === 'string') { + snackbar.error('snackbar.course.compliance.waive_failed'); + return; + } + + if ('error' in result && 'field' in result && result.field) { + this.errors[result.field] = result.error; + } + } + }); + + if (response?.data) { + this.overviewKey = null; + await this.getOverview(courseId); + } + + return response?.data; + } + + override reset() { + super.reset(); + this.overview = null; + this.learnerHistory = null; + this.overviewKey = null; + this.learnerHistoryKey = null; + } +} + +export const complianceApi = new ComplianceApi(); diff --git a/apps/dashboard/src/lib/features/course/api/content.svelte.ts b/apps/dashboard/src/lib/features/course/api/content.svelte.ts index e43fa7ff..7c20bc6f 100644 --- a/apps/dashboard/src/lib/features/course/api/content.svelte.ts +++ b/apps/dashboard/src/lib/features/course/api/content.svelte.ts @@ -26,9 +26,8 @@ export class ContentApi extends BaseApiWithErrors { this.errors = {}; }, onError: (error) => { - if (typeof error === 'string') { - snackbar.error('Failed to update content'); - } + const errorMessage = typeof error === 'string' ? error : (error as any)?.error; + snackbar.error(errorMessage || 'Failed to update content'); } }); @@ -51,9 +50,8 @@ export class ContentApi extends BaseApiWithErrors { this.errors = {}; }, onError: (error) => { - if (typeof error === 'string') { - snackbar.error('Failed to delete content'); - } + const errorMessage = typeof error === 'string' ? error : (error as any)?.error; + snackbar.error(errorMessage || 'Failed to delete content'); } }); 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 da3a70b8..f91b1402 100644 --- a/apps/dashboard/src/lib/features/course/api/course.svelte.ts +++ b/apps/dashboard/src/lib/features/course/api/course.svelte.ts @@ -17,6 +17,7 @@ import type { } from '../utils/types'; import type { TCourseType } from '@cio/db/types'; import { ZCourseCreate, ZCourseUpdate, type TCourseUpdate } from '@cio/utils/validation/course/course'; +import { DEFAULT_COMPLIANCE_SETTINGS } from '../utils/compliance-utils'; import { capturePosthogEvent } from '$lib/utils/services/posthog'; import { currentOrg } from '$lib/utils/store/org'; import { get } from 'svelte/store'; @@ -357,7 +358,8 @@ export class CourseApi extends BaseApiWithErrors { title: fields.title, description: fields.description, type: fields.type, - organizationId: org.id + organizationId: org.id, + compliance: fields.type === 'COMPLIANCE' ? DEFAULT_COMPLIANCE_SETTINGS : undefined }; // Client-side validation diff --git a/apps/dashboard/src/lib/features/course/api/index.ts b/apps/dashboard/src/lib/features/course/api/index.ts index 496f4d1b..a77600ee 100644 --- a/apps/dashboard/src/lib/features/course/api/index.ts +++ b/apps/dashboard/src/lib/features/course/api/index.ts @@ -1,4 +1,5 @@ export * from './attendance.svelte'; +export * from './compliance.svelte'; export * from './course-clone.svelte'; export * from './course.svelte'; export * from './courses.svelte'; diff --git a/apps/dashboard/src/lib/features/course/components/card-dropdown.svelte b/apps/dashboard/src/lib/features/course/components/card-dropdown.svelte index ac58d04f..a89bd91c 100644 --- a/apps/dashboard/src/lib/features/course/components/card-dropdown.svelte +++ b/apps/dashboard/src/lib/features/course/components/card-dropdown.svelte @@ -1,6 +1,7 @@ @@ -52,6 +58,13 @@ + {#if isPublished} + + + {$t('courses.course_card.context_menu.open_course_site')} + + + {/if} {$t('courses.course_card.context_menu.clone')} diff --git a/apps/dashboard/src/lib/features/course/components/card.svelte b/apps/dashboard/src/lib/features/course/components/card.svelte index 0a683c31..f4349642 100644 --- a/apps/dashboard/src/lib/features/course/components/card.svelte +++ b/apps/dashboard/src/lib/features/course/components/card.svelte @@ -1,9 +1,11 @@ - + {#snippet media()} Course banner image {/snippet} @@ -150,7 +212,7 @@ {#if actions} {@render actions()} {:else if !isLMS && !isOnLandingPage} - + {/if} {/snippet} @@ -195,6 +257,22 @@

{progressRate}%

+ + {#if type === 'COMPLIANCE'} +
+ {#if complianceStatusKey} + + {$t(complianceStatusKey)} + + {/if} + + {#if complianceDate?.value} +

+ {$t(complianceDate.labelKey)}: {formatDate(complianceDate.value)} +

+ {/if} +
+ {/if} {/if} {:else} 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 7baf2cd7..3d69e607 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 @@ -7,6 +7,7 @@ type CourseSortOrder } from '../utils/constants'; import { Checkbox } from '@cio/ui/base/checkbox'; + import { Button } from '@cio/ui/base/button'; import { SortPopover } from '$features/ui'; import { t } from '$lib/utils/functions/translations'; @@ -15,7 +16,7 @@ slug: string; name: string; color: string; - courseCount: number; + courseCount?: number; } interface FilterTagGroup { @@ -34,8 +35,14 @@ tagGroups?: FilterTagGroup[]; sortOptions?: readonly SortOption[]; isFiltering?: boolean; + hasActiveFilters?: boolean; + publishedStatus?: 'all' | 'published' | 'unpublished'; + courseType?: string; + courseTypeOptions?: { value: string; label: string }[]; onToggleTag?: (tagSlug: string, checked: boolean) => void; onClearFilters?: () => void | Promise; + onPublishedStatusChange?: (status: 'all' | 'published' | 'unpublished') => void; + onCourseTypeChange?: (type: string) => void; } let { @@ -45,8 +52,14 @@ tagGroups = [], sortOptions = COURSE_SORT_OPTIONS, isFiltering = false, + hasActiveFilters: hasActiveFiltersOverride = undefined, + publishedStatus = $bindable('all'), + courseType = $bindable('all'), + courseTypeOptions = [], onToggleTag = () => {}, - onClearFilters = () => {} + onClearFilters = () => {}, + onPublishedStatusChange, + onCourseTypeChange }: Props = $props(); const translatedSortOptions = $derived( @@ -54,12 +67,28 @@ ); const hasActiveFilters = $derived( - sortKey !== DEFAULT_COURSE_SORT || selectedOrder !== DEFAULT_SORT_ORDER || selectedTags.length > 0 + hasActiveFiltersOverride !== undefined + ? hasActiveFiltersOverride + : sortKey !== DEFAULT_COURSE_SORT || + selectedOrder !== DEFAULT_SORT_ORDER || + selectedTags.length > 0 || + publishedStatus !== 'all' || + courseType !== 'all' ); function isTagSelected(tagSlug: string) { return selectedTags.includes(tagSlug); } + + function setPublishedStatus(status: 'all' | 'published' | 'unpublished') { + publishedStatus = status; + onPublishedStatusChange?.(status); + } + + function setCourseType(type: string) { + courseType = type; + onCourseTypeChange?.(type); + } {#snippet additionalContent()}
-

{$t('courses.tag_filters.tags')}

- {#if tagGroups.length === 0} -

{$t('courses.tag_filters.empty_tags')}

- {:else} -
- {#each tagGroups as group (group.id)} - {#if group.tags.length > 0} -
-
-

{group.name}

- {#if group.description} -

{group.description}

- {/if} -
-
- {#each group.tags as tag (tag.id)} - - {/each} -
-
- {/if} - {/each} +
+

{$t('widgets.filter.status')}

+
+ + +
+
+ + {#if courseTypeOptions.length > 0} +
+

{$t('widgets.filter.course_type')}

+
+ + {#each courseTypeOptions as opt (opt.value)} + + {/each} +
+
+ {/if} + + {#if tagGroups.length > 0} +
+

{$t('courses.tag_filters.tags')}

+
+ {#each tagGroups as group (group.id)} + {#if group.tags.length > 0} +
+
+

{group.name}

+ {#if group.description} +

{group.description}

+ {/if} +
+
+ {#each group.tags as tag (tag.id)} + + {/each} +
+
+ {/if} + {/each} +
+
+ {:else if tagGroups.length === 0 && courseTypeOptions.length === 0 && publishedStatus === undefined} +

{$t('courses.tag_filters.empty_tags')}

{/if}
{/snippet} diff --git a/apps/dashboard/src/lib/features/course/components/course-header.svelte b/apps/dashboard/src/lib/features/course/components/course-header.svelte index 49d8b1d3..5407caea 100644 --- a/apps/dashboard/src/lib/features/course/components/course-header.svelte +++ b/apps/dashboard/src/lib/features/course/components/course-header.svelte @@ -10,10 +10,13 @@ import * as Empty from '@cio/ui/base/empty'; import { currentOrg, currentOrgDomain } from '$lib/utils/store/org'; import { isStudentExperience } from '$lib/utils/store/app'; + import SparklesIcon from '@lucide/svelte/icons/sparkles'; import { setupProgressApi } from '$features/setup/api/setup-progress.svelte'; import { courseApi } from '$features/course/api'; import { openCoursePreview } from '$features/course/utils/course-preview'; + import { toggleAiAssistant } from '$features/ai-assistant/utils/store'; import { t } from '$lib/utils/functions/translations'; + import { isFreePlan } from '$lib/utils/store/org'; import CoursePublishBadge from './course-publish-badge.svelte'; const siteName = $derived($currentOrg.siteName); @@ -56,6 +59,13 @@ + {#if !$isFreePlan} + + {/if} + - -
- - - -
{#if Object.values(errors).length}
diff --git a/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte b/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte index 19c27970..5a230d9b 100644 --- a/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte +++ b/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte @@ -29,10 +29,12 @@ import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content'; import { t } from '$lib/utils/functions/translations'; import { snackbar } from '$features/ui/snackbar/store'; + import { toggleConfetti } from '$features/ui/confetti/store'; import { ContentType } from '@cio/utils/constants/content'; import { getOrderedNavigableContent } from '$features/course/utils/content'; import { isOrgStudent } from '$lib/utils/store/app'; import { openCourseCompletionModal } from '$features/course/store/course-completion-modal'; + import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils'; import { toExerciseQuestionModel } from './question-type-utils'; import { getExerciseQuestionLabels } from './question-labels'; import axios from 'axios'; @@ -152,6 +154,9 @@ } courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true }); + toggleConfetti(); + setTimeout(toggleConfetti, 3000); + const allContentItems = getOrderedNavigableContent(courseApi.course); const allComplete = $isOrgStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete); @@ -573,7 +578,7 @@
- {:else if courseApi.course?.type === 'SELF_PACED'} + {:else if isSelfPacedLikeCourse(courseApi.course?.type)}
l.id === lessonId) : null); - const isLessonLocked = $derived( - $isOrgStudent && currentLessonItem && !(currentLessonItem.isUnlocked ?? false) - ); + const isLessonLocked = $derived($isOrgStudent && currentLessonItem && !(currentLessonItem.isUnlocked ?? false)); const isMarkCompleteDisabled = $derived(isMarkingComplete || isLessonLocked || isLessonComplete); async function markLessonComplete(currentLessonId: string) { diff --git a/apps/dashboard/src/lib/features/course/components/lesson/content-section-list.svelte b/apps/dashboard/src/lib/features/course/components/lesson/content-section-list.svelte index f86b5883..695c6b64 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/content-section-list.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/content-section-list.svelte @@ -283,6 +283,7 @@ const wasDeleted = await contentApi.deleteContent(courseId, { sectionId: deletingData.id }); if (wasDeleted) { removeSection(deletingData.id); + snackbar.success('snackbar.lessons.section_deleted'); } return; } @@ -438,7 +439,7 @@
diff --git a/apps/dashboard/src/lib/features/course/components/lesson/section-header.svelte b/apps/dashboard/src/lib/features/course/components/lesson/section-header.svelte index cf1792c8..8e6a86ef 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/section-header.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/section-header.svelte @@ -67,7 +67,7 @@ {:else}
-

{section.title}

+

{section.title}

{/if} diff --git a/apps/dashboard/src/lib/features/course/components/new-course-modal.svelte b/apps/dashboard/src/lib/features/course/components/new-course-modal.svelte index b10055ac..a3f6e699 100644 --- a/apps/dashboard/src/lib/features/course/components/new-course-modal.svelte +++ b/apps/dashboard/src/lib/features/course/components/new-course-modal.svelte @@ -30,6 +30,13 @@ subtitle: $t('new_course_modal.self_paced_subtitle'), type: 'SELF_PACED' as TCourseType, isDisabled: false + }, + { + id: 'compliance', + title: $t('new_course_modal.compliance_label'), + subtitle: $t('new_course_modal.compliance_subtitle'), + type: 'COMPLIANCE' as TCourseType, + isDisabled: false } ]; const courseTypeOptionsForGroup = options.map((o) => ({ @@ -68,7 +75,7 @@ {#snippet course_type_selector()} {$t('courses.new_course_modal.type_selector_title')} - + {/snippet}
(isDeleteFeedModal = true)} /> diff --git a/apps/dashboard/src/lib/features/course/components/newsfeed/card/comments.svelte b/apps/dashboard/src/lib/features/course/components/newsfeed/card/comments.svelte index 56d0bc1a..414c7ea8 100644 --- a/apps/dashboard/src/lib/features/course/components/newsfeed/card/comments.svelte +++ b/apps/dashboard/src/lib/features/course/components/newsfeed/card/comments.svelte @@ -124,7 +124,7 @@ }; -
+
+ + + +
+ + + {#if learnerRows.length > 0} + + + + + {$t('course.navItem.compliance.bulk_actions.select_all')} + + {$t('course.navItem.compliance.fields.learner')} + {$t('course.navItem.compliance.fields.email')} + {$t('course.navItem.compliance.fields.status')} + {$t('course.navItem.compliance.fields.due_date')} + {$t('course.navItem.compliance.fields.valid_until')} + {$t('course.navItem.compliance.fields.score')} + + + + {#each learnerRows as learner} + + + {#if learner.profileId} + toggleLearner(learner.profileId!)} + /> + {/if} + + {learner.fullname || learner.username || $t('course.navItem.compliance.not_available')} + {learner.email || $t('course.navItem.compliance.not_available')} + + + {$t(getStatusLabelKey(learner.status ?? 'no_record'))} + + + {formatDate(learner.dueDate)} + {formatDate(learner.validUntil)} + {formatPercent(learner.score)} + + {/each} + + + {:else} + + {/if} + + +{:else} + +{/if} + + + + + {getBulkActionTitle()} + + {$t('course.navItem.compliance.bulk_actions.modal.description', { count: selectedProfileIds.length })} + + + +
+ {#if bulkActionType === 'reset' || bulkActionType === 'extend'} + + {/if} + + {#if bulkActionType === 'waive'} +
+ + + +
+ {/if} +
+ + + + + {#if bulkActionType === 'reset'} + + {:else if bulkActionType === 'extend'} + + {:else if bulkActionType === 'waive'} + + {/if} + +
+
diff --git a/apps/dashboard/src/lib/features/course/pages/exercise.svelte b/apps/dashboard/src/lib/features/course/pages/exercise.svelte index 3692cf8c..6e962050 100644 --- a/apps/dashboard/src/lib/features/course/pages/exercise.svelte +++ b/apps/dashboard/src/lib/features/course/pages/exercise.svelte @@ -37,7 +37,9 @@ import UpdateDescription from '$features/course/components/exercise/update-description.svelte'; import { ContentNavigationActions } from '$features/course/components/lesson'; import { RefreshPageData, RoleBasedSecurity } from '$features/ui'; + import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils'; import { getOrderedNavigableContent } from '$features/course/utils/content'; + import { refreshExercisePageData } from '$features/course/utils/exercise-page-utils'; import { Empty } from '@cio/ui/custom/empty'; import VideoIcon from '@lucide/svelte/icons/video'; import { @@ -48,6 +50,7 @@ import type { SubmissionListItem } from '$features/course/utils/types'; import { Badge } from '@cio/ui/base/badge'; import { isAutoGradableQuestionType } from '@cio/question-types'; + import * as Dialog from '@cio/ui/base/dialog'; interface Props { exerciseId?: string; @@ -81,8 +84,23 @@ let preview: boolean = $state(false); let shouldDeleteExercise = $state(false); let isSaving = $state(false); + let isDeleting = $state(false); let selectedTab = $state('questions'); + async function handleDeleteExercise() { + if (!courseApi.course?.id) return; + isDeleting = true; + + await exerciseApi.delete(courseApi.course.id, exerciseId); + + if (exerciseApi.success) { + courseApi.removeContentItem(exerciseId, ContentType.Exercise); + goBack(); + } + isDeleting = false; + shouldDeleteExercise = false; + } + function patchExerciseListItemLocally() { const nonDeletedQuestionsCount = ($questionnaire.questions || []).filter((question) => !question.deletedAt).length; @@ -231,7 +249,7 @@ ); const isExerciseUnlocked = $derived(exerciseContentItem?.isUnlocked ?? false); - const isSelfPacedCourse = $derived(courseApi.course?.type === 'SELF_PACED'); + const isSelfPacedCourse = $derived(isSelfPacedLikeCourse(courseApi.course?.type)); const activeQuestionTypeIds = $derived( ($questionnaire.questions ?? []).filter((q) => !q.deletedAt).map((q) => getQuestionTypeId(q)) ); @@ -255,7 +273,7 @@ {$questionnaire.title || 'Exercise'} {#if teacherAutoGradeBadge === 'auto'} - + {$t('course.navItem.lessons.exercises.all_exercises.auto_graded_badge')} {:else if teacherAutoGradeBadge === 'manual'} @@ -323,7 +341,7 @@ {/if} - + refreshExercisePageData(courseApi.course?.id ?? '', exerciseId)} /> @@ -356,14 +374,13 @@ {$t('course.navItem.lessons.exercises.all_exercises.analytics.submissions')} - {submissions.length} + {submissions.length} {#if !preview} + + { + if (!isOpen) shouldDeleteExercise = false; + }} + > + + + {$t('course.navItem.lessons.exercises.all_exercises.edit_mode.delete_modal')} + +
e.preventDefault()}> +

+ {$t('course.navItem.lessons.exercises.all_exercises.edit_mode.sure')} +

+ +
+ + +
+
+
+
{/if} {/snippet} diff --git a/apps/dashboard/src/lib/features/course/pages/index.ts b/apps/dashboard/src/lib/features/course/pages/index.ts index b7eb292e..39ebed2f 100644 --- a/apps/dashboard/src/lib/features/course/pages/index.ts +++ b/apps/dashboard/src/lib/features/course/pages/index.ts @@ -1,6 +1,7 @@ export { default as AnalyticsPage } from './analytics.svelte'; export { default as AttendancePage } from './attendance.svelte'; export { default as CertificatesPage } from './certificates.svelte'; +export { default as CompliancePage } from './compliance.svelte'; export { default as CourseSettingsPage } from './settings.svelte'; export { default as CoursesPage } from './courses.svelte'; export { default as ExercisePage } from './exercise.svelte'; diff --git a/apps/dashboard/src/lib/features/course/pages/settings.svelte b/apps/dashboard/src/lib/features/course/pages/settings.svelte index c9a9f70f..52c9ae73 100644 --- a/apps/dashboard/src/lib/features/course/pages/settings.svelte +++ b/apps/dashboard/src/lib/features/course/pages/settings.svelte @@ -26,6 +26,7 @@ import { isObject } from '$lib/utils/functions/isObject'; import { snackbar } from '$features/ui/snackbar/store'; import { generateSlug } from '@cio/utils/functions'; + import { DEFAULT_COMPLIANCE_SETTINGS } from '../utils/compliance-utils'; import { DeleteModal } from '$features/ui'; import { courseApi } from '$features/course/api'; import { tagApi } from '$features/tag/api'; @@ -147,6 +148,10 @@ if (!courseApi.course) return; + if ($settings.isPublished && !courseApi.course.slug) { + courseApi.course.slug = generateSlug($settings.courseTitle, { appendTimestamp: true }); + } + const metadataPayload = { ...(isObject(courseApi.course.metadata) ? courseApi.course.metadata : {}), lessonTabsOrder: $settings.tabs, @@ -163,7 +168,9 @@ logo: logoUrl, isPublished: $settings.isPublished, metadata: metadataPayload, - slug: courseApi.course.slug ?? undefined + slug: courseApi.course.slug ?? undefined, + compliance: + $settings.type === 'COMPLIANCE' ? (courseApi.course.compliance ?? DEFAULT_COMPLIANCE_SETTINGS) : undefined }; const normalizedSelectedTagIds = normalizeTagIds(selectedTagIds); @@ -525,6 +532,10 @@ +
+ + +
diff --git a/apps/dashboard/src/lib/features/course/utils/compliance-utils.ts b/apps/dashboard/src/lib/features/course/utils/compliance-utils.ts new file mode 100644 index 00000000..4b0bb3bf --- /dev/null +++ b/apps/dashboard/src/lib/features/course/utils/compliance-utils.ts @@ -0,0 +1,128 @@ +import type { TCourseType } from '@cio/db/types'; +import type { TComplianceSettings } from '@cio/utils/validation/course'; +import type { UserEnrolledCourses } from '$features/course/types'; + +export const DEFAULT_COMPLIANCE_SETTINGS = { + retakeIntervalMonths: 12, + gracePeriodDays: 0, + reminderDaysBefore: [30, 7, 1], + isMandatory: true, + framework: null, + maxRetakeAttempts: null, + passingScore: 80 +} satisfies TComplianceSettings; + +export function isSelfPacedLikeCourse(courseType: TCourseType | null | undefined): boolean { + return courseType === 'SELF_PACED' || courseType === 'COMPLIANCE'; +} + +type StudentComplianceStatus = + | 'not_started' + | 'in_progress' + | 'compliant' + | 'expiring_soon' + | 'in_grace_period' + | 'non_compliant' + | 'waived' + | null + | undefined; + +type StudentEnrolledCourse = UserEnrolledCourses[number]; + +function getCourseContentProgress(course: StudentEnrolledCourse) { + const exercisesCompleted = typeof course.exercisesCompleted === 'number' ? course.exercisesCompleted : 0; + const totalExercises = typeof course.exerciseCount === 'number' ? course.exerciseCount : 0; + const totalItems = (course.lessonCount ?? 0) + totalExercises; + + if (totalItems <= 0) { + return 0; + } + + const completedItems = (course.progressRate ?? 0) + exercisesCompleted; + return Math.round((completedItems / totalItems) * 100); +} + +export function getStudentCourseComplianceStatus(course: StudentEnrolledCourse): StudentComplianceStatus { + if (course.type !== 'COMPLIANCE') { + return null; + } + + if (!('complianceStatus' in course)) { + return null; + } + + return course.complianceStatus as StudentComplianceStatus; +} + +export function isStudentCourseComplete(course: StudentEnrolledCourse) { + if (course.type === 'COMPLIANCE') { + const complianceStatus = getStudentCourseComplianceStatus(course); + return complianceStatus === 'compliant' || complianceStatus === 'waived'; + } + + return getCourseContentProgress(course) >= 100; +} + +export function getStudentCourseProgressPercent(course: StudentEnrolledCourse) { + return getCourseContentProgress(course); +} + +export function getStudentCourseComplianceDate(course: StudentEnrolledCourse) { + if (course.type !== 'COMPLIANCE') { + return null; + } + + const complianceStatus = getStudentCourseComplianceStatus(course); + const validUntil = 'complianceValidUntil' in course ? (course.complianceValidUntil ?? null) : null; + const dueDate = 'complianceDueDate' in course ? (course.complianceDueDate ?? null) : null; + + if ((complianceStatus === 'compliant' || complianceStatus === 'waived') && validUntil) { + return { + labelKey: 'courses.course_card.valid_until', + value: validUntil + }; + } + + if (dueDate) { + return { + labelKey: 'courses.course_card.due_date', + value: dueDate + }; + } + + return null; +} + +export function getStudentCourseComplianceStatusKey(course: StudentEnrolledCourse) { + const complianceStatus = getStudentCourseComplianceStatus(course); + return complianceStatus ? `course.navItem.compliance.status.${complianceStatus}` : null; +} + +export function getStudentCourseComplianceStatusVariant(course: StudentEnrolledCourse) { + const complianceStatus = getStudentCourseComplianceStatus(course); + if (!complianceStatus) { + return 'outline'; + } + + if (complianceStatus === 'compliant') { + return 'success'; + } + + if ( + complianceStatus === 'expiring_soon' || + complianceStatus === 'in_progress' || + complianceStatus === 'in_grace_period' + ) { + return 'warning'; + } + + if (complianceStatus === 'non_compliant') { + return 'destructive'; + } + + if (complianceStatus === 'waived') { + return 'secondary'; + } + + return 'outline'; +} diff --git a/apps/dashboard/src/lib/features/course/utils/content.ts b/apps/dashboard/src/lib/features/course/utils/content.ts index 8267ed6a..8abfcb07 100644 --- a/apps/dashboard/src/lib/features/course/utils/content.ts +++ b/apps/dashboard/src/lib/features/course/utils/content.ts @@ -7,6 +7,12 @@ import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents'; export type ContentItem = CourseContentItem; export type ContentSection = CourseContentSection; +export type MentionableContent = { + id: string; + title: string; + type: CourseContentType; +}; + export const CONTENT_DEFINITIONS = { [CourseContentType.Section]: { label: 'Section', @@ -28,7 +34,7 @@ const EMPTY_CONTENT = { items: [] as ContentItem[] }; -export function getContentRoute(courseId: string, item: ContentItem) { +export function getContentRoute(courseId: string, item: { id: string; type: string }) { if (item.type === CourseContentType.Lesson) { return `/courses/${courseId}/lessons/${item.id}`; } @@ -37,6 +43,10 @@ export function getContentRoute(courseId: string, item: ContentItem) { return `/courses/${courseId}/exercises/${item.id}`; } + if (item.type === CourseContentType.Section) { + return `/courses/${courseId}/lessons#section-${item.id}`; + } + return ''; } @@ -53,3 +63,33 @@ export function getOrderedNavigableContent(course: Course | null): ContentItem[] NAVIGABLE_CONTENT_TYPES.includes(item.type as (typeof NAVIGABLE_CONTENT_TYPES)[number]) ); } + +/** + * Flat list of mentionable content for the AI chat @ menu. + * Includes sections (when grouped) interleaved with their lessons/exercises in display order. + */ +export function getMentionableContent(course: Course | null): MentionableContent[] { + const content = getCourseContent(course); + + if (!content.grouped) { + return content.items + .filter((item) => NAVIGABLE_CONTENT_TYPES.includes(item.type as (typeof NAVIGABLE_CONTENT_TYPES)[number])) + .map((item) => ({ id: item.id, title: item.title, type: item.type as CourseContentType })); + } + + const result: MentionableContent[] = []; + + for (const section of content.sections) { + if (section.id !== 'ungrouped' && section.title) { + result.push({ id: section.id, title: section.title, type: CourseContentType.Section }); + } + + for (const item of section.items) { + if (NAVIGABLE_CONTENT_TYPES.includes(item.type as (typeof NAVIGABLE_CONTENT_TYPES)[number])) { + result.push({ id: item.id, title: item.title, type: item.type as CourseContentType }); + } + } + } + + return result; +} diff --git a/apps/dashboard/src/lib/features/course/utils/exercise-page-utils.ts b/apps/dashboard/src/lib/features/course/utils/exercise-page-utils.ts new file mode 100644 index 00000000..41b9ff26 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/utils/exercise-page-utils.ts @@ -0,0 +1,54 @@ +import type { Exercise } from '$features/course/utils/types'; +import type { Question } from '$features/course/types'; +import { exerciseApi } from '$features/course/api'; +import { + clearQuestionnaireValidation, + questionnaire, + questionnaireMetaData +} from '$features/course/components/exercise/store'; +import { getQuestionTypeId, getQuestionTypeOptionById } from '$features/course/components/exercise/question-type-utils'; +import { normalizeQuestionOrder } from '$features/course/components/exercise/order-utils'; + +export function hydrateExercisePageData(exercise: Exercise, exerciseId: string) { + let questions: Question[] = []; + + if (exercise.questions && Array.isArray(exercise.questions)) { + const mappedQuestions = exercise.questions.map((question) => { + const questionType = getQuestionTypeOptionById(getQuestionTypeId(question)); + + return { + ...question, + questionTypeId: questionType.id, + questionType + }; + }); + + questions = normalizeQuestionOrder(mappedQuestions); + } + + clearQuestionnaireValidation(); + + questionnaire.set({ + title: exercise.title, + description: exercise.description, + dueBy: exercise.dueBy, + isTitleDirty: false, + isDescriptionDirty: false, + isDueByDirty: false, + questions, + totalSubmissions: 0, + allowMultipleAttempts: !!exercise.allowMultipleAttempts + }); + + questionnaireMetaData.update((metadata) => ({ ...metadata, exerciseId })); +} + +export async function refreshExercisePageData(courseId: string, exerciseId: string) { + await exerciseApi.get(courseId, exerciseId); + + if (!exerciseApi.exercise) return null; + + hydrateExercisePageData(exerciseApi.exercise, exerciseId); + + return exerciseApi.exercise; +} diff --git a/apps/dashboard/src/lib/features/course/utils/settings-store.ts b/apps/dashboard/src/lib/features/course/utils/settings-store.ts index 6c70c4f6..89bb8b69 100644 --- a/apps/dashboard/src/lib/features/course/utils/settings-store.ts +++ b/apps/dashboard/src/lib/features/course/utils/settings-store.ts @@ -6,7 +6,7 @@ type CourseSettings = { courseTitle: string; courseDescription: string; grading: boolean; - type: 'LIVE_CLASS' | 'SELF_PACED'; + type: 'LIVE_CLASS' | 'SELF_PACED' | 'COMPLIANCE'; allowNewStudents: boolean; tabs: { id: number; name: string }[]; lessonDownload: boolean; diff --git a/apps/dashboard/src/lib/features/course/utils/types.ts b/apps/dashboard/src/lib/features/course/utils/types.ts index 627ec267..33e9861e 100644 --- a/apps/dashboard/src/lib/features/course/utils/types.ts +++ b/apps/dashboard/src/lib/features/course/utils/types.ts @@ -376,6 +376,31 @@ export type GetCourseAnalyticsResponse = InferResponseType; export type CourseAnalytics = GetCourseAnalyticsSuccess['data']; +export type GetCourseComplianceOverviewRequest = (typeof classroomio.course)[':courseId']['compliance']['$get']; +export type GetCourseComplianceOverviewResponse = InferResponseType; +export type GetCourseComplianceOverviewSuccess = Extract; +export type CourseComplianceOverview = GetCourseComplianceOverviewSuccess['data']; +export type CourseComplianceLearner = CourseComplianceOverview['learners'][number]; + +export type GetLearnerComplianceHistoryRequest = + (typeof classroomio.course)[':courseId']['compliance']['learners'][':profileId']['$get']; +export type GetLearnerComplianceHistoryResponse = InferResponseType; +export type GetLearnerComplianceHistorySuccess = Extract; +export type LearnerComplianceHistory = GetLearnerComplianceHistorySuccess['data']; +export type LearnerComplianceRecord = LearnerComplianceHistory['history'][number]; + +export type ResetCourseComplianceRequest = (typeof classroomio.course)[':courseId']['compliance']['reset']['$post']; +export type ResetCourseComplianceResponse = InferResponseType; +export type ResetCourseComplianceSuccess = Extract; + +export type ExtendCourseComplianceRequest = (typeof classroomio.course)[':courseId']['compliance']['extend']['$post']; +export type ExtendCourseComplianceResponse = InferResponseType; +export type ExtendCourseComplianceSuccess = Extract; + +export type WaiveCourseComplianceRequest = (typeof classroomio.course)[':courseId']['compliance']['waive']['$post']; +export type WaiveCourseComplianceResponse = InferResponseType; +export type WaiveCourseComplianceSuccess = Extract; + // Derived types from Lesson export type LessonVideoType = NonNullable[number]['type']; export type LessonDocument = NonNullable[number]; diff --git a/apps/dashboard/src/lib/features/lms/components/learning.svelte b/apps/dashboard/src/lib/features/lms/components/learning.svelte index 408a9b33..279d3b30 100644 --- a/apps/dashboard/src/lib/features/lms/components/learning.svelte +++ b/apps/dashboard/src/lib/features/lms/components/learning.svelte @@ -1,31 +1,52 @@ @@ -36,9 +57,9 @@
- {#if last3Courses.length > 0} + {#if highlightedCourses.length > 0}
- {#each last3Courses as course} + {#each highlightedCourses as course}
{course.description}

+ + {#if course.type === 'COMPLIANCE'} + {@const complianceStatusKey = getStudentCourseComplianceStatusKey(course)} + {@const complianceDate = getStudentCourseComplianceDate(course)} +
+ {#if complianceStatusKey} + + {$t(complianceStatusKey)} + + {/if} + + {#if complianceDate?.value} +

+ {$t(complianceDate.labelKey)}: {formatDate(complianceDate.value)} +

+ {/if} +
+ {/if}
- +
{/each}
diff --git a/apps/dashboard/src/lib/features/lms/pages/dashboard.svelte b/apps/dashboard/src/lib/features/lms/pages/dashboard.svelte index 94f00b71..4364f4ef 100644 --- a/apps/dashboard/src/lib/features/lms/pages/dashboard.svelte +++ b/apps/dashboard/src/lib/features/lms/pages/dashboard.svelte @@ -1,97 +1,299 @@ - -
-
-

- {$currentOrg.customization.dashboard.bannerText - ? $currentOrg.customization.dashboard.bannerText - : $t('dashboard.lms_dashboard_hero')} -

- -
- student Learning Pictogram -
-
-
- - - - -

- {$t('dashboard.your_progress')} -

-
-
-
- student Learning score -
- -

- {$t('dashboard.your_progress')} +

+ +
+
+
+

+ {$t('dashboard.pick_up_where_you_left_off')}

- {#if totalLessons > 0} -

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

+

+ {#if currentCourse} + {$t('dashboard.course_awaits_you', { title: currentCourse.title })} + {:else} + {$t('dashboard.learning_awaits_you')} + {/if} +

+
+ +
-
+
-
+ +
+
+ + + + + + + + + + + +
+
+ +
+
+

+ {$t('dashboard.currently_learning')} +

+ +
+ {#if highlightedCourses.length > 0} + {#each highlightedCourses as course} + {@const courseProgress = getStudentCourseProgressPercent(course)} + {@const totalItems = getCourseTotalItems(course)} + {@const completedItems = getCourseCompletedItems(course)} +
+
+
+
+ {#if course.logo} + + {:else} + + {/if} +
+ +
+

{course.title}

+ {#if course.description} +

{course.description}

+ {/if} + + {#if course.type === 'COMPLIANCE'} + {@const complianceStatusKey = getStudentCourseComplianceStatusKey(course)} +
+ {#if complianceStatusKey} + + {$t(complianceStatusKey)} + + {/if} +
+ {/if} +
+
+ + +
+ +
+
+
+ + {$t('dashboard.items_done', { completed: completedItems, total: totalItems })} + + {courseProgress}% +
+ +
+
+
+ {/each} + {:else} +
+ +
+ {/if} +
+
+ +
+

+ {$t('dashboard.compliance')} +

+ +
+
+
+
+ +
+
+

{$t('dashboard.compliance_score')}

+

+ {#if complianceCourses.length > 0} + {$t('dashboard.required_courses_completed', { + completed: completedComplianceCourses.length, + total: complianceCourses.length + })} + {:else} + {$t('dashboard.compliance_score_empty')} + {/if} +

+
+
+ +

+ {#if complianceScore === null} + -- + {:else} + {complianceScore}% + {/if} +

+
+
+
+
+ diff --git a/apps/dashboard/src/lib/features/lms/pages/mylearning.svelte b/apps/dashboard/src/lib/features/lms/pages/mylearning.svelte index a8fb7c3b..574cc99e 100644 --- a/apps/dashboard/src/lib/features/lms/pages/mylearning.svelte +++ b/apps/dashboard/src/lib/features/lms/pages/mylearning.svelte @@ -5,19 +5,12 @@ import { currentOrg } from '$lib/utils/store/org'; import { t } from '$lib/utils/functions/translations'; import { coursesApi } from '$features/course/api'; - import { calcCourseProgress } from '$features/course/utils/functions'; + import { isStudentCourseComplete } from '$features/course/utils/compliance-utils'; let searchValue = $state(''); function isCourseComplete(course: (typeof coursesApi.enrolledCourses)[number]): boolean { - const c = course as { exerciseCount?: number; exercisesCompleted?: number }; - const progress = calcCourseProgress({ - lessonsCompleted: course.progressRate ?? 0, - totalLessons: course.lessonCount ?? 0, - exercisesCompleted: typeof c.exercisesCompleted === 'number' ? c.exercisesCompleted : 0, - totalExercises: typeof c.exerciseCount === 'number' ? c.exerciseCount : 0 - }); - return progress >= 100; + return isStudentCourseComplete(course); } const coursesInProgress = $derived(coursesApi.enrolledCourses.filter((course) => !isCourseComplete(course))); diff --git a/apps/dashboard/src/lib/features/org/api/org.svelte.ts b/apps/dashboard/src/lib/features/org/api/org.svelte.ts index 2878123e..d01a8e3e 100644 --- a/apps/dashboard/src/lib/features/org/api/org.svelte.ts +++ b/apps/dashboard/src/lib/features/org/api/org.svelte.ts @@ -489,8 +489,8 @@ class OrgApi extends BaseApiWithErrors { snackbar.error(result); return; } - if ('error' in result && 'field' in result) { - this.errors[result.field as string] = result.error; + if ('error' in result) { + snackbar.error(result.error); } } }); diff --git a/apps/dashboard/src/lib/features/org/components/login-activity-chart.svelte b/apps/dashboard/src/lib/features/org/components/login-activity-chart.svelte new file mode 100644 index 00000000..3f3f5ad3 --- /dev/null +++ b/apps/dashboard/src/lib/features/org/components/login-activity-chart.svelte @@ -0,0 +1,52 @@ + + +
+
+

+ {$t('dashboard.login_activity_title')} +

+

+ {$t('dashboard.login_activity_description')} +

+
+ +
+ {#if hasData} + + + + {:else} +
+ {$t('dashboard.login_activity_no_data')} +
+ {/if} +
+
diff --git a/apps/dashboard/src/lib/features/org/utils/landing-page.ts b/apps/dashboard/src/lib/features/org/utils/landing-page.ts index 299e2bc6..cca7dd36 100644 --- a/apps/dashboard/src/lib/features/org/utils/landing-page.ts +++ b/apps/dashboard/src/lib/features/org/utils/landing-page.ts @@ -319,8 +319,7 @@ export function mapPublicCoursesToLandingPageCourses(courses: OrgPublicCourses): const tags = Array.isArray(courseRecord.tags) ? (courseRecord.tags as OrgLandingPageProps['courses'][number]['tags']) : undefined; - const courseSlug = - typeof courseRecord.slug === 'string' && courseRecord.slug.length > 0 ? courseRecord.slug : course.id; + const courseSlug = typeof courseRecord.slug === 'string' && courseRecord.slug.length > 0 ? courseRecord.slug : ''; const courseCost = typeof courseRecord.cost === 'number' ? courseRecord.cost : undefined; const courseCurrency = typeof courseRecord.currency === 'string' ? courseRecord.currency : undefined; const lessonCount = typeof courseRecord.lessonCount === 'number' ? courseRecord.lessonCount : undefined; @@ -356,7 +355,7 @@ export function mapPublicCoursesToLandingPageCourses(courses: OrgPublicCourses): : undefined, tags, image, - link: `/course/${courseSlug}`, + link: courseSlug ? `/course/${courseSlug}` : undefined, price, duration, level diff --git a/apps/dashboard/src/lib/features/org/utils/types.ts b/apps/dashboard/src/lib/features/org/utils/types.ts index e56cafed..81a3aea8 100644 --- a/apps/dashboard/src/lib/features/org/utils/types.ts +++ b/apps/dashboard/src/lib/features/org/utils/types.ts @@ -57,6 +57,10 @@ export type GetDashStatsRequest = typeof classroomio.dash.stats.$get; export type DashStatsResponse = InferResponseType | null; export type DashStatsSuccess = Extract, { success: true }>; +export type GetLoginActivityRequest = (typeof classroomio.dash)['login-activity']['$get']; +export type LoginActivitySuccess = Extract, { success: true }>; +export type LoginActivityData = LoginActivitySuccess['data']; + // invite team types export type InviteTeamRequest = (typeof classroomio.organization)['team']['invite']['$post']; export type InviteTeamSuccess = Extract, { success: true }>; diff --git a/apps/dashboard/src/lib/features/people/components/bulk-email-section.svelte b/apps/dashboard/src/lib/features/people/components/bulk-email-section.svelte index ff7805d8..dea8c9cc 100644 --- a/apps/dashboard/src/lib/features/people/components/bulk-email-section.svelte +++ b/apps/dashboard/src/lib/features/people/components/bulk-email-section.svelte @@ -1,4 +1,5 @@ -
-

{$t(titleKey)}

-

- {$t(descriptionKey)} -

- - -
- -
-
+ + {$t(titleKey)} + {$t(descriptionKey)} + + + + + + + + diff --git a/apps/dashboard/src/lib/features/people/components/existing-students-section.svelte b/apps/dashboard/src/lib/features/people/components/existing-students-section.svelte index dc25a1bc..dffd1a73 100644 --- a/apps/dashboard/src/lib/features/people/components/existing-students-section.svelte +++ b/apps/dashboard/src/lib/features/people/components/existing-students-section.svelte @@ -143,9 +143,7 @@ -
- -
+ diff --git a/apps/dashboard/src/lib/features/people/components/tutor-select-section.svelte b/apps/dashboard/src/lib/features/people/components/tutor-select-section.svelte index 9110fbbf..16ec5685 100644 --- a/apps/dashboard/src/lib/features/people/components/tutor-select-section.svelte +++ b/apps/dashboard/src/lib/features/people/components/tutor-select-section.svelte @@ -42,7 +42,7 @@
{#if selectedTutors.length > 0}
- {#each selectedTutors as tutor} + {#each selectedTutors as tutor (tutor.id)} {tutor.text} +
+ {#each filteredRecents as item (item.kind + item.id)} + handleSelect(item)}> + +
+
{item.title}
+ {#if item.subtitle} +
{item.subtitle}
+ {/if} +
+
+ {/each} + + {/if} + + {#if hasQuery} + + + {#if scope === 'org'} + + + + {/if} + item.kind === 'settings')} + fallbackIcon={Settings} + onSelect={handleSelect} + /> + {:else} + + {/if} + + diff --git a/apps/dashboard/src/lib/features/search/components/keyboard-shortcut-listener.svelte b/apps/dashboard/src/lib/features/search/components/keyboard-shortcut-listener.svelte new file mode 100644 index 00000000..826d959a --- /dev/null +++ b/apps/dashboard/src/lib/features/search/components/keyboard-shortcut-listener.svelte @@ -0,0 +1,17 @@ + + + diff --git a/apps/dashboard/src/lib/features/search/components/search-result-group.svelte b/apps/dashboard/src/lib/features/search/components/search-result-group.svelte new file mode 100644 index 00000000..74cbc6dd --- /dev/null +++ b/apps/dashboard/src/lib/features/search/components/search-result-group.svelte @@ -0,0 +1,31 @@ + + +{#if items.length > 0} + + {#each items as item (item.kind + item.id)} + {@const Icon = item.icon ?? fallbackIcon} + onSelect(item)}> + +
+
{item.title}
+ {#if item.subtitle} +
{item.subtitle}
+ {/if} +
+
+ {/each} +
+{/if} diff --git a/apps/dashboard/src/lib/features/search/index.ts b/apps/dashboard/src/lib/features/search/index.ts new file mode 100644 index 00000000..e0f579ef --- /dev/null +++ b/apps/dashboard/src/lib/features/search/index.ts @@ -0,0 +1,2 @@ +export { default as CommandPalette } from './components/command-palette.svelte'; +export { default as KeyboardShortcutListener } from './components/keyboard-shortcut-listener.svelte'; diff --git a/apps/dashboard/src/lib/features/search/store/search-store.svelte.ts b/apps/dashboard/src/lib/features/search/store/search-store.svelte.ts new file mode 100644 index 00000000..afc10b27 --- /dev/null +++ b/apps/dashboard/src/lib/features/search/store/search-store.svelte.ts @@ -0,0 +1,22 @@ +import type { SearchScope } from '../utils/types'; + +class SearchStore { + isOpen = $state(false); + scope: SearchScope = $state('org'); + + open(scope: SearchScope = 'org') { + this.scope = scope; + this.isOpen = true; + } + + close() { + this.isOpen = false; + } + + toggle(scope: SearchScope = 'org') { + this.scope = scope; + this.isOpen = !this.isOpen; + } +} + +export const searchStore = new SearchStore(); diff --git a/apps/dashboard/src/lib/features/search/utils/recents.ts b/apps/dashboard/src/lib/features/search/utils/recents.ts new file mode 100644 index 00000000..8f1dcac9 --- /dev/null +++ b/apps/dashboard/src/lib/features/search/utils/recents.ts @@ -0,0 +1,59 @@ +import type { SearchResultItem, SearchScope } from './types'; + +const MAX_RECENT_ITEMS = 8; +const STORAGE_PREFIX = 'cio:search:recents'; + +type StoredSearchResultItem = Omit; + +function getStorageKey(orgId: string, scope: SearchScope) { + return `${STORAGE_PREFIX}:${scope}:${orgId}`; +} + +function canUseStorage() { + return typeof window !== 'undefined' && Boolean(window.localStorage); +} + +export function getRecents(orgId?: string | null, scope: SearchScope = 'org'): SearchResultItem[] { + if (!orgId || !canUseStorage()) { + return []; + } + + try { + const storedValue = window.localStorage.getItem(getStorageKey(orgId, scope)); + if (!storedValue) { + return []; + } + + return JSON.parse(storedValue) as StoredSearchResultItem[]; + } catch (error) { + console.error('getRecents error:', error); + return []; + } +} + +export function addRecent(orgId: string | null | undefined, item: SearchResultItem, scope: SearchScope = 'org') { + if (!orgId || !canUseStorage()) { + return; + } + + try { + const { icon: _icon, ...storedItem } = item; + const existingItems = getRecents(orgId, scope); + const nextItems = [ + storedItem, + ...existingItems.filter((existingItem) => existingItem.id !== item.id || existingItem.kind !== item.kind) + ].slice(0, MAX_RECENT_ITEMS); + + window.localStorage.setItem(getStorageKey(orgId, scope), JSON.stringify(nextItems)); + } catch (error) { + console.error('addRecent error:', error); + } +} + +export function clearRecents(orgId?: string | null, scope: SearchScope = 'org') { + if (!orgId || !canUseStorage()) { + return; + } + + window.localStorage.removeItem(getStorageKey(orgId, scope)); +} diff --git a/apps/dashboard/src/lib/features/search/utils/static-catalog.ts b/apps/dashboard/src/lib/features/search/utils/static-catalog.ts new file mode 100644 index 00000000..5fac35dc --- /dev/null +++ b/apps/dashboard/src/lib/features/search/utils/static-catalog.ts @@ -0,0 +1,126 @@ +import type { AccountOrg } from '$features/app/types'; +import { baseNavConfig as lmsNavConfig } from '$features/ui/navigation/lms-navigation'; +import { baseNavConfig as orgNavConfig } from '$features/ui/navigation/org-navigation'; +import type { SearchResultItem } from './types'; + +function resolveTitle(titleKey: string, translate: (key: string) => string) { + return titleKey.includes('.') ? translate(titleKey) : titleKey; +} + +export function buildStaticCatalog( + translate: (key: string) => string, + currentOrgPath: string, + isOrgAdmin: boolean | null +): SearchResultItem[] { + const items: SearchResultItem[] = []; + + for (const navItem of orgNavConfig) { + if (navItem.requiresAdmin && !isOrgAdmin) { + continue; + } + + const url = navItem.path === '' ? currentOrgPath : `${currentOrgPath}${navItem.path}`; + + if (!navItem.useHashUrl && !navItem.disabled) { + items.push({ + kind: 'nav', + id: `nav:${navItem.path || '/'}`, + title: resolveTitle(navItem.titleKey, translate), + subtitle: translate('app.search.command_palette.result_types.page'), + url, + icon: navItem.icon + }); + } + + for (const subItem of navItem.items ?? []) { + if (subItem.requiresAdmin && !isOrgAdmin) { + continue; + } + + items.push({ + kind: 'settings', + id: `settings:${subItem.path}`, + title: resolveTitle(subItem.titleKey, translate), + subtitle: resolveTitle(navItem.titleKey, translate), + url: `${currentOrgPath}${subItem.path}`, + icon: navItem.icon + }); + } + + for (const nestedRoute of navItem.nestedRoutes ?? []) { + items.push({ + kind: navItem.path === '/settings' ? 'settings' : 'nav', + id: `${navItem.path || 'nav'}:${nestedRoute.path}`, + title: resolveTitle(nestedRoute.titleKey, translate), + subtitle: resolveTitle(navItem.titleKey, translate), + url: `${currentOrgPath}${navItem.path}/${nestedRoute.path}`, + icon: navItem.icon + }); + } + } + + return items; +} + +export function buildLmsStaticCatalog( + translate: (key: string) => string, + currentOrg: AccountOrg | null +): SearchResultItem[] { + const items: SearchResultItem[] = []; + + for (const navItem of lmsNavConfig) { + if (navItem.show && !navItem.show(currentOrg)) { + continue; + } + + const url = navItem.path === '' ? '/lms' : `/lms${navItem.path}`; + + if (!navItem.useHashUrl) { + items.push({ + kind: 'nav', + id: `lms-nav:${navItem.path || '/'}`, + title: resolveTitle(navItem.titleKey, translate), + subtitle: translate('app.search.command_palette.result_types.page'), + url, + icon: navItem.icon + }); + } + + for (const subItem of navItem.items ?? []) { + items.push({ + kind: 'settings', + id: `lms-settings:${subItem.path}`, + title: resolveTitle(subItem.titleKey, translate), + subtitle: resolveTitle(navItem.titleKey, translate), + url: `/lms${subItem.path}`, + icon: navItem.icon + }); + } + + for (const nestedRoute of navItem.nestedRoutes ?? []) { + items.push({ + kind: 'nav', + id: `lms:${navItem.path || 'nav'}:${nestedRoute.path}`, + title: resolveTitle(nestedRoute.titleKey, translate), + subtitle: resolveTitle(navItem.titleKey, translate), + url: `/lms${navItem.path}/${nestedRoute.path}`, + icon: navItem.icon + }); + } + } + + return items; +} + +export function filterStaticCatalog(items: SearchResultItem[], query: string) { + const normalizedQuery = query.trim().toLowerCase(); + + if (!normalizedQuery) { + return items; + } + + return items.filter((item) => { + const haystack = `${item.title} ${item.subtitle ?? ''}`.toLowerCase(); + return haystack.includes(normalizedQuery); + }); +} diff --git a/apps/dashboard/src/lib/features/search/utils/types.ts b/apps/dashboard/src/lib/features/search/utils/types.ts new file mode 100644 index 00000000..6e36441b --- /dev/null +++ b/apps/dashboard/src/lib/features/search/utils/types.ts @@ -0,0 +1,26 @@ +import { classroomio, type InferResponseType } from '$lib/utils/services/api'; +import type { Component } from 'svelte'; + +export type SearchOrgRequest = (typeof classroomio.organization)['search']['$get']; +export type SearchOrgResponse = InferResponseType; +export type SearchOrgSuccess = Extract; +export type SearchOrgData = SearchOrgSuccess['data']; + +export type SearchLmsRequest = (typeof classroomio.organization)['search']['lms']['$get']; +export type SearchLmsResponse = InferResponseType; +export type SearchLmsSuccess = Extract; +export type SearchLmsData = SearchLmsSuccess['data']; + +export type SearchResultKind = 'course' | 'program' | 'widget' | 'tag' | 'audience' | 'nav' | 'settings'; +export type SearchScope = 'org' | 'lms'; + +export interface SearchResultItem { + kind: SearchResultKind; + id: string; + title: string; + subtitle?: string; + url: string; + icon?: Component; +} + +export type GroupedSearchResults = Record; diff --git a/apps/dashboard/src/lib/features/settings/pages/billing.svelte b/apps/dashboard/src/lib/features/settings/pages/billing.svelte index cc51dd57..a19337ca 100644 --- a/apps/dashboard/src/lib/features/settings/pages/billing.svelte +++ b/apps/dashboard/src/lib/features/settings/pages/billing.svelte @@ -1,16 +1,55 @@ - + 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 4442ff5f..304fce47 100644 --- a/apps/dashboard/src/lib/features/ui/sidebar/footer/menu.svelte +++ b/apps/dashboard/src/lib/features/ui/sidebar/footer/menu.svelte @@ -15,8 +15,17 @@ import { t } from '$lib/utils/functions/translations'; import { profile } from '$lib/utils/store/user'; + import { currentOrg } from '$lib/utils/store/org'; + import { ROLE } from '@cio/utils/constants'; const sidebar = useSidebar(); + + function getRoleLabel(roleId: number): string { + if (roleId === ROLE.ADMIN) return $t('course.navItem.people.roles.admin'); + if (roleId === ROLE.TUTOR) return $t('course.navItem.people.roles.tutor'); + if (roleId === ROLE.STUDENT) return $t('course.navItem.people.roles.student'); + return ''; + } {#if variant === 'breadcrumb'} - {#if $currentOrg.name} + {#if $currentOrg.name}sfsf {shortenName($currentOrg.name)} diff --git a/apps/dashboard/src/lib/features/ui/sort-popover.svelte b/apps/dashboard/src/lib/features/ui/sort-popover.svelte index fa6594e8..52b41219 100644 --- a/apps/dashboard/src/lib/features/ui/sort-popover.svelte +++ b/apps/dashboard/src/lib/features/ui/sort-popover.svelte @@ -92,33 +92,35 @@
-
-

- {$t('courses.tag_filters.sort')} -

-
- {#each sortOptions as option (option.value)} - - {/each} + {#if sortOptions.length > 0} +
+

+ {$t('courses.tag_filters.sort')} +

+
+ {#each sortOptions as option (option.value)} + + {/each} +
-
-
-

- {$t('courses.tag_filters.order')} -

- - {$t('courses.tag_filters.ascending')} - {$t('courses.tag_filters.descending')} - -
+
+

+ {$t('courses.tag_filters.order')} +

+ + {$t('courses.tag_filters.ascending')} + {$t('courses.tag_filters.descending')} + +
+ {/if} {@render additionalContent?.()}
diff --git a/apps/dashboard/src/lib/features/ui/visit-org-site-btn.svelte b/apps/dashboard/src/lib/features/ui/visit-org-site-btn.svelte index f9f91817..f55239d0 100644 --- a/apps/dashboard/src/lib/features/ui/visit-org-site-btn.svelte +++ b/apps/dashboard/src/lib/features/ui/visit-org-site-btn.svelte @@ -1,7 +1,8 @@ + {/snippet} + + + +
+

{$t('courses.tag_filters.popover_title')}

+ +
+ +
+ +
+

+ {$t('widgets.filter.status')} +

+
+ {#each [['all', $t('widgets.filter.all')], ['published', $t('widgets.status.published')], ['unpublished', $t('widgets.filter.unpublished')]] as [val, label] (val)} + + {/each} +
+
+ + +
+

+ {$t('widgets.filter.course_type')} +

+
+ + {#each courseTypeOptions as opt (opt.value)} + + {/each} +
+
+ + + {#if availableTags.length > 0} +
+

+ {$t('courses.tag_filters.tags')} +

+
+ {#each availableTags as tag (tag.id)} + + {/each} +
+
+ {/if} +
+
+ diff --git a/apps/dashboard/src/lib/features/widget/components/layout-options.svelte b/apps/dashboard/src/lib/features/widget/components/layout-options.svelte new file mode 100644 index 00000000..512dd528 --- /dev/null +++ b/apps/dashboard/src/lib/features/widget/components/layout-options.svelte @@ -0,0 +1,242 @@ + + +{#if draftLayoutType === 'card_grid'} + + {$t('widgets.layoutOptions.columns')} + + {draftConfig.layoutOptions.cardGrid.columns} + + 2 + 3 + + + + + + + {$t('widgets.layoutOptions.show_rating')} + +{:else if draftLayoutType === 'tag_filter'} + + + {$t('widgets.layoutOptions.tag_source')} + + + {draftConfig.layoutOptions.tagFilter.tagSource === 'auto' + ? $t('widgets.layoutOptions.tag_source_auto') + : $t('widgets.layoutOptions.tag_source_manual')} + + + {$t('widgets.layoutOptions.tag_source_auto')} + {$t('widgets.layoutOptions.tag_source_manual')} + + + + + + {$t('widgets.layoutOptions.show_all_option')} + +{:else if draftLayoutType === 'carousel'} + + {$t('widgets.layoutOptions.visible_cards')} + + {draftConfig.layoutOptions.carousel.visibleCards} + + 2 + 3 + 4 + + + + + + + {$t('widgets.layoutOptions.auto_play')} + + {#if draftConfig.layoutOptions.carousel.autoPlay} + + {/if} + + + {$t('widgets.layoutOptions.show_dots')} + + + + {$t('widgets.layoutOptions.show_arrows')} + + + + {$t('widgets.layoutOptions.loop')} + +{:else if draftLayoutType === 'primary_course'} + + {$t('widgets.layoutOptions.featured_course')} + { + draftConfig.layoutOptions.primaryCourse.featuredCourseId = value || null; + }} + > + + {featuredCourseOptions.find((c) => c.id === draftConfig.layoutOptions.primaryCourse.featuredCourseId)?.title ?? + $t('widgets.layoutOptions.none')} + + + {#each featuredCourseOptions as course (course.id)} + {course.title} + {/each} + + + + + + +{:else if draftLayoutType === 'compact_list'} + + + {$t('widgets.layoutOptions.density')} + + + {draftConfig.layoutOptions.compactList.density === 'comfortable' + ? $t('widgets.layoutOptions.density_comfortable') + : $t('widgets.layoutOptions.density_compact')} + + + {$t('widgets.layoutOptions.density_comfortable')} + {$t('widgets.layoutOptions.density_compact')} + + + + + + {$t('widgets.layoutOptions.show_thumbnail')} + + + + {$t('widgets.layoutOptions.show_tags')} + +{:else if draftLayoutType === 'editorial_spotlight'} + + {$t('widgets.layoutOptions.main_course')} + { + draftConfig.layoutOptions.editorialSpotlight.mainCourseId = value || null; + }} + > + + {featuredCourseOptions.find((c) => c.id === draftConfig.layoutOptions.editorialSpotlight.mainCourseId)?.title ?? + $t('widgets.layoutOptions.none')} + + + {#each featuredCourseOptions as course (course.id)} + {course.title} + {/each} + + + + + + {$t('widgets.layoutOptions.title_style')} + + + {draftConfig.layoutOptions.editorialSpotlight.titleStyle === 'serif' + ? $t('widgets.layoutOptions.title_style_serif') + : $t('widgets.layoutOptions.title_style_sans')} + + + {$t('widgets.layoutOptions.title_style_serif')} + {$t('widgets.layoutOptions.title_style_sans')} + + + +{:else if draftLayoutType === 'category_shelf'} + + {$t('widgets.layoutOptions.category_tags')} +
+ {#each tagOptions as tag (tag.id)} + {@const checked = draftConfig.layoutOptions.categoryShelf.categoryTagIds.includes(tag.id)} + + {/each} +
+
+ + + {$t('widgets.layoutOptions.show_all_tab')} + + +{/if} diff --git a/apps/dashboard/src/lib/features/widget/components/widget-card-dropdown.svelte b/apps/dashboard/src/lib/features/widget/components/widget-card-dropdown.svelte new file mode 100644 index 00000000..ada3db03 --- /dev/null +++ b/apps/dashboard/src/lib/features/widget/components/widget-card-dropdown.svelte @@ -0,0 +1,74 @@ + + + + e.stopPropagation()} + > + + + + + + { + e.stopPropagation(); + draftName = name; + renameOpen = true; + }} + > + {$t('widgets.actions.rename')} + + + { + e.stopPropagation(); + handleDelete(); + }} + > + {$t('widgets.actions.delete')} + + + + + + + + {$t('widgets.actions.rename')} + + + + + + + + diff --git a/apps/dashboard/src/lib/features/widget/components/widget-editor-header.svelte b/apps/dashboard/src/lib/features/widget/components/widget-editor-header.svelte new file mode 100644 index 00000000..e27ff6bd --- /dev/null +++ b/apps/dashboard/src/lib/features/widget/components/widget-editor-header.svelte @@ -0,0 +1,114 @@ + + +
+
+ + +
+ {#if editingName} +
+ + +
+ {:else} +
+

{draftName}

+ +
+ {/if} + +
+ + {$t(`widgets.status.${status.toLowerCase()}`)} + + + {isPaidPlan ? $t('widgets.plan.paid') : $t('widgets.plan.free')} + +
+
+
+ +
+ {#if isDirty} + + {/if} + + +
+
diff --git a/apps/dashboard/src/lib/features/widget/components/widget-iframe-preview.svelte b/apps/dashboard/src/lib/features/widget/components/widget-iframe-preview.svelte new file mode 100644 index 00000000..1bfddf16 --- /dev/null +++ b/apps/dashboard/src/lib/features/widget/components/widget-iframe-preview.svelte @@ -0,0 +1,160 @@ + + +{#if iframeError} +
+

{iframeError}

+
+{:else} +
+ +
+{/if} diff --git a/apps/dashboard/src/lib/features/widget/components/widget-layout-thumbnail.svelte b/apps/dashboard/src/lib/features/widget/components/widget-layout-thumbnail.svelte new file mode 100644 index 00000000..738e5660 --- /dev/null +++ b/apps/dashboard/src/lib/features/widget/components/widget-layout-thumbnail.svelte @@ -0,0 +1,74 @@ + + +
+ + + {#if !imgFailed} + {label (imgFailed = true)} + /> + {:else} +
+ + {label ?? layoutType.replace('_', ' ')} + +
+ {/if} +
diff --git a/apps/dashboard/src/lib/features/widget/components/widget-preview.svelte b/apps/dashboard/src/lib/features/widget/components/widget-preview.svelte new file mode 100644 index 00000000..a3977ce7 --- /dev/null +++ b/apps/dashboard/src/lib/features/widget/components/widget-preview.svelte @@ -0,0 +1,31 @@ + + +{#if !payload} +
+ {$t('widgets.preview.empty')} +
+{:else} + +{/if} diff --git a/apps/dashboard/src/lib/features/widget/index.ts b/apps/dashboard/src/lib/features/widget/index.ts new file mode 100644 index 00000000..5d8d5b78 --- /dev/null +++ b/apps/dashboard/src/lib/features/widget/index.ts @@ -0,0 +1,3 @@ +export * from './api/widget.svelte'; +export * from './pages'; +export * from './utils/layout-thumbnail'; diff --git a/apps/dashboard/src/lib/features/widget/pages/index.ts b/apps/dashboard/src/lib/features/widget/pages/index.ts new file mode 100644 index 00000000..32a03065 --- /dev/null +++ b/apps/dashboard/src/lib/features/widget/pages/index.ts @@ -0,0 +1,2 @@ +export { default as WidgetEditorPage } from './widget-editor.svelte'; +export { default as WidgetsPage } from './widgets.svelte'; diff --git a/apps/dashboard/src/lib/features/widget/pages/widget-editor.svelte b/apps/dashboard/src/lib/features/widget/pages/widget-editor.svelte new file mode 100644 index 00000000..65a8b0a3 --- /dev/null +++ b/apps/dashboard/src/lib/features/widget/pages/widget-editor.svelte @@ -0,0 +1,175 @@ + + +{#if store.detail} +
+ store.saveWidget()} + onPublish={() => store.publishWidget()} + onDiscard={() => store.resetDraft()} + onRenameStart={() => (store.editingName = true)} + onRenameFinish={() => (store.editingName = false)} + /> + +
+ + + + + + + +
+
+
+ +
+
+
+
+
+{/if} diff --git a/apps/dashboard/src/lib/features/widget/pages/widgets.svelte b/apps/dashboard/src/lib/features/widget/pages/widgets.svelte new file mode 100644 index 00000000..62231209 --- /dev/null +++ b/apps/dashboard/src/lib/features/widget/pages/widgets.svelte @@ -0,0 +1,112 @@ + + +{#if widgetApi.widgets.length === 0} + + + +{:else} + +{/if} diff --git a/apps/dashboard/src/lib/features/widget/panels/design-panel.svelte b/apps/dashboard/src/lib/features/widget/panels/design-panel.svelte new file mode 100644 index 00000000..a18f9a7b --- /dev/null +++ b/apps/dashboard/src/lib/features/widget/panels/design-panel.svelte @@ -0,0 +1,83 @@ + + +
+ + + {$t('widgets.editor.design')} + + {$t('widgets.form.theme_preset')} + + {draftConfig.themePreset} + + {#each availableThemes as theme (theme)} + {theme} + {/each} + + + + + + + + + + + + + {$t('widgets.form.show_powered_by')} + + + + +
diff --git a/apps/dashboard/src/lib/features/widget/panels/embed-panel.svelte b/apps/dashboard/src/lib/features/widget/panels/embed-panel.svelte new file mode 100644 index 00000000..bc7330c3 --- /dev/null +++ b/apps/dashboard/src/lib/features/widget/panels/embed-panel.svelte @@ -0,0 +1,46 @@ + + +
+ +
+ +
+ + {#if detail.versions.length > 0} +
+

{$t('widgets.editor.version_history')}

+ {#each detail.versions as version (version.id)} +
+
+
v{version.version}
+
{version.publishedAt}
+
+ +
+ {/each} +
+ {/if} + +
+ +
+
diff --git a/apps/dashboard/src/lib/features/widget/panels/layout-panel.svelte b/apps/dashboard/src/lib/features/widget/panels/layout-panel.svelte new file mode 100644 index 00000000..c508d102 --- /dev/null +++ b/apps/dashboard/src/lib/features/widget/panels/layout-panel.svelte @@ -0,0 +1,160 @@ + + +
+
+

+ {$t('widgets.form.layout_type')} +

+ +
+ {#each layoutOptionIds as layoutId (layoutId)} + + + {#snippet child({ props })} +
+

+ {$t(`widgets.layout.${layoutId}`)} +

+ +
+ {/snippet} +
+ +
+

+ {$t(`widgets.layout.${layoutId}`)} +

+ + +
+
+
+ {/each} +
+
+
+ + {#if layoutValidationError} +
+ {$t(layoutValidationError)} +
+ {/if} + + + + {$t('widgets.layoutOptions.section')} + + + + + + + {$t('widgets.editor.layout')} + + {$t('widgets.form.sort_by')} + + {$t(`widgets.sort.${draftConfig.sortBy}`)} + + {$t('widgets.sort.manual')} + {$t('widgets.sort.newest')} + {$t('widgets.sort.title')} + + + + + + + + + + + {$t('widgets.editor.content')} + + + {$t('widgets.form.show_course_image')} + + + + {$t('widgets.form.show_type_badge')} + + + + {$t('widgets.form.show_lessons_count')} + + + + {$t('widgets.form.show_description')} + + + + {$t('widgets.form.shorten_long_description')} + + + + {$t('widgets.form.show_price')} + + + + {$t('widgets.form.show_rating')} + + + +
diff --git a/apps/dashboard/src/lib/features/widget/panels/select-courses-panel.svelte b/apps/dashboard/src/lib/features/widget/panels/select-courses-panel.svelte new file mode 100644 index 00000000..e8a2f726 --- /dev/null +++ b/apps/dashboard/src/lib/features/widget/panels/select-courses-panel.svelte @@ -0,0 +1,152 @@ + + +
+ + + + + {$t('widgets.editor.sources')} + + {$t('widgets.form.selection_mode')} + + + {draftSelectionMode === 'manual' ? $t('widgets.selection.manual') : $t('widgets.selection.published')} + + + {$t('widgets.selection.manual')} + {$t('widgets.selection.published')} + + + + + + + + + {$t('widgets.editor.available_courses')} +
+ + (filterPublished = val)} + onFilterCourseTypeChange={(val) => (filterCourseType = val)} + onFilterTagSlugsChange={(slugs) => (filterTagSlugs = slugs)} + /> +
+ + {#if courseOptions.length === 0} +

+ {$t('widgets.preview.empty')} +

+ {:else} + + {#snippet titleSuffix(option)} + {@const course = filteredCourses.find((entry) => entry.id === option.id)} + {#if course?.courseType && courseTypeLabels[course.courseType]} + {courseTypeLabels[course.courseType]} + {/if} + {/snippet} + {#snippet footer(option)} + {@const course = filteredCourses.find((entry) => entry.id === option.id)} + {#if course} +
+
+ {#if course.isPublished} + + + {$t('widgets.status.live')} + + {:else} + + + {$t('widgets.filter.unpublished')} + + {/if} + · + {course.lessonCount} {$t('widgets.preview.lessons_suffix')} + {#if course.exerciseCount > 0} + · + {course.exerciseCount} {$t('widgets.preview.exercises_suffix')} + {/if} +
+ {#if course.tags.length > 0} + + {/if} +
+ {/if} + {/snippet} +
+ {/if} +
+
+
diff --git a/apps/dashboard/src/lib/features/widget/store/widget-editor.store.svelte.ts b/apps/dashboard/src/lib/features/widget/store/widget-editor.store.svelte.ts new file mode 100644 index 00000000..97e22641 --- /dev/null +++ b/apps/dashboard/src/lib/features/widget/store/widget-editor.store.svelte.ts @@ -0,0 +1,246 @@ +import { getDefaultWidgetConfig } from '@cio/utils/validation/widget'; +import type { TWidgetPayload } from '@cio/utils/validation/widget'; +import type { WidgetDetail } from '../utils/types'; +import { widgetApi } from '../api/widget.svelte'; +import { deepPlain, buildCourseFilterPredicate, type FilterPublished } from '../utils/widget-editor-utils'; +import { goto } from '$app/navigation'; +import { resolve } from '$app/paths'; +import { currentOrgPath } from '$lib/utils/store/org'; +import { get } from 'svelte/store'; + +export type PanelId = 'select-courses' | 'layout' | 'design' | 'embed'; + +class WidgetEditorStore { + // ── persisted detail (last server-synced) ────────────────────────────────── + detail = $state(null); + + // ── draft fields ─────────────────────────────────────────────────────────── + draftName = $state(''); + draftLayoutType = $state('card_grid'); + draftSelectionMode = $state('manual'); + draftConfig = $state(getDefaultWidgetConfig()); + selectedCourseIds = $state([]); + + // ── UI state ─────────────────────────────────────────────────────────────── + activePanel = $state('select-courses'); + editingName = $state(false); + courseSearch = $state(''); + filterPublished = $state('all'); + filterCourseType = $state('all'); + filterTagSlugs = $state([]); + + // ── preview ──────────────────────────────────────────────────────────────── + previewPayload = $state(null); + #previewRequestSeq = 0; + + // ── sync guard ───────────────────────────────────────────────────────────── + #syncedDetailSignature = $state(''); + + // ── derived ──────────────────────────────────────────────────────────────── + readonly featuredCourseOptions = $derived.by(() => { + if (!this.detail) return []; + const candidateIds = + this.draftSelectionMode === 'manual' + ? this.selectedCourseIds + : this.detail.availableCourses.map((course) => course.id); + const lookup = new Map(this.detail.availableCourses.map((course) => [course.id, course] as const)); + return candidateIds + .map((id) => lookup.get(id)) + .filter((course): course is NonNullable> => Boolean(course)) + .map((course) => ({ id: course.id, title: course.title })); + }); + + readonly tagOptions = $derived(this.detail?.availableTags ?? []); + + readonly layoutValidationError = $derived.by((): string | null => { + if (!this.detail) return null; + if (this.draftLayoutType === 'primary_course' && !this.draftConfig.layoutOptions.primaryCourse.featuredCourseId) { + return 'widgets.layoutOptions.featured_course_required'; + } + if ( + this.draftLayoutType === 'editorial_spotlight' && + !this.draftConfig.layoutOptions.editorialSpotlight.mainCourseId + ) { + return 'widgets.layoutOptions.main_course_required'; + } + if ( + this.draftLayoutType === 'category_shelf' && + this.draftSelectionMode === 'published' && + this.draftConfig.layoutOptions.categoryShelf.categoryTagIds.length === 0 + ) { + return 'widgets.layoutOptions.category_tags_required'; + } + return null; + }); + + readonly filteredCourses = $derived.by(() => { + if (!this.detail) return []; + const predicate = buildCourseFilterPredicate({ + courseSearch: this.courseSearch, + filterPublished: this.filterPublished, + filterCourseType: this.filterCourseType, + filterTagSlugs: this.filterTagSlugs + }); + return this.detail.availableCourses.filter(predicate); + }); + + readonly activeFilterCount = $derived( + (this.filterPublished !== 'all' ? 1 : 0) + (this.filterCourseType !== 'all' ? 1 : 0) + this.filterTagSlugs.length + ); + + readonly isDirty = $derived( + this.detail !== null && + JSON.stringify({ + name: this.draftName, + layoutType: this.draftLayoutType, + selectionMode: this.draftSelectionMode, + config: this.draftConfig, + selectedCourseIds: this.selectedCourseIds + }) !== + JSON.stringify({ + name: this.detail.widget.name, + layoutType: this.detail.widget.layoutType, + selectionMode: this.detail.widget.selectionMode, + config: this.detail.widget.config, + selectedCourseIds: this.detail.widget.selectedCourseIds + }) + ); + + // ── public mutations ─────────────────────────────────────────────────────── + + applyDetail(nextDetail: WidgetDetail) { + this.detail = nextDetail; + this.draftName = nextDetail.widget.name; + this.draftLayoutType = nextDetail.widget.layoutType; + this.draftSelectionMode = nextDetail.widget.selectionMode; + this.draftConfig = deepPlain(nextDetail.widget.config); + this.selectedCourseIds = [...nextDetail.widget.selectedCourseIds]; + } + + syncFromInitial(initialDetail: WidgetDetail) { + const nextSignature = [ + initialDetail.widget.id, + initialDetail.widget.updatedAt, + initialDetail.widget.latestPublishedVersionId ?? '' + ].join(':'); + + if (this.#syncedDetailSignature === nextSignature) return; + + this.#syncedDetailSignature = nextSignature; + this.applyDetail(initialDetail); + } + + resetDraft() { + if (!this.detail) return; + this.applyDetail(this.detail); + } + + clearFilters() { + this.filterPublished = 'all'; + this.filterCourseType = 'all'; + this.filterTagSlugs = []; + } + + toggleCategoryTag(tagId: string, checked: boolean) { + const current = this.draftConfig.layoutOptions.categoryShelf.categoryTagIds; + this.draftConfig.layoutOptions.categoryShelf.categoryTagIds = checked + ? [...current, tagId] + : current.filter((id) => id !== tagId); + if (!checked && this.draftConfig.layoutOptions.categoryShelf.defaultCategoryTagId === tagId) { + this.draftConfig.layoutOptions.categoryShelf.defaultCategoryTagId = null; + } + } + + async saveWidget(silent = false): Promise { + if (!this.detail) return false; + + const updatedWidget = await widgetApi.updateWidget( + this.detail.widget.id, + { + name: this.draftName, + layoutType: this.draftLayoutType, + selectionMode: this.draftSelectionMode, + config: this.draftConfig, + selectedCourseIds: this.selectedCourseIds + }, + silent + ); + + if (!updatedWidget) return false; + + await widgetApi.getWidget(this.detail.widget.id); + if (widgetApi.widgetDetail) { + this.applyDetail(widgetApi.widgetDetail); + } + + return true; + } + + async publishWidget() { + const saved = await this.saveWidget(true); + if (!saved || !this.detail) return; + + const published = await widgetApi.publishWidget(this.detail.widget.id); + if (!published) return; + + await widgetApi.getWidget(this.detail.widget.id); + if (widgetApi.widgetDetail) { + this.applyDetail(widgetApi.widgetDetail); + } + + this.activePanel = 'embed'; + } + + async rollback(versionId: string) { + if (!this.detail) return; + + await widgetApi.rollbackWidget(this.detail.widget.id, versionId); + await widgetApi.getWidget(this.detail.widget.id); + if (widgetApi.widgetDetail) { + this.applyDetail(widgetApi.widgetDetail); + } + } + + async deleteWidget() { + if (!this.detail) return; + + await widgetApi.deleteWidget(this.detail.widget.id); + goto(resolve(`${get(currentOrgPath)}/widgets`, {})); + } + + /** Kicks off a debounced preview fetch. Call inside a `$effect`. */ + startPreviewEffect() { + if (!this.detail) return; + + // Subscribing to JSON.stringify forces deep reactivity on nested $state mutations. + void JSON.stringify({ + config: this.draftConfig, + layoutType: this.draftLayoutType, + selectionMode: this.draftSelectionMode, + selectedCourseIds: this.selectedCourseIds + }); + + const snapshot = { + config: deepPlain(this.draftConfig), + layoutType: this.draftLayoutType, + selectionMode: this.draftSelectionMode, + selectedCourseIds: [...this.selectedCourseIds] + }; + + const widgetId = this.detail.widget.id; + const seq = ++this.#previewRequestSeq; + let cancelled = false; + + void (async () => { + const payload = await widgetApi.previewWidget(widgetId, snapshot); + if (cancelled || seq !== this.#previewRequestSeq || !payload) return; + this.previewPayload = payload; + })(); + + return () => { + cancelled = true; + }; + } +} + +export const widgetEditorStore = new WidgetEditorStore(); diff --git a/apps/dashboard/src/lib/features/widget/utils/layout-thumbnail.ts b/apps/dashboard/src/lib/features/widget/utils/layout-thumbnail.ts new file mode 100644 index 00000000..57b83d9c --- /dev/null +++ b/apps/dashboard/src/lib/features/widget/utils/layout-thumbnail.ts @@ -0,0 +1,8 @@ +import type { TWidgetLayoutType } from '@cio/utils/validation/widget'; + +/** Served from `apps/dashboard/static/templates/widget-layouts/*.png` (~720px max edge for sharp downscaling). */ +export const WIDGET_LAYOUT_THUMBNAIL_BASE = '/templates/widget-layouts'; + +export function widgetLayoutThumbnailSrc(layoutType: TWidgetLayoutType): string { + return `${WIDGET_LAYOUT_THUMBNAIL_BASE}/${layoutType}.png`; +} diff --git a/apps/dashboard/src/lib/features/widget/utils/preview-protocol.ts b/apps/dashboard/src/lib/features/widget/utils/preview-protocol.ts new file mode 100644 index 00000000..97de7b90 --- /dev/null +++ b/apps/dashboard/src/lib/features/widget/utils/preview-protocol.ts @@ -0,0 +1,67 @@ +import type { TWidgetPayload } from '@cio/utils/validation/widget'; + +// Parent → iframe messages +export type WidgetPreviewParentMessage = + | { type: 'WIDGET_PREVIEW_RENDER'; payload: TWidgetPayload; requestId: string } + | { type: 'WIDGET_PREVIEW_PING' }; + +// Iframe → parent messages +export type WidgetPreviewIframeMessage = + | { type: 'WIDGET_PREVIEW_READY'; iframeId: string; timestamp: number } + | { type: 'WIDGET_PREVIEW_RENDERED'; requestId: string; timestamp: number } + | { type: 'WIDGET_PREVIEW_ERROR'; requestId: string; error: string; timestamp: number } + | { type: 'WIDGET_PREVIEW_RESIZE'; height: number }; + +export type WidgetPreviewMessage = WidgetPreviewParentMessage | WidgetPreviewIframeMessage; + +const VALID_PARENT_TYPES = new Set(['WIDGET_PREVIEW_RENDER', 'WIDGET_PREVIEW_PING']); +const VALID_IFRAME_TYPES = new Set([ + 'WIDGET_PREVIEW_READY', + 'WIDGET_PREVIEW_RENDERED', + 'WIDGET_PREVIEW_ERROR', + 'WIDGET_PREVIEW_RESIZE' +]); + +export function isValidParentMessage(data: unknown): data is WidgetPreviewParentMessage { + if (!data || typeof data !== 'object' || !('type' in data)) { + return false; + } + + return VALID_PARENT_TYPES.has((data as { type: string }).type); +} + +export function isValidIframeMessage(data: unknown): data is WidgetPreviewIframeMessage { + if (!data || typeof data !== 'object' || !('type' in data)) { + return false; + } + + return VALID_IFRAME_TYPES.has((data as { type: string }).type); +} + +export function getAllowedOrigins(): string[] { + const origins: string[] = []; + const dashboardUrl = typeof window !== 'undefined' ? window.location.origin : ''; + + if (dashboardUrl) { + origins.push(dashboardUrl); + } + + if (typeof window !== 'undefined' && window.location.hostname === 'localhost') { + origins.push(`http://localhost:${window.location.port}`); + } + + return origins; +} + +export function isAllowedOrigin(origin: string): boolean { + if (typeof window !== 'undefined' && window.location.hostname === 'localhost') { + try { + const url = new URL(origin); + return url.hostname === 'localhost'; + } catch { + return false; + } + } + + return origin === window.location.origin; +} diff --git a/apps/dashboard/src/lib/features/widget/utils/types.ts b/apps/dashboard/src/lib/features/widget/utils/types.ts new file mode 100644 index 00000000..c6257728 --- /dev/null +++ b/apps/dashboard/src/lib/features/widget/utils/types.ts @@ -0,0 +1,34 @@ +import { classroomio, type InferResponseType } from '$lib/utils/services/api'; +import type { + TCreateWidget, + TPreviewWidget, + TUpdateWidget, + TWidgetConfig, + TWidgetDetail, + TWidgetPayload +} from '@cio/utils/validation/widget'; + +export type GetWidgetsRequest = (typeof classroomio.organization)['widgets']['$get']; +export type GetWidgetDetailRequest = (typeof classroomio.organization)['widgets'][':widgetId']['$get']; +export type CreateWidgetRequest = (typeof classroomio.organization)['widgets']['$post']; +export type UpdateWidgetRequest = (typeof classroomio.organization)['widgets'][':widgetId']['$put']; +export type PreviewWidgetRequest = (typeof classroomio.organization)['widgets'][':widgetId']['preview']['$post']; +export type PublishWidgetRequest = (typeof classroomio.organization)['widgets'][':widgetId']['publish']['$post']; +export type RollbackWidgetRequest = (typeof classroomio.organization)['widgets'][':widgetId']['rollback']['$post']; +export type DeleteWidgetRequest = (typeof classroomio.organization)['widgets'][':widgetId']['$delete']; + +export type GetWidgetsSuccess = Extract, { success: true }>; +export type WidgetListItem = GetWidgetsSuccess['data'][number]; + +export type GetWidgetDetailSuccess = Extract, { success: true }>; +export type WidgetDetailResponse = GetWidgetDetailSuccess['data']; +export type WidgetDetail = TWidgetDetail & WidgetDetailResponse; + +export type PublishWidgetSuccess = Extract, { success: true }>; +export type RollbackWidgetSuccess = Extract, { success: true }>; + +export type WidgetConfig = TWidgetConfig; +export type WidgetPayload = TWidgetPayload; +export type CreateWidgetInput = TCreateWidget; +export type UpdateWidgetInput = TUpdateWidget; +export type PreviewWidgetInput = TPreviewWidget; diff --git a/apps/dashboard/src/lib/features/widget/utils/widget-editor-utils.ts b/apps/dashboard/src/lib/features/widget/utils/widget-editor-utils.ts new file mode 100644 index 00000000..3d6e0c87 --- /dev/null +++ b/apps/dashboard/src/lib/features/widget/utils/widget-editor-utils.ts @@ -0,0 +1,29 @@ +import type { WidgetDetail } from './types'; + +/** Plain deep clone — `structuredClone` throws on Svelte `$state` proxies. */ +export function deepPlain(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +export type FilterPublished = 'all' | 'published' | 'unpublished'; + +export function buildCourseFilterPredicate(opts: { + courseSearch: string; + filterPublished: FilterPublished; + filterCourseType: string; + filterTagSlugs: string[]; +}) { + return (course: WidgetDetail['availableCourses'][number]): boolean => { + if (opts.courseSearch && !course.title.toLowerCase().includes(opts.courseSearch.toLowerCase())) return false; + if (opts.filterPublished === 'published' && !course.isPublished) return false; + if (opts.filterPublished === 'unpublished' && course.isPublished) return false; + if (opts.filterCourseType !== 'all' && course.courseType !== opts.filterCourseType) return false; + if ( + opts.filterTagSlugs.length > 0 && + !opts.filterTagSlugs.some((slug) => course.tags.some((tag) => tag.slug === slug)) + ) + return false; + + return true; + }; +} diff --git a/apps/dashboard/src/lib/utils/constants/routes.ts b/apps/dashboard/src/lib/utils/constants/routes.ts index 570ab4e1..b39e342e 100644 --- a/apps/dashboard/src/lib/utils/constants/routes.ts +++ b/apps/dashboard/src/lib/utils/constants/routes.ts @@ -33,7 +33,9 @@ export const PUBLIC_ROUTES = [ `^${ROUTE.COURSES}/?$`, '/404', `^${ROUTE.VERIFY_EMAIL_ERROR}$`, - ROUTE.AUTH_FAILED + ROUTE.AUTH_FAILED, + '^/csp-report$', + '^/sticky-debug$' ]; export const PUBLIC_API_ROUTES = ['/api/polar', '/api/lmz', '/api/verify']; diff --git a/apps/dashboard/src/lib/utils/csp.ts b/apps/dashboard/src/lib/utils/csp.ts index d41e4dd9..6b849f45 100644 --- a/apps/dashboard/src/lib/utils/csp.ts +++ b/apps/dashboard/src/lib/utils/csp.ts @@ -18,6 +18,7 @@ function buildCspExtensions(): Record { const frameSrc = or('CSP_FRAME_SRC_DOMAINS'); const fontSrc = or('CSP_FONT_SRC_DOMAINS'); const mediaSrc = or('CSP_MEDIA_SRC_DOMAINS'); + const frameAncestorsExtra = or('CSP_FRAME_ANCESTORS_DOMAINS'); const serverUrl = process.env.PUBLIC_SERVER_URL; const connectSrc = serverUrl ? [...connectSrcBase, serverUrl] : connectSrcBase; @@ -35,6 +36,9 @@ function buildCspExtensions(): Record { extensions['img-src'] = mediaSrc; extensions['media-src'] = mediaSrc; } + if (frameAncestorsExtra.length) { + extensions['frame-ancestors'] = frameAncestorsExtra; + } return extensions; } diff --git a/apps/dashboard/src/lib/utils/functions/resizable-panel.ts b/apps/dashboard/src/lib/utils/functions/resizable-panel.ts new file mode 100644 index 00000000..63669680 --- /dev/null +++ b/apps/dashboard/src/lib/utils/functions/resizable-panel.ts @@ -0,0 +1,113 @@ +type ResizablePanelDragState = { + hasDragged: boolean; + width: number; +}; + +type StartResizablePanelDragOptions = { + event: PointerEvent; + startWidth: number; + resolveWidth: (params: { startWidth: number; startX: number; currentX: number; deltaX: number }) => number; + onPreview: (width: number) => void; + onCommit: (state: ResizablePanelDragState) => void; + onDragStart?: () => void; + onDragEnd?: (state: ResizablePanelDragState) => void; + cursor?: string; + dragThreshold?: number; +}; + +export function startResizablePanelDrag({ + event, + startWidth, + resolveWidth, + onPreview, + onCommit, + onDragStart, + onDragEnd, + cursor = 'col-resize', + dragThreshold = 3 +}: StartResizablePanelDragOptions) { + const startX = event.clientX; + const previousCursor = document.body.style.cursor; + const previousUserSelect = document.body.style.userSelect; + let hasDragged = false; + let nextWidth = startWidth; + let animationFrame: number | null = null; + let hasStopped = false; + + document.body.style.cursor = cursor; + document.body.style.userSelect = 'none'; + onDragStart?.(); + + function flushPreview() { + animationFrame = null; + onPreview(nextWidth); + } + + function schedulePreview() { + if (animationFrame !== null) { + return; + } + + animationFrame = window.requestAnimationFrame(flushPreview); + } + + function stopResize(shouldCommit: boolean) { + if (hasStopped) { + return; + } + + hasStopped = true; + + if (animationFrame !== null) { + window.cancelAnimationFrame(animationFrame); + animationFrame = null; + onPreview(nextWidth); + } + + document.body.style.cursor = previousCursor; + document.body.style.userSelect = previousUserSelect; + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + window.removeEventListener('pointercancel', handlePointerCancel); + + const state = { hasDragged, width: nextWidth }; + + if (shouldCommit) { + onCommit(state); + } + + onDragEnd?.(state); + } + + function handlePointerMove(moveEvent: PointerEvent) { + const deltaX = moveEvent.clientX - startX; + nextWidth = resolveWidth({ + startWidth, + startX, + currentX: moveEvent.clientX, + deltaX + }); + + if (Math.abs(deltaX) > dragThreshold) { + hasDragged = true; + } + + schedulePreview(); + } + + function handlePointerUp() { + stopResize(true); + } + + function handlePointerCancel() { + stopResize(false); + } + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', handlePointerUp); + window.addEventListener('pointercancel', handlePointerCancel); + + return () => { + stopResize(false); + }; +} diff --git a/apps/dashboard/src/lib/utils/services/api/index.ts b/apps/dashboard/src/lib/utils/services/api/index.ts index 94577720..006df8c3 100644 --- a/apps/dashboard/src/lib/utils/services/api/index.ts +++ b/apps/dashboard/src/lib/utils/services/api/index.ts @@ -102,13 +102,18 @@ class ApiClient { } } + if (isFormData) { + // Let the browser set the multipart boundary, but keep auth/org headers. + headers.delete('Content-Type'); + } + // Create abort controller for timeout const timeoutController = new AbortController(); const timeoutId = setTimeout(() => timeoutController.abort(), timeout); const requestInit: RequestInit = { ...fetchConfig, - headers: isFormData ? undefined : headers, + headers, body: requestBody, signal: mergeAbortSignals(fetchConfig.signal, timeoutController.signal) }; diff --git a/apps/dashboard/src/lib/utils/translations/da.json b/apps/dashboard/src/lib/utils/translations/da.json index f1ae5ed9..fc9f8427 100644 --- a/apps/dashboard/src/lib/utils/translations/da.json +++ b/apps/dashboard/src/lib/utils/translations/da.json @@ -219,7 +219,8 @@ "complete_marked": "Markeret som komplet", "complete_download": "Download fuldført", "version_restored": "Version blev gendannet" - } + }, + "section_deleted": "Kursusafsnittet blev slettet" }, "attendance": { "error": { @@ -654,7 +655,8 @@ "add": "Tilføj tag", "popover_title": "Vælg tags", "empty_groups": "Ingen tags tilgængelige endnu" - } + }, + "compliance": "Overholdelse" }, "analytics": { "title": "Analytics" @@ -1117,7 +1119,7 @@ "finish": "Afslut", "next": "Næste", "check": "Tjek", - "auto_graded_badge": "Auto-graded", + "auto_graded_badge": "Auto-grade", "manual_grading_required_badge": "Manual grading required", "points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.", "analytics": { @@ -1697,6 +1699,81 @@ "edit": "Rediger", "delete": "Slet" } + }, + "compliance": { + "title": "Overholdelse", + "not_compliance_title": "Overholdelse er ikke aktiveret", + "not_compliance_description": "Dette kursus er ikke konfigureret som et compliance-kursus.", + "loading": "Indlæser overholdelsesposter...", + "learners": "Elever", + "learners_description": "Denne tabel viser den seneste cyklus for hver elev.", + "current_cycle": "Nuværende cyklus", + "current_cycle_description": "Din aktive overholdelsescyklus og fornyelsesdatoer.", + "history": "Tidligere cyklusser", + "history_description": "Hver række er en registreret overholdelsescyklus for dette kursus.", + "no_active_cycle": "Ingen aktiv compliance-cyklus", + "empty_history": "Der er ikke registreret nogen overholdelseshistorik endnu.", + "empty_overview": "Der er endnu ingen registrering af elevoverholdelse.", + "not_available": "Ikke tilgængelig", + "status": { + "no_record": "Ingen rekord", + "not_started": "Ikke startet", + "in_progress": "I gang", + "compliant": "Overensstemmende", + "expiring_soon": "Udløber snart", + "in_grace_period": "I afdragsfri periode", + "non_compliant": "Ikke-kompatibel", + "waived": "Frafaldet" + }, + "summary": { + "total_learners": "Samlede elever", + "no_record": "Ingen rekord", + "not_started": "Ikke startet", + "in_progress": "I gang", + "compliant": "Overensstemmende", + "expiring_soon": "Udløber snart", + "in_grace_period": "I afdragsfri periode", + "non_compliant": "Ikke-kompatibel", + "waived": "Frafaldet" + }, + "fields": { + "learner": "Lærer", + "email": "E-mail", + "status": "Status", + "cycle": "Cyklus", + "due_date": "Forfaldsdato", + "completed_at": "Afsluttet kl", + "valid_until": "Gælder indtil kl", + "score": "Score", + "attempts": "Forsøg" + }, + "bulk_actions": { + "title": "Massehandlinger", + "description": "Vælg elever og anvend en overholdelseshandling på deres nuværende cyklus.", + "select_all": "Vælg alle synlige elever", + "selected_count": "{count} valgt", + "clear_selection": "Ryd markering", + "due_date_label": "Forfaldsdato", + "due_date_helper": "Bruges til nulstilling og forlængelse.", + "reset_action": "Nulstil cyklus", + "extend_action": "Forlæng forfaldsdatoen", + "waive_title": "Giv afkald på udvalgte elever", + "waive_description": "Anvend dispensation til de valgte elever. Du kan lade udløbsdatoen stå tom for en tidsubegrænset dispensation.", + "waiver_reason_label": "Årsag til afkald", + "waiver_reason_helper": "Valgfri kontekst for revisioner og fremtidige anmeldelser.", + "waiver_expires_at_label": "Afkaldet udløber kl", + "waiver_expires_at_helper": "Lad stå tom, hvis dispensationen ikke udløber automatisk.", + "waive_action": "Anvend dispensation", + "modal": { + "description": "Du opdaterer {count} valgte elever.", + "cancel": "Annuller", + "apply": "Anvend handling" + } + }, + "tabs": { + "overview": "Oversigt", + "learners": "Elever" + } } }, "search": { @@ -1712,6 +1789,11 @@ "content_count": { "lessons": "{count} lektioner", "exercises": "{count} øvelser" + }, + "compliance": { + "title": "Overholdelse", + "due_date": "Forfalder", + "valid_until": "Gælder indtil kl" } }, "navItems": { @@ -1724,7 +1806,9 @@ "nav_analytics": "Analytics", "nav_certificates": "Certifikater", "nav_landing_page": "Landingsside", - "nav_settings": "Indstillinger" + "nav_settings": "Indstillinger", + "nav_compliance": "Overholdelse", + "nav_ai_assistant": "Assistent" }, "completion": { "modal": { @@ -1812,8 +1896,11 @@ "share": "Del", "invite": "Inviter", "delete": "Slet", - "open": "Åbn" - } + "open": "Åbn", + "open_course_site": "Åben kursusside" + }, + "due_date": "Forfalder", + "valid_until": "Gælder indtil kl" }, "course_filter": { "date_created_newest": "Date Created: Newest first", @@ -1865,9 +1952,9 @@ "lms_dashboard_hero": "I dag er endnu en dag til at bringe dig tættere på dine læringsmål. Giv ikke op, jo mere du lærer, jo bedre bliver du.", "lessons_completed": "Lektioner afsluttet", "no_courses_started": "Ingen kurser startet", - "certificates_earned": "Certificates earned", + "certificates_earned": "Certificates issued", "certificates_earned_description": "Total certificates issued to students in your organization", - "certification_rate": "certification rate", + "certification_rate": "Certificeret", "recent_certifications": "Recent certifications", "no_certifications_yet": "No certificates earned yet", "no_certifications_yet_description": "When students complete a course and earn a certificate, they will appear here", @@ -1886,7 +1973,28 @@ "publish_first_course": "Udgiv dit første kursus", "publish_first_course_description": "For at spore tilmeldinger skal du udgive et kursus og invitere elever", "publish_course": "Udgiv kursus", - "items_completed": "Varer afsluttet" + "items_completed": "Varer afsluttet", + "currently_learning": "Lærer i øjeblikket", + "learner": "Lærer", + "progress": "Fremskridt", + "pick_up_where_you_left_off": "Fortsæt hvor du slap", + "course_awaits_you": "{title} venter på dig", + "learning_awaits_you": "Dit næste kursus venter på dig", + "lms_today_progress": "Du er {progress} % gennem din læring - hvert skridt tæller.", + "lms_today_empty": "Din læringsvej er klar, når du er.", + "compliance_score": "Overholdelsesscore", + "compliance": "Overholdelse", + "compliance_score_subtitle": "{completed} af {total} overholdelseskurser er i øjeblikket opfyldt", + "compliance_score_empty": "Ingen overholdelseskurser tildelt endnu", + "compliance_met": "{completed} af {total} opfyldt", + "required_courses_completed": "{completed} af {total} obligatoriske kurser gennemført", + "streak": "Streak", + "days_active": "dage aktive", + "items_done": "{completed} af {total} udført", + "login_activity_title": "Student login aktivitet", + "login_activity_description": "Ugens mest aktive dage (sidste 90 dage)", + "login_activity_no_data": "Ingen login-aktivitet endnu", + "login_activity_logins": "logins" }, "schedule": { "title": "Dit skema", @@ -2097,7 +2205,8 @@ "upgrade": "Opgrader nu", "tags": "Tags", "automation": "Automatisering", - "programs": "Programmer" + "programs": "Programmer", + "widgets": "Widgets" }, "media_manager": { "page_title": "Media Manager - ClassroomIO", @@ -2218,7 +2327,10 @@ "provider": "Vi bruger Polar til at hjælpe med at administrere din fakturering", "open_billing": "Åben fakturering", "error": "Der opstod en fejl under åbning af fakturering", - "page_subtitle": "Your plan, billing portal, and how you pay for ClassroomIO." + "page_subtitle": "Your plan, billing portal, and how you pay for ClassroomIO.", + "ai_credits": "Brug af AI-assistent", + "ai_tokens_used": "tokens brugt denne måned", + "ai_bonus_credits": "tilgængelige bonuskreditter" }, "integrations": { "heading": "Telegram", @@ -2667,9 +2779,34 @@ }, "app": { "search": { - "placeholder": "Søg" + "placeholder": "Søg", + "command_palette": { + "title": "Kommandopalet", + "description": "Søg og naviger på tværs af din organisation", + "placeholder": "Søg i kurser, programmer, widgets, studerende, tags, indstillinger...", + "org_placeholder": "Søg i kurser, programmer, widgets, studerende, tags, indstillinger...", + "lms_placeholder": "Søg på dine kurser, programmer og læringssider...", + "lms_description": "Søg og naviger dit læringsarbejdsområde", + "empty": "Ingen resultater fundet", + "loading": "Søger...", + "clear_recents": "Ryd seneste", + "result_types": { + "page": "Side" + }, + "groups": { + "recent": "Seneste", + "suggested": "Foreslået", + "courses": "Kurser", + "programs": "Programmer", + "widgets": "Widgets", + "tags": "Tags", + "students": "studerende", + "settings": "Indstillinger" + } + } }, - "cancel": "Annuller" + "cancel": "Annuller", + "save": "Gem" }, "welcome_modal": { "we_at": "Vi kl", @@ -2700,7 +2837,9 @@ "live_class_label": "Live klasse", "live_class_subtitle": "Ideel til bootcamps, hvor undervisningen er tidsbaseret", "self_paced_label": "Selv tempo", - "self_paced_subtitle": "Bedst til kurser, hvor eleverne kan tage lektioner i deres eget tempo" + "self_paced_subtitle": "Bedst til kurser, hvor eleverne kan tage lektioner i deres eget tempo", + "compliance_label": "Overholdelse", + "compliance_subtitle": "Tilbagevendende træning med gentagninger, påmindelser og fornyelsessporing" }, "delete_modal": { "label": "Slet", @@ -3079,5 +3218,226 @@ "sort_students": "studerende", "sort_status": "Status" } + }, + "ai_assistant": { + "empty_state": "Bed mig om at hjælpe med dit kursusindhold - lav lektioner, generer spørgsmål, eller planlæg et kursus ud fra et dokument.", + "input_placeholder": "Spørg AI-assistenten...", + "attach_document": "Vedhæft et dokument (PDF, DOCX, PPTX)", + "upgrade_to_upload": "Opgrader for at uploade dokumenter", + "tokens_exhausted": "Månedlig AI-token-grænse nået. Køb flere kreditter i Indstillinger > Fakturering, eller vent på nulstilling.", + "chat_history": "Chat historie", + "no_conversations": "Ingen samtaler endnu", + "untitled": "Uden titel", + "new_chat": "Ny chat", + "tokens_label": "tokens", + "implementing_course_plan": "Implementering af kursusplan", + "resume": "Genoptag", + "stopped_content_kept": "Stoppet. Oprettet indhold er blevet bevaret.", + "mention_lesson": "Lektion", + "mention_exercise": "Motion", + "mention_section": "Sektion", + "mention_no_results": "Intet matchende indhold", + "uploading_document": "Uploader dokument..." + }, + "widgets": { + "heading": "Widgets", + "subtitle": "Udgive brandede kursussamlinger til eksterne hjemmesider.", + "empty_state": "Ingen widgets endnu. Opret en for at generere en indlejrbar kursusblok.", + "filter": { + "status": "Status", + "all": "Alle", + "unpublished": "Upubliceret", + "course_type": "Kursustype" + }, + "notifications": { + "created": "Widget oprettet", + "saved": "Widget er gemt", + "published": "Widget offentliggjort", + "rolled_back": "Widget gendannet", + "deleted": "Widget arkiveret" + }, + "form": { + "name": "Widget navn", + "selection_mode": "Valgtilstand", + "course_search": "Søg efter kurser", + "layout_type": "Layout type", + "sort_by": "Sorter efter", + "cta_label": "CTA-mærke", + "load_more_label": "Indlæs mere etiket", + "limit_courses": "Begræns kurser", + "max_count": "Max antal kurser", + "show_course_image": "Vis kursusbillede", + "show_type_badge": "Vis kursustypemærke", + "show_lessons_count": "Vis lektioner tæller", + "show_description": "Vis beskrivelsesuddrag", + "shorten_long_description": "Forkort lang beskrivelse", + "show_price": "Vis pris", + "show_rating": "Vis bedømmelse", + "theme_preset": "Forudindstillet tema", + "primary_color": "Primær farve", + "background_color": "Baggrundsfarve", + "text_color": "Tekstfarve", + "border_color": "Kantfarve", + "border_radius": "Grænseradius", + "font_family": "Fontfamilie", + "font_scale": "Skriftstørrelsesskala", + "show_powered_by": "Vis Classroomio branding", + "custom_css": "Brugerdefineret CSS", + "custom_css_locked": "Opgrader for at aktivere tilpasset CSS.", + "embed_code": "Integrer kode" + }, + "create": { + "description": "Giv din widget et navn for at komme i gang." + }, + "actions": { + "create": "Opret widget", + "edit": "Rediger", + "back": "Tilbage til widgets", + "save": "Gem", + "discard": "Kassér ændringer", + "publish": "Udgiv", + "archive": "Arkiv", + "restore": "Gendan", + "copy_embed": "Kopiér indlejring", + "rename": "Omdøb", + "delete": "Slet" + }, + "preview": { + "heading": "Forhåndsvisning", + "saved": "Gemt", + "unsaved": "Ikke-gemte ændringer", + "load_failed": "Forhåndsvisningen kunne ikke indlæses. Prøv at opdatere siden.", + "empty": "Forhåndsvisning er ikke tilgængelig.", + "all_tags": "Alle", + "none": "Ingen", + "lessons_suffix": "lektioner", + "last_synced": "Synkroniseret", + "refresh": "Opdater", + "exercises_suffix": "øvelser", + "columns": { + "course": "Kursus", + "lessons": "Lektioner", + "tags": "Tags" + } + }, + "plan": { + "free": "Gratis plan", + "paid": "Betalt plan" + }, + "status": { + "draft": "Udkast", + "published": "Udgivet", + "archived": "Arkiveret", + "live": "Live" + }, + "sort": { + "manual": "Manual", + "newest": "Nyeste", + "title": "Titel" + }, + "layoutOptions": { + "section": "Layout muligheder", + "columns": "Kolonner", + "max_count": "Max kurser", + "show_rating": "Vis bedømmelse", + "tag_source": "Tagkilde", + "tag_source_auto": "Auto fra kurser", + "tag_source_manual": "Manuelt valg", + "default_tag": "Standard tag", + "show_all_option": "Vis indstillingen \"Alle\".", + "visible_cards": "Synlige kort", + "auto_play": "Automatisk afspilning", + "auto_play_interval": "Automatisk afspilningsinterval (ms)", + "show_dots": "Vis prikker", + "show_arrows": "Vis pile", + "loop": "Løkke", + "featured_course": "Udvalgt kursus", + "featured_course_required": "Et udvalgt kursus er påkrævet til dette layout.", + "main_course": "Hoved spotlight kursus", + "main_course_required": "Vælg et kursus for at sætte fokus på.", + "eyebrow_label": "Etiket af øjenbryn", + "cta_label": "CTA-mærke", + "secondary_max_count": "Sekundære kurser", + "density": "Tæthed", + "density_comfortable": "Komfortabel", + "density_compact": "Kompakt", + "show_thumbnail": "Vis thumbnail", + "show_tags": "Vis tags", + "title_style": "Titel stil", + "title_style_serif": "Serif", + "title_style_sans": "Sans", + "category_tags": "Kategori tags", + "category_tags_required": "Vælg mindst ét tag til kategorihylden.", + "default_category": "Standardkategori", + "show_all_tab": "Vis fanen \"Alle\".", + "max_per_category": "Max kurser pr. kategori", + "none": "Ingen" + }, + "layout_description": { + "card_grid": "Universalkatalog med masser af pusterum.", + "tag_filter": "Lad eleverne indsnævre et bredt katalog efter emne.", + "carousel": "Udvalgte rækker i stramme hjemmesidesektioner.", + "primary_course": "Markedsfør et flagskib eller et nystartet kursus.", + "compact_list": "Indlejret sidepanel og sider med minimalt fodaftryk.", + "editorial_spotlight": "Landingssider i blogstil og udvalgte udvalg.", + "category_shelf": "Store kataloger grupperet efter emne, nemme at scanne." + }, + "layout": { + "card_grid": "Kortgitter", + "tag_filter": "Tag filter", + "carousel": "Karrusel", + "primary_course": "Primær kursus", + "compact_list": "Kompakt liste", + "editorial_spotlight": "Redaktionelt fokus", + "category_shelf": "Kategori hylde" + }, + "selection": { + "manual": "Manuelt valg", + "published": "Alle offentliggjorte kurser" + }, + "editor": { + "sources": "Widget Kilde", + "available_courses": "Tilgængelige kurser", + "layout": "Layout", + "content": "Indhold", + "design": "Design", + "version_history": "Versionshistorik", + "save_changes": "Gem ændringer", + "rename_widget": "Omdøb widget", + "continue_to_layout": "Fortsæt til layout", + "pick_layout": "Vælg et layout", + "pick_design": "Udseende", + "panel_subtitle": "Konfigurer, hvordan din widget ser ud og opfører sig.", + "embed_share": "Integrer og del", + "discard_short": "Kassér", + "canvas_status": "Forhåndsvisning", + "finish_editing_name": "Færdig", + "waiting_org": "Indlæser organisation...", + "load_failed": "Kunne ikke indlæse denne widget. Prøv igen fra widgetlisten.", + "nav": { + "courses": "Kurser", + "layout": "Layout", + "design": "Design", + "embed": "Integrer" + }, + "viewport": { + "mobile": "Mobil", + "tablet": "Tablet", + "desktop": "Desktop" + } + }, + "tabs": { + "select_courses": "Vælg Kurser", + "layout": "Layout", + "design": "Design", + "embed": "Integrer kode" + }, + "defaults": { + "name_prefix": "Kursus-widget" + }, + "empty": { + "heading": "Ingen widgets endnu", + "description": "Opret en widget for at generere en indlejrbar kursusblok til eksterne websteder." + } } } diff --git a/apps/dashboard/src/lib/utils/translations/de.json b/apps/dashboard/src/lib/utils/translations/de.json index 44843b3c..c56b1c11 100644 --- a/apps/dashboard/src/lib/utils/translations/de.json +++ b/apps/dashboard/src/lib/utils/translations/de.json @@ -219,7 +219,8 @@ "complete_marked": "Als abgeschlossen markiert", "complete_download": "Download abgeschlossen", "version_restored": "Version erfolgreich wiederhergestellt" - } + }, + "section_deleted": "Kursabschnitt erfolgreich gelöscht" }, "attendance": { "error": { @@ -654,7 +655,8 @@ "add": "Tag hinzufügen", "popover_title": "Wählen Sie Tags aus", "empty_groups": "Noch keine Tags verfügbar" - } + }, + "compliance": "Compliance" }, "analytics": { "title": "Analytik" @@ -1117,7 +1119,7 @@ "finish": "Fertig", "next": "Als nächstes", "check": "Überprüfen", - "auto_graded_badge": "Auto-graded", + "auto_graded_badge": "Auto-grade", "manual_grading_required_badge": "Manual grading required", "points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.", "analytics": { @@ -1697,6 +1699,81 @@ "edit": "Bearbeiten", "delete": "Löschen" } + }, + "compliance": { + "title": "Compliance", + "not_compliance_title": "Compliance ist nicht aktiviert", + "not_compliance_description": "Dieser Kurs ist nicht als Compliance-Kurs konfiguriert.", + "loading": "Compliance-Datensätze werden geladen...", + "learners": "Lernende", + "learners_description": "Diese Tabelle zeigt den neuesten Zyklus für jeden Lernenden.", + "current_cycle": "Aktueller Zyklus", + "current_cycle_description": "Ihr aktiver Compliance-Zyklus und Ihre Verlängerungstermine.", + "history": "Vergangene Zyklen", + "history_description": "Jede Zeile ist ein aufgezeichneter Compliance-Zyklus für diesen Kurs.", + "no_active_cycle": "Kein aktiver Compliance-Zyklus", + "empty_history": "Es wurde noch kein Compliance-Verlauf erfasst.", + "empty_overview": "Es sind noch keine Compliance-Aufzeichnungen für Lernende verfügbar.", + "not_available": "Nicht verfügbar", + "status": { + "no_record": "Keine Aufzeichnung", + "not_started": "Nicht gestartet", + "in_progress": "In Bearbeitung", + "compliant": "Konform", + "expiring_soon": "Läuft bald ab", + "in_grace_period": "In der Schonfrist", + "non_compliant": "Nicht konform", + "waived": "Verzichtet" + }, + "summary": { + "total_learners": "Insgesamt Lernende", + "no_record": "Keine Aufzeichnung", + "not_started": "Nicht gestartet", + "in_progress": "In Bearbeitung", + "compliant": "Konform", + "expiring_soon": "Läuft bald ab", + "in_grace_period": "In der Schonfrist", + "non_compliant": "Nicht konform", + "waived": "Verzichtet" + }, + "fields": { + "learner": "Lernender", + "email": "E-Mail", + "status": "Status", + "cycle": "Zyklus", + "due_date": "Fälligkeitsdatum", + "completed_at": "Abgeschlossen um", + "valid_until": "Gültig bis", + "score": "Punktzahl", + "attempts": "Versuche" + }, + "bulk_actions": { + "title": "Massenaktionen", + "description": "Wählen Sie Lernende aus und wenden Sie eine Compliance-Aktion auf ihren aktuellen Zyklus an.", + "select_all": "Wählen Sie alle sichtbaren Lernenden aus", + "selected_count": "{count} ausgewählt", + "clear_selection": "Klare Auswahl", + "due_date_label": "Fälligkeitsdatum", + "due_date_helper": "Wird zum Zurücksetzen und Erweitern von Aktionen verwendet.", + "reset_action": "Zyklus zurücksetzen", + "extend_action": "Fälligkeitstermin verlängern", + "waive_title": "Verzicht auf ausgewählte Lernende", + "waive_description": "Wenden Sie einen Verzicht auf die ausgewählten Lernenden an. Für einen unbefristeten Verzicht können Sie das Ablaufdatum leer lassen.", + "waiver_reason_label": "Verzichtsgrund", + "waiver_reason_helper": "Optionaler Kontext für Audits und zukünftige Überprüfungen.", + "waiver_expires_at_label": "Der Verzicht endet am", + "waiver_expires_at_helper": "Lassen Sie das Feld leer, wenn die Verzichtserklärung nicht automatisch ablaufen soll.", + "waive_action": "Verzicht beantragen", + "modal": { + "description": "Sie aktualisieren {count} ausgewählte Lernende.", + "cancel": "Abbrechen", + "apply": "Aktion anwenden" + } + }, + "tabs": { + "overview": "Übersicht", + "learners": "Lernende" + } } }, "search": { @@ -1712,6 +1789,11 @@ "content_count": { "lessons": "{count} Lektionen", "exercises": "{count} Übungen" + }, + "compliance": { + "title": "Compliance", + "due_date": "Fällig", + "valid_until": "Gültig bis" } }, "navItems": { @@ -1724,7 +1806,9 @@ "nav_analytics": "Analytik", "nav_certificates": "Zertifikate", "nav_landing_page": "Landingpage", - "nav_settings": "Einstellungen" + "nav_settings": "Einstellungen", + "nav_compliance": "Compliance", + "nav_ai_assistant": "Assistent" }, "completion": { "modal": { @@ -1812,8 +1896,11 @@ "share": "Teilen", "invite": "Einladen", "delete": "Löschen", - "open": "Offen" - } + "open": "Offen", + "open_course_site": "Kursseite öffnen" + }, + "due_date": "Fällig", + "valid_until": "Gültig bis" }, "course_filter": { "date_created_newest": "Date Created: Newest first", @@ -1865,9 +1952,9 @@ "lms_dashboard_hero": "Heute ist ein weiterer Tag, um Sie Ihren Lernzielen näher zu bringen. Geben Sie nicht auf, je mehr Sie lernen, desto besser werden Sie.", "lessons_completed": "Lektionen abgeschlossen", "no_courses_started": "Es wurden keine Kurse gestartet", - "certificates_earned": "Certificates earned", + "certificates_earned": "Certificates issued", "certificates_earned_description": "Total certificates issued to students in your organization", - "certification_rate": "certification rate", + "certification_rate": "Zertifiziert", "recent_certifications": "Recent certifications", "no_certifications_yet": "No certificates earned yet", "no_certifications_yet_description": "When students complete a course and earn a certificate, they will appear here", @@ -1886,7 +1973,28 @@ "publish_first_course": "Veröffentlichen Sie Ihren ersten Kurs", "publish_first_course_description": "Um Anmeldungen zu verfolgen, veröffentlichen Sie einen Kurs und laden Sie Studierende ein", "publish_course": "Kurs veröffentlichen", - "items_completed": "Artikel abgeschlossen" + "items_completed": "Artikel abgeschlossen", + "currently_learning": "Derzeit lernen", + "learner": "Lernender", + "progress": "Fortschritt", + "pick_up_where_you_left_off": "Machen Sie dort weiter, wo Sie aufgehört haben", + "course_awaits_you": "{title} erwartet Sie", + "learning_awaits_you": "Ihr nächster Kurs erwartet Sie", + "lms_today_progress": "Ihr Lernfortschritt beträgt {progress} % – jeder Schritt zählt.", + "lms_today_empty": "Ihr Lernpfad ist fertig, wenn Sie es sind.", + "compliance_score": "Compliance-Score", + "compliance": "Compliance", + "compliance_score_subtitle": "{completed} von {total} Compliance-Kursen, die derzeit absolviert wurden", + "compliance_score_empty": "Noch keine Compliance-Kurse zugewiesen", + "compliance_met": "{completed} von {total} erfüllt", + "required_courses_completed": "{completed} von {total} erforderlichen Kursen abgeschlossen", + "streak": "Streifen", + "days_active": "Tage aktiv", + "items_done": "{completed} von {total} erledigt", + "login_activity_title": "Anmeldeaktivität für Studenten", + "login_activity_description": "Aktivste Tage der Woche (letzte 90 Tage)", + "login_activity_no_data": "Noch keine Login-Aktivität", + "login_activity_logins": "Anmeldungen" }, "schedule": { "title": "Ihr Zeitplan", @@ -2097,7 +2205,8 @@ "upgrade": "Jetzt upgraden", "tags": "Schlagworte", "automation": "Automatisierung", - "programs": "Programme" + "programs": "Programme", + "widgets": "Widgets" }, "media_manager": { "page_title": "Medienmanager - ClassroomIO", @@ -2218,7 +2327,10 @@ "provider": "Wir verwenden Polar, um Ihre Abrechnung zu verwalten", "open_billing": "Abrechnung öffnen", "error": "Beim Öffnen der Abrechnung ist ein Fehler aufgetreten", - "page_subtitle": "Your plan, billing portal, and how you pay for ClassroomIO." + "page_subtitle": "Your plan, billing portal, and how you pay for ClassroomIO.", + "ai_credits": "Verwendung des KI-Assistenten", + "ai_tokens_used": "Token, die diesen Monat verwendet wurden", + "ai_bonus_credits": "Bonusguthaben verfügbar" }, "integrations": { "heading": "Telegramm", @@ -2667,9 +2779,34 @@ }, "app": { "search": { - "placeholder": "Suchen" + "placeholder": "Suchen", + "command_palette": { + "title": "Befehlspalette", + "description": "Suchen und navigieren Sie in Ihrer Organisation", + "placeholder": "Durchsuchen Sie Kurse, Programme, Widgets, Studenten, Tags, Einstellungen ...", + "org_placeholder": "Durchsuchen Sie Kurse, Programme, Widgets, Studenten, Tags, Einstellungen ...", + "lms_placeholder": "Durchsuchen Sie Ihre Kurse, Programme und Lernseiten ...", + "lms_description": "Durchsuchen und navigieren Sie in Ihrem Lernarbeitsbereich", + "empty": "Keine Ergebnisse gefunden", + "loading": "Suche...", + "clear_recents": "Aktuelle Nachrichten löschen", + "result_types": { + "page": "Seite" + }, + "groups": { + "recent": "Neu", + "suggested": "Vorgeschlagen", + "courses": "Kurse", + "programs": "Programme", + "widgets": "Widgets", + "tags": "Schlagworte", + "students": "Studenten", + "settings": "Einstellungen" + } + } }, - "cancel": "Abbrechen" + "cancel": "Abbrechen", + "save": "Speichern" }, "welcome_modal": { "we_at": "Wir bei", @@ -2700,7 +2837,9 @@ "live_class_label": "Live-Klasse", "live_class_subtitle": "Ideal für Bootcamps, bei denen der Unterricht zeitbasiert ist", "self_paced_label": "Eigenständig", - "self_paced_subtitle": "Am besten für Kurse geeignet, bei denen die Schüler den Unterricht in ihrem eigenen Tempo absolvieren können" + "self_paced_subtitle": "Am besten für Kurse geeignet, bei denen die Schüler den Unterricht in ihrem eigenen Tempo absolvieren können", + "compliance_label": "Compliance", + "compliance_subtitle": "Wiederkehrende Schulungen mit Wiederholungen, Erinnerungen und Verlängerungsverfolgung" }, "delete_modal": { "label": "Löschen", @@ -3079,5 +3218,226 @@ "sort_students": "Studenten", "sort_status": "Status" } + }, + "ai_assistant": { + "empty_state": "Bitten Sie mich, Ihnen bei den Inhalten Ihres Kurses zu helfen – Lektionen zu entwerfen, Fragen zu generieren oder einen Kurs anhand eines Dokuments zu planen.", + "input_placeholder": "Fragen Sie den KI-Assistenten...", + "attach_document": "Ein Dokument anhängen (PDF, DOCX, PPTX)", + "upgrade_to_upload": "Upgrade zum Hochladen von Dokumenten", + "tokens_exhausted": "Das monatliche AI-Token-Limit wurde erreicht. Kaufen Sie mehr Credits unter Einstellungen > Abrechnung oder warten Sie auf das Zurücksetzen.", + "chat_history": "Chat-Verlauf", + "no_conversations": "Noch keine Gespräche", + "untitled": "Ohne Titel", + "new_chat": "Neuer Chat", + "tokens_label": "Token", + "implementing_course_plan": "Kursplan umsetzen", + "resume": "Lebenslauf", + "stopped_content_kept": "Angehalten. Erstellte Inhalte wurden beibehalten.", + "mention_lesson": "Lektion", + "mention_exercise": "Übung", + "mention_section": "Abschnitt", + "mention_no_results": "Kein passender Inhalt", + "uploading_document": "Dokument wird hochgeladen..." + }, + "widgets": { + "heading": "Widgets", + "subtitle": "Veröffentlichen Sie gebrandete Kurssammlungen für externe Websites.", + "empty_state": "Noch keine Widgets. Erstellen Sie einen, um einen einbettbaren Kursblock zu generieren.", + "filter": { + "status": "Status", + "all": "Alle", + "unpublished": "Unveröffentlicht", + "course_type": "Kurstyp" + }, + "notifications": { + "created": "Widget erstellt", + "saved": "Widget gespeichert", + "published": "Widget veröffentlicht", + "rolled_back": "Widget wiederhergestellt", + "deleted": "Widget archiviert" + }, + "form": { + "name": "Widget-Name", + "selection_mode": "Auswahlmodus", + "course_search": "Kurse suchen", + "layout_type": "Layouttyp", + "sort_by": "Sortieren nach", + "cta_label": "CTA-Label", + "load_more_label": "Weitere Etiketten laden", + "limit_courses": "Kurse begrenzen", + "max_count": "Maximale Kursanzahl", + "show_course_image": "Kursbild anzeigen", + "show_type_badge": "Kurstyp-Abzeichen anzeigen", + "show_lessons_count": "Anzahl der Lektionen anzeigen", + "show_description": "Beschreibungsauszug anzeigen", + "shorten_long_description": "Lange Beschreibung kürzen", + "show_price": "Preis anzeigen", + "show_rating": "Bewertung anzeigen", + "theme_preset": "Theme-Voreinstellung", + "primary_color": "Primärfarbe", + "background_color": "Hintergrundfarbe", + "text_color": "Textfarbe", + "border_color": "Randfarbe", + "border_radius": "Grenzradius", + "font_family": "Schriftfamilie", + "font_scale": "Schriftgrößenskala", + "show_powered_by": "Classroomio-Branding anzeigen", + "custom_css": "Benutzerdefiniertes CSS", + "custom_css_locked": "Führen Sie ein Upgrade durch, um benutzerdefiniertes CSS zu aktivieren.", + "embed_code": "Code einbetten" + }, + "create": { + "description": "Geben Sie Ihrem Widget einen Namen, um zu beginnen." + }, + "actions": { + "create": "Widget erstellen", + "edit": "Bearbeiten", + "back": "Zurück zu den Widgets", + "save": "Speichern", + "discard": "Änderungen verwerfen", + "publish": "Veröffentlichen", + "archive": "Archiv", + "restore": "Wiederherstellen", + "copy_embed": "Einbetten kopieren", + "rename": "Umbenennen", + "delete": "Löschen" + }, + "preview": { + "heading": "Vorschau", + "saved": "Gespeichert", + "unsaved": "Nicht gespeicherte Änderungen", + "load_failed": "Vorschau konnte nicht geladen werden. Versuchen Sie, die Seite zu aktualisieren.", + "empty": "Vorschau nicht verfügbar.", + "all_tags": "Alle", + "none": "Keine", + "lessons_suffix": "Unterricht", + "last_synced": "Synchronisiert", + "refresh": "Aktualisieren", + "exercises_suffix": "Übungen", + "columns": { + "course": "Kurs", + "lessons": "Lektionen", + "tags": "Schlagworte" + } + }, + "plan": { + "free": "Kostenloser Plan", + "paid": "Bezahlter Plan" + }, + "status": { + "draft": "Entwurf", + "published": "Veröffentlicht", + "archived": "Archiviert", + "live": "Lebe" + }, + "sort": { + "manual": "Handbuch", + "newest": "Neueste", + "title": "Titel" + }, + "layoutOptions": { + "section": "Layoutoptionen", + "columns": "Spalten", + "max_count": "Max-Kurse", + "show_rating": "Bewertung anzeigen", + "tag_source": "Tag-Quelle", + "tag_source_auto": "Automatisch aus Kursen", + "tag_source_manual": "Manuelle Auswahl", + "default_tag": "Standard-Tag", + "show_all_option": "Option „Alle“ anzeigen", + "visible_cards": "Sichtbare Karten", + "auto_play": "Automatische Wiedergabe", + "auto_play_interval": "Auto-Play-Intervall (ms)", + "show_dots": "Punkte anzeigen", + "show_arrows": "Pfeile anzeigen", + "loop": "Schleife", + "featured_course": "Empfohlener Kurs", + "featured_course_required": "Für dieses Layout ist ein vorgestellter Kurs erforderlich.", + "main_course": "Hauptkurs im Rampenlicht", + "main_course_required": "Wählen Sie einen Kurs aus, den Sie hervorheben möchten.", + "eyebrow_label": "Augenbrauenetikett", + "cta_label": "CTA-Label", + "secondary_max_count": "Sekundärkurse", + "density": "Dichte", + "density_comfortable": "Bequem", + "density_compact": "Kompakt", + "show_thumbnail": "Miniaturansicht anzeigen", + "show_tags": "Tags anzeigen", + "title_style": "Titelstil", + "title_style_serif": "Serife", + "title_style_sans": "Ohne", + "category_tags": "Kategorie-Tags", + "category_tags_required": "Wählen Sie mindestens ein Tag für das Kategorieregal aus.", + "default_category": "Standardkategorie", + "show_all_tab": "Registerkarte „Alle“ anzeigen", + "max_per_category": "Max. Kurse pro Kategorie", + "none": "Keine" + }, + "layout_description": { + "card_grid": "Allzweckkatalog mit viel Spielraum.", + "tag_filter": "Lassen Sie die Schüler einen breiten Katalog nach Themen eingrenzen.", + "carousel": "Hervorgehobene Zeilen in engen Homepage-Abschnitten.", + "primary_course": "Bewerben Sie einen Flaggschiff- oder neu eingeführten Kurs.", + "compact_list": "Einbettungen in der Seitenleiste und Seiten mit minimalem Platzbedarf.", + "editorial_spotlight": "Landingpages im Blog-Stil und kuratierte Tipps.", + "category_shelf": "Große Kataloge, nach Themen gruppiert, leicht zu scannen." + }, + "layout": { + "card_grid": "Kartengitter", + "tag_filter": "Tag-Filter", + "carousel": "Karussell", + "primary_course": "Grundkurs", + "compact_list": "Kompakte Liste", + "editorial_spotlight": "Redaktionelles Rampenlicht", + "category_shelf": "Kategorieregal" + }, + "selection": { + "manual": "Manuelle Auswahl", + "published": "Alle veröffentlichten Kurse" + }, + "editor": { + "sources": "Widget-Quelle", + "available_courses": "Verfügbare Kurse", + "layout": "Layout", + "content": "Inhalt", + "design": "Design", + "version_history": "Versionsgeschichte", + "save_changes": "Änderungen speichern", + "rename_widget": "Widget umbenennen", + "continue_to_layout": "Weiter zum Layout", + "pick_layout": "Wählen Sie ein Layout", + "pick_design": "Aussehen", + "panel_subtitle": "Konfigurieren Sie, wie Ihr Widget aussieht und sich verhält.", + "embed_share": "Einbetten und teilen", + "discard_short": "Verwerfen", + "canvas_status": "Vorschau", + "finish_editing_name": "Fertig", + "waiting_org": "Organisation wird geladen…", + "load_failed": "Dieses Widget konnte nicht geladen werden. Versuchen Sie es noch einmal über die Widgets-Liste.", + "nav": { + "courses": "Kurse", + "layout": "Layout", + "design": "Design", + "embed": "Einbetten" + }, + "viewport": { + "mobile": "Mobil", + "tablet": "Tablette", + "desktop": "Desktop" + } + }, + "tabs": { + "select_courses": "Wählen Sie Kurse aus", + "layout": "Layout", + "design": "Design", + "embed": "Code einbetten" + }, + "defaults": { + "name_prefix": "Kurs-Widget" + }, + "empty": { + "heading": "Noch keine Widgets", + "description": "Erstellen Sie ein Widget, um einen einbettbaren Kursblock für externe Websites zu generieren." + } } } diff --git a/apps/dashboard/src/lib/utils/translations/en.json b/apps/dashboard/src/lib/utils/translations/en.json index 92c7346b..872a4320 100644 --- a/apps/dashboard/src/lib/utils/translations/en.json +++ b/apps/dashboard/src/lib/utils/translations/en.json @@ -61,7 +61,9 @@ "live_class_label": "Live Class", "live_class_subtitle": "Ideal for bootcamps where lessons are time based", "self_paced_label": "Self Paced", - "self_paced_subtitle": "Best for courses where students can take lessons at their own pace" + "self_paced_subtitle": "Best for courses where students can take lessons at their own pace", + "compliance_label": "Compliance", + "compliance_subtitle": "Recurring training with retakes, reminders, and renewal tracking" }, "onboarding": { "sell": "Sell courses online", @@ -90,12 +92,39 @@ }, "app": { "cancel": "Cancel", + "save": "Save", "search": { - "placeholder": "Search" + "placeholder": "Search", + "command_palette": { + "title": "Command palette", + "description": "Search and navigate across your organization", + "placeholder": "Search courses, programs, widgets, students, tags, settings...", + "org_placeholder": "Search courses, programs, widgets, students, tags, settings...", + "lms_placeholder": "Search your courses, programs, and learning pages...", + "lms_description": "Search and navigate your learning workspace", + "empty": "No results found", + "loading": "Searching...", + "clear_recents": "Clear recents", + "groups": { + "recent": "Recent", + "suggested": "Suggested", + "courses": "Courses", + "programs": "Programs", + "widgets": "Widgets", + "tags": "Tags", + "students": "Students", + "settings": "Settings" + }, + "result_types": { + "page": "Page" + } + } } }, "common": { - "refresh": "Refresh" + "unsaved_changes": { + "message": "You have unsaved changes. Are you sure you want to leave?" + } }, "login": { "welcome": "Welcome back", @@ -530,8 +559,7 @@ "save": "Save", "cancel": "Cancel", "add": "Add New Link" - }, - "save_changes": "Save Changes" + } }, "integrations": { "page_subtitle": "Connect Telegram to get notified when important things happen.", @@ -551,7 +579,10 @@ "active": "You don't have any active billing", "provider": "We use Polar to help manage your billing", "open_billing": "Open billing", - "error": "An error occurred while opening billing" + "error": "An error occurred while opening billing", + "ai_credits": "AI Assistant Usage", + "ai_tokens_used": "tokens used this month", + "ai_bonus_credits": "bonus credits available" } }, "media_manager": { @@ -742,6 +773,7 @@ "community": "Community", "audience": "Audience", "media": "Media", + "widgets": "Widgets", "setup": "Setup", "automation": "Automation", "help": "Help", @@ -770,6 +802,207 @@ "per_org": "Per Organization" } }, + "widgets": { + "heading": "Widgets", + "subtitle": "Publish branded course collections for external websites.", + "empty_state": "No widgets yet. Create one to generate an embeddable course block.", + "empty": { + "heading": "No widgets yet", + "description": "Create a widget to generate an embeddable course block for external websites." + }, + "defaults": { + "name_prefix": "Course Widget" + }, + "tabs": { + "select_courses": "Select Courses", + "layout": "Layout", + "design": "Design", + "embed": "Embed Code" + }, + "editor": { + "sources": "Widget Source", + "available_courses": "Available Courses", + "layout": "Layout", + "content": "Content", + "design": "Design", + "version_history": "Version History", + "save_changes": "Save changes", + "rename_widget": "Rename widget", + "continue_to_layout": "Continue to layout", + "pick_layout": "Choose a layout", + "pick_design": "Appearance", + "panel_subtitle": "Configure how your widget looks and behaves.", + "embed_share": "Embed & share", + "discard_short": "Discard", + "canvas_status": "Preview", + "viewport": { + "mobile": "Mobile", + "tablet": "Tablet", + "desktop": "Desktop" + }, + "finish_editing_name": "Done", + "waiting_org": "Loading organization…", + "load_failed": "Could not load this widget. Try again from the widgets list.", + "nav": { + "courses": "Courses", + "layout": "Layout", + "design": "Design", + "embed": "Embed" + } + }, + "selection": { + "manual": "Manual selection", + "published": "All published courses" + }, + "layout": { + "card_grid": "Card grid", + "tag_filter": "Tag filter", + "carousel": "Carousel", + "primary_course": "Primary course", + "compact_list": "Compact list", + "editorial_spotlight": "Editorial spotlight", + "category_shelf": "Category shelf" + }, + "layout_description": { + "card_grid": "General-purpose catalog with lots of breathing room.", + "tag_filter": "Let students narrow down a wide catalog by topic.", + "carousel": "Featured rows in tight homepage sections.", + "primary_course": "Promote a flagship or newly launched course.", + "compact_list": "Sidebar embeds and minimal-footprint pages.", + "editorial_spotlight": "Blog-style landing pages and curated picks.", + "category_shelf": "Large catalogs grouped by topic, easy to scan." + }, + "layoutOptions": { + "section": "Layout options", + "columns": "Columns", + "max_count": "Max courses", + "show_rating": "Show rating", + "tag_source": "Tag source", + "tag_source_auto": "Auto from courses", + "tag_source_manual": "Manual selection", + "default_tag": "Default tag", + "show_all_option": "Show \"All\" option", + "visible_cards": "Visible cards", + "auto_play": "Auto play", + "auto_play_interval": "Auto play interval (ms)", + "show_dots": "Show dots", + "show_arrows": "Show arrows", + "loop": "Loop", + "featured_course": "Featured course", + "featured_course_required": "A featured course is required for this layout.", + "main_course": "Main spotlight course", + "main_course_required": "Pick a course to spotlight.", + "eyebrow_label": "Eyebrow label", + "cta_label": "CTA label", + "secondary_max_count": "Secondary courses", + "density": "Density", + "density_comfortable": "Comfortable", + "density_compact": "Compact", + "show_thumbnail": "Show thumbnail", + "show_tags": "Show tags", + "title_style": "Title style", + "title_style_serif": "Serif", + "title_style_sans": "Sans", + "category_tags": "Category tags", + "category_tags_required": "Select at least one tag for the category shelf.", + "default_category": "Default category", + "show_all_tab": "Show \"All\" tab", + "max_per_category": "Max courses per category", + "none": "None" + }, + "sort": { + "manual": "Manual", + "newest": "Newest", + "title": "Title" + }, + "status": { + "draft": "Draft", + "published": "Published", + "archived": "Archived", + "live": "Live" + }, + "plan": { + "free": "Free plan", + "paid": "Paid plan" + }, + "preview": { + "heading": "Preview", + "saved": "Saved", + "unsaved": "Unsaved changes", + "load_failed": "Preview could not load. Try refreshing the page.", + "empty": "Preview unavailable.", + "all_tags": "All", + "none": "None", + "lessons_suffix": "lessons", + "last_synced": "Synced", + "refresh": "Refresh", + "columns": { + "course": "Course", + "lessons": "Lessons", + "tags": "Tags" + }, + "exercises_suffix": "exercises" + }, + "actions": { + "create": "Create Widget", + "edit": "Edit", + "back": "Back to widgets", + "save": "Save", + "discard": "Discard changes", + "publish": "Publish", + "archive": "Archive", + "restore": "Restore", + "copy_embed": "Copy embed", + "rename": "Rename", + "delete": "Delete" + }, + "create": { + "description": "Give your widget a name to get started." + }, + "form": { + "name": "Widget name", + "selection_mode": "Selection mode", + "course_search": "Search courses", + "layout_type": "Layout type", + "sort_by": "Sort by", + "cta_label": "CTA label", + "load_more_label": "Load more label", + "limit_courses": "Limit courses", + "max_count": "Max course count", + "show_course_image": "Show course image", + "show_type_badge": "Show course type badge", + "show_lessons_count": "Show lessons count", + "show_description": "Show description excerpt", + "shorten_long_description": "Shorten long description", + "show_price": "Show price", + "show_rating": "Show rating", + "theme_preset": "Theme preset", + "primary_color": "Primary color", + "background_color": "Background color", + "text_color": "Text color", + "border_color": "Border color", + "border_radius": "Border radius", + "font_family": "Font family", + "font_scale": "Font size scale", + "show_powered_by": "Show Classroomio branding", + "custom_css": "Custom CSS", + "custom_css_locked": "Upgrade to enable custom CSS.", + "embed_code": "Embed code" + }, + "notifications": { + "created": "Widget created", + "saved": "Widget saved", + "published": "Widget published", + "rolled_back": "Widget restored", + "deleted": "Widget archived" + }, + "filter": { + "status": "Status", + "all": "All", + "unpublished": "Unpublished", + "course_type": "Course Type" + } + }, "setup": { "your_checklist": "Your Checklist", "get_started": "Get Started", @@ -949,18 +1182,35 @@ "view_site": "View Site", "visit_site": "Visit Website", "current_lesson": "Currently Learning", + "currently_learning": "Currently Learning", + "learner": "Learner", "your_progress": "Your Progress", + "progress": "Progress", "continue": "Continue", "continue_learning": "Continue Learning", + "pick_up_where_you_left_off": "Pick up where you left off", + "course_awaits_you": "{title} awaits you", + "learning_awaits_you": "Your next course awaits you", + "lms_today_progress": "You're {progress}% through your learning - every step counts.", + "lms_today_empty": "Your learning path is ready when you are.", + "compliance_score": "Compliance Score", + "compliance": "Compliance", + "compliance_score_subtitle": "{completed} of {total} compliance courses currently satisfied", + "compliance_score_empty": "No compliance courses assigned yet", + "compliance_met": "{completed} of {total} met", + "required_courses_completed": "{completed} of {total} required courses completed", + "streak": "Streak", + "days_active": "days active", "no_courses": "No courses in progress yet", "start_course": "Once you start a course, your progress will reflect here", "lms_dashboard_hero": "Today is another day to bring you closer to your learning goals. Don’t give up, the more you learn the better you get.", "lessons_completed": "Lessons completed", "items_completed": "Items completed", + "items_done": "{completed} of {total} done", "no_courses_started": "No courses started", - "certificates_earned": "Certificates earned", + "certificates_earned": "Certificates issued", "certificates_earned_description": "Total certificates issued to students in your organization", - "certification_rate": "certification rate", + "certification_rate": "Certified", "recent_certifications": "Recent certifications", "no_certifications_yet": "No certificates earned yet", "no_certifications_yet_description": "When students complete a course and earn a certificate, they will appear here", @@ -978,7 +1228,11 @@ "recent_enrollments": "Recent Enrollments", "publish_first_course": "Publish Your First Course", "publish_first_course_description": "To track enrollments, publish a course and invite students", - "publish_course": "Publish Course" + "publish_course": "Publish Course", + "login_activity_title": "Student Login Activity", + "login_activity_description": "Most active days of the week (last 90 days)", + "login_activity_no_data": "No login activity yet", + "login_activity_logins": "logins" }, "ai": { "help_me": "Help me write", @@ -1161,7 +1415,8 @@ "clone": "Clone", "share": "Share", "invite": "Invite", - "delete": "Delete" + "delete": "Delete", + "open_course_site": "Open course site" }, "error_message": "An error occurred.", "lessons_number": "lessons", @@ -1171,6 +1426,8 @@ "unpublished": "Unpublished", "continue_course": "Continue", "learn_more": "Learn more", + "due_date": "Due", + "valid_until": "Valid until", "tags_overflow_aria": "{count} more tags", "list_view": { "title": "Title", @@ -1213,14 +1470,21 @@ "nav_attendance": "Attendance", "nav_submissions": "Submissions", "nav_marks": "Marks", + "nav_compliance": "Compliance", "nav_people": "People", "nav_analytics": "Analytics", "nav_certificates": "Certificates", "nav_landing_page": "Landing Page", - "nav_settings": "Settings" + "nav_settings": "Settings", + "nav_ai_assistant": "Assistant" }, "sidebar": { "back_to_course": "Back to course", + "compliance": { + "title": "Compliance", + "due_date": "Due", + "valid_until": "Valid until" + }, "content_count": { "lessons": "{count} lessons", "exercises": "{count} exercises" @@ -1572,7 +1836,7 @@ "finish": "Finish", "next": "Next", "check": "Check", - "auto_graded_badge": "Auto-graded", + "auto_graded_badge": "Auto-grade", "manual_grading_required_badge": "Manual grading required", "points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.", "description": { @@ -2025,6 +2289,81 @@ }, "no_student": "No Student Added" }, + "compliance": { + "title": "Compliance", + "not_compliance_title": "Compliance is not enabled", + "not_compliance_description": "This course is not configured as a compliance course.", + "loading": "Loading compliance records...", + "learners": "Learners", + "learners_description": "This table shows the latest cycle for each learner.", + "current_cycle": "Current cycle", + "current_cycle_description": "Your active compliance cycle and renewal dates.", + "history": "Past cycles", + "history_description": "Each row is one recorded compliance cycle for this course.", + "no_active_cycle": "No active compliance cycle", + "empty_history": "No compliance history has been recorded yet.", + "empty_overview": "No learner compliance records are available yet.", + "not_available": "Not available", + "tabs": { + "overview": "Overview", + "learners": "Learners" + }, + "bulk_actions": { + "title": "Bulk actions", + "description": "Select learners and apply a compliance action to their current cycle.", + "select_all": "Select all visible learners", + "selected_count": "{count} selected", + "clear_selection": "Clear selection", + "due_date_label": "Due date", + "due_date_helper": "Used for reset and extend actions.", + "reset_action": "Reset cycle", + "extend_action": "Extend due date", + "waive_title": "Waive selected learners", + "waive_description": "Apply a waiver to the selected learners. You can leave the expiration empty for an open-ended waiver.", + "waiver_reason_label": "Waiver reason", + "waiver_reason_helper": "Optional context for audits and future reviews.", + "waiver_expires_at_label": "Waiver expires at", + "waiver_expires_at_helper": "Leave empty if the waiver should not expire automatically.", + "waive_action": "Apply waiver", + "modal": { + "description": "You are updating {count} selected learners.", + "cancel": "Cancel", + "apply": "Apply action" + } + }, + "fields": { + "learner": "Learner", + "email": "Email", + "status": "Status", + "cycle": "Cycle", + "due_date": "Due date", + "completed_at": "Completed at", + "valid_until": "Valid until", + "score": "Score", + "attempts": "Attempts" + }, + "summary": { + "total_learners": "Total learners", + "no_record": "No record", + "not_started": "Not started", + "in_progress": "In progress", + "compliant": "Compliant", + "expiring_soon": "Expiring soon", + "in_grace_period": "In grace period", + "non_compliant": "Non-compliant", + "waived": "Waived" + }, + "status": { + "no_record": "No record", + "not_started": "Not started", + "in_progress": "In progress", + "compliant": "Compliant", + "expiring_soon": "Expiring soon", + "in_grace_period": "In grace period", + "non_compliant": "Non-compliant", + "waived": "Waived" + } + }, "people": { "title": "People", "add": "Add", @@ -2346,6 +2685,7 @@ "course_type_desc": "Update the course type for a more personalized experience", "live_class": "Live class", "self_paced": "Self paced", + "compliance": "Compliance", "tags": { "title": "Tags", "description": "Assign tags so learners can discover this course with filters.", @@ -2765,6 +3105,23 @@ "failed_deletion": "Deletion Failed, please try again later" } }, + "course": { + "success": { + "comment_deleted": "Comment Deleted", + "comment_added": "Comment added", + "pinned": "Pinned", + "unpinned": "Unpinned", + "successfully": "Successfully", + "feed_delete_success": "Feed deleted successfully" + }, + "error": { + "reaction_error": "An error occurred while reacting to news feed", + "commenting_error": "An error occurred while creating comment", + "toggle_error": "Failed to toggle pinned feed", + "news_feed_fail": "Failed to fetch news feeds", + "feed_fetch_error": "An error occured while fetching feed" + } + }, "lms": { "error": { "username_exists": "username already exists", @@ -2799,23 +3156,6 @@ "complete": "Complete :)" } }, - "course": { - "success": { - "comment_deleted": "Comment Deleted", - "comment_added": "Comment added", - "pinned": "Pinned", - "unpinned": "Unpinned", - "successfully": "Successfully", - "feed_delete_success": "Feed deleted successfully" - }, - "error": { - "reaction_error": "An error occurred while reacting to news feed", - "commenting_error": "An error occurred while creating comment", - "toggle_error": "Failed to toggle pinned feed", - "news_feed_fail": "Failed to fetch news feeds", - "feed_fetch_error": "An error occured while fetching feed" - } - }, "program": { "success": { "comment_deleted": "Comment deleted", @@ -2855,7 +3195,7 @@ "lesson_update_failed": "Failed to update lesson", "section_created": "Course section created successfully", "section_create_failed": "Failed to create course section", - "section_updated": "Course section updated successfully", + "section_deleted": "Course section deleted successfully", "section_update_failed": "Failed to update course section", "sections_reordered": "Course sections reordered successfully", "sections_reorder_failed": "Failed to reorder course sections", @@ -3078,9 +3418,24 @@ "invalid_field": "Invalid value" } }, - "common": { - "unsaved_changes": { - "message": "You have unsaved changes. Are you sure you want to leave?" - } + "ai_assistant": { + "empty_state": "Ask me to help with your course content — draft lessons, generate questions, or plan a course from a document.", + "input_placeholder": "Ask the AI assistant...", + "attach_document": "Attach a document (PDF, DOCX, PPTX)", + "upgrade_to_upload": "Upgrade to upload documents", + "tokens_exhausted": "Monthly AI token limit reached. Buy more credits in Settings > Billing or wait for reset.", + "chat_history": "Chat History", + "no_conversations": "No conversations yet", + "untitled": "Untitled", + "new_chat": "New chat", + "tokens_label": "tokens", + "implementing_course_plan": "Implementing Course Plan", + "resume": "Resume", + "stopped_content_kept": "Stopped. Created content has been kept.", + "mention_lesson": "Lesson", + "mention_exercise": "Exercise", + "mention_section": "Section", + "mention_no_results": "No matching content", + "uploading_document": "Uploading document..." } } diff --git a/apps/dashboard/src/lib/utils/translations/es.json b/apps/dashboard/src/lib/utils/translations/es.json index ac53cc7a..c284399a 100644 --- a/apps/dashboard/src/lib/utils/translations/es.json +++ b/apps/dashboard/src/lib/utils/translations/es.json @@ -219,7 +219,8 @@ "complete_marked": "Marcado como completo", "complete_download": "Descarga Completa", "version_restored": "Versión restaurada exitosamente" - } + }, + "section_deleted": "Sección del curso eliminada exitosamente" }, "attendance": { "error": { @@ -654,7 +655,8 @@ "add": "Agregar etiqueta", "popover_title": "Seleccionar etiquetas", "empty_groups": "Aún no hay etiquetas disponibles" - } + }, + "compliance": "Cumplimiento" }, "analytics": { "title": "Analítica" @@ -1117,7 +1119,7 @@ "finish": "terminar", "next": "Siguiente", "check": "comprobar", - "auto_graded_badge": "Auto-graded", + "auto_graded_badge": "Auto-grade", "manual_grading_required_badge": "Manual grading required", "points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.", "analytics": { @@ -1697,6 +1699,81 @@ "edit": "Editar", "delete": "Eliminar" } + }, + "compliance": { + "title": "Cumplimiento", + "not_compliance_title": "El cumplimiento no está habilitado", + "not_compliance_description": "Este curso no está configurado como un curso de cumplimiento.", + "loading": "Cargando registros de cumplimiento...", + "learners": "Estudiantes", + "learners_description": "Esta tabla muestra el último ciclo de cada alumno.", + "current_cycle": "Ciclo actual", + "current_cycle_description": "Su ciclo de cumplimiento activo y fechas de renovación.", + "history": "Ciclos pasados", + "history_description": "Cada fila es un ciclo de cumplimiento registrado para este curso.", + "no_active_cycle": "Sin ciclo de cumplimiento activo", + "empty_history": "Aún no se ha registrado ningún historial de cumplimiento.", + "empty_overview": "Aún no hay registros de cumplimiento de los alumnos disponibles.", + "not_available": "No disponible", + "status": { + "no_record": "Sin registro", + "not_started": "No iniciado", + "in_progress": "En progreso", + "compliant": "Cumple", + "expiring_soon": "Expira pronto", + "in_grace_period": "En periodo de gracia", + "non_compliant": "No conforme", + "waived": "Renunciado" + }, + "summary": { + "total_learners": "Alumnos totales", + "no_record": "Sin registro", + "not_started": "No iniciado", + "in_progress": "En progreso", + "compliant": "Cumple", + "expiring_soon": "Expira pronto", + "in_grace_period": "En periodo de gracia", + "non_compliant": "No conforme", + "waived": "Renunciado" + }, + "fields": { + "learner": "aprendiz", + "email": "Correo electrónico", + "status": "Estado", + "cycle": "ciclo", + "due_date": "fecha de vencimiento", + "completed_at": "Completado en", + "valid_until": "Válido hasta", + "score": "Puntuación", + "attempts": "Intentos" + }, + "bulk_actions": { + "title": "Acciones masivas", + "description": "Seleccione estudiantes y aplique una acción de cumplimiento a su ciclo actual.", + "select_all": "Seleccionar todos los alumnos visibles", + "selected_count": "{count} seleccionado", + "clear_selection": "Borrar selección", + "due_date_label": "fecha de vencimiento", + "due_date_helper": "Se utiliza para restablecer y ampliar acciones.", + "reset_action": "Reiniciar ciclo", + "extend_action": "Extender la fecha de vencimiento", + "waive_title": "Renunciar a los alumnos seleccionados", + "waive_description": "Aplicar una exención a los alumnos seleccionados. Puede dejar el vencimiento vacío para una exención abierta.", + "waiver_reason_label": "Motivo de la renuncia", + "waiver_reason_helper": "Contexto opcional para auditorías y revisiones futuras.", + "waiver_expires_at_label": "La exención vence en", + "waiver_expires_at_helper": "Déjelo en blanco si la exención no debe caducar automáticamente.", + "waive_action": "Aplicar exención", + "modal": { + "description": "Estás actualizando a {count} alumnos seleccionados.", + "cancel": "Cancelar", + "apply": "Aplicar acción" + } + }, + "tabs": { + "overview": "Descripción general", + "learners": "Estudiantes" + } } }, "search": { @@ -1712,6 +1789,11 @@ "content_count": { "lessons": "{count} lecciones", "exercises": "{contar} ejercicios" + }, + "compliance": { + "title": "Cumplimiento", + "due_date": "debido", + "valid_until": "Válido hasta" } }, "navItems": { @@ -1724,7 +1806,9 @@ "nav_analytics": "Analítica", "nav_certificates": "Certificados", "nav_landing_page": "Página de destino", - "nav_settings": "Configuración" + "nav_settings": "Configuración", + "nav_compliance": "Cumplimiento", + "nav_ai_assistant": "Asistente" }, "completion": { "modal": { @@ -1812,8 +1896,11 @@ "share": "Compartir", "invite": "invitar", "delete": "Eliminar", - "open": "Abierto" - } + "open": "Abierto", + "open_course_site": "Abrir sitio del curso" + }, + "due_date": "debido", + "valid_until": "Válido hasta" }, "course_filter": { "date_created_newest": "Date Created: Newest first", @@ -1865,9 +1952,9 @@ "lms_dashboard_hero": "Hoy es otro día para acercarte a tus objetivos de aprendizaje. No te rindas, cuanto más aprendas, mejor serás.", "lessons_completed": "Lecciones completadas", "no_courses_started": "No se iniciaron cursos", - "certificates_earned": "Certificates earned", + "certificates_earned": "Certificates issued", "certificates_earned_description": "Total certificates issued to students in your organization", - "certification_rate": "certification rate", + "certification_rate": "Certificados", "recent_certifications": "Recent certifications", "no_certifications_yet": "No certificates earned yet", "no_certifications_yet_description": "When students complete a course and earn a certificate, they will appear here", @@ -1886,7 +1973,28 @@ "publish_first_course": "Publica tu primer curso", "publish_first_course_description": "Para realizar un seguimiento de las inscripciones, publicar un curso e invitar a estudiantes", "publish_course": "Publicar curso", - "items_completed": "Artículos completados" + "items_completed": "Artículos completados", + "currently_learning": "Actualmente aprendiendo", + "learner": "aprendiz", + "progress": "Progreso", + "pick_up_where_you_left_off": "Continua donde lo dejaste", + "course_awaits_you": "{título} te espera", + "learning_awaits_you": "Tu próximo curso te espera", + "lms_today_progress": "Estás {progresando}% en tu aprendizaje: cada paso cuenta.", + "lms_today_empty": "Tu camino de aprendizaje estará listo cuando tú lo estés.", + "compliance_score": "Puntuación de cumplimiento", + "compliance": "Cumplimiento", + "compliance_score_subtitle": "{completado} de {total} cursos de cumplimiento actualmente satisfechos", + "compliance_score_empty": "Aún no se han asignado cursos de cumplimiento", + "compliance_met": "{completado} de {total} cumplido", + "required_courses_completed": "{completado} de {total} cursos requeridos completados", + "streak": "racha", + "days_active": "dias activos", + "items_done": "{completado} de {total} hecho", + "login_activity_title": "Actividad de inicio de sesión del estudiante", + "login_activity_description": "Días más activos de la semana (últimos 90 días)", + "login_activity_no_data": "Aún no hay actividad de inicio de sesión", + "login_activity_logins": "inicios de sesión" }, "schedule": { "title": "Tu horario", @@ -2097,7 +2205,8 @@ "upgrade": "Actualiza ahora", "tags": "Etiquetas", "automation": "Automatización", - "programs": "Programas" + "programs": "Programas", + "widgets": "widgets" }, "media_manager": { "page_title": "Administrador de medios - ClassroomIO", @@ -2218,7 +2327,10 @@ "provider": "Usamos Polar para ayudarte a gestionar tu facturación", "open_billing": "Facturación abierta", "error": "Se produjo un error al abrir la facturación.", - "page_subtitle": "Your plan, billing portal, and how you pay for ClassroomIO." + "page_subtitle": "Your plan, billing portal, and how you pay for ClassroomIO.", + "ai_credits": "Uso del asistente de IA", + "ai_tokens_used": "tokens usados este mes", + "ai_bonus_credits": "créditos de bonificación disponibles" }, "integrations": { "heading": "Telegrama", @@ -2667,9 +2779,34 @@ }, "app": { "search": { - "placeholder": "Buscar" + "placeholder": "Buscar", + "command_palette": { + "title": "paleta de comandos", + "description": "Busque y navegue por su organización", + "placeholder": "Busca cursos, programas, widgets, estudiantes, etiquetas, configuraciones...", + "org_placeholder": "Busca cursos, programas, widgets, estudiantes, etiquetas, configuraciones...", + "lms_placeholder": "Busque sus cursos, programas y páginas de aprendizaje...", + "lms_description": "Busque y navegue por su espacio de trabajo de aprendizaje", + "empty": "No se encontraron resultados", + "loading": "Buscando...", + "clear_recents": "Borrar recientes", + "result_types": { + "page": "Página" + }, + "groups": { + "recent": "Reciente", + "suggested": "sugerido", + "courses": "Cursos", + "programs": "Programas", + "widgets": "widgets", + "tags": "Etiquetas", + "students": "estudiantes", + "settings": "Configuración" + } + } }, - "cancel": "Cancelar" + "cancel": "Cancelar", + "save": "Guardar" }, "welcome_modal": { "we_at": "nosotros en", @@ -2700,7 +2837,9 @@ "live_class_label": "Clase en vivo", "live_class_subtitle": "Ideal para bootcamps donde las lecciones se basan en el tiempo.", "self_paced_label": "a tu propio ritmo", - "self_paced_subtitle": "Lo mejor para cursos donde los estudiantes pueden tomar lecciones a su propio ritmo" + "self_paced_subtitle": "Lo mejor para cursos donde los estudiantes pueden tomar lecciones a su propio ritmo", + "compliance_label": "Cumplimiento", + "compliance_subtitle": "Capacitación recurrente con repeticiones, recordatorios y seguimiento de renovaciones." }, "delete_modal": { "label": "Eliminar", @@ -3079,5 +3218,226 @@ "sort_students": "estudiantes", "sort_status": "Estado" } + }, + "ai_assistant": { + "empty_state": "Pídeme que te ayude con el contenido de tu curso: redacta lecciones, genera preguntas o planifica un curso a partir de un documento.", + "input_placeholder": "Pregúntale al asistente de IA...", + "attach_document": "Adjunte un documento (PDF, DOCX, PPTX)", + "upgrade_to_upload": "Actualizar para cargar documentos", + "tokens_exhausted": "Se alcanzó el límite mensual de tokens de IA. Compre más créditos en Configuración > Facturación o espere a que se restablezcan.", + "chat_history": "Historial de chat", + "no_conversations": "Aún no hay conversaciones", + "untitled": "Sin título", + "new_chat": "Nuevo chat", + "tokens_label": "fichas", + "implementing_course_plan": "Implementación del plan del curso", + "resume": "Currículum", + "stopped_content_kept": "Detenido. El contenido creado se ha mantenido.", + "mention_lesson": "Lección", + "mention_exercise": "ejercicio", + "mention_section": "Sección", + "mention_no_results": "No hay contenido coincidente", + "uploading_document": "Subiendo documento..." + }, + "widgets": { + "heading": "widgets", + "subtitle": "Publique colecciones de cursos de marca para sitios web externos.", + "empty_state": "Aún no hay widgets. Cree uno para generar un bloque de curso integrable.", + "filter": { + "status": "Estado", + "all": "Todos", + "unpublished": "Inédito", + "course_type": "Tipo de curso" + }, + "notifications": { + "created": "Widget creado", + "saved": "Widget guardado", + "published": "Widget publicado", + "rolled_back": "Widget restaurado", + "deleted": "Widget archivado" + }, + "form": { + "name": "Nombre del widget", + "selection_mode": "Modo de selección", + "course_search": "Buscar cursos", + "layout_type": "Tipo de diseño", + "sort_by": "Ordenar por", + "cta_label": "etiqueta de acción", + "load_more_label": "Cargar más etiqueta", + "limit_courses": "Limitar cursos", + "max_count": "Recuento máximo de cursos", + "show_course_image": "Mostrar imagen del curso", + "show_type_badge": "Mostrar insignia de tipo de curso", + "show_lessons_count": "Mostrar recuento de lecciones", + "show_description": "Mostrar extracto de descripción", + "shorten_long_description": "Acortar descripción larga", + "show_price": "Mostrar precio", + "show_rating": "Mostrar calificación", + "theme_preset": "Tema preestablecido", + "primary_color": "color primario", + "background_color": "Color de fondo", + "text_color": "Color del texto", + "border_color": "Color del borde", + "border_radius": "Radio del borde", + "font_family": "Familia de fuentes", + "font_scale": "Escala de tamaño de fuente", + "show_powered_by": "Mostrar la marca Classroomio", + "custom_css": "CSS personalizado", + "custom_css_locked": "Actualice para habilitar CSS personalizado.", + "embed_code": "código para insertar" + }, + "create": { + "description": "Dale un nombre a tu widget para comenzar." + }, + "actions": { + "create": "Crear widget", + "edit": "Editar", + "back": "Volver a los widgets", + "save": "Guardar", + "discard": "Descartar cambios", + "publish": "Publicar", + "archive": "Archivo", + "restore": "Restaurar", + "copy_embed": "Copiar incrustado", + "rename": "Cambiar nombre", + "delete": "Eliminar" + }, + "preview": { + "heading": "Vista previa", + "saved": "Guardado", + "unsaved": "Cambios no guardados", + "load_failed": "No se pudo cargar la vista previa. Intente actualizar la página.", + "empty": "Vista previa no disponible.", + "all_tags": "Todos", + "none": "Ninguno", + "lessons_suffix": "lecciones", + "last_synced": "Sincronizado", + "refresh": "Actualizar", + "exercises_suffix": "ejercicios", + "columns": { + "course": "Curso", + "lessons": "Lecciones", + "tags": "Etiquetas" + } + }, + "plan": { + "free": "Plan gratuito", + "paid": "Plan pago" + }, + "status": { + "draft": "Borrador", + "published": "Publicado", + "archived": "Archivado", + "live": "en vivo" + }, + "sort": { + "manual": "manuales", + "newest": "Lo más nuevo", + "title": "Título" + }, + "layoutOptions": { + "section": "Opciones de diseño", + "columns": "columnas", + "max_count": "cursos máximos", + "show_rating": "Mostrar calificación", + "tag_source": "Fuente de la etiqueta", + "tag_source_auto": "Auto de cursos", + "tag_source_manual": "selección manual", + "default_tag": "Etiqueta predeterminada", + "show_all_option": "Mostrar la opción \"Todos\"", + "visible_cards": "Tarjetas visibles", + "auto_play": "reproducción automática", + "auto_play_interval": "Intervalo de reproducción automática (ms)", + "show_dots": "Mostrar puntos", + "show_arrows": "Mostrar flechas", + "loop": "Bucle", + "featured_course": "Curso destacado", + "featured_course_required": "Se requiere un curso destacado para este diseño.", + "main_course": "Curso de foco principal", + "main_course_required": "Elija un curso para destacar.", + "eyebrow_label": "Etiqueta de cejas", + "cta_label": "etiqueta de acción", + "secondary_max_count": "cursos secundarios", + "density": "densidad", + "density_comfortable": "Cómodo", + "density_compact": "Compacto", + "show_thumbnail": "Mostrar miniatura", + "show_tags": "Mostrar etiquetas", + "title_style": "Estilo de título", + "title_style_serif": "Serifa", + "title_style_sans": "sin", + "category_tags": "Etiquetas de categoría", + "category_tags_required": "Seleccione al menos una etiqueta para el estante de categorías.", + "default_category": "Categoría predeterminada", + "show_all_tab": "Mostrar pestaña \"Todos\"", + "max_per_category": "Cursos máximos por categoría", + "none": "Ninguno" + }, + "layout_description": { + "card_grid": "Catálogo de uso general con mucho espacio para respirar.", + "tag_filter": "Permita que los estudiantes reduzcan un amplio catálogo por tema.", + "carousel": "Filas destacadas en secciones estrechas de la página de inicio.", + "primary_course": "Promocionar un curso emblemático o recién lanzado.", + "compact_list": "Incrustaciones de barra lateral y páginas que ocupan un espacio mínimo.", + "editorial_spotlight": "Páginas de destino estilo blog y selecciones seleccionadas.", + "category_shelf": "Grandes catálogos agrupados por temas, fáciles de escanear." + }, + "layout": { + "card_grid": "Cuadrícula de tarjetas", + "tag_filter": "Filtro de etiquetas", + "carousel": "carrusel", + "primary_course": "curso primario", + "compact_list": "Lista compacta", + "editorial_spotlight": "Foco editorial", + "category_shelf": "estante de categoría" + }, + "selection": { + "manual": "selección manual", + "published": "Todos los cursos publicados" + }, + "editor": { + "sources": "Fuente del widget", + "available_courses": "Cursos disponibles", + "layout": "Diseño", + "content": "Contenido", + "design": "Diseño", + "version_history": "Historial de versiones", + "save_changes": "Guardar cambios", + "rename_widget": "Cambiar nombre del widget", + "continue_to_layout": "Continuar al diseño", + "pick_layout": "Elige un diseño", + "pick_design": "Apariencia", + "panel_subtitle": "Configure cómo se ve y se comporta su widget.", + "embed_share": "Insertar y compartir", + "discard_short": "Descartar", + "canvas_status": "Vista previa", + "finish_editing_name": "hecho", + "waiting_org": "Cargando organización…", + "load_failed": "No se pudo cargar este widget. Inténtalo de nuevo desde la lista de widgets.", + "nav": { + "courses": "Cursos", + "layout": "Diseño", + "design": "Diseño", + "embed": "Insertar" + }, + "viewport": { + "mobile": "Móvil", + "tablet": "tableta", + "desktop": "Escritorio" + } + }, + "tabs": { + "select_courses": "Seleccionar cursos", + "layout": "Diseño", + "design": "Diseño", + "embed": "Código para insertar" + }, + "defaults": { + "name_prefix": "Widget del curso" + }, + "empty": { + "heading": "Aún no hay widgets", + "description": "Cree un widget para generar un bloque de curso integrable para sitios web externos." + } } } diff --git a/apps/dashboard/src/lib/utils/translations/fr.json b/apps/dashboard/src/lib/utils/translations/fr.json index ef362cd6..a46161c3 100644 --- a/apps/dashboard/src/lib/utils/translations/fr.json +++ b/apps/dashboard/src/lib/utils/translations/fr.json @@ -219,7 +219,8 @@ "complete_marked": "Marqué comme terminé", "complete_download": "Téléchargement terminé", "version_restored": "Version restaurée avec succès" - } + }, + "section_deleted": "Section de cours supprimée avec succès" }, "attendance": { "error": { @@ -654,7 +655,8 @@ "add": "Ajouter une balise", "popover_title": "Sélectionnez les balises", "empty_groups": "Aucun tag disponible pour l'instant" - } + }, + "compliance": "Conformité" }, "analytics": { "title": "Analyse" @@ -1117,7 +1119,7 @@ "finish": "Terminer", "next": "Suivant", "check": "Vérifier", - "auto_graded_badge": "Auto-graded", + "auto_graded_badge": "Auto-grade", "manual_grading_required_badge": "Manual grading required", "points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.", "analytics": { @@ -1697,6 +1699,81 @@ "edit": "Modifier", "delete": "Supprimer" } + }, + "compliance": { + "title": "Conformité", + "not_compliance_title": "La conformité n'est pas activée", + "not_compliance_description": "Ce cours n'est pas configuré comme cours de conformité.", + "loading": "Chargement des enregistrements de conformité...", + "learners": "Apprenants", + "learners_description": "Ce tableau montre le dernier cycle pour chaque apprenant.", + "current_cycle": "Cycle actuel", + "current_cycle_description": "Votre cycle de conformité actif et vos dates de renouvellement.", + "history": "Cycles passés", + "history_description": "Chaque ligne correspond à un cycle de conformité enregistré pour ce cours.", + "no_active_cycle": "Aucun cycle de conformité actif", + "empty_history": "Aucun historique de conformité n’a encore été enregistré.", + "empty_overview": "Aucun dossier de conformité des apprenants n’est encore disponible.", + "not_available": "Non disponible", + "status": { + "no_record": "Aucun enregistrement", + "not_started": "Pas commencé", + "in_progress": "En cours", + "compliant": "Conforme", + "expiring_soon": "Expire bientôt", + "in_grace_period": "En période de grâce", + "non_compliant": "Non conforme", + "waived": "Renoncé" + }, + "summary": { + "total_learners": "Nombre total d'apprenants", + "no_record": "Aucun enregistrement", + "not_started": "Pas démarré", + "in_progress": "En cours", + "compliant": "Conforme", + "expiring_soon": "Expire bientôt", + "in_grace_period": "En période de grâce", + "non_compliant": "Non conforme", + "waived": "Renoncé" + }, + "fields": { + "learner": "Apprenant", + "email": "Courriel", + "status": "Statut", + "cycle": "Cycle", + "due_date": "Date d'échéance", + "completed_at": "Terminé à", + "valid_until": "Valable jusqu'au", + "score": "Score", + "attempts": "Tentatives" + }, + "bulk_actions": { + "title": "Actions groupées", + "description": "Sélectionnez les apprenants et appliquez une action de conformité à leur cycle actuel.", + "select_all": "Sélectionnez tous les apprenants visibles", + "selected_count": "{count} sélectionnés", + "clear_selection": "Effacer la sélection", + "due_date_label": "Date d'échéance", + "due_date_helper": "Utilisé pour les actions de réinitialisation et d’extension.", + "reset_action": "Cycle de réinitialisation", + "extend_action": "Prolonger la date d'échéance", + "waive_title": "Renoncer aux apprenants sélectionnés", + "waive_description": "Appliquer une dérogation aux apprenants sélectionnés. Vous pouvez laisser l’expiration vide pour une renonciation illimitée.", + "waiver_reason_label": "Raison de la renonciation", + "waiver_reason_helper": "Contexte facultatif pour les audits et les examens futurs.", + "waiver_expires_at_label": "La renonciation expire à", + "waiver_expires_at_helper": "Laissez vide si la renonciation ne doit pas expirer automatiquement.", + "waive_action": "Demander une renonciation", + "modal": { + "description": "Vous mettez à jour {count} apprenants sélectionnés.", + "cancel": "Annuler", + "apply": "Appliquer l'action" + } + }, + "tabs": { + "overview": "Aperçu", + "learners": "Apprenants" + } } }, "search": { @@ -1712,6 +1789,11 @@ "content_count": { "lessons": "{count} leçons", "exercises": "{count} exercices" + }, + "compliance": { + "title": "Conformité", + "due_date": "Due", + "valid_until": "Valable jusqu'au" } }, "navItems": { @@ -1724,7 +1806,9 @@ "nav_analytics": "Analyse", "nav_certificates": "Certificats", "nav_landing_page": "Page de destination", - "nav_settings": "Paramètres" + "nav_settings": "Paramètres", + "nav_compliance": "Conformité", + "nav_ai_assistant": "Assistante" }, "completion": { "modal": { @@ -1812,8 +1896,11 @@ "share": "Partager", "invite": "Inviter", "delete": "Supprimer", - "open": "Ouvert" - } + "open": "Ouvert", + "open_course_site": "Site de cours ouvert" + }, + "due_date": "Due", + "valid_until": "Valable jusqu'au" }, "course_filter": { "date_created_newest": "Date Created: Newest first", @@ -1865,9 +1952,9 @@ "lms_dashboard_hero": "Aujourd'hui est un autre jour pour vous rapprocher de vos objectifs d'apprentissage. N'abandonnez pas, plus vous apprenez, mieux vous vous améliorez.", "lessons_completed": "Leçons terminées", "no_courses_started": "Aucun cours n'a commencé", - "certificates_earned": "Certificates earned", + "certificates_earned": "Certificates issued", "certificates_earned_description": "Total certificates issued to students in your organization", - "certification_rate": "certification rate", + "certification_rate": "Certifiés", "recent_certifications": "Recent certifications", "no_certifications_yet": "No certificates earned yet", "no_certifications_yet_description": "When students complete a course and earn a certificate, they will appear here", @@ -1886,7 +1973,28 @@ "publish_first_course": "Publiez votre premier cours", "publish_first_course_description": "Pour suivre les inscriptions, publier un cours et inviter des étudiants", "publish_course": "Publier le cours", - "items_completed": "Éléments terminés" + "items_completed": "Éléments terminés", + "currently_learning": "En cours d'apprentissage", + "learner": "Apprenant", + "progress": "Progrès", + "pick_up_where_you_left_off": "Reprenez là où vous vous étiez arrêté", + "course_awaits_you": "{titre} vous attend", + "learning_awaits_you": "Votre prochain cours vous attend", + "lms_today_progress": "Vous avez progressé de {progress} % dans votre apprentissage : chaque étape compte.", + "lms_today_empty": "Votre parcours d’apprentissage est prêt quand vous l’êtes.", + "compliance_score": "Score de conformité", + "compliance": "Conformité", + "compliance_score_subtitle": "{terminé} sur {total} cours de conformité actuellement suivis", + "compliance_score_empty": "Aucun cours de conformité attribué pour l'instant", + "compliance_met": "{terminé} sur {total} atteint", + "required_courses_completed": "{terminé} sur {total} cours obligatoires terminés", + "streak": "Série", + "days_active": "jours d'activité", + "items_done": "{terminé} sur {total} réalisé", + "login_activity_title": "Activité de connexion des étudiants", + "login_activity_description": "Jours les plus actifs de la semaine (90 derniers jours)", + "login_activity_no_data": "Aucune activité de connexion pour le moment", + "login_activity_logins": "connexions" }, "schedule": { "title": "Votre emploi du temps", @@ -2097,7 +2205,8 @@ "upgrade": "Mettre à niveau maintenant", "tags": "Balises", "automation": "Automation", - "programs": "Programmes" + "programs": "Programmes", + "widgets": "Widgets" }, "media_manager": { "page_title": "Gestionnaire de médias - ClassroomIO", @@ -2218,7 +2327,10 @@ "provider": "Nous utilisons Polar pour vous aider à gérer votre facturation", "open_billing": "Facturation ouverte", "error": "Une erreur s'est produite lors de l'ouverture de la facturation", - "page_subtitle": "Your plan, billing portal, and how you pay for ClassroomIO." + "page_subtitle": "Your plan, billing portal, and how you pay for ClassroomIO.", + "ai_credits": "Utilisation de l'assistant IA", + "ai_tokens_used": "jetons utilisés ce mois-ci", + "ai_bonus_credits": "crédits bonus disponibles" }, "integrations": { "heading": "Télégramme", @@ -2667,9 +2779,34 @@ }, "app": { "search": { - "placeholder": "Rechercher" + "placeholder": "Rechercher", + "command_palette": { + "title": "Palette de commandes", + "description": "Recherchez et naviguez dans votre organisation", + "placeholder": "Rechercher des cours, des programmes, des widgets, des étudiants, des tags, des paramètres...", + "org_placeholder": "Rechercher des cours, des programmes, des widgets, des étudiants, des tags, des paramètres...", + "lms_placeholder": "Recherchez vos cours, programmes et pages d'apprentissage...", + "lms_description": "Recherchez et parcourez votre espace de travail d'apprentissage", + "empty": "Aucun résultat trouvé", + "loading": "Recherche...", + "clear_recents": "Effacer les récents", + "result_types": { + "page": "Pages" + }, + "groups": { + "recent": "Récent", + "suggested": "Suggéré", + "courses": "Cours", + "programs": "Programmes", + "widgets": "Widgets", + "tags": "Balises", + "students": "Étudiants", + "settings": "Paramètres" + } + } }, - "cancel": "Annuler" + "cancel": "Annuler", + "save": "Enregistrer" }, "welcome_modal": { "we_at": "Nous à", @@ -2700,7 +2837,9 @@ "live_class_label": "Cours en direct", "live_class_subtitle": "Idéal pour les bootcamps où les cours sont basés sur le temps", "self_paced_label": "À votre rythme", - "self_paced_subtitle": "Idéal pour les cours où les étudiants peuvent suivre des cours à leur propre rythme" + "self_paced_subtitle": "Idéal pour les cours où les étudiants peuvent suivre des cours à leur propre rythme", + "compliance_label": "Conformité", + "compliance_subtitle": "Formation récurrente avec reprises, rappels et suivi de renouvellement" }, "delete_modal": { "label": "Supprimer", @@ -3079,5 +3218,226 @@ "sort_students": "Étudiants", "sort_status": "Statut" } + }, + "ai_assistant": { + "empty_state": "Demandez-moi de vous aider avec le contenu de votre cours : rédigez des leçons, générez des questions ou planifiez un cours à partir d'un document.", + "input_placeholder": "Demandez à l'assistant IA...", + "attach_document": "Joindre un document (PDF, DOCX, PPTX)", + "upgrade_to_upload": "Mise à niveau pour télécharger des documents", + "tokens_exhausted": "Limite mensuelle de jetons IA atteinte. Achetez plus de crédits dans Paramètres > Facturation ou attendez la réinitialisation.", + "chat_history": "Historique des discussions", + "no_conversations": "Aucune conversation pour l'instant", + "untitled": "Sans titre", + "new_chat": "Nouvelle discussion", + "tokens_label": "jetons", + "implementing_course_plan": "Mise en œuvre du plan de cours", + "resume": "CV", + "stopped_content_kept": "Arrêté. Le contenu créé a été conservé.", + "mention_lesson": "Leçon", + "mention_exercise": "Exercice", + "mention_section": "Section", + "mention_no_results": "Aucun contenu correspondant", + "uploading_document": "Téléchargement du document..." + }, + "widgets": { + "heading": "Widgets", + "subtitle": "Publiez des collections de cours de marque pour des sites Web externes.", + "empty_state": "Aucun widget pour l'instant. Créez-en un pour générer un bloc de cours intégrable.", + "filter": { + "status": "Statut", + "all": "Tout", + "unpublished": "Inédit", + "course_type": "Type de cours" + }, + "notifications": { + "created": "Widget créé", + "saved": "Widget enregistré", + "published": "Widget publié", + "rolled_back": "Widget restauré", + "deleted": "Widget archivé" + }, + "form": { + "name": "Nom du widget", + "selection_mode": "Mode de sélection", + "course_search": "Rechercher des cours", + "layout_type": "Type de mise en page", + "sort_by": "Trier par", + "cta_label": "Étiquette CTA", + "load_more_label": "Charger plus d'étiquette", + "limit_courses": "Cours limités", + "max_count": "Nombre maximum de cours", + "show_course_image": "Afficher l'image du cours", + "show_type_badge": "Afficher le badge du type de cours", + "show_lessons_count": "Afficher le nombre de leçons", + "show_description": "Afficher l'extrait de la description", + "shorten_long_description": "Raccourcir la description longue", + "show_price": "Afficher le prix", + "show_rating": "Afficher la note", + "theme_preset": "Thème prédéfini", + "primary_color": "Couleur primaire", + "background_color": "Couleur de fond", + "text_color": "Couleur du texte", + "border_color": "Couleur de la bordure", + "border_radius": "Rayon de la frontière", + "font_family": "Famille de polices", + "font_scale": "Échelle de taille de police", + "show_powered_by": "Afficher la marque Classroomio", + "custom_css": "CSS personnalisé", + "custom_css_locked": "Mettez à niveau pour activer le CSS personnalisé.", + "embed_code": "Code intégré" + }, + "create": { + "description": "Donnez un nom à votre widget pour commencer." + }, + "actions": { + "create": "Créer un widget", + "edit": "Modifier", + "back": "Retour aux widgets", + "save": "Enregistrer", + "discard": "Ignorer les modifications", + "publish": "Publier", + "archive": "Archiver", + "restore": "Restaurer", + "copy_embed": "Copier l'intégration", + "rename": "Renommer", + "delete": "Supprimer" + }, + "preview": { + "heading": "Aperçu", + "saved": "Enregistré", + "unsaved": "Modifications non enregistrées", + "load_failed": "L'aperçu n'a pas pu se charger. Essayez d'actualiser la page.", + "empty": "Aperçu indisponible.", + "all_tags": "Tout", + "none": "Aucun", + "lessons_suffix": "leçons", + "last_synced": "Synchronisé", + "refresh": "Actualiser", + "exercises_suffix": "exercices", + "columns": { + "course": "Cours", + "lessons": "Leçons", + "tags": "Balises" + } + }, + "plan": { + "free": "Forfait gratuit", + "paid": "Forfait payant" + }, + "status": { + "draft": "Brouillon", + "published": "Publié", + "archived": "Archivé", + "live": "En direct" + }, + "sort": { + "manual": "Manuel", + "newest": "Le plus récent", + "title": "Titre" + }, + "layoutOptions": { + "section": "Options de mise en page", + "columns": "Colonnes", + "max_count": "Cours maximum", + "show_rating": "Afficher la note", + "tag_source": "Source de la balise", + "tag_source_auto": "Auto à partir des cours", + "tag_source_manual": "Sélection manuelle", + "default_tag": "Balise par défaut", + "show_all_option": "Afficher l'option \"Tous\"", + "visible_cards": "Cartes visibles", + "auto_play": "Lecture automatique", + "auto_play_interval": "Intervalle de lecture automatique (ms)", + "show_dots": "Afficher les points", + "show_arrows": "Afficher les flèches", + "loop": "Boucle", + "featured_course": "Cours en vedette", + "featured_course_required": "Un cours vedette est requis pour cette mise en page.", + "main_course": "Cours principal", + "main_course_required": "Choisissez un cours à mettre en lumière.", + "eyebrow_label": "Étiquette de sourcil", + "cta_label": "Étiquette CTA", + "secondary_max_count": "Cours secondaires", + "density": "Densité", + "density_comfortable": "Confortable", + "density_compact": "Compacte", + "show_thumbnail": "Afficher la vignette", + "show_tags": "Afficher les balises", + "title_style": "Style de titre", + "title_style_serif": "Serif", + "title_style_sans": "Sans", + "category_tags": "Balises de catégorie", + "category_tags_required": "Sélectionnez au moins une balise pour l’étagère des catégories.", + "default_category": "Catégorie par défaut", + "show_all_tab": "Afficher l'onglet \"Tous\"", + "max_per_category": "Max de cours par catégorie", + "none": "Aucun" + }, + "layout_description": { + "card_grid": "Catalogue à usage général avec beaucoup de marge de manœuvre.", + "tag_filter": "Laissez les étudiants affiner un large catalogue par sujet.", + "carousel": "Lignes en vedette dans des sections étroites de la page d'accueil.", + "primary_course": "Faites la promotion d’un cours phare ou nouvellement lancé.", + "compact_list": "Intégrations de la barre latérale et pages à empreinte minimale.", + "editorial_spotlight": "Pages de destination de style blog et sélections organisées.", + "category_shelf": "Grands catalogues regroupés par thème, faciles à numériser." + }, + "layout": { + "card_grid": "Grille de cartes", + "tag_filter": "Filtre de balises", + "carousel": "Carrousel", + "primary_course": "Cours primaire", + "compact_list": "Liste compacte", + "editorial_spotlight": "Coup de projecteur éditorial", + "category_shelf": "Étagère de catégorie" + }, + "selection": { + "manual": "Sélection manuelle", + "published": "Tous les cours publiés" + }, + "editor": { + "sources": "Source du widget", + "available_courses": "Cours disponibles", + "layout": "Disposition", + "content": "Contenu", + "design": "Conception", + "version_history": "Historique des versions", + "save_changes": "Enregistrer les modifications", + "rename_widget": "Renommer le widget", + "continue_to_layout": "Continuer la mise en page", + "pick_layout": "Choisissez une mise en page", + "pick_design": "Apparence", + "panel_subtitle": "Configurez l'apparence et le comportement de votre widget.", + "embed_share": "Intégrer et partager", + "discard_short": "Jeter", + "canvas_status": "Aperçu", + "finish_editing_name": "Terminé", + "waiting_org": "Chargement de l'organisation…", + "load_failed": "Impossible de charger ce widget. Réessayez à partir de la liste des widgets.", + "nav": { + "courses": "Cours", + "layout": "Disposition", + "design": "Conception", + "embed": "Intégrer" + }, + "viewport": { + "mobile": "Mobile", + "tablet": "Tablette", + "desktop": "Bureau" + } + }, + "tabs": { + "select_courses": "Sélectionnez des cours", + "layout": "Disposition", + "design": "Conception", + "embed": "Code intégré" + }, + "defaults": { + "name_prefix": "Widget de cours" + }, + "empty": { + "heading": "Pas encore de widget", + "description": "Créez un widget pour générer un bloc de cours intégrable pour les sites Web externes." + } } } diff --git a/apps/dashboard/src/lib/utils/translations/hi.json b/apps/dashboard/src/lib/utils/translations/hi.json index 4048b781..4c413fb9 100644 --- a/apps/dashboard/src/lib/utils/translations/hi.json +++ b/apps/dashboard/src/lib/utils/translations/hi.json @@ -219,7 +219,8 @@ "complete_marked": "पूर्ण के रूप में चिह्नित", "complete_download": "पूर्ण डाउनलोड करें", "version_restored": "संस्करण सफलतापूर्वक पुनर्स्थापित किया गया" - } + }, + "section_deleted": "पाठ्यक्रम अनुभाग सफलतापूर्वक हटा दिया गया" }, "attendance": { "error": { @@ -654,7 +655,8 @@ "add": "टैग जोड़ें", "popover_title": "टैग चुनें", "empty_groups": "अभी तक कोई टैग उपलब्ध नहीं है" - } + }, + "compliance": "अनुपालन" }, "analytics": { "title": "विश्लेषिकी" @@ -1117,7 +1119,7 @@ "finish": "ख़त्म करो", "next": "अगला", "check": "जांचें", - "auto_graded_badge": "Auto-graded", + "auto_graded_badge": "Auto-grade", "manual_grading_required_badge": "Manual grading required", "points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.", "analytics": { @@ -1697,6 +1699,81 @@ "edit": "संपादित करें", "delete": "हटाएँ" } + }, + "compliance": { + "title": "अनुपालन", + "not_compliance_title": "अनुपालन सक्षम नहीं है", + "not_compliance_description": "यह पाठ्यक्रम अनुपालन पाठ्यक्रम के रूप में कॉन्फ़िगर नहीं किया गया है।", + "loading": "अनुपालन रिकॉर्ड लोड हो रहा है...", + "learners": "शिक्षार्थी", + "learners_description": "यह तालिका प्रत्येक शिक्षार्थी के लिए नवीनतम चक्र दिखाती है।", + "current_cycle": "वर्तमान चक्र", + "current_cycle_description": "आपका सक्रिय अनुपालन चक्र और नवीनीकरण तिथियाँ।", + "history": "विगत कल्प", + "history_description": "प्रत्येक पंक्ति इस पाठ्यक्रम के लिए एक रिकॉर्ड किया गया अनुपालन चक्र है।", + "no_active_cycle": "कोई सक्रिय अनुपालन चक्र नहीं", + "empty_history": "अभी तक कोई अनुपालन इतिहास दर्ज नहीं किया गया है।", + "empty_overview": "अभी तक कोई शिक्षार्थी अनुपालन रिकॉर्ड उपलब्ध नहीं है।", + "not_available": "उपलब्ध नहीं है", + "status": { + "no_record": "कोई रिकॉर्ड नहीं", + "not_started": "शुरू नहीं हुआ", + "in_progress": "प्रगति पर है", + "compliant": "आज्ञाकारी", + "expiring_soon": "जल्द ही समाप्त हो रहा है", + "in_grace_period": "अनुग्रह अवधि में", + "non_compliant": "अनुपालन न करने वाला", + "waived": "माफ़ कर दिया गया" + }, + "summary": { + "total_learners": "कुल शिक्षार्थी", + "no_record": "कोई रिकॉर्ड नहीं", + "not_started": "शुरू नहीं हुआ", + "in_progress": "प्रगति पर है", + "compliant": "आज्ञाकारी", + "expiring_soon": "जल्द ही समाप्त हो रहा है", + "in_grace_period": "अनुग्रह अवधि में", + "non_compliant": "अनुपालन न करने वाला", + "waived": "माफ़ कर दिया गया" + }, + "fields": { + "learner": "शिक्षार्थी", + "email": "ईमेल", + "status": "स्थिति", + "cycle": "साइकिल", + "due_date": "नियत तिथि", + "completed_at": "पर पूरा हुआ", + "valid_until": "तक वैध है", + "score": "स्कोर", + "attempts": "प्रयास" + }, + "bulk_actions": { + "title": "थोक क्रियाएं", + "description": "शिक्षार्थियों का चयन करें और उनके वर्तमान चक्र पर अनुपालन कार्रवाई लागू करें।", + "select_all": "सभी दृश्यमान शिक्षार्थियों का चयन करें", + "selected_count": "{गिनती} चयनित", + "clear_selection": "स्पष्ट चयन", + "due_date_label": "नियत तिथि", + "due_date_helper": "कार्यों को रीसेट करने और विस्तारित करने के लिए उपयोग किया जाता है।", + "reset_action": "चक्र रीसेट करें", + "extend_action": "देय तिथि बढ़ाएँ", + "waive_title": "चयनित शिक्षार्थियों को माफ करें", + "waive_description": "चयनित शिक्षार्थियों के लिए छूट लागू करें। आप ओपन-एंडेड छूट के लिए समाप्ति तिथि को खाली छोड़ सकते हैं।", + "waiver_reason_label": "छूट का कारण", + "waiver_reason_helper": "ऑडिट और भविष्य की समीक्षाओं के लिए वैकल्पिक संदर्भ।", + "waiver_expires_at_label": "छूट समाप्त हो रही है", + "waiver_expires_at_helper": "यदि छूट स्वतः समाप्त नहीं होनी चाहिए तो खाली छोड़ दें।", + "waive_action": "छूट लागू करें", + "modal": { + "description": "आप चयनित शिक्षार्थियों की {गिनती} अद्यतन कर रहे हैं।", + "cancel": "रद्द करें", + "apply": "कार्रवाई लागू करें" + } + }, + "tabs": { + "overview": "सिंहावलोकन", + "learners": "शिक्षार्थी" + } } }, "search": { @@ -1712,6 +1789,11 @@ "content_count": { "lessons": "{गिनती} पाठ", "exercises": "{गिनती} अभ्यास" + }, + "compliance": { + "title": "अनुपालन", + "due_date": "देय", + "valid_until": "तक वैध है" } }, "navItems": { @@ -1724,7 +1806,9 @@ "nav_analytics": "विश्लेषिकी", "nav_certificates": "प्रमाणपत्र", "nav_landing_page": "लैंडिंग पृष्ठ", - "nav_settings": "सेटिंग्स" + "nav_settings": "सेटिंग्स", + "nav_compliance": "अनुपालन", + "nav_ai_assistant": "सहायक" }, "completion": { "modal": { @@ -1812,8 +1896,11 @@ "share": "साझा करें", "invite": "आमंत्रित करें", "delete": "हटाएँ", - "open": "खुला" - } + "open": "खुला", + "open_course_site": "पाठ्यक्रम साइट खोलें" + }, + "due_date": "देय", + "valid_until": "तक वैध है" }, "course_filter": { "date_created_newest": "Date Created: Newest first", @@ -1865,9 +1952,9 @@ "lms_dashboard_hero": "आज का दिन आपको आपके सीखने के लक्ष्यों के करीब लाने का एक और दिन है। हार मत मानो, जितना अधिक सीखोगे उतना बेहतर हो जाओगे।", "lessons_completed": "पाठ पूरा हुआ", "no_courses_started": "कोई पाठ्यक्रम शुरू नहीं हुआ", - "certificates_earned": "Certificates earned", + "certificates_earned": "Certificates issued", "certificates_earned_description": "Total certificates issued to students in your organization", - "certification_rate": "certification rate", + "certification_rate": "प्रमाणित", "recent_certifications": "Recent certifications", "no_certifications_yet": "No certificates earned yet", "no_certifications_yet_description": "When students complete a course and earn a certificate, they will appear here", @@ -1886,7 +1973,28 @@ "publish_first_course": "अपना पहला पाठ्यक्रम प्रकाशित करें", "publish_first_course_description": "नामांकन पर नज़र रखने के लिए, एक पाठ्यक्रम प्रकाशित करें और छात्रों को आमंत्रित करें", "publish_course": "पाठ्यक्रम प्रकाशित करें", - "items_completed": "आइटम पूरे हो गए" + "items_completed": "आइटम पूरे हो गए", + "currently_learning": "वर्तमान में सीख रहा हूँ", + "learner": "शिक्षार्थी", + "progress": "प्रगति", + "pick_up_where_you_left_off": "वहीं से शुरू करें जहां आपने छोड़ा था", + "course_awaits_you": "{शीर्षक} आपकी प्रतीक्षा कर रहा है", + "learning_awaits_you": "आपका अगला कोर्स आपका इंतजार कर रहा है", + "lms_today_progress": "आप अपने सीखने के माध्यम से {प्रगति}% कर रहे हैं - हर कदम मायने रखता है।", + "lms_today_empty": "जब आप हों तो आपका सीखने का मार्ग तैयार है।", + "compliance_score": "अनुपालन स्कोर", + "compliance": "अनुपालन", + "compliance_score_subtitle": "वर्तमान में संतुष्ट {कुल} अनुपालन पाठ्यक्रमों में से {पूर्ण}", + "compliance_score_empty": "अभी तक कोई अनुपालन पाठ्यक्रम नहीं सौंपा गया है", + "compliance_met": "{कुल} में से {पूर्ण} मिले", + "required_courses_completed": "कुल आवश्यक पाठ्यक्रमों में से {पूर्ण} पूर्ण", + "streak": "लकीर", + "days_active": "सक्रिय दिन", + "items_done": "{कुल} का {पूर्ण} हो गया", + "login_activity_title": "छात्र लॉगिन गतिविधि", + "login_activity_description": "सप्ताह के सर्वाधिक सक्रिय दिन (पिछले 90 दिन)", + "login_activity_no_data": "अभी तक कोई लॉगिन गतिविधि नहीं", + "login_activity_logins": "लॉगिन" }, "schedule": { "title": "आपका शेड्यूल", @@ -2097,7 +2205,8 @@ "upgrade": "अभी अपग्रेड करें", "tags": "टैग", "automation": "स्वचालन", - "programs": "कार्यक्रम" + "programs": "कार्यक्रम", + "widgets": "विजेट्स" }, "media_manager": { "page_title": "मीडिया मैनेजर - क्लासरूमआईओ", @@ -2218,7 +2327,10 @@ "provider": "हम आपकी बिलिंग प्रबंधित करने में सहायता के लिए पोलर का उपयोग करते हैं", "open_billing": "बिलिंग खोलें", "error": "बिलिंग खोलते समय एक त्रुटि उत्पन्न हुई", - "page_subtitle": "Your plan, billing portal, and how you pay for ClassroomIO." + "page_subtitle": "Your plan, billing portal, and how you pay for ClassroomIO.", + "ai_credits": "एआई सहायक उपयोग", + "ai_tokens_used": "इस महीने उपयोग किए गए टोकन", + "ai_bonus_credits": "बोनस क्रेडिट उपलब्ध" }, "integrations": { "heading": "टेलीग्राम", @@ -2667,9 +2779,34 @@ }, "app": { "search": { - "placeholder": "खोजें" + "placeholder": "खोजें", + "command_palette": { + "title": "कमांड पैलेट", + "description": "अपने संगठन में खोजें और नेविगेट करें", + "placeholder": "पाठ्यक्रम, कार्यक्रम, विजेट, छात्र, टैग, सेटिंग्स खोजें...", + "org_placeholder": "पाठ्यक्रम, कार्यक्रम, विजेट, छात्र, टैग, सेटिंग्स खोजें...", + "lms_placeholder": "अपने पाठ्यक्रम, कार्यक्रम और शिक्षण पृष्ठ खोजें...", + "lms_description": "अपने शिक्षण कार्यक्षेत्र को खोजें और नेविगेट करें", + "empty": "कोई परिणाम नहीं मिला", + "loading": "खोज रहे हैं...", + "clear_recents": "हालिया साफ़ करें", + "result_types": { + "page": "पेज" + }, + "groups": { + "recent": "हाल का", + "suggested": "सुझाव दिया", + "courses": "पाठ्यक्रम", + "programs": "कार्यक्रम", + "widgets": "विजेट्स", + "tags": "टैग", + "students": "छात्र", + "settings": "सेटिंग्स" + } + } }, - "cancel": "रद्द करें" + "cancel": "रद्द करें", + "save": "सहेजें" }, "welcome_modal": { "we_at": "हम पर", @@ -2700,7 +2837,9 @@ "live_class_label": "लाइव क्लास", "live_class_subtitle": "बूटकैंप के लिए आदर्श जहां पाठ समय आधारित होते हैं", "self_paced_label": "स्वयं गति", - "self_paced_subtitle": "उन पाठ्यक्रमों के लिए सर्वोत्तम जहां छात्र अपनी गति से पाठ ले सकते हैं" + "self_paced_subtitle": "उन पाठ्यक्रमों के लिए सर्वोत्तम जहां छात्र अपनी गति से पाठ ले सकते हैं", + "compliance_label": "अनुपालन", + "compliance_subtitle": "रीटेक, रिमाइंडर और नवीनीकरण ट्रैकिंग के साथ आवर्ती प्रशिक्षण" }, "delete_modal": { "label": "हटाएँ", @@ -3079,5 +3218,226 @@ "sort_students": "छात्र", "sort_status": "स्थिति" } + }, + "ai_assistant": { + "empty_state": "मुझसे अपने पाठ्यक्रम की सामग्री में मदद करने के लिए कहें - पाठों का मसौदा तैयार करें, प्रश्न तैयार करें, या किसी दस्तावेज़ से पाठ्यक्रम की योजना बनाएं।", + "input_placeholder": "एआई सहायक से पूछें...", + "attach_document": "एक दस्तावेज़ संलग्न करें (पीडीएफ, DOCX, PPTX)", + "upgrade_to_upload": "दस्तावेज़ अपलोड करने के लिए अपग्रेड करें", + "tokens_exhausted": "मासिक एआई टोकन सीमा पूरी हो गई। सेटिंग्स > बिलिंग में अधिक क्रेडिट खरीदें या रीसेट की प्रतीक्षा करें।", + "chat_history": "चैट इतिहास", + "no_conversations": "अभी तक कोई बातचीत नहीं", + "untitled": "शीर्षकहीन", + "new_chat": "नई चैट", + "tokens_label": "टोकन", + "implementing_course_plan": "पाठ्यक्रम योजना का कार्यान्वयन", + "resume": "बायोडाटा", + "stopped_content_kept": "रुक गया. निर्मित सामग्री रखी गई है.", + "mention_lesson": "पाठ", + "mention_exercise": "व्यायाम", + "mention_section": "धारा", + "mention_no_results": "कोई मेल खाती सामग्री नहीं", + "uploading_document": "दस्तावेज़ अपलोड हो रहा है..." + }, + "widgets": { + "heading": "विजेट्स", + "subtitle": "बाहरी वेबसाइटों के लिए ब्रांडेड पाठ्यक्रम संग्रह प्रकाशित करें।", + "empty_state": "अभी तक कोई विजेट नहीं. एम्बेड करने योग्य पाठ्यक्रम ब्लॉक बनाने के लिए एक बनाएं।", + "filter": { + "status": "स्थिति", + "all": "सब", + "unpublished": "अप्रकाशित", + "course_type": "कोर्स का प्रकार" + }, + "notifications": { + "created": "विजेट बनाया गया", + "saved": "विजेट सहेजा गया", + "published": "विजेट प्रकाशित", + "rolled_back": "विजेट पुनर्स्थापित किया गया", + "deleted": "विजेट संग्रहीत" + }, + "form": { + "name": "विजेट नाम", + "selection_mode": "चयन मोड", + "course_search": "पाठ्यक्रम खोजें", + "layout_type": "लेआउट प्रकार", + "sort_by": "क्रमबद्ध करें", + "cta_label": "सीटीए लेबल", + "load_more_label": "अधिक लेबल लोड करें", + "limit_courses": "पाठ्यक्रम सीमित करें", + "max_count": "अधिकतम कोर्स गिनती", + "show_course_image": "पाठ्यक्रम छवि दिखाएँ", + "show_type_badge": "पाठ्यक्रम प्रकार का बैज दिखाएँ", + "show_lessons_count": "पाठों की गिनती दिखाएँ", + "show_description": "विवरण अंश दिखाएँ", + "shorten_long_description": "लंबे विवरण को छोटा करें", + "show_price": "कीमत दिखाएँ", + "show_rating": "रेटिंग दिखाएँ", + "theme_preset": "थीम पूर्व निर्धारित", + "primary_color": "प्राथमिक रंग", + "background_color": "पृष्ठभूमि का रंग", + "text_color": "पाठ का रंग", + "border_color": "सीमा का रंग", + "border_radius": "सीमा त्रिज्या", + "font_family": "फ़ॉन्ट परिवार", + "font_scale": "फ़ॉन्ट आकार पैमाना", + "show_powered_by": "क्लासरूमियो ब्रांडिंग दिखाएं", + "custom_css": "कस्टम सीएसएस", + "custom_css_locked": "कस्टम सीएसएस सक्षम करने के लिए अपग्रेड करें।", + "embed_code": "कोड एंबेड करें" + }, + "create": { + "description": "आरंभ करने के लिए अपने विजेट को एक नाम दें." + }, + "actions": { + "create": "विजेट बनाएं", + "edit": "संपादित करें", + "back": "विजेट्स पर वापस जाएँ", + "save": "सहेजें", + "discard": "परिवर्तन त्यागें", + "publish": "प्रकाशित करें", + "archive": "पुरालेख", + "restore": "पुनर्स्थापित करें", + "copy_embed": "एंबेड कॉपी करें", + "rename": "नाम बदलें", + "delete": "हटाएँ" + }, + "preview": { + "heading": "पूर्वावलोकन", + "saved": "सहेजा गया", + "unsaved": "सहेजे न गए परिवर्तन", + "load_failed": "पूर्वावलोकन लोड नहीं हो सका. पृष्ठ को ताज़ा करने का प्रयास करें.", + "empty": "पूर्वावलोकन अनुपलब्ध.", + "all_tags": "सब", + "none": "कोई नहीं", + "lessons_suffix": "सबक", + "last_synced": "समन्वयित", + "refresh": "ताज़ा करें", + "exercises_suffix": "व्यायाम", + "columns": { + "course": "पाठ्यक्रम", + "lessons": "सबक", + "tags": "टैग" + } + }, + "plan": { + "free": "निःशुल्क योजना", + "paid": "सशुल्क योजना" + }, + "status": { + "draft": "ड्राफ्ट", + "published": "प्रकाशित", + "archived": "संग्रहीत", + "live": "जियो" + }, + "sort": { + "manual": "मैनुअल", + "newest": "नवीनतम", + "title": "शीर्षक" + }, + "layoutOptions": { + "section": "लेआउट विकल्प", + "columns": "कॉलम", + "max_count": "अधिकतम पाठ्यक्रम", + "show_rating": "रेटिंग दिखाएँ", + "tag_source": "टैग स्रोत", + "tag_source_auto": "पाठ्यक्रम से ऑटो", + "tag_source_manual": "मैन्युअल चयन", + "default_tag": "डिफ़ॉल्ट टैग", + "show_all_option": "\"सभी\" विकल्प दिखाएँ", + "visible_cards": "दर्शनीय कार्ड", + "auto_play": "ऑटो प्ले", + "auto_play_interval": "ऑटो प्ले अंतराल (एमएस)", + "show_dots": "बिंदु दिखाएँ", + "show_arrows": "तीर दिखाओ", + "loop": "पाश", + "featured_course": "विशेष पाठ्यक्रम", + "featured_course_required": "इस लेआउट के लिए एक विशेष पाठ्यक्रम की आवश्यकता है।", + "main_course": "मुख्य स्पॉटलाइट कोर्स", + "main_course_required": "सुर्खियों में आने के लिए एक कोर्स चुनें।", + "eyebrow_label": "भौंह लेबल", + "cta_label": "सीटीए लेबल", + "secondary_max_count": "माध्यमिक पाठ्यक्रम", + "density": "घनत्व", + "density_comfortable": "आरामदायक", + "density_compact": "सघन", + "show_thumbnail": "थंबनेल दिखाएँ", + "show_tags": "टैग दिखाएँ", + "title_style": "शीर्षक शैली", + "title_style_serif": "सेरिफ़", + "title_style_sans": "सं", + "category_tags": "श्रेणी टैग", + "category_tags_required": "श्रेणी शेल्फ़ के लिए कम से कम एक टैग चुनें.", + "default_category": "डिफ़ॉल्ट श्रेणी", + "show_all_tab": "\"सभी\" टैब दिखाएँ", + "max_per_category": "प्रति श्रेणी अधिकतम पाठ्यक्रम", + "none": "कोई नहीं" + }, + "layout_description": { + "card_grid": "बहुत सारी सांस लेने की जगह के साथ सामान्य प्रयोजन कैटलॉग।", + "tag_filter": "छात्रों को विषय के आधार पर एक विस्तृत सूची बनाने दें।", + "carousel": "मुखपृष्ठ के तंग अनुभागों में विशेष पंक्तियाँ।", + "primary_course": "किसी फ्लैगशिप या नए लॉन्च किए गए पाठ्यक्रम का प्रचार करें।", + "compact_list": "साइडबार एम्बेड और न्यूनतम-फ़ुटप्रिंट पृष्ठ।", + "editorial_spotlight": "ब्लॉग-शैली के लैंडिंग पृष्ठ और क्यूरेटेड चयन।", + "category_shelf": "विषय के आधार पर समूहीकृत बड़े कैटलॉग, स्कैन करने में आसान।" + }, + "layout": { + "card_grid": "कार्ड ग्रिड", + "tag_filter": "टैग फ़िल्टर", + "carousel": "हिंडोला", + "primary_course": "प्राथमिक पाठ्यक्रम", + "compact_list": "संक्षिप्त सूची", + "editorial_spotlight": "संपादकीय स्पॉटलाइट", + "category_shelf": "श्रेणी शेल्फ" + }, + "selection": { + "manual": "मैन्युअल चयन", + "published": "सभी प्रकाशित पाठ्यक्रम" + }, + "editor": { + "sources": "विजेट स्रोत", + "available_courses": "उपलब्ध पाठ्यक्रम", + "layout": "लेआउट", + "content": "सामग्री", + "design": "डिज़ाइन", + "version_history": "संस्करण इतिहास", + "save_changes": "परिवर्तन सहेजें", + "rename_widget": "विजेट का नाम बदलें", + "continue_to_layout": "लेआउट करना जारी रखें", + "pick_layout": "एक लेआउट चुनें", + "pick_design": "दिखावट", + "panel_subtitle": "कॉन्फ़िगर करें कि आपका विजेट कैसा दिखता है और व्यवहार करता है।", + "embed_share": "एम्बेड करें और साझा करें", + "discard_short": "त्यागें", + "canvas_status": "पूर्वावलोकन", + "finish_editing_name": "हो गया", + "waiting_org": "संगठन लोड हो रहा है...", + "load_failed": "यह विजेट लोड नहीं किया जा सका. विजेट सूची से पुनः प्रयास करें.", + "nav": { + "courses": "पाठ्यक्रम", + "layout": "लेआउट", + "design": "डिज़ाइन", + "embed": "एम्बेड करें" + }, + "viewport": { + "mobile": "मोबाइल", + "tablet": "गोली", + "desktop": "डेस्कटॉप" + } + }, + "tabs": { + "select_courses": "पाठ्यक्रम चुनें", + "layout": "लेआउट", + "design": "डिज़ाइन", + "embed": "कोड एम्बेड करें" + }, + "defaults": { + "name_prefix": "पाठ्यक्रम विजेट" + }, + "empty": { + "heading": "अभी तक कोई विजेट नहीं", + "description": "बाहरी वेबसाइटों के लिए एम्बेड करने योग्य पाठ्यक्रम ब्लॉक तैयार करने के लिए एक विजेट बनाएं।" + } } } diff --git a/apps/dashboard/src/lib/utils/translations/pl.json b/apps/dashboard/src/lib/utils/translations/pl.json index ca6e39e8..4127fd5e 100644 --- a/apps/dashboard/src/lib/utils/translations/pl.json +++ b/apps/dashboard/src/lib/utils/translations/pl.json @@ -219,7 +219,8 @@ "complete_marked": "Oznaczono jako ukończone", "complete_download": "Pobierz ukończono", "version_restored": "Wersja została przywrócona pomyślnie" - } + }, + "section_deleted": "Sekcja kursu została pomyślnie usunięta" }, "attendance": { "error": { @@ -654,7 +655,8 @@ "add": "Dodaj tag", "popover_title": "Wybierz tagi", "empty_groups": "Brak dostępnych tagów" - } + }, + "compliance": "Zgodność" }, "analytics": { "title": "Analityka" @@ -1117,7 +1119,7 @@ "finish": "Zakończ", "next": "Następny", "check": "Sprawdź", - "auto_graded_badge": "Auto-graded", + "auto_graded_badge": "Auto-grade", "manual_grading_required_badge": "Manual grading required", "points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.", "analytics": { @@ -1697,6 +1699,81 @@ "edit": "Edytuj", "delete": "Usuń" } + }, + "compliance": { + "title": "Zgodność", + "not_compliance_title": "Zgodność nie jest włączona", + "not_compliance_description": "Ten kurs nie jest skonfigurowany jako kurs dotyczący zgodności.", + "loading": "Ładowanie rekordów zgodności...", + "learners": "Uczniowie", + "learners_description": "Ta tabela pokazuje najnowszy cykl dla każdego ucznia.", + "current_cycle": "Aktualny cykl", + "current_cycle_description": "Twój aktywny cykl zgodności i daty odnowienia.", + "history": "Poprzednie cykle", + "history_description": "Każdy wiersz to jeden zarejestrowany cykl zgodności dla tego kursu.", + "no_active_cycle": "Brak aktywnego cyklu zgodności", + "empty_history": "Nie zarejestrowano jeszcze żadnej historii zgodności.", + "empty_overview": "Nie są jeszcze dostępne żadne zapisy dotyczące przestrzegania zasad przez ucznia.", + "not_available": "Niedostępne", + "status": { + "no_record": "Brak rekordu", + "not_started": "Nie rozpoczęte", + "in_progress": "W toku", + "compliant": "Zgodny", + "expiring_soon": "Wkrótce wygaśnie", + "in_grace_period": "W okresie karencji", + "non_compliant": "Niezgodny", + "waived": "Zrzeczenie się" + }, + "summary": { + "total_learners": "Wszystkich uczniów", + "no_record": "Brak rekordu", + "not_started": "Nie rozpoczęte", + "in_progress": "W toku", + "compliant": "Zgodny", + "expiring_soon": "Wkrótce wygaśnie", + "in_grace_period": "W okresie karencji", + "non_compliant": "Niezgodny", + "waived": "Zrzeczenie się" + }, + "fields": { + "learner": "Uczeń", + "email": "E-mail", + "status": "Stan", + "cycle": "Cykl", + "due_date": "Termin", + "completed_at": "Zakończono o godz", + "valid_until": "Ważne do", + "score": "Wynik", + "attempts": "Próby" + }, + "bulk_actions": { + "title": "Działania zbiorcze", + "description": "Wybierz uczniów i zastosuj akcję zgodności w ich bieżącym cyklu.", + "select_all": "Wybierz wszystkich widocznych uczniów", + "selected_count": "Wybrano {count}", + "clear_selection": "Wyczyść wybór", + "due_date_label": "Termin", + "due_date_helper": "Używane do resetowania i rozszerzania działań.", + "reset_action": "Zresetuj cykl", + "extend_action": "Przedłuż termin", + "waive_title": "Zrezygnuj z wybranych uczniów", + "waive_description": "Zastosuj zwolnienie do wybranych uczniów. W przypadku zrzeczenia się na czas nieokreślony możesz pozostawić pole wygaśnięcia puste.", + "waiver_reason_label": "Powód odstąpienia", + "waiver_reason_helper": "Opcjonalny kontekst audytów i przyszłych przeglądów.", + "waiver_expires_at_label": "Zrzeczenie się wygasa o godz", + "waiver_expires_at_helper": "Pozostaw puste, jeśli zrzeczenie się nie powinno wygasać automatycznie.", + "waive_action": "Zastosuj zwolnienie", + "modal": { + "description": "Aktualizujesz {count} wybranych uczniów.", + "cancel": "Anuluj", + "apply": "Zastosuj działanie" + } + }, + "tabs": { + "overview": "Przegląd", + "learners": "Uczniowie" + } } }, "search": { @@ -1712,6 +1789,11 @@ "content_count": { "lessons": "{count} lekcji", "exercises": "Ćwiczenia: {count}" + }, + "compliance": { + "title": "Zgodność", + "due_date": "Termin", + "valid_until": "Ważne do" } }, "navItems": { @@ -1724,7 +1806,9 @@ "nav_analytics": "Analityka", "nav_certificates": "Certyfikaty", "nav_landing_page": "Strona docelowa", - "nav_settings": "Ustawienia" + "nav_settings": "Ustawienia", + "nav_compliance": "Zgodność", + "nav_ai_assistant": "Asystent" }, "completion": { "modal": { @@ -1812,8 +1896,11 @@ "share": "Udostępnij", "invite": "Zaproś", "delete": "Usuń", - "open": "Otwórz" - } + "open": "Otwórz", + "open_course_site": "Otwarta strona kursu" + }, + "due_date": "Termin", + "valid_until": "Ważne do" }, "course_filter": { "date_created_newest": "Date Created: Newest first", @@ -1865,9 +1952,9 @@ "lms_dashboard_hero": "Dziś jest kolejny dzień, który przybliży Cię do Twoich celów edukacyjnych. Nie poddawaj się, im więcej się uczysz, tym jesteś lepszy.", "lessons_completed": "Lekcje zakończone", "no_courses_started": "Żadne kursy nie zostały rozpoczęte", - "certificates_earned": "Certificates earned", + "certificates_earned": "Certificates issued", "certificates_earned_description": "Total certificates issued to students in your organization", - "certification_rate": "certification rate", + "certification_rate": "Certyfikowani", "recent_certifications": "Recent certifications", "no_certifications_yet": "No certificates earned yet", "no_certifications_yet_description": "When students complete a course and earn a certificate, they will appear here", @@ -1886,7 +1973,28 @@ "publish_first_course": "Opublikuj swój pierwszy kurs", "publish_first_course_description": "Aby śledzić zapisy, opublikuj kurs i zaproś uczniów", "publish_course": "Publikuj kurs", - "items_completed": "Elementy ukończone" + "items_completed": "Elementy ukończone", + "currently_learning": "Aktualnie się uczę", + "learner": "Uczeń", + "progress": "Postęp", + "pick_up_where_you_left_off": "Rozpocznij od miejsca, w którym skończyłeś", + "course_awaits_you": "{title} czeka na Ciebie", + "learning_awaits_you": "Kolejny kurs już na Ciebie czeka", + "lms_today_progress": "Twoje postępy w nauce są następujące: liczy się każdy krok.", + "lms_today_empty": "Twoja ścieżka edukacyjna jest gotowa, gdy Ty jesteś.", + "compliance_score": "Wynik zgodności", + "compliance": "Zgodność", + "compliance_score_subtitle": "Obecnie ukończone {completed} z {total} kursów compliance", + "compliance_score_empty": "Nie przydzielono jeszcze żadnych kursów z zakresu zgodności", + "compliance_met": "Osiągnięto {ukończone} z {total}", + "required_courses_completed": "Ukończono {completed} z {total} wymaganych kursów", + "streak": "Smuga", + "days_active": "dni aktywne", + "items_done": "Wykonano {ukończone} z {total}", + "login_activity_title": "Aktywność logowania ucznia", + "login_activity_description": "Najbardziej aktywne dni tygodnia (ostatnie 90 dni)", + "login_activity_no_data": "Brak aktywności związanej z logowaniem", + "login_activity_logins": "logowania" }, "schedule": { "title": "Twój harmonogram", @@ -2097,7 +2205,8 @@ "upgrade": "Uaktualnij teraz", "tags": "Tagi", "automation": "Automatyzacja", - "programs": "Programy" + "programs": "Programy", + "widgets": "Widżety" }, "media_manager": { "page_title": "Menedżer multimediów - ClassroomIO", @@ -2218,7 +2327,10 @@ "provider": "Używamy Polar do zarządzania Twoimi rozliczeniami", "open_billing": "Otwórz rozliczenia", "error": "Wystąpił błąd podczas otwierania rozliczeń", - "page_subtitle": "Your plan, billing portal, and how you pay for ClassroomIO." + "page_subtitle": "Your plan, billing portal, and how you pay for ClassroomIO.", + "ai_credits": "Użycie Asystenta AI", + "ai_tokens_used": "tokeny wykorzystane w tym miesiącu", + "ai_bonus_credits": "dostępne kredyty bonusowe" }, "integrations": { "heading": "Telegram", @@ -2667,9 +2779,34 @@ }, "app": { "search": { - "placeholder": "Szukaj" + "placeholder": "Szukaj", + "command_palette": { + "title": "Paleta poleceń", + "description": "Wyszukuj i poruszaj się po organizacji", + "placeholder": "Szukaj kursów, programów, widżetów, uczniów, tagów, ustawień...", + "org_placeholder": "Szukaj kursów, programów, widżetów, uczniów, tagów, ustawień...", + "lms_placeholder": "Przeszukuj swoje kursy, programy i strony edukacyjne...", + "lms_description": "Wyszukuj i poruszaj się po swoim obszarze roboczym do nauki", + "empty": "Nie znaleziono żadnych wyników", + "loading": "Wyszukiwanie...", + "clear_recents": "Wyczyść ostatnie", + "result_types": { + "page": "Strona" + }, + "groups": { + "recent": "Najnowsze", + "suggested": "Sugerowane", + "courses": "Kursy", + "programs": "Programy", + "widgets": "Widżety", + "tags": "Tagi", + "students": "Studenci", + "settings": "Ustawienia" + } + } }, - "cancel": "Anuluj" + "cancel": "Anuluj", + "save": "Zapisz" }, "welcome_modal": { "we_at": "My o", @@ -2700,7 +2837,9 @@ "live_class_label": "Zajęcia na żywo", "live_class_subtitle": "Idealny na bootcampy, gdzie lekcje zależą od czasu", "self_paced_label": "Samodzielne tempo", - "self_paced_subtitle": "Najlepsze rozwiązanie w przypadku kursów, w których uczniowie mogą brać udział w lekcjach we własnym tempie" + "self_paced_subtitle": "Najlepsze rozwiązanie w przypadku kursów, w których uczniowie mogą brać udział w lekcjach we własnym tempie", + "compliance_label": "Zgodność", + "compliance_subtitle": "Szkolenia cykliczne z powtórzeniami, przypomnieniami i śledzeniem odnowień" }, "delete_modal": { "label": "Usuń", @@ -3079,5 +3218,226 @@ "sort_students": "Studenci", "sort_status": "Stan" } + }, + "ai_assistant": { + "empty_state": "Poproś mnie o pomoc w przygotowaniu treści kursu — szkicuj lekcje, generuj pytania lub planuj kurs na podstawie dokumentu.", + "input_placeholder": "Zapytaj asystenta AI...", + "attach_document": "Załącz dokument (PDF, DOCX, PPTX)", + "upgrade_to_upload": "Uaktualnij, aby przesyłać dokumenty", + "tokens_exhausted": "Osiągnięto miesięczny limit tokenów AI. Kup więcej kredytów w Ustawieniach > Rozliczenia lub poczekaj na reset.", + "chat_history": "Historia czatu", + "no_conversations": "Nie ma jeszcze żadnych rozmów", + "untitled": "Bez tytułu", + "new_chat": "Nowy czat", + "tokens_label": "żetony", + "implementing_course_plan": "Wdrażanie planu kursu", + "resume": "Wznów", + "stopped_content_kept": "Zatrzymany. Utworzona treść została zachowana.", + "mention_lesson": "Lekcja", + "mention_exercise": "Ćwicz", + "mention_section": "Sekcja", + "mention_no_results": "Brak pasującej treści", + "uploading_document": "Przesyłanie dokumentu..." + }, + "widgets": { + "heading": "Widżety", + "subtitle": "Publikuj markowe zbiory kursów na zewnętrznych stronach internetowych.", + "empty_state": "Nie ma jeszcze widżetów. Utwórz go, aby wygenerować osadzony blok kursu.", + "filter": { + "status": "Stan", + "all": "Wszystko", + "unpublished": "Niepublikowane", + "course_type": "Typ kursu" + }, + "notifications": { + "created": "Widżet został utworzony", + "saved": "Widżet został zapisany", + "published": "Widżet opublikowany", + "rolled_back": "Widżet przywrócony", + "deleted": "Widżet zarchiwizowany" + }, + "form": { + "name": "Nazwa widgetu", + "selection_mode": "Tryb selekcji", + "course_search": "Wyszukaj kursy", + "layout_type": "Typ układu", + "sort_by": "Sortuj według", + "cta_label": "Etykieta CTA", + "load_more_label": "Załaduj więcej etykiet", + "limit_courses": "Ogranicz kursy", + "max_count": "Maksymalna liczba kursów", + "show_course_image": "Pokaż obraz kursu", + "show_type_badge": "Pokaż plakietkę typu kursu", + "show_lessons_count": "Pokaż liczbę lekcji", + "show_description": "Pokaż fragment opisu", + "shorten_long_description": "Skróć długi opis", + "show_price": "Pokaż cenę", + "show_rating": "Pokaż ocenę", + "theme_preset": "Wstępnie ustawiony motyw", + "primary_color": "Kolor podstawowy", + "background_color": "Kolor tła", + "text_color": "Kolor tekstu", + "border_color": "Kolor obramowania", + "border_radius": "Promień granicy", + "font_family": "Rodzina czcionek", + "font_scale": "Skala wielkości czcionki", + "show_powered_by": "Pokaż markę Classroomio", + "custom_css": "Niestandardowy CSS", + "custom_css_locked": "Uaktualnij, aby włączyć niestandardowy CSS.", + "embed_code": "Wstaw kod" + }, + "create": { + "description": "Nadaj swojemu widżetowi nazwę, aby rozpocząć." + }, + "actions": { + "create": "Utwórz widget", + "edit": "Edytuj", + "back": "Powrót do widżetów", + "save": "Zapisz", + "discard": "Odrzuć zmiany", + "publish": "Publikuj", + "archive": "Archiwum", + "restore": "Przywróć", + "copy_embed": "Skopiuj osadzenie", + "rename": "Zmień nazwę", + "delete": "Usuń" + }, + "preview": { + "heading": "Podgląd", + "saved": "Zapisano", + "unsaved": "Niezapisane zmiany", + "load_failed": "Nie udało się wczytać podglądu. Spróbuj odświeżyć stronę.", + "empty": "Podgląd niedostępny.", + "all_tags": "Wszystko", + "none": "Żadne", + "lessons_suffix": "lekcje", + "last_synced": "Zsynchronizowane", + "refresh": "Odśwież", + "exercises_suffix": "ćwiczenia", + "columns": { + "course": "Kurs", + "lessons": "Lekcje", + "tags": "Tagi" + } + }, + "plan": { + "free": "Darmowy plan", + "paid": "Płatny plan" + }, + "status": { + "draft": "Wersja robocza", + "published": "Opublikowano", + "archived": "Zarchiwizowane", + "live": "Na żywo" + }, + "sort": { + "manual": "Instrukcja", + "newest": "Najnowszy", + "title": "Tytuł" + }, + "layoutOptions": { + "section": "Opcje układu", + "columns": "Kolumny", + "max_count": "Maksymalne kursy", + "show_rating": "Pokaż ocenę", + "tag_source": "Źródło tagu", + "tag_source_auto": "Auto z kursów", + "tag_source_manual": "Wybór ręczny", + "default_tag": "Domyślny tag", + "show_all_option": "Pokaż opcję „Wszystkie”.", + "visible_cards": "Widoczne karty", + "auto_play": "Automatyczne odtwarzanie", + "auto_play_interval": "Interwał automatycznego odtwarzania (ms)", + "show_dots": "Pokaż kropki", + "show_arrows": "Pokaż strzałki", + "loop": "Pętla", + "featured_course": "Polecany kurs", + "featured_course_required": "Dla tego układu wymagany jest polecany kurs.", + "main_course": "Główny kurs w centrum uwagi", + "main_course_required": "Wybierz kurs, który chcesz wyróżnić.", + "eyebrow_label": "Etykieta brwi", + "cta_label": "Etykieta CTA", + "secondary_max_count": "Kursy średnie", + "density": "Gęstość", + "density_comfortable": "Wygodne", + "density_compact": "Kompaktowy", + "show_thumbnail": "Pokaż miniaturę", + "show_tags": "Pokaż tagi", + "title_style": "Styl tytułu", + "title_style_serif": "Szeryf", + "title_style_sans": "Bez", + "category_tags": "Tagi kategorii", + "category_tags_required": "Wybierz co najmniej jeden tag dla półki kategorii.", + "default_category": "Kategoria domyślna", + "show_all_tab": "Pokaż zakładkę „Wszystkie”.", + "max_per_category": "Maksymalna liczba kursów na kategorię", + "none": "Żadne" + }, + "layout_description": { + "card_grid": "Katalog ogólnego przeznaczenia z dużą ilością miejsca do oddychania.", + "tag_filter": "Pozwól uczniom zawęzić szeroki katalog według tematów.", + "carousel": "Polecane wiersze w ciasnych sekcjach strony głównej.", + "primary_course": "Promuj sztandarowy lub nowo uruchomiony kurs.", + "compact_list": "Osadzone paski boczne i strony o minimalnej powierzchni.", + "editorial_spotlight": "Strony docelowe w stylu bloga i wybrane typy.", + "category_shelf": "Duże katalogi pogrupowane tematycznie, łatwe do skanowania." + }, + "layout": { + "card_grid": "Siatka kart", + "tag_filter": "Filtr tagów", + "carousel": "Karuzela", + "primary_course": "Kurs podstawowy", + "compact_list": "Lista kompaktowa", + "editorial_spotlight": "Uwaga redakcyjna", + "category_shelf": "Półka kategorii" + }, + "selection": { + "manual": "Wybór ręczny", + "published": "Wszystkie opublikowane kursy" + }, + "editor": { + "sources": "Źródło widgetu", + "available_courses": "Dostępne kursy", + "layout": "Układ", + "content": "Treść", + "design": "Projekt", + "version_history": "Historia wersji", + "save_changes": "Zapisz zmiany", + "rename_widget": "Zmień nazwę widżetu", + "continue_to_layout": "Kontynuuj układ", + "pick_layout": "Wybierz układ", + "pick_design": "Wygląd", + "panel_subtitle": "Skonfiguruj wygląd i zachowanie widgetu.", + "embed_share": "Umieść i udostępnij", + "discard_short": "Odrzuć", + "canvas_status": "Podgląd", + "finish_editing_name": "Gotowe", + "waiting_org": "Ładowanie organizacji…", + "load_failed": "Nie można załadować tego widgetu. Spróbuj ponownie, korzystając z listy widżetów.", + "nav": { + "courses": "Kursy", + "layout": "Układ", + "design": "Projekt", + "embed": "Osadź" + }, + "viewport": { + "mobile": "Mobilny", + "tablet": "Tablet", + "desktop": "Pulpit" + } + }, + "tabs": { + "select_courses": "Wybierz Kursy", + "layout": "Układ", + "design": "Projekt", + "embed": "Wstaw kod" + }, + "defaults": { + "name_prefix": "Widżet kursu" + }, + "empty": { + "heading": "Nie ma jeszcze widżetów", + "description": "Utwórz widżet, aby wygenerować osadzony blok kursu dla zewnętrznych stron internetowych." + } } } diff --git a/apps/dashboard/src/lib/utils/translations/pt.json b/apps/dashboard/src/lib/utils/translations/pt.json index d8964b5a..4d9ed7ad 100644 --- a/apps/dashboard/src/lib/utils/translations/pt.json +++ b/apps/dashboard/src/lib/utils/translations/pt.json @@ -219,7 +219,8 @@ "complete_marked": "Marcado como concluído", "complete_download": "Download completo", "version_restored": "Versão restaurada com sucesso" - } + }, + "section_deleted": "Seção do curso excluída com sucesso" }, "attendance": { "error": { @@ -654,7 +655,8 @@ "add": "Adicionar etiqueta", "popover_title": "Selecione tags", "empty_groups": "Nenhuma tag disponível ainda" - } + }, + "compliance": "Conformidade" }, "analytics": { "title": "Análise" @@ -1117,7 +1119,7 @@ "finish": "Concluir", "next": "Próximo", "check": "Verifique", - "auto_graded_badge": "Auto-graded", + "auto_graded_badge": "Auto-grade", "manual_grading_required_badge": "Manual grading required", "points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.", "analytics": { @@ -1697,6 +1699,81 @@ "edit": "Editar", "delete": "Excluir" } + }, + "compliance": { + "title": "Conformidade", + "not_compliance_title": "A conformidade não está ativada", + "not_compliance_description": "Este curso não está configurado como um curso de compliance.", + "loading": "Carregando registros de conformidade...", + "learners": "Alunos", + "learners_description": "Esta tabela mostra o ciclo mais recente de cada aluno.", + "current_cycle": "Ciclo atual", + "current_cycle_description": "Seu ciclo de conformidade ativo e datas de renovação.", + "history": "Ciclos anteriores", + "history_description": "Cada linha é um ciclo de conformidade registrado para este curso.", + "no_active_cycle": "Nenhum ciclo de conformidade ativo", + "empty_history": "Nenhum histórico de conformidade foi registrado ainda.", + "empty_overview": "Nenhum registro de conformidade do aluno está disponível ainda.", + "not_available": "Não disponível", + "status": { + "no_record": "Sem registro", + "not_started": "Não iniciado", + "in_progress": "Em andamento", + "compliant": "Compatível", + "expiring_soon": "Expira em breve", + "in_grace_period": "Em período de carência", + "non_compliant": "Não conforme", + "waived": "Dispensado" + }, + "summary": { + "total_learners": "Total de alunos", + "no_record": "Sem registro", + "not_started": "Não iniciado", + "in_progress": "Em andamento", + "compliant": "Compatível", + "expiring_soon": "Expira em breve", + "in_grace_period": "Em período de carência", + "non_compliant": "Não conforme", + "waived": "Dispensado" + }, + "fields": { + "learner": "Aluno", + "email": "E-mail", + "status": "Estado", + "cycle": "Ciclo", + "due_date": "Data de vencimento", + "completed_at": "Concluído em", + "valid_until": "Válido até", + "score": "Pontuação", + "attempts": "Tentativas" + }, + "bulk_actions": { + "title": "Ações em massa", + "description": "Selecione os alunos e aplique uma ação de conformidade ao seu ciclo atual.", + "select_all": "Selecione todos os alunos visíveis", + "selected_count": "{contagem} selecionados", + "clear_selection": "Limpar seleção", + "due_date_label": "Data de vencimento", + "due_date_helper": "Usado para redefinir e estender ações.", + "reset_action": "Ciclo de redefinição", + "extend_action": "Estender a data de vencimento", + "waive_title": "Dispensar alunos selecionados", + "waive_description": "Aplicar uma isenção aos alunos selecionados. Você pode deixar o vencimento em branco para uma isenção ilimitada.", + "waiver_reason_label": "Motivo da renúncia", + "waiver_reason_helper": "Contexto opcional para auditorias e revisões futuras.", + "waiver_expires_at_label": "A isenção expira em", + "waiver_expires_at_helper": "Deixe em branco se a isenção não expirar automaticamente.", + "waive_action": "Aplicar isenção", + "modal": { + "description": "Você está atualizando {count} alunos selecionados.", + "cancel": "Cancelar", + "apply": "Aplicar ação" + } + }, + "tabs": { + "overview": "Visão geral", + "learners": "Alunos" + } } }, "search": { @@ -1712,6 +1789,11 @@ "content_count": { "lessons": "{contar} aulas", "exercises": "{contar} exercícios" + }, + "compliance": { + "title": "Conformidade", + "due_date": "Devido", + "valid_until": "Válido até" } }, "navItems": { @@ -1724,7 +1806,9 @@ "nav_analytics": "Análise", "nav_certificates": "Certificados", "nav_landing_page": "Página inicial", - "nav_settings": "Configurações" + "nav_settings": "Configurações", + "nav_compliance": "Conformidade", + "nav_ai_assistant": "Assistente" }, "completion": { "modal": { @@ -1812,8 +1896,11 @@ "share": "Compartilhar", "invite": "Convidar", "delete": "Excluir", - "open": "Abrir" - } + "open": "Abrir", + "open_course_site": "Abrir site do curso" + }, + "due_date": "Devido", + "valid_until": "Válido até" }, "course_filter": { "date_created_newest": "Date Created: Newest first", @@ -1865,9 +1952,9 @@ "lms_dashboard_hero": "Hoje é mais um dia para aproximar você de seus objetivos de aprendizagem. Não desista, quanto mais você aprende melhor você fica.", "lessons_completed": "Lições concluídas", "no_courses_started": "Nenhum curso iniciado", - "certificates_earned": "Certificates earned", + "certificates_earned": "Certificates issued", "certificates_earned_description": "Total certificates issued to students in your organization", - "certification_rate": "certification rate", + "certification_rate": "Certificados", "recent_certifications": "Recent certifications", "no_certifications_yet": "No certificates earned yet", "no_certifications_yet_description": "When students complete a course and earn a certificate, they will appear here", @@ -1886,7 +1973,28 @@ "publish_first_course": "Publique seu primeiro curso", "publish_first_course_description": "Para rastrear matrículas, publicar um curso e convidar alunos", "publish_course": "Publicar curso", - "items_completed": "Itens concluídos" + "items_completed": "Itens concluídos", + "currently_learning": "Atualmente aprendendo", + "learner": "Aluno", + "progress": "Progresso", + "pick_up_where_you_left_off": "Continue de onde parou", + "course_awaits_you": "{title} espera por você", + "learning_awaits_you": "Seu próximo curso espera por você", + "lms_today_progress": "Você está {progress}% em seu aprendizado - cada etapa conta.", + "lms_today_empty": "Seu caminho de aprendizagem estará pronto quando você estiver.", + "compliance_score": "Pontuação de Conformidade", + "compliance": "Conformidade", + "compliance_score_subtitle": "{concluído} de {total} cursos de conformidade atualmente satisfeitos", + "compliance_score_empty": "Nenhum curso de compliance atribuído ainda", + "compliance_met": "{concluído} de {total} alcançado", + "required_courses_completed": "{concluído} de {total} cursos obrigatórios concluídos", + "streak": "Sequência", + "days_active": "dias ativos", + "items_done": "{concluído} de {total} concluído", + "login_activity_title": "Atividade de login do aluno", + "login_activity_description": "Dias mais ativos da semana (últimos 90 dias)", + "login_activity_no_data": "Nenhuma atividade de login ainda", + "login_activity_logins": "logins" }, "schedule": { "title": "Sua programação", @@ -2097,7 +2205,8 @@ "upgrade": "Atualize agora", "tags": "Etiquetas", "automation": "Automação", - "programs": "Programas" + "programs": "Programas", + "widgets": "Widgets" }, "media_manager": { "page_title": "Gerenciador de mídia - ClassroomIO", @@ -2218,7 +2327,10 @@ "provider": "Usamos a Polar para ajudar a gerenciar seu faturamento", "open_billing": "Faturamento aberto", "error": "Ocorreu um erro ao abrir o faturamento", - "page_subtitle": "Your plan, billing portal, and how you pay for ClassroomIO." + "page_subtitle": "Your plan, billing portal, and how you pay for ClassroomIO.", + "ai_credits": "Uso do assistente de IA", + "ai_tokens_used": "tokens usados este mês", + "ai_bonus_credits": "créditos de bônus disponíveis" }, "integrations": { "heading": "Telegrama", @@ -2667,9 +2779,34 @@ }, "app": { "search": { - "placeholder": "Pesquisar" + "placeholder": "Pesquisar", + "command_palette": { + "title": "Paleta de comandos", + "description": "Pesquise e navegue pela sua organização", + "placeholder": "Pesquise cursos, programas, widgets, alunos, tags, configurações...", + "org_placeholder": "Pesquise cursos, programas, widgets, alunos, tags, configurações...", + "lms_placeholder": "Pesquise seus cursos, programas e páginas de aprendizagem...", + "lms_description": "Pesquise e navegue no seu espaço de trabalho de aprendizagem", + "empty": "Nenhum resultado encontrado", + "loading": "Procurando...", + "clear_recents": "Limpar recentes", + "result_types": { + "page": "Página" + }, + "groups": { + "recent": "Recente", + "suggested": "Sugerido", + "courses": "Cursos", + "programs": "Programas", + "widgets": "Widgets", + "tags": "Etiquetas", + "students": "Alunos", + "settings": "Configurações" + } + } }, - "cancel": "Cancelar" + "cancel": "Cancelar", + "save": "Salvar" }, "welcome_modal": { "we_at": "Nós em", @@ -2700,7 +2837,9 @@ "live_class_label": "Aula ao vivo", "live_class_subtitle": "Ideal para bootcamps onde as aulas são baseadas no tempo", "self_paced_label": "Individualizado", - "self_paced_subtitle": "Melhor para cursos onde os alunos podem ter aulas em seu próprio ritmo" + "self_paced_subtitle": "Melhor para cursos onde os alunos podem ter aulas em seu próprio ritmo", + "compliance_label": "Conformidade", + "compliance_subtitle": "Treinamento recorrente com repetições, lembretes e acompanhamento de renovação" }, "delete_modal": { "label": "Excluir", @@ -3079,5 +3218,226 @@ "sort_students": "Alunos", "sort_status": "Estado" } + }, + "ai_assistant": { + "empty_state": "Peça-me para ajudar com o conteúdo do seu curso – redigir lições, gerar perguntas ou planejar um curso a partir de um documento.", + "input_placeholder": "Pergunte ao assistente de IA...", + "attach_document": "Anexe um documento (PDF, DOCX, PPTX)", + "upgrade_to_upload": "Atualize para fazer upload de documentos", + "tokens_exhausted": "Limite mensal de tokens de IA atingido. Compre mais créditos em Configurações > Faturamento ou aguarde a redefinição.", + "chat_history": "Histórico de bate-papo", + "no_conversations": "Ainda não há conversas", + "untitled": "Sem título", + "new_chat": "Novo bate-papo", + "tokens_label": "fichas", + "implementing_course_plan": "Implementando Plano de Curso", + "resume": "Currículo", + "stopped_content_kept": "Parado. O conteúdo criado foi mantido.", + "mention_lesson": "Lição", + "mention_exercise": "Exercício", + "mention_section": "Seção", + "mention_no_results": "Nenhum conteúdo correspondente", + "uploading_document": "Carregando documento..." + }, + "widgets": { + "heading": "Widgets", + "subtitle": "Publique coleções de cursos de marca para sites externos.", + "empty_state": "Ainda não há widgets. Crie um para gerar um bloco de curso incorporável.", + "filter": { + "status": "Estado", + "all": "Todos", + "unpublished": "Não publicado", + "course_type": "Tipo de curso" + }, + "notifications": { + "created": "Widget criado", + "saved": "Widget salvo", + "published": "Widget publicado", + "rolled_back": "Widget restaurado", + "deleted": "Widget arquivado" + }, + "form": { + "name": "Nome do widget", + "selection_mode": "Modo de seleção", + "course_search": "Pesquisar cursos", + "layout_type": "Tipo de layout", + "sort_by": "Classificar por", + "cta_label": "Etiqueta de CTA", + "load_more_label": "Carregar mais etiqueta", + "limit_courses": "Limitar cursos", + "max_count": "Contagem máxima de cursos", + "show_course_image": "Mostrar imagem do curso", + "show_type_badge": "Mostrar selo de tipo de curso", + "show_lessons_count": "Mostrar contagem de aulas", + "show_description": "Mostrar trecho da descrição", + "shorten_long_description": "Encurte a descrição longa", + "show_price": "Mostrar preço", + "show_rating": "Mostrar classificação", + "theme_preset": "Predefinição de tema", + "primary_color": "Cor primária", + "background_color": "Cor de fundo", + "text_color": "Cor do texto", + "border_color": "Cor da borda", + "border_radius": "Raio da borda", + "font_family": "Família de fontes", + "font_scale": "Escala de tamanho da fonte", + "show_powered_by": "Mostrar a marca Classroomio", + "custom_css": "CSS personalizado", + "custom_css_locked": "Atualize para ativar CSS personalizado.", + "embed_code": "Incorporar código" + }, + "create": { + "description": "Dê um nome ao seu widget para começar." + }, + "actions": { + "create": "Criar widget", + "edit": "Editar", + "back": "Voltar para widgets", + "save": "Salvar", + "discard": "Descartar alterações", + "publish": "Publicar", + "archive": "Arquivo", + "restore": "Restaurar", + "copy_embed": "Copiar incorporação", + "rename": "Renomear", + "delete": "Excluir" + }, + "preview": { + "heading": "Visualização", + "saved": "Salvo", + "unsaved": "Alterações não salvas", + "load_failed": "A visualização não pôde ser carregada. Tente atualizar a página.", + "empty": "Visualização indisponível.", + "all_tags": "Todos", + "none": "Nenhum", + "lessons_suffix": "lições", + "last_synced": "Sincronizado", + "refresh": "Atualizar", + "exercises_suffix": "exercícios", + "columns": { + "course": "Curso", + "lessons": "Lições", + "tags": "Etiquetas" + } + }, + "plan": { + "free": "Plano grátis", + "paid": "Plano pago" + }, + "status": { + "draft": "Rascunho", + "published": "Publicado", + "archived": "Arquivado", + "live": "Ao vivo" + }, + "sort": { + "manual": "Manuais", + "newest": "Mais recente", + "title": "Título" + }, + "layoutOptions": { + "section": "Opções de layout", + "columns": "Colunas", + "max_count": "Cursos máximos", + "show_rating": "Mostrar classificação", + "tag_source": "Fonte da etiqueta", + "tag_source_auto": "Automático de cursos", + "tag_source_manual": "Seleção manual", + "default_tag": "Etiqueta padrão", + "show_all_option": "Mostrar opção \"Todos\"", + "visible_cards": "Cartões visíveis", + "auto_play": "Reprodução automática", + "auto_play_interval": "Intervalo de reprodução automática (ms)", + "show_dots": "Mostrar pontos", + "show_arrows": "Mostrar setas", + "loop": "Ciclo", + "featured_course": "Curso em destaque", + "featured_course_required": "Um curso em destaque é necessário para este layout.", + "main_course": "Curso principal de destaque", + "main_course_required": "Escolha um curso para destacar.", + "eyebrow_label": "Etiqueta de sobrancelha", + "cta_label": "Etiqueta de CTA", + "secondary_max_count": "Cursos secundários", + "density": "Densidade", + "density_comfortable": "Confortável", + "density_compact": "Compacto", + "show_thumbnail": "Mostrar miniatura", + "show_tags": "Mostrar tags", + "title_style": "Estilo do título", + "title_style_serif": "Serif", + "title_style_sans": "Sem", + "category_tags": "Tags de categoria", + "category_tags_required": "Selecione pelo menos uma tag para a divisória de categorias.", + "default_category": "Categoria padrão", + "show_all_tab": "Mostrar guia \"Todos\"", + "max_per_category": "Máximo de cursos por categoria", + "none": "Nenhum" + }, + "layout_description": { + "card_grid": "Catálogo de uso geral com muito espaço para respirar.", + "tag_filter": "Deixe os alunos restringirem um amplo catálogo por tópico.", + "carousel": "Linhas em destaque em seções restritas da página inicial.", + "primary_course": "Promova um curso emblemático ou recém-lançado.", + "compact_list": "Incorporações de barra lateral e páginas com espaço mínimo.", + "editorial_spotlight": "Páginas de destino em estilo blog e escolhas selecionadas.", + "category_shelf": "Grandes catálogos agrupados por tópico, fáceis de digitalizar." + }, + "layout": { + "card_grid": "Grade de cartão", + "tag_filter": "Filtro de tags", + "carousel": "Carrossel", + "primary_course": "Curso primário", + "compact_list": "Lista compacta", + "editorial_spotlight": "Destaque editorial", + "category_shelf": "Estante de categoria" + }, + "selection": { + "manual": "Seleção manual", + "published": "Todos os cursos publicados" + }, + "editor": { + "sources": "Fonte do widget", + "available_courses": "Cursos Disponíveis", + "layout": "Disposição", + "content": "Conteúdo", + "design": "Projeto", + "version_history": "Histórico de versões", + "save_changes": "Salvar alterações", + "rename_widget": "Renomear widget", + "continue_to_layout": "Continuar para o layout", + "pick_layout": "Escolha um layout", + "pick_design": "Aparência", + "panel_subtitle": "Configure a aparência e o comportamento do seu widget.", + "embed_share": "Incorporar e compartilhar", + "discard_short": "Descartar", + "canvas_status": "Visualização", + "finish_editing_name": "Concluído", + "waiting_org": "Carregando organização…", + "load_failed": "Não foi possível carregar este widget. Tente novamente na lista de widgets.", + "nav": { + "courses": "Cursos", + "layout": "Disposição", + "design": "Projeto", + "embed": "Incorporar" + }, + "viewport": { + "mobile": "Móvel", + "tablet": "Tábua", + "desktop": "Área de trabalho" + } + }, + "tabs": { + "select_courses": "Selecione os cursos", + "layout": "Disposição", + "design": "Projeto", + "embed": "Código incorporado" + }, + "defaults": { + "name_prefix": "Widget do curso" + }, + "empty": { + "heading": "Nenhum widget ainda", + "description": "Crie um widget para gerar um bloco de curso incorporável para sites externos." + } } } diff --git a/apps/dashboard/src/lib/utils/translations/ru.json b/apps/dashboard/src/lib/utils/translations/ru.json index fc0a7063..92ad9a79 100644 --- a/apps/dashboard/src/lib/utils/translations/ru.json +++ b/apps/dashboard/src/lib/utils/translations/ru.json @@ -219,7 +219,8 @@ "complete_marked": "Помечено как завершенное", "complete_download": "Загрузка завершена", "version_restored": "Версия успешно восстановлена" - } + }, + "section_deleted": "Раздел курса успешно удален" }, "attendance": { "error": { @@ -654,7 +655,8 @@ "add": "Добавить тег", "popover_title": "Выберите теги", "empty_groups": "Тегов пока нет" - } + }, + "compliance": "Соответствие" }, "analytics": { "title": "Аналитика" @@ -1117,7 +1119,7 @@ "finish": "Готово", "next": "Далее", "check": "Проверить", - "auto_graded_badge": "Auto-graded", + "auto_graded_badge": "Auto-grade", "manual_grading_required_badge": "Manual grading required", "points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.", "analytics": { @@ -1697,6 +1699,81 @@ "edit": "Редактировать", "delete": "Удалить" } + }, + "compliance": { + "title": "Соответствие", + "not_compliance_title": "Соответствие не включено", + "not_compliance_description": "Этот курс не настроен как курс соответствия.", + "loading": "Загрузка записей о соответствии...", + "learners": "Учащиеся", + "learners_description": "В этой таблице показан последний цикл для каждого учащегося.", + "current_cycle": "Текущий цикл", + "current_cycle_description": "Ваш активный цикл соответствия и даты продления.", + "history": "Прошлые циклы", + "history_description": "Каждая строка представляет собой один записанный цикл соответствия для этого курса.", + "no_active_cycle": "Нет активного цикла соответствия", + "empty_history": "История соответствия еще не зарегистрирована.", + "empty_overview": "Записей о соблюдении требований учащимися пока нет.", + "not_available": "Недоступно", + "status": { + "no_record": "Нет записи", + "not_started": "Не запущено", + "in_progress": "В процессе", + "compliant": "Соответствует", + "expiring_soon": "Срок действия скоро истекает", + "in_grace_period": "В льготный период", + "non_compliant": "Не соответствует", + "waived": "Отказано" + }, + "summary": { + "total_learners": "Всего учащихся", + "no_record": "Нет записи", + "not_started": "Не запущено", + "in_progress": "В процессе", + "compliant": "Соответствует", + "expiring_soon": "Срок действия скоро истекает", + "in_grace_period": "В льготный период", + "non_compliant": "Не соответствует", + "waived": "Отказано" + }, + "fields": { + "learner": "Учащийся", + "email": "электронная почта", + "status": "Статус", + "cycle": "Цикл", + "due_date": "Срок сдачи", + "completed_at": "Завершено в", + "valid_until": "Действительно до", + "score": "Оценка", + "attempts": "Попытки" + }, + "bulk_actions": { + "title": "Массовые действия", + "description": "Выберите учащихся и примените действие по обеспечению соответствия к их текущему циклу.", + "select_all": "Выбрать всех видимых учащихся", + "selected_count": "{count} выбрано", + "clear_selection": "Очистить выбор", + "due_date_label": "Срок сдачи", + "due_date_helper": "Используется для действий сброса и продления.", + "reset_action": "Сброс цикла", + "extend_action": "Продлить срок выполнения", + "waive_title": "Отказаться от участия выбранных учащихся", + "waive_description": "Примените отказ к выбранным учащимся. Вы можете оставить срок действия пустым для бессрочного отказа.", + "waiver_reason_label": "Причина отказа", + "waiver_reason_helper": "Дополнительный контекст для аудитов и будущих проверок.", + "waiver_expires_at_label": "Отказ истекает в", + "waiver_expires_at_helper": "Оставьте пустым, если срок действия отказа не истекает автоматически.", + "waive_action": "Применить отказ", + "modal": { + "description": "Вы обновляете {count} выбранных учащихся.", + "cancel": "Отмена", + "apply": "Применить действие" + } + }, + "tabs": { + "overview": "Обзор", + "learners": "Учащиеся" + } } }, "search": { @@ -1712,6 +1789,11 @@ "content_count": { "lessons": "{count} уроков", "exercises": "{count} упражнений" + }, + "compliance": { + "title": "Соответствие", + "due_date": "Срок погашения", + "valid_until": "Действительно до" } }, "navItems": { @@ -1724,7 +1806,9 @@ "nav_analytics": "Аналитика", "nav_certificates": "Сертификаты", "nav_landing_page": "Целевая страница", - "nav_settings": "Настройки" + "nav_settings": "Настройки", + "nav_compliance": "Соответствие", + "nav_ai_assistant": "Ассистент" }, "completion": { "modal": { @@ -1812,8 +1896,11 @@ "share": "Поделиться", "invite": "Пригласить", "delete": "Удалить", - "open": "Открыть" - } + "open": "Открыть", + "open_course_site": "Открытый сайт курса" + }, + "due_date": "Срок погашения", + "valid_until": "Действительно до" }, "course_filter": { "date_created_newest": "Date Created: Newest first", @@ -1865,9 +1952,9 @@ "lms_dashboard_hero": "Сегодня еще один день, который приблизит вас к вашим целям обучения. Не сдавайтесь, чем больше вы узнаете, тем лучше у вас получится.", "lessons_completed": "Уроки завершены", "no_courses_started": "Курсы не начались", - "certificates_earned": "Certificates earned", + "certificates_earned": "Certificates issued", "certificates_earned_description": "Total certificates issued to students in your organization", - "certification_rate": "certification rate", + "certification_rate": "Сертифицированы", "recent_certifications": "Recent certifications", "no_certifications_yet": "No certificates earned yet", "no_certifications_yet_description": "When students complete a course and earn a certificate, they will appear here", @@ -1886,7 +1973,28 @@ "publish_first_course": "Опубликуйте свой первый курс", "publish_first_course_description": "Чтобы отслеживать зачисления, опубликуйте курс и пригласите студентов.", "publish_course": "Опубликовать курс", - "items_completed": "Объекты завершены" + "items_completed": "Объекты завершены", + "currently_learning": "В настоящее время учусь", + "learner": "Учащийся", + "progress": "Прогресс", + "pick_up_where_you_left_off": "Продолжайте с того места, где вы остановились", + "course_awaits_you": "{title} ждет вас", + "learning_awaits_you": "Следующий курс ждет вас", + "lms_today_progress": "Ваш прогресс в обучении – {progress} %. Каждый шаг имеет значение.", + "lms_today_empty": "Ваш путь обучения готов, когда вы будете готовы.", + "compliance_score": "Оценка соответствия", + "compliance": "Соответствие", + "compliance_score_subtitle": "{completed} из {total} курсов по обеспечению соответствия требованиям, пройденных на данный момент", + "compliance_score_empty": "Курсы соответствия еще не назначены", + "compliance_met": "выполнено {завершено} из {total}", + "required_courses_completed": "Пройдено {completed} из {total} обязательных курсов", + "streak": "Полоса", + "days_active": "дней активности", + "items_done": "{завершено} из {total} выполнено", + "login_activity_title": "Активность входа учащихся", + "login_activity_description": "Самые активные дни недели (последние 90 дней)", + "login_activity_no_data": "Пока нет активности входа", + "login_activity_logins": "логины" }, "schedule": { "title": "Ваше расписание", @@ -2097,7 +2205,8 @@ "upgrade": "Обновите сейчас", "tags": "Теги", "automation": "Автоматизация", - "programs": "Программы" + "programs": "Программы", + "widgets": "Виджеты" }, "media_manager": { "page_title": "Медиа-менеджер — ClassroomIO", @@ -2218,7 +2327,10 @@ "provider": "Мы используем Polar, чтобы управлять вашими платежами.", "open_billing": "Открыть биллинг", "error": "Произошла ошибка при открытии счета.", - "page_subtitle": "Your plan, billing portal, and how you pay for ClassroomIO." + "page_subtitle": "Your plan, billing portal, and how you pay for ClassroomIO.", + "ai_credits": "Использование AI-помощника", + "ai_tokens_used": "жетоны, использованные в этом месяце", + "ai_bonus_credits": "доступны бонусные кредиты" }, "integrations": { "heading": "Телеграмма", @@ -2667,9 +2779,34 @@ }, "app": { "search": { - "placeholder": "Поиск" + "placeholder": "Поиск", + "command_palette": { + "title": "Палитра команд", + "description": "Ищите и перемещайтесь по вашей организации", + "placeholder": "Поиск курсов, программ, виджетов, студентов, тегов, настроек...", + "org_placeholder": "Поиск курсов, программ, виджетов, студентов, тегов, настроек...", + "lms_placeholder": "Выполняйте поиск по своим курсам, программам и страницам обучения...", + "lms_description": "Ищите и перемещайтесь по своему учебному рабочему пространству", + "empty": "Результаты не найдены", + "loading": "Поиск...", + "clear_recents": "Очистить последние", + "result_types": { + "page": "Страница" + }, + "groups": { + "recent": "Недавние", + "suggested": "Предлагается", + "courses": "Курсы", + "programs": "Программы", + "widgets": "Виджеты", + "tags": "Теги", + "students": "Студенты", + "settings": "Настройки" + } + } }, - "cancel": "Отмена" + "cancel": "Отмена", + "save": "Сохранить" }, "welcome_modal": { "we_at": "Мы в", @@ -2700,7 +2837,9 @@ "live_class_label": "Живой урок", "live_class_subtitle": "Идеально подходит для учебных курсов, где уроки основаны на времени", "self_paced_label": "Самостоятельный темп", - "self_paced_subtitle": "Лучше всего подходит для курсов, где студенты могут брать уроки в удобном для них темпе." + "self_paced_subtitle": "Лучше всего подходит для курсов, где студенты могут брать уроки в удобном для них темпе.", + "compliance_label": "Соответствие", + "compliance_subtitle": "Регулярное обучение с повторной сдачей, напоминаниями и отслеживанием продлений" }, "delete_modal": { "label": "Удалить", @@ -3079,5 +3218,226 @@ "sort_students": "Студенты", "sort_status": "Статус" } + }, + "ai_assistant": { + "empty_state": "Попросите меня помочь с содержанием вашего курса: набросайте уроки, сформулируйте вопросы или спланируйте курс на основе документа.", + "input_placeholder": "Спросите ИИ-помощника...", + "attach_document": "Прикрепите документ (PDF, DOCX, PPTX)", + "upgrade_to_upload": "Обновите, чтобы загружать документы", + "tokens_exhausted": "Достигнут месячный лимит токенов AI. Купите больше кредитов в «Настройки» > «Оплата» или дождитесь сброса.", + "chat_history": "История чата", + "no_conversations": "Пока нет разговоров", + "untitled": "Без названия", + "new_chat": "Новый чат", + "tokens_label": "жетоны", + "implementing_course_plan": "Реализация плана курса", + "resume": "Резюме", + "stopped_content_kept": "Остановился. Созданный контент сохранен.", + "mention_lesson": "Урок", + "mention_exercise": "Упражнение", + "mention_section": "Раздел", + "mention_no_results": "Нет подходящего контента", + "uploading_document": "Загрузка документа..." + }, + "widgets": { + "heading": "Виджеты", + "subtitle": "Публикуйте фирменные коллекции курсов для внешних веб-сайтов.", + "empty_state": "Виджетов пока нет. Создайте его, чтобы создать встраиваемый блок курса.", + "filter": { + "status": "Статус", + "all": "Все", + "unpublished": "Неопубликовано", + "course_type": "Тип курса" + }, + "notifications": { + "created": "Виджет создан", + "saved": "Виджет сохранен.", + "published": "Виджет опубликован", + "rolled_back": "Виджет восстановлен", + "deleted": "Виджет заархивирован" + }, + "form": { + "name": "Название виджета", + "selection_mode": "Режим выбора", + "course_search": "Поиск курсов", + "layout_type": "Тип макета", + "sort_by": "Сортировать по", + "cta_label": "Метка призыва к действию", + "load_more_label": "Загрузить еще этикетку", + "limit_courses": "Ограничить курсы", + "max_count": "Максимальное количество курсов", + "show_course_image": "Показать изображение курса", + "show_type_badge": "Показать значок типа курса", + "show_lessons_count": "Показать количество уроков", + "show_description": "Показать отрывок из описания", + "shorten_long_description": "Сократить длинное описание", + "show_price": "Показать цену", + "show_rating": "Показать рейтинг", + "theme_preset": "Предустановка темы", + "primary_color": "Основной цвет", + "background_color": "Цвет фона", + "text_color": "Цвет текста", + "border_color": "Цвет границы", + "border_radius": "Радиус границы", + "font_family": "Семейство шрифтов", + "font_scale": "Шкала размера шрифта", + "show_powered_by": "Показать брендинг Classroomio", + "custom_css": "Пользовательский CSS", + "custom_css_locked": "Обновите, чтобы включить пользовательский CSS.", + "embed_code": "Встроить код" + }, + "create": { + "description": "Дайте вашему виджету имя, чтобы начать." + }, + "actions": { + "create": "Создать виджет", + "edit": "Редактировать", + "back": "Вернуться к виджетам", + "save": "Сохранить", + "discard": "Отменить изменения", + "publish": "Опубликовать", + "archive": "Архив", + "restore": "Восстановить", + "copy_embed": "Копировать встроить", + "rename": "Переименовать", + "delete": "Удалить" + }, + "preview": { + "heading": "Предварительный просмотр", + "saved": "Сохранено", + "unsaved": "Несохраненные изменения", + "load_failed": "Не удалось загрузить предварительный просмотр. Попробуйте обновить страницу.", + "empty": "Предварительный просмотр недоступен.", + "all_tags": "Все", + "none": "Нет", + "lessons_suffix": "уроки", + "last_synced": "Синхронизировано", + "refresh": "Обновить", + "exercises_suffix": "упражнения", + "columns": { + "course": "Курс", + "lessons": "Уроки", + "tags": "Теги" + } + }, + "plan": { + "free": "Бесплатный план", + "paid": "Платный план" + }, + "status": { + "draft": "Черновик", + "published": "Опубликовано", + "archived": "В архиве", + "live": "Живи" + }, + "sort": { + "manual": "Руководство", + "newest": "Новейший", + "title": "Название" + }, + "layoutOptions": { + "section": "Варианты макета", + "columns": "Столбцы", + "max_count": "Макс курсы", + "show_rating": "Показать рейтинг", + "tag_source": "Источник тега", + "tag_source_auto": "Авто с курсов", + "tag_source_manual": "Ручной выбор", + "default_tag": "Тег по умолчанию", + "show_all_option": "Показать опцию «Все»", + "visible_cards": "Видимые карты", + "auto_play": "Автоматическое воспроизведение", + "auto_play_interval": "Интервал автоматического воспроизведения (мс)", + "show_dots": "Показать точки", + "show_arrows": "Показать стрелки", + "loop": "Петля", + "featured_course": "Рекомендуемый курс", + "featured_course_required": "Для этого макета требуется рекомендуемый курс.", + "main_course": "Основной курс", + "main_course_required": "Выберите курс, на который стоит обратить внимание.", + "eyebrow_label": "Этикетка для бровей", + "cta_label": "Метка призыва к действию", + "secondary_max_count": "Средние курсы", + "density": "Плотность", + "density_comfortable": "Удобный", + "density_compact": "Компактный", + "show_thumbnail": "Показать миниатюру", + "show_tags": "Показать теги", + "title_style": "Стиль заголовка", + "title_style_serif": "с засечками", + "title_style_sans": "Санс", + "category_tags": "Теги категорий", + "category_tags_required": "Выберите хотя бы один тег для полки категории.", + "default_category": "Категория по умолчанию", + "show_all_tab": "Показать вкладку «Все»", + "max_per_category": "Максимальное количество курсов по категории", + "none": "Нет" + }, + "layout_description": { + "card_grid": "Каталог общего назначения с большим пространством для передышки.", + "tag_filter": "Позвольте учащимся сузить широкий каталог по темам.", + "carousel": "Избранные строки в узких разделах главной страницы.", + "primary_course": "Продвигайте флагманский или недавно запущенный курс.", + "compact_list": "Встраивание боковой панели и страницы минимального размера.", + "editorial_spotlight": "Целевые страницы в стиле блога и тщательно подобранные подборки.", + "category_shelf": "Большие каталоги, сгруппированные по темам, легко сканируются." + }, + "layout": { + "card_grid": "Сетка карточек", + "tag_filter": "Фильтр тегов", + "carousel": "Карусель", + "primary_course": "Начальный курс", + "compact_list": "Компактный список", + "editorial_spotlight": "В центре внимания редакции", + "category_shelf": "Категория полка" + }, + "selection": { + "manual": "Ручной выбор", + "published": "Все опубликованные курсы" + }, + "editor": { + "sources": "Источник виджета", + "available_courses": "Доступные курсы", + "layout": "Макет", + "content": "Содержание", + "design": "Дизайн", + "version_history": "История версий", + "save_changes": "Сохранить изменения", + "rename_widget": "Переименовать виджет", + "continue_to_layout": "Продолжить оформление", + "pick_layout": "Выберите макет", + "pick_design": "Внешний вид", + "panel_subtitle": "Настройте внешний вид и поведение вашего виджета.", + "embed_share": "Встроить и поделиться", + "discard_short": "Отбросить", + "canvas_status": "Предварительный просмотр", + "finish_editing_name": "Готово", + "waiting_org": "Загрузка организации…", + "load_failed": "Не удалось загрузить этот виджет. Попробуйте еще раз из списка виджетов.", + "nav": { + "courses": "Курсы", + "layout": "Макет", + "design": "Дизайн", + "embed": "Встроить" + }, + "viewport": { + "mobile": "мобильный", + "tablet": "Таблетка", + "desktop": "Рабочий стол" + } + }, + "tabs": { + "select_courses": "Выберите курсы", + "layout": "Макет", + "design": "Дизайн", + "embed": "Встроить код" + }, + "defaults": { + "name_prefix": "Виджет курса" + }, + "empty": { + "heading": "Виджетов пока нет", + "description": "Создайте виджет для создания встраиваемого блока курса для внешних веб-сайтов." + } } } diff --git a/apps/dashboard/src/lib/utils/translations/vi.json b/apps/dashboard/src/lib/utils/translations/vi.json index fdefb198..8417e921 100644 --- a/apps/dashboard/src/lib/utils/translations/vi.json +++ b/apps/dashboard/src/lib/utils/translations/vi.json @@ -219,7 +219,8 @@ "complete_marked": "Được đánh dấu là hoàn thành", "complete_download": "Tải xuống hoàn tất", "version_restored": "Đã khôi phục phiên bản thành công" - } + }, + "section_deleted": "Phần khóa học đã được xóa thành công" }, "attendance": { "error": { @@ -654,7 +655,8 @@ "add": "Thêm thẻ", "popover_title": "Chọn thẻ", "empty_groups": "Chưa có thẻ nào" - } + }, + "compliance": "Tuân thủ" }, "analytics": { "title": "Phân tích" @@ -1117,7 +1119,7 @@ "finish": "Kết thúc", "next": "Tiếp theo", "check": "Kiểm tra", - "auto_graded_badge": "Auto-graded", + "auto_graded_badge": "Auto-grade", "manual_grading_required_badge": "Manual grading required", "points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.", "analytics": { @@ -1697,6 +1699,81 @@ "edit": "Chỉnh sửa", "delete": "Xóa" } + }, + "compliance": { + "title": "Tuân thủ", + "not_compliance_title": "Tuân thủ chưa được kích hoạt", + "not_compliance_description": "Khóa học này không được cấu hình như một khóa học tuân thủ.", + "loading": "Đang tải hồ sơ tuân thủ...", + "learners": "Người học", + "learners_description": "Bảng này hiển thị chu kỳ mới nhất cho mỗi người học.", + "current_cycle": "Chu kỳ hiện tại", + "current_cycle_description": "Chu kỳ tuân thủ hiện hoạt và ngày gia hạn của bạn.", + "history": "Chu kỳ trước", + "history_description": "Mỗi hàng là một chu kỳ tuân thủ được ghi lại cho khóa học này.", + "no_active_cycle": "Không có chu kỳ tuân thủ tích cực", + "empty_history": "Chưa có lịch sử tuân thủ nào được ghi lại.", + "empty_overview": "Chưa có hồ sơ tuân thủ của người học.", + "not_available": "Không có sẵn", + "status": { + "no_record": "Không có hồ sơ", + "not_started": "Chưa bắt đầu", + "in_progress": "Đang tiến hành", + "compliant": "tuân thủ", + "expiring_soon": "Sắp hết hạn", + "in_grace_period": "Đang trong thời gian ân hạn", + "non_compliant": "Không tuân thủ", + "waived": "Miễn trừ" + }, + "summary": { + "total_learners": "Tổng số người học", + "no_record": "Không có hồ sơ", + "not_started": "Chưa bắt đầu", + "in_progress": "Đang tiến hành", + "compliant": "tuân thủ", + "expiring_soon": "Sắp hết hạn", + "in_grace_period": "Đang trong thời gian ân hạn", + "non_compliant": "Không tuân thủ", + "waived": "Miễn trừ" + }, + "fields": { + "learner": "Người học", + "email": "Email", + "status": "Trạng thái", + "cycle": "chu kỳ", + "due_date": "Ngày đến hạn", + "completed_at": "Hoàn thành lúc", + "valid_until": "Có hiệu lực cho đến khi", + "score": "Điểm", + "attempts": "Nỗ lực" + }, + "bulk_actions": { + "title": "Hành động hàng loạt", + "description": "Chọn người học và áp dụng hành động tuân thủ cho chu kỳ hiện tại của họ.", + "select_all": "Chọn tất cả những người học có thể nhìn thấy", + "selected_count": "đã chọn {count}", + "clear_selection": "Xóa lựa chọn", + "due_date_label": "Ngày đến hạn", + "due_date_helper": "Được sử dụng để thiết lập lại và mở rộng hành động.", + "reset_action": "Đặt lại chu kỳ", + "extend_action": "Gia hạn ngày đáo hạn", + "waive_title": "Từ bỏ những người học đã chọn", + "waive_description": "Áp dụng sự miễn trừ cho những người học đã chọn. Bạn có thể để trống thời hạn hết hạn đối với quyền miễn trừ có thời hạn.", + "waiver_reason_label": "Lý do miễn trừ", + "waiver_reason_helper": "Bối cảnh tùy chọn cho việc kiểm tra và xem xét trong tương lai.", + "waiver_expires_at_label": "Miễn trừ hết hạn vào", + "waiver_expires_at_helper": "Để trống nếu quyền miễn trừ không tự động hết hạn.", + "waive_action": "Áp dụng miễn trừ", + "modal": { + "description": "Bạn đang cập nhật {count} học viên đã chọn.", + "cancel": "Hủy bỏ", + "apply": "Áp dụng hành động" + } + }, + "tabs": { + "overview": "Tổng quan", + "learners": "Người học" + } } }, "search": { @@ -1712,6 +1789,11 @@ "content_count": { "lessons": "{đếm} bài học", "exercises": "bài tập {đếm}" + }, + "compliance": { + "title": "Tuân thủ", + "due_date": "Đến hạn", + "valid_until": "Có hiệu lực cho đến khi" } }, "navItems": { @@ -1724,7 +1806,9 @@ "nav_analytics": "Phân tích", "nav_certificates": "Chứng chỉ", "nav_landing_page": "Trang đích", - "nav_settings": "Cài đặt" + "nav_settings": "Cài đặt", + "nav_compliance": "Tuân thủ", + "nav_ai_assistant": "Trợ lý" }, "completion": { "modal": { @@ -1812,8 +1896,11 @@ "share": "Chia sẻ", "invite": "Mời", "delete": "Xóa", - "open": "Mở" - } + "open": "Mở", + "open_course_site": "Mở trang web khóa học" + }, + "due_date": "Đến hạn", + "valid_until": "Có hiệu lực cho đến khi" }, "course_filter": { "date_created_newest": "Date Created: Newest first", @@ -1865,9 +1952,9 @@ "lms_dashboard_hero": "Hôm nay là một ngày khác để đưa bạn đến gần hơn với mục tiêu học tập của mình. Đừng bỏ cuộc, bạn càng học nhiều thì bạn càng tiến bộ hơn.", "lessons_completed": "Bài học đã hoàn thành", "no_courses_started": "Không có khóa học nào bắt đầu", - "certificates_earned": "Certificates earned", + "certificates_earned": "Certificates issued", "certificates_earned_description": "Total certificates issued to students in your organization", - "certification_rate": "certification rate", + "certification_rate": "Đã chứng nhận", "recent_certifications": "Recent certifications", "no_certifications_yet": "No certificates earned yet", "no_certifications_yet_description": "When students complete a course and earn a certificate, they will appear here", @@ -1886,7 +1973,28 @@ "publish_first_course": "Xuất bản khóa học đầu tiên của bạn", "publish_first_course_description": "Để theo dõi việc ghi danh, xuất bản một khóa học và mời sinh viên", "publish_course": "Xuất bản khóa học", - "items_completed": "Các hạng mục đã hoàn thành" + "items_completed": "Các hạng mục đã hoàn thành", + "currently_learning": "Hiện đang học", + "learner": "Người học", + "progress": "Tiến độ", + "pick_up_where_you_left_off": "Tiếp tục nơi bạn đã dừng lại", + "course_awaits_you": "{title} đang chờ bạn", + "learning_awaits_you": "Khóa học tiếp theo đang chờ bạn", + "lms_today_progress": "Bạn đã đạt được {tiến bộ}% trong quá trình học tập - mỗi bước đều có giá trị.", + "lms_today_empty": "Con đường học tập của bạn đã sẵn sàng khi bạn sẵn sàng.", + "compliance_score": "Điểm tuân thủ", + "compliance": "Tuân thủ", + "compliance_score_subtitle": "{completed} trong số {total} khóa học tuân thủ hiện đã được đáp ứng", + "compliance_score_empty": "Chưa có khóa học tuân thủ nào được chỉ định", + "compliance_met": "{hoàn thành} trong tổng số {tổng} đã đáp ứng", + "required_courses_completed": "Đã hoàn thành {completed} trong số {total} khóa học bắt buộc", + "streak": "Vệt", + "days_active": "ngày hoạt động", + "items_done": "{đã hoàn thành} trong tổng số {tổng} đã hoàn thành", + "login_activity_title": "Hoạt động đăng nhập của sinh viên", + "login_activity_description": "Những ngày hoạt động tích cực nhất trong tuần (90 ngày qua)", + "login_activity_no_data": "Chưa có hoạt động đăng nhập", + "login_activity_logins": "thông tin đăng nhập" }, "schedule": { "title": "Lịch trình của bạn", @@ -2097,7 +2205,8 @@ "upgrade": "Nâng cấp ngay", "tags": "Thẻ", "automation": "Tự động hóa", - "programs": "Chương trình" + "programs": "Chương trình", + "widgets": "Widget" }, "media_manager": { "page_title": "Quản lý truyền thông - ClassroomIO", @@ -2218,7 +2327,10 @@ "provider": "Chúng tôi sử dụng Polar để giúp quản lý việc thanh toán của bạn", "open_billing": "Thanh toán mở", "error": "Đã xảy ra lỗi khi mở thanh toán", - "page_subtitle": "Your plan, billing portal, and how you pay for ClassroomIO." + "page_subtitle": "Your plan, billing portal, and how you pay for ClassroomIO.", + "ai_credits": "Cách sử dụng trợ lý AI", + "ai_tokens_used": "mã thông báo được sử dụng trong tháng này", + "ai_bonus_credits": "tín dụng thưởng có sẵn" }, "integrations": { "heading": "điện tín", @@ -2667,9 +2779,34 @@ }, "app": { "search": { - "placeholder": "Tìm kiếm" + "placeholder": "Tìm kiếm", + "command_palette": { + "title": "Bảng lệnh", + "description": "Tìm kiếm và điều hướng trong tổ chức của bạn", + "placeholder": "Tìm kiếm các khóa học, chương trình, widget, sinh viên, thẻ, cài đặt...", + "org_placeholder": "Tìm kiếm các khóa học, chương trình, widget, sinh viên, thẻ, cài đặt...", + "lms_placeholder": "Tìm kiếm các khóa học, chương trình và trang học tập của bạn...", + "lms_description": "Tìm kiếm và điều hướng không gian học tập của bạn", + "empty": "Không tìm thấy kết quả nào", + "loading": "Đang tìm kiếm...", + "clear_recents": "Xóa gần đây", + "result_types": { + "page": "Trang" + }, + "groups": { + "recent": "Gần đây", + "suggested": "được đề xuất", + "courses": "Khóa học", + "programs": "Chương trình", + "widgets": "Widget", + "tags": "Thẻ", + "students": "Sinh viên", + "settings": "Cài đặt" + } + } }, - "cancel": "Hủy bỏ" + "cancel": "Hủy bỏ", + "save": "Lưu" }, "welcome_modal": { "we_at": "Chúng tôi ở", @@ -2700,7 +2837,9 @@ "live_class_label": "Lớp học trực tiếp", "live_class_subtitle": "Lý tưởng cho các chương trình đào tạo nơi các bài học dựa trên thời gian", "self_paced_label": "tự nhịp độ", - "self_paced_subtitle": "Tốt nhất cho các khóa học mà sinh viên có thể học theo tốc độ của riêng mình" + "self_paced_subtitle": "Tốt nhất cho các khóa học mà sinh viên có thể học theo tốc độ của riêng mình", + "compliance_label": "Tuân thủ", + "compliance_subtitle": "Đào tạo định kỳ với tính năng thi lại, nhắc nhở và theo dõi gia hạn" }, "delete_modal": { "label": "Xóa", @@ -3079,5 +3218,226 @@ "sort_students": "Sinh viên", "sort_status": "Trạng thái" } + }, + "ai_assistant": { + "empty_state": "Hãy yêu cầu tôi trợ giúp về nội dung khóa học của bạn — soạn thảo bài học, tạo câu hỏi hoặc lên kế hoạch cho khóa học từ một tài liệu.", + "input_placeholder": "Hãy hỏi trợ lý AI...", + "attach_document": "Đính kèm tài liệu (PDF, DOCX, PPTX)", + "upgrade_to_upload": "Nâng cấp để tải lên tài liệu", + "tokens_exhausted": "Đã đạt đến giới hạn mã thông báo AI hàng tháng. Mua thêm tín dụng trong Cài đặt > Thanh toán hoặc đợi đặt lại.", + "chat_history": "Lịch sử trò chuyện", + "no_conversations": "Chưa có cuộc trò chuyện nào", + "untitled": "Không có tiêu đề", + "new_chat": "Trò chuyện mới", + "tokens_label": "mã thông báo", + "implementing_course_plan": "Thực hiện kế hoạch khóa học", + "resume": "Tiếp tục", + "stopped_content_kept": "Đã dừng lại. Nội dung được tạo đã được giữ lại.", + "mention_lesson": "Bài học", + "mention_exercise": "tập thể dục", + "mention_section": "Phần", + "mention_no_results": "Không có nội dung phù hợp", + "uploading_document": "Đang tải tài liệu lên..." + }, + "widgets": { + "heading": "Widget", + "subtitle": "Xuất bản bộ sưu tập khóa học có thương hiệu cho các trang web bên ngoài.", + "empty_state": "Chưa có vật dụng nào. Tạo một cái để tạo khối khóa học có thể nhúng.", + "filter": { + "status": "Trạng thái", + "all": "Tất cả", + "unpublished": "Chưa được xuất bản", + "course_type": "Loại khóa học" + }, + "notifications": { + "created": "Đã tạo tiện ích", + "saved": "Đã lưu tiện ích", + "published": "Đã xuất bản tiện ích", + "rolled_back": "Đã khôi phục tiện ích", + "deleted": "Đã lưu trữ tiện ích" + }, + "form": { + "name": "Tên tiện ích", + "selection_mode": "Chế độ lựa chọn", + "course_search": "Tìm kiếm khóa học", + "layout_type": "Kiểu bố cục", + "sort_by": "Sắp xếp theo", + "cta_label": "Nhãn CTA", + "load_more_label": "Tải thêm nhãn", + "limit_courses": "Giới hạn các khóa học", + "max_count": "Số khóa học tối đa", + "show_course_image": "Hiển thị hình ảnh khóa học", + "show_type_badge": "Hiển thị huy hiệu loại khóa học", + "show_lessons_count": "Hiển thị số bài học", + "show_description": "Hiển thị đoạn trích mô tả", + "shorten_long_description": "Rút ngắn mô tả dài", + "show_price": "Hiển thị giá", + "show_rating": "Hiển thị xếp hạng", + "theme_preset": "Đặt trước chủ đề", + "primary_color": "Màu cơ bản", + "background_color": "Màu nền", + "text_color": "Màu văn bản", + "border_color": "Màu viền", + "border_radius": "Bán kính viền", + "font_family": "Họ phông chữ", + "font_scale": "Tỷ lệ cỡ chữ", + "show_powered_by": "Hiển thị nhãn hiệu Classroomio", + "custom_css": "CSS tùy chỉnh", + "custom_css_locked": "Nâng cấp để kích hoạt CSS tùy chỉnh.", + "embed_code": "Nhúng mã" + }, + "create": { + "description": "Đặt tên cho tiện ích của bạn để bắt đầu." + }, + "actions": { + "create": "Tạo tiện ích", + "edit": "Chỉnh sửa", + "back": "Quay lại tiện ích", + "save": "Lưu", + "discard": "Hủy thay đổi", + "publish": "Xuất bản", + "archive": "Lưu trữ", + "restore": "Khôi phục", + "copy_embed": "Sao chép nhúng", + "rename": "Đổi tên", + "delete": "Xóa" + }, + "preview": { + "heading": "Xem trước", + "saved": "Đã lưu", + "unsaved": "Những thay đổi chưa được lưu", + "load_failed": "Không thể tải bản xem trước. Hãy thử làm mới trang.", + "empty": "Xem trước không có sẵn.", + "all_tags": "Tất cả", + "none": "không có", + "lessons_suffix": "bài học", + "last_synced": "Đã đồng bộ hóa", + "refresh": "Làm mới", + "exercises_suffix": "bài tập", + "columns": { + "course": "Khóa học", + "lessons": "Bài học", + "tags": "Thẻ" + } + }, + "plan": { + "free": "Gói miễn phí", + "paid": "Gói trả phí" + }, + "status": { + "draft": "bản nháp", + "published": "Đã xuất bản", + "archived": "Đã lưu trữ", + "live": "Trực tiếp" + }, + "sort": { + "manual": "Hướng dẫn sử dụng", + "newest": "Mới nhất", + "title": "Tiêu đề" + }, + "layoutOptions": { + "section": "Tùy chọn bố cục", + "columns": "Cột", + "max_count": "Khóa học tối đa", + "show_rating": "Hiển thị xếp hạng", + "tag_source": "Nguồn thẻ", + "tag_source_auto": "Tự động từ các khóa học", + "tag_source_manual": "Lựa chọn thủ công", + "default_tag": "Thẻ mặc định", + "show_all_option": "Hiển thị tùy chọn \"Tất cả\"", + "visible_cards": "Thẻ hiển thị", + "auto_play": "Tự động phát", + "auto_play_interval": "Khoảng thời gian phát tự động (ms)", + "show_dots": "Hiển thị dấu chấm", + "show_arrows": "Hiển thị mũi tên", + "loop": "Vòng lặp", + "featured_course": "Khóa học nổi bật", + "featured_course_required": "Một khóa học đặc trưng là cần thiết cho cách bố trí này.", + "main_course": "Khóa học nổi bật chính", + "main_course_required": "Chọn một khóa học để làm nổi bật.", + "eyebrow_label": "Nhãn lông mày", + "cta_label": "Nhãn CTA", + "secondary_max_count": "Các khóa học trung học", + "density": "Mật độ", + "density_comfortable": "Thoải mái", + "density_compact": "Nhỏ gọn", + "show_thumbnail": "Hiển thị hình thu nhỏ", + "show_tags": "Hiển thị thẻ", + "title_style": "Kiểu tiêu đề", + "title_style_serif": "Serif", + "title_style_sans": "không có", + "category_tags": "Thẻ danh mục", + "category_tags_required": "Chọn ít nhất một thẻ cho giá danh mục.", + "default_category": "Danh mục mặc định", + "show_all_tab": "Hiển thị tab \"Tất cả\"", + "max_per_category": "Các khóa học tối đa cho mỗi danh mục", + "none": "không có" + }, + "layout_description": { + "card_grid": "Danh mục mục đích chung với nhiều không gian thở.", + "tag_filter": "Hãy để học sinh thu hẹp một danh mục rộng theo chủ đề.", + "carousel": "Các hàng nổi bật trong phần trang chủ chặt chẽ.", + "primary_course": "Quảng bá một khóa học hàng đầu hoặc mới ra mắt.", + "compact_list": "Nhúng thanh bên và các trang có dấu chân tối thiểu.", + "editorial_spotlight": "Các trang đích theo phong cách blog và các lựa chọn được tuyển chọn.", + "category_shelf": "Danh mục lớn được nhóm theo chủ đề, dễ quét." + }, + "layout": { + "card_grid": "Lưới thẻ", + "tag_filter": "Bộ lọc thẻ", + "carousel": "băng chuyền", + "primary_course": "Khóa học sơ cấp", + "compact_list": "Danh sách thu gọn", + "editorial_spotlight": "Tiêu điểm biên tập", + "category_shelf": "kệ chuyên mục" + }, + "selection": { + "manual": "Lựa chọn thủ công", + "published": "Tất cả các khóa học đã xuất bản" + }, + "editor": { + "sources": "Nguồn tiện ích", + "available_courses": "Các khóa học có sẵn", + "layout": "Bố cục", + "content": "Nội dung", + "design": "Thiết kế", + "version_history": "Lịch sử phiên bản", + "save_changes": "Lưu thay đổi", + "rename_widget": "Đổi tên tiện ích", + "continue_to_layout": "Tiếp tục bố cục", + "pick_layout": "Chọn bố cục", + "pick_design": "Ngoại hình", + "panel_subtitle": "Định cấu hình giao diện và hoạt động của tiện ích của bạn.", + "embed_share": "Nhúng và chia sẻ", + "discard_short": "Loại bỏ", + "canvas_status": "Xem trước", + "finish_editing_name": "Xong", + "waiting_org": "Đang tải tổ chức…", + "load_failed": "Không thể tải tiện ích này. Hãy thử lại từ danh sách tiện ích.", + "nav": { + "courses": "Khóa học", + "layout": "Bố cục", + "design": "Thiết kế", + "embed": "Nhúng" + }, + "viewport": { + "mobile": "Điện thoại di động", + "tablet": "Máy tính bảng", + "desktop": "Máy tính để bàn" + } + }, + "tabs": { + "select_courses": "Chọn khóa học", + "layout": "Bố cục", + "design": "Thiết kế", + "embed": "Mã nhúng" + }, + "defaults": { + "name_prefix": "Tiện ích khóa học" + }, + "empty": { + "heading": "Chưa có tiện ích nào", + "description": "Tạo một tiện ích để tạo khối khóa học có thể nhúng cho các trang web bên ngoài." + } } } diff --git a/apps/dashboard/src/routes/(app)/+layout.svelte b/apps/dashboard/src/routes/(app)/+layout.svelte index 86fbc189..eb36241e 100644 --- a/apps/dashboard/src/routes/(app)/+layout.svelte +++ b/apps/dashboard/src/routes/(app)/+layout.svelte @@ -2,16 +2,10 @@ import { page } from '$app/state'; import { UpgradeModal, PageLoadProgress, PageRestricted } from '$features/ui'; - import { user } from '$lib/utils/store/user'; - import { setTheme } from '$lib/utils/functions/theme'; - import { setupCloudAnalytics } from '$lib/utils/functions/appSetup'; - import { globalStore } from '$lib/utils/store/app'; - import { currentOrg } from '$lib/utils/store/org'; - import { appInitApi } from '$features/app/init.svelte'; - import { onMount } from 'svelte'; - import { authClient } from '$lib/utils/services/auth/client'; + import { CommandPalette, KeyboardShortcutListener } from '$features/search'; import { isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute'; - import { env } from '$env/dynamic/public'; + import { currentOrg } from '$lib/utils/store/org'; + import { authClient } from '$lib/utils/services/auth/client'; interface Props { children?: import('svelte').Snippet; @@ -28,79 +22,28 @@ let path = $derived(page.url.pathname); - function intialAppSetup() { - setupCloudAnalytics(); - - if (data?.locals?.user) { - user.set({ - ...$user, - isLoggedIn: true, - currentSession: data.locals.user - }); - } - - if (!data.isOrgSite || !data.org) return; - - $globalStore.orgSiteName = data.orgSiteName || ''; - $globalStore.isOrgSite = data.isOrgSite; - - currentOrg.set(data.org); - - const theme = data.org?.theme; - setTheme(theme || 'blue'); - } - - onMount(() => { - const loadingIndicator = document.getElementById('app-loading-indicator'); - if (loadingIndicator) { - loadingIndicator.style.display = 'none'; - } - - intialAppSetup(); - - if (data.skipAuth) return; - }); - const session = authClient.useSession(); - const isSessionReady = $derived(!$session.isPending && !$session.isRefetching && $session.data); $effect(() => { if ($session.isPending || $session.isRefetching || !!$session.data) { return; } + if (data.skipAuth) return; + if (isPublicRoute(path) && (path !== '/' || data.isOrgSite)) { return; } if (!$session.data && !path.startsWith('/login')) { - console.log('session data is not available, go to login'); window.location.href = '/login'; } }); - - $effect(() => { - if (!data?.locals?.fromSessions && isSessionReady) { - authClient.getSession().then(() => { - console.log('triggered new session'); - }); - } - }); - - $effect(() => { - console.log('env public server url', env.PUBLIC_SERVER_URL); - console.log('env public is selfhosted', env.PUBLIC_IS_SELFHOSTED); - if (isSessionReady && !appInitApi.isInitializedAndReady && !appInitApi.loading) { - appInitApi.setupApp($session.data as App.Locals, { - isOrgSite: data.isOrgSite, - orgSiteName: data.orgSiteName - }); - } - }); - + + {#if data.org?.isRestricted || $currentOrg.isRestricted} diff --git a/apps/dashboard/src/routes/(app)/courses/[id]/+layout.svelte b/apps/dashboard/src/routes/(app)/courses/[id]/+layout.svelte index d699b919..87e98eb5 100644 --- a/apps/dashboard/src/routes/(app)/courses/[id]/+layout.svelte +++ b/apps/dashboard/src/routes/(app)/courses/[id]/+layout.svelte @@ -1,4 +1,5 @@ @@ -91,12 +142,21 @@ {/if} - - + + - + @@ -119,4 +179,6 @@ {@render children?.()} {/if} + + diff --git a/apps/dashboard/src/routes/(app)/courses/[id]/compliance/+page.svelte b/apps/dashboard/src/routes/(app)/courses/[id]/compliance/+page.svelte new file mode 100644 index 00000000..418cab73 --- /dev/null +++ b/apps/dashboard/src/routes/(app)/courses/[id]/compliance/+page.svelte @@ -0,0 +1,33 @@ + + + + + + + {$t('course.navItem.compliance.title')} + + + + + + + + {#snippet child()} + + {/snippet} + + diff --git a/apps/dashboard/src/routes/(app)/courses/[id]/compliance/+page.ts b/apps/dashboard/src/routes/(app)/courses/[id]/compliance/+page.ts new file mode 100644 index 00000000..f9f261ad --- /dev/null +++ b/apps/dashboard/src/routes/(app)/courses/[id]/compliance/+page.ts @@ -0,0 +1,5 @@ +export const load = ({ params }) => { + return { + courseId: params.id || '' + }; +}; diff --git a/apps/dashboard/src/routes/(app)/courses/[id]/exercises/[exerciseId]/+page.svelte b/apps/dashboard/src/routes/(app)/courses/[id]/exercises/[exerciseId]/+page.svelte index b439b6b9..92e18e8c 100644 --- a/apps/dashboard/src/routes/(app)/courses/[id]/exercises/[exerciseId]/+page.svelte +++ b/apps/dashboard/src/routes/(app)/courses/[id]/exercises/[exerciseId]/+page.svelte @@ -6,20 +6,10 @@ import LockIcon from '@lucide/svelte/icons/lock'; import * as Page from '@cio/ui/base/page'; import { ExercisePage } from '$features/course/pages'; - import { - questionnaire, - questionnaireMetaData, - clearQuestionnaireValidation, - reset - } from '$features/course/components/exercise/store'; + import { questionnaireMetaData, reset } from '$features/course/components/exercise/store'; import { isOrgStudent } from '$lib/utils/store/app'; import { t } from '$lib/utils/functions/translations'; - import type { Question } from '$features/course/types'; - import { - getQuestionTypeId, - getQuestionTypeOptionById - } from '$features/course/components/exercise/question-type-utils'; - import { normalizeQuestionOrder } from '$features/course/components/exercise/order-utils'; + import { hydrateExercisePageData } from '$features/course/utils/exercise-page-utils'; let { data = $bindable() } = $props(); @@ -37,36 +27,7 @@ reset(); } - clearQuestionnaireValidation(); - - const exercise = data.exercise; - - // Transform questions and set questionnaire store - let questions: Question[] = []; - if (exercise.questions && Array.isArray(exercise.questions)) { - const mapped = exercise.questions.map((question) => { - const questionType = getQuestionTypeOptionById(getQuestionTypeId(question)); - return { - ...question, - questionTypeId: questionType.id, - questionType - }; - }); - questions = normalizeQuestionOrder(mapped); - } - - questionnaire.set({ - title: exercise.title, - description: exercise.description, - dueBy: exercise.dueBy, - isTitleDirty: false, - isDescriptionDirty: false, - isDueByDirty: false, - questions: questions, - totalSubmissions: 0, - allowMultipleAttempts: !!exercise.allowMultipleAttempts - }); - questionnaireMetaData.update((m) => ({ ...m, exerciseId: data.exerciseId })); + hydrateExercisePageData(data.exercise, data.exerciseId); }); diff --git a/apps/dashboard/src/routes/(app)/courses/[id]/settings/+page.svelte b/apps/dashboard/src/routes/(app)/courses/[id]/settings/+page.svelte index 38d6688b..72ffd36f 100644 --- a/apps/dashboard/src/routes/(app)/courses/[id]/settings/+page.svelte +++ b/apps/dashboard/src/routes/(app)/courses/[id]/settings/+page.svelte @@ -23,7 +23,7 @@ - + {$t('course.navItem.settings.heading')} diff --git a/apps/dashboard/src/routes/(app)/lms/+page.svelte b/apps/dashboard/src/routes/(app)/lms/+page.svelte index f09aea66..e2f1073d 100644 --- a/apps/dashboard/src/routes/(app)/lms/+page.svelte +++ b/apps/dashboard/src/routes/(app)/lms/+page.svelte @@ -8,11 +8,40 @@ import { currentOrg } from '$lib/utils/store/org'; import { classroomio } from '$lib/utils/services/api'; import * as Page from '@cio/ui/base/page'; + import { coursesApi } from '$features/course/api'; import type { PendingOrgInvite } from '$features/lms/utils/types'; let pendingInvite = $state(null); let showInviteModal = $state(false); + const todayLabel = new Intl.DateTimeFormat('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric' + }).format(new Date()); + + const firstName = $derived($profile.fullname?.trim().split(/\s+/)[0] || $t('dashboard.learner')); + + let totalCompleted = $derived( + coursesApi.enrolledCourses.reduce((acc, course) => { + const exercisesCompleted = + 'exercisesCompleted' in course && typeof course.exercisesCompleted === 'number' ? course.exercisesCompleted : 0; + + return acc + (course.progressRate || 0) + exercisesCompleted; + }, 0) + ); + + let totalLessons = $derived( + coursesApi.enrolledCourses.reduce((acc, course) => { + const exercises = + 'exerciseCount' in course && typeof course.exerciseCount === 'number' ? course.exerciseCount : 0; + + return acc + (course.lessonCount || 0) + exercises; + }, 0) + ); + + let progressPercentage = $derived(totalLessons > 0 ? Math.round((totalCompleted / totalLessons) * 100) : 0); + onMount(async () => { if (!$profile.id || !$currentOrg.id) return; @@ -38,15 +67,24 @@ Student Dashboard - ClassroomIO - + + {todayLabel} - {$t(getGreeting())} - {$profile.fullname}! + {$t(getGreeting())}, + {firstName}. + + {#if totalLessons > 0} + {$t('dashboard.lms_today_progress', { progress: progressPercentage })} + {:else} + {$t('dashboard.lms_today_empty')} + {/if} + + {#snippet child()} diff --git a/apps/dashboard/src/routes/(app)/lms/settings/+page.svelte b/apps/dashboard/src/routes/(app)/lms/settings/+page.svelte index 4c2d3347..c15213ef 100644 --- a/apps/dashboard/src/routes/(app)/lms/settings/+page.svelte +++ b/apps/dashboard/src/routes/(app)/lms/settings/+page.svelte @@ -1,6 +1,5 @@ + + + {$t('widgets.heading')} - ClassroomIO + + + + + + {$t('widgets.heading')} + {$t('widgets.subtitle')} + + + + + + + + {#snippet child()} + (createModalOpen = true)} /> + {/snippet} + + + + + + + {$t('widgets.actions.create')} + {$t('widgets.create.description')} + + + + + + + + diff --git a/apps/dashboard/src/routes/(app)/widget-preview/+page.svelte b/apps/dashboard/src/routes/(app)/widget-preview/+page.svelte new file mode 100644 index 00000000..475226b6 --- /dev/null +++ b/apps/dashboard/src/routes/(app)/widget-preview/+page.svelte @@ -0,0 +1,115 @@ + + + + + + +
+ {#if errorMessage} +
{errorMessage}
+ {:else} + + {/if} +
diff --git a/apps/dashboard/src/routes/(app)/widgets/[widgetId]/+page.svelte b/apps/dashboard/src/routes/(app)/widgets/[widgetId]/+page.svelte new file mode 100644 index 00000000..5452fbb1 --- /dev/null +++ b/apps/dashboard/src/routes/(app)/widgets/[widgetId]/+page.svelte @@ -0,0 +1,62 @@ + + +
+ {#if !$currentOrg.id} +
+ +

{$t('widgets.editor.waiting_org')}

+
+ {:else if loadError} +
+

{loadError}

+ +
+ {:else if !detail} +
+ +
+ {:else} + + {/if} +
diff --git a/apps/dashboard/src/routes/(org-site)/+layout.svelte b/apps/dashboard/src/routes/(org-site)/+layout.svelte index de24b60d..7cc1f48b 100644 --- a/apps/dashboard/src/routes/(org-site)/+layout.svelte +++ b/apps/dashboard/src/routes/(org-site)/+layout.svelte @@ -1,47 +1,16 @@ {#if data.org?.isRestricted || $currentOrg.isRestricted} diff --git a/apps/dashboard/src/routes/(org-site)/course/[slug]/enroll/+page.svelte b/apps/dashboard/src/routes/(org-site)/course/[slug]/enroll/+page.svelte index 5ea07181..0acb44c9 100644 --- a/apps/dashboard/src/routes/(org-site)/course/[slug]/enroll/+page.svelte +++ b/apps/dashboard/src/routes/(org-site)/course/[slug]/enroll/+page.svelte @@ -25,6 +25,10 @@ data.course?.status === 'ACTIVE' && Boolean(data.course?.isPublished) ); + $inspect(data.requiresPaymentOrInvite); + $inspect(canJoinCourse); + $inspect(data); + $inspect('loading', loading); function getBlockedMessage(): string { if (data.requiresPaymentOrInvite) { @@ -56,6 +60,7 @@ snackbar.error(getBlockedMessage()); return; } + console.log('profile', $profile); loading = true; @@ -133,7 +138,7 @@
-
diff --git a/apps/dashboard/src/routes/+layout.svelte b/apps/dashboard/src/routes/+layout.svelte index dca2a6a3..e2090891 100644 --- a/apps/dashboard/src/routes/+layout.svelte +++ b/apps/dashboard/src/routes/+layout.svelte @@ -1,7 +1,15 @@
diff --git a/apps/dashboard/src/routes/+page.svelte b/apps/dashboard/src/routes/+page.svelte index 599e8146..cc415596 100644 --- a/apps/dashboard/src/routes/+page.svelte +++ b/apps/dashboard/src/routes/+page.svelte @@ -3,26 +3,27 @@ import { resolve } from '$app/paths'; import type { Component } from 'svelte'; + import { appInitApi } from '$features/app/init.svelte'; import { Spinner } from '@cio/ui/base/spinner'; + import { Button } from '@cio/ui/base/button'; + import FrownIcon from '@lucide/svelte/icons/frown'; + import { Empty } from '@cio/ui/custom/empty'; import { SimpleLogoNav } from '@cio/ui/custom/simple-logo-nav'; import { buildOrgLandingPageProps, importThemeComponent, normalizeLandingPageSettings } from '$features/org/utils/landing-page'; - import { basePath, globalStore } from '$lib/utils/store/app'; - import { currentOrg } from '$lib/utils/store/org'; - import { setTheme } from '$lib/utils/functions/theme'; + import { basePath } from '$lib/utils/store/app'; import { t } from '$lib/utils/functions/translations'; import { user } from '$lib/utils/store/user'; - import PageLoader from '$features/org/components/landing-page/page-loader.svelte'; let { data } = $props(); // eslint-disable-next-line @typescript-eslint/no-explicit-any let ThemeComponent = $state | null>(null); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let DashboardEntry = $state | null>(null); + + const hasSetupError = $derived(!appInitApi.loading && !!appInitApi.error); const pageTitle = $derived( data.isOrgSite && data.org ? data.org.name : "ClassroomIO - The Course Platform That's Actually Easy To Use" @@ -53,35 +54,12 @@ }); onMount(async () => { - const loadingIndicator = document.getElementById('app-loading-indicator'); - if (loadingIndicator) { - loadingIndicator.style.display = 'none'; - } - - if (!data.isOrgSite || !data.org) { - const mod = await import('./_dashboard-entry.svelte'); - DashboardEntry = mod.default; - return; - } - - if (data.locals?.user) { - user.set({ - ...$user, - isLoggedIn: true, - currentSession: data.locals.user - }); - } - - $globalStore.orgSiteName = data.orgSiteName || ''; - $globalStore.isOrgSite = true; - currentOrg.set(data.org); - setTheme(data.org.theme || 'blue'); + if (!data.isOrgSite || !data.org) return; const settings = normalizeLandingPageSettings(data.org.landingpage); const mod = await importThemeComponent(settings.theme); ThemeComponent = mod.default; }); - @@ -92,10 +70,23 @@ {#if ThemeComponent && landingPageProps} {:else} - + {/if} -{:else if DashboardEntry} - +{:else if hasSetupError} + +

{appInitApi.error}

+
+ + +
+
{:else}
diff --git a/apps/dashboard/src/routes/_dashboard-entry.svelte b/apps/dashboard/src/routes/_dashboard-entry.svelte deleted file mode 100644 index dd0fd824..00000000 --- a/apps/dashboard/src/routes/_dashboard-entry.svelte +++ /dev/null @@ -1,62 +0,0 @@ - - -{#if !isLoadingApp && appInitApi.error} - - {#if appInitApi.error} -

{appInitApi.error}

- {/if} -
- - -
-
-{:else} -
- - -
-{/if} diff --git a/apps/dashboard/src/routes/csp-report/+server.ts b/apps/dashboard/src/routes/csp-report/+server.ts index 56a42f17..f1fb2f72 100644 --- a/apps/dashboard/src/routes/csp-report/+server.ts +++ b/apps/dashboard/src/routes/csp-report/+server.ts @@ -1,26 +1,19 @@ import type { RequestHandler } from './$types'; +/** + * Accepts browser CSP violation reports (report-only policy `report-uri`). + * Must stay public — no session — so POST is not redirected to /login. + */ export const POST: RequestHandler = async ({ request }) => { try { - const report = await request.json(); - - // Log the CSP violation for monitoring - // console.warn('CSP Violation Report:', { - // timestamp: new Date().toISOString(), - // documentURI: report['document-uri'], - // violatedDirective: report['violated-directive'], - // blockedURI: report['blocked-uri'], - // effectiveDirective: report['effective-directive'], - // originalPolicy: report['original-policy'], - // sourceFile: report['source-file'], - // lineNumber: report['line-number'], - // columnNumber: report['column-number'], - // userAgent: request.headers.get('user-agent') - // }); - - return new Response('OK', { status: 200 }); + const raw = await request.text(); + if (raw) { + JSON.parse(raw); + // Optional: log parsed report for monitoring (shape varies by directive) + } } catch (error) { - console.error('Error processing CSP report:', error); - return new Response('Bad Request', { status: 400 }); + console.error('csp-report: invalid JSON body', error); } + + return new Response(null, { status: 204 }); }; diff --git a/apps/dashboard/static/images/avatar-plain.svg b/apps/dashboard/static/images/avatar-plain.svg new file mode 100644 index 00000000..236ef150 --- /dev/null +++ b/apps/dashboard/static/images/avatar-plain.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/static/images/avatar.svg b/apps/dashboard/static/images/avatar.svg new file mode 100644 index 00000000..f209f03d --- /dev/null +++ b/apps/dashboard/static/images/avatar.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/static/sticky-test.css b/apps/dashboard/static/sticky-test.css new file mode 100644 index 00000000..8922a72c --- /dev/null +++ b/apps/dashboard/static/sticky-test.css @@ -0,0 +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-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-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-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--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-300:oklch(87.9% .169 91.605);--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-blue-600:oklch(54.6% .245 262.881);--ui-color-pink-500:oklch(65.6% .241 354.308);--ui-color-slate-500:oklch(55.4% .046 257.417);--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-neutral-800:oklch(26.9% 0 0);--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-2xl:42rem;--ui-container-3xl:48rem;--ui-container-4xl:56rem;--ui-container-7xl:80rem;--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-text-3xl:1.875rem;--ui-text-3xl--line-height:calc(2.25 / 1.875);--ui-text-4xl:2.25rem;--ui-text-4xl--line-height:calc(2.5 / 2.25);--ui-text-5xl:3rem;--ui-text-5xl--line-height:1;--ui-text-6xl:3.75rem;--ui-text-6xl--line-height:1;--ui-text-7xl:4.5rem;--ui-text-7xl--line-height:1;--ui-font-weight-light:300;--ui-font-weight-normal:400;--ui-font-weight-medium:500;--ui-font-weight-semibold:600;--ui-font-weight-bold:700;--ui-font-weight-black:900;--ui-tracking-tighter:-.05em;--ui-tracking-tight:-.025em;--ui-tracking-wide:.025em;--ui-tracking-widest:.1em;--ui-leading-tight:1.25;--ui-leading-snug:1.375;--ui-leading-normal:1.5;--ui-leading-relaxed:1.625;--ui-radius-xs:.125rem;--ui-radius-2xl:1rem;--ui-radius-3xl:1.5rem;--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-blur-xl:24px;--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{.cio-marketing-border-beam{pointer-events:none;border-radius:inherit;border:calc(var(--border-width) * 1px) solid transparent;-webkit-mask-composite:source-in;-webkit-mask-source-type:auto,auto;position:absolute;inset:0;-webkit-mask-image:linear-gradient(#0000,#0000),linear-gradient(#fff,#fff);mask-image:linear-gradient(#0000,#0000),linear-gradient(#fff,#fff);-webkit-mask-position:0 0,0 0;mask-position:0 0,0 0;-webkit-mask-size:auto,auto;mask-size:auto,auto;-webkit-mask-repeat:repeat,repeat;mask-repeat:repeat,repeat;-webkit-mask-clip:padding-box,border-box;mask-clip:padding-box,border-box;-webkit-mask-origin:border-box,border-box;mask-origin:border-box,border-box;-webkit-mask-composite:source-in;mask-composite:intersect;-webkit-mask-source-type:auto,auto;mask-mode:match-source,match-source}.cio-marketing-border-beam:after{content:"";aspect-ratio:1;width:calc(var(--size) * 1px);animation-delay:var(--delay);background:linear-gradient(to left, var(--color-from), var(--color-to), transparent);offset-anchor:calc(var(--anchor) * 1%) 50%;offset-path:rect(0 auto auto 0 round calc(var(--size) * 1px));animation:border-beam calc(var(--duration) * 1s) infinite linear;position:absolute}}@layer utilities{.ui\:\@container\/field-group{container:field-group/inline-size}.ui\:\@container-\[size\]{container-type: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-full{inset:-100%}.ui\:inset-\(--cut\){inset:var(--cut)}.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\:bottom-full{bottom:100%}.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-10{z-index:calc(10 * -1)}.ui\:-z-20{z-index:calc(20 * -1)}.ui\:-z-30{z-index:calc(30 * -1)}.ui\:z-0{z-index:0}.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-100{z-index:100}.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-0\.5{margin-inline:calc(var(--ui-spacing) * .5)}.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-3{margin-block:calc(var(--ui-spacing) * 3)}.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-6{margin-top:calc(var(--ui-spacing) * 6)}.ui\:mt-10{margin-top:calc(var(--ui-spacing) * 10)}.ui\:mt-12{margin-top:calc(var(--ui-spacing) * 12)}.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\:mb-6{margin-bottom:calc(var(--ui-spacing) * 6)}.ui\:mb-8{margin-bottom:calc(var(--ui-spacing) * 8)}.ui\:mb-10{margin-bottom:calc(var(--ui-spacing) * 10)}.ui\:mb-12{margin-bottom:calc(var(--ui-spacing) * 12)}.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\:box-border{box-sizing:border-box}.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-\[1\]{aspect-ratio:1}.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-\[2\.4px\]{width:2.4px;height:2.4px}.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-20{height:calc(var(--ui-spacing) * 20)}.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-50\!{height:calc(var(--ui-spacing) * 50)!important}.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-\[100cqh\]{height:100cqh}.ui\:h-\[280px\]{height:280px}.ui\:h-\[500px\]{height:500px}.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-32{max-height:calc(var(--ui-spacing) * 32)}.ui\:max-h-52{max-height:calc(var(--ui-spacing) * 52)}.ui\:max-h-60{max-height:calc(var(--ui-spacing) * 60)}.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-8{min-height:calc(var(--ui-spacing) * 8)}.ui\:min-h-14{min-height:calc(var(--ui-spacing) * 14)}.ui\:min-h-16{min-height:calc(var(--ui-spacing) * 16)}.ui\:min-h-40{min-height:calc(var(--ui-spacing) * 40)}.ui\:min-h-96{min-height:calc(var(--ui-spacing) * 96)}.ui\:min-h-\[44px\]{min-height:44px}.ui\:min-h-\[63px\]{min-height:63px}.ui\:min-h-\[240px\]{min-height:240px}.ui\:min-h-\[320px\]{min-height:320px}.ui\:min-h-\[calc\(100vh-48px\)\]{min-height:calc(100vh - 48px)}.ui\:min-h-screen{min-height:100vh}.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\.5{width:calc(var(--ui-spacing) * 1.5)}.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-\[50px\]{width:50px}.ui\:w-\[80\%\]{width:80%}.ui\:w-\[100px\]{width:100px}.ui\:w-auto{width:auto}.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-2xl{max-width:var(--ui-container-2xl)}.ui\:max-w-3xl{max-width:var(--ui-container-3xl)}.ui\:max-w-4xl{max-width:var(--ui-container-4xl)}.ui\:max-w-6{max-width:calc(var(--ui-spacing) * 6)}.ui\:max-w-7xl{max-width:var(--ui-container-7xl)}.ui\:max-w-80{max-width:calc(var(--ui-spacing) * 80)}.ui\:max-w-\[16rem\]{max-width:16rem}.ui\:max-w-\[60\%\]{max-width:60%}.ui\:max-w-\[200px\]{max-width:200px}.ui\:max-w-\[300px\]{max-width:300px}.ui\:max-w-\[320px\]{max-width:320px}.ui\:max-w-\[1200px\]{max-width:1200px}.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-none{max-width:none}.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-18{min-width:calc(var(--ui-spacing) * 18)}.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-\[5rem\]{min-width:5rem}.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\:origin-center{transform-origin:50%}.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-4{--tw-translate-x:calc(var(--ui-spacing) * 4);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-4{--tw-translate-y:calc(var(--ui-spacing) * 4);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\:\[translate\:0_0\]{translate:0}.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\:transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.ui\:transform-gpu{transform:translateZ(0) var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.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-magicslide{animation:magicslide var(--speed) ease-in-out infinite alternate}.ui\:animate-meteor{animation:5s linear infinite meteor}.ui\:animate-pulse{animation:var(--ui-animate-pulse)}.ui\:animate-shine{animation:2s linear infinite shine}.ui\:animate-spin{animation:var(--ui-animate-spin)}.ui\:animate-spin-around{animation:spin-around calc(var(--speed) * 2) infinite linear}.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-1{grid-template-columns:repeat(1,minmax(0,1fr))}.ui\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.ui\:grid-cols-4{grid-template-columns:repeat(4,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-nowrap\!{flex-wrap:nowrap!important}.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-center{justify-items:center}.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)}.ui\:gap-8{gap:calc(var(--ui-spacing) * 8)}.ui\:gap-16{gap:calc(var(--ui-spacing) * 16)}: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)}:where(.ui\:space-x-8>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--ui-spacing) * 8) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--ui-spacing) * 8) * calc(1 - var(--tw-space-x-reverse)))}.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-center{align-self:center}.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-\(--radius\){border-radius:var(--radius)}.ui\:rounded-2xl{border-radius:var(--ui-radius-2xl)}.ui\:rounded-3xl{border-radius:var(--ui-radius-3xl)}.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-md\!{border-radius:calc(var(--radius) - 2px)!important}.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-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.ui\:border-dashed{--tw-border-style:dashed;border-style:dashed}.ui\:border-none{--tw-border-style:none;border-style:none}.ui\:border-solid{--tw-border-style:solid;border-style:solid}.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-background\/40{border-color:var(--background)}@supports (color:color-mix(in lab, red, red)){.ui\:border-background\/40{border-color:color-mix(in oklab, var(--background) 40%, transparent)}}.ui\:border-border,.ui\:border-border\/40{border-color:var(--border)}@supports (color:color-mix(in lab, red, red)){.ui\:border-border\/40{border-color:color-mix(in oklab, var(--border) 40%, transparent)}}.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-border\/60{border-color:var(--border)}@supports (color:color-mix(in lab, red, red)){.ui\:border-border\/60{border-color:color-mix(in oklab, var(--border) 60%, 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,.ui\:border-muted-foreground\/40{border-color:var(--muted-foreground)}@supports (color:color-mix(in lab, red, red)){.ui\:border-muted-foreground\/40{border-color:color-mix(in oklab, var(--muted-foreground) 40%, transparent)}}.ui\:border-neutral-800{border-color:var(--ui-color-neutral-800)}.ui\:border-primary,.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\:border-white\/10{border-color:var(--ui-color-white)}@supports (color:color-mix(in lab, red, red)){.ui\:border-white\/10{border-color:color-mix(in oklab, var(--ui-color-white) 10%, transparent)}}.ui\:border-l-foreground{border-left-color:var(--foreground)}.ui\:border-l-transparent{border-left-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-background\/90{background-color:var(--background)}@supports (color:color-mix(in lab, red, red)){.ui\:bg-background\/90{background-color:color-mix(in oklab, var(--background) 90%, 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-foreground{background-color:var(--foreground)}.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\/10{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.ui\:bg-muted\/10{background-color:color-mix(in oklab, var(--muted) 10%, transparent)}}.ui\:bg-muted\/30{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.ui\:bg-muted\/30{background-color:color-mix(in oklab, var(--muted) 30%, transparent)}}.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\/5{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.ui\:bg-primary\/5{background-color:color-mix(in oklab, var(--primary) 5%, transparent)}}.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-primary\/60{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.ui\:bg-primary\/60{background-color:color-mix(in oklab, var(--primary) 60%, 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-slate-500{background-color:var(--ui-color-slate-500)}.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\:bg-\[linear-gradient\(110deg\,\#000103\,45\%\,\#1e2631\,55\%\,\#000103\)\]{background-image:linear-gradient(110deg,#000103,45%,#1e2631,55%,#000103)}.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\:from-slate-500{--tw-gradient-from:var(--ui-color-slate-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\:via-blue-600\/30{--tw-gradient-via:var(--ui-color-blue-600)}@supports (color:color-mix(in lab, red, red)){.ui\:via-blue-600\/30{--tw-gradient-via:color-mix(in oklab, var(--ui-color-blue-600) 30%, transparent)}}.ui\:via-blue-600\/30{--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.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-\[length\:200\%_100\%\]{background-size:200% 100%}.ui\:bg-clip-padding{background-clip:padding-box}.ui\:fill-amber-400{fill:var(--ui-color-amber-400)}.ui\:fill-current{fill:currentColor}.ui\:fill-none{fill:none}.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-1\!{padding:calc(var(--ui-spacing) * 1)!important}.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\:px-8{padding-inline:calc(var(--ui-spacing) * 8)}.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-5{padding-block:calc(var(--ui-spacing) * 5)}.ui\:py-6{padding-block:calc(var(--ui-spacing) * 6)}.ui\:py-10{padding-block:calc(var(--ui-spacing) * 10)}.ui\:py-12{padding-block:calc(var(--ui-spacing) * 12)}.ui\:py-16{padding-block:calc(var(--ui-spacing) * 16)}.ui\:py-20{padding-block:calc(var(--ui-spacing) * 20)}.ui\:py-24{padding-block:calc(var(--ui-spacing) * 24)}.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\:pt-3\!{padding-top:calc(var(--ui-spacing) * 3)!important}.ui\:pt-4{padding-top:calc(var(--ui-spacing) * 4)}.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-2{padding-bottom:calc(var(--ui-spacing) * 2)}.ui\:pb-3{padding-bottom:calc(var(--ui-spacing) * 3)}.ui\:pb-4{padding-bottom:calc(var(--ui-spacing) * 4)}.ui\:pb-16{padding-bottom:calc(var(--ui-spacing) * 16)}.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\:text-right{text-align:right}.ui\:align-middle{vertical-align:middle}.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-3xl{font-size:var(--ui-text-3xl);line-height:var(--tw-leading,var(--ui-text-3xl--line-height))}.ui\:text-4xl{font-size:var(--ui-text-4xl);line-height:var(--tw-leading,var(--ui-text-4xl--line-height))}.ui\:text-5xl{font-size:var(--ui-text-5xl);line-height:var(--tw-leading,var(--ui-text-5xl--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\:text-\[13px\]{font-size:13px}.ui\:leading-7{--tw-leading:calc(var(--ui-spacing) * 7);line-height:calc(var(--ui-spacing) * 7)}.ui\:leading-\[1\.1\]{--tw-leading:1.1;line-height:1.1}.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\:leading-tight{--tw-leading:var(--ui-leading-tight);line-height:var(--ui-leading-tight)}.ui\:font-black{--tw-font-weight:var(--ui-font-weight-black);font-weight:var(--ui-font-weight-black)}.ui\:font-bold{--tw-font-weight:var(--ui-font-weight-bold);font-weight:var(--ui-font-weight-bold)}.ui\:font-light{--tw-font-weight:var(--ui-font-weight-light);font-weight:var(--ui-font-weight-light)}.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-tighter{--tw-tracking:var(--ui-tracking-tighter);letter-spacing:var(--ui-tracking-tighter)}.ui\:tracking-wide{--tw-tracking:var(--ui-tracking-wide);letter-spacing:var(--ui-tracking-wide)}.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-accent-foreground{color:var(--accent-foreground)}.ui\:text-amber-200{color:var(--ui-color-amber-200)}.ui\:text-amber-500{color:var(--ui-color-amber-500)}.ui\:text-amber-600{color:var(--ui-color-amber-600)}.ui\:text-amber-900{color:var(--ui-color-amber-900)}.ui\:text-background,.ui\:text-background\/60{color:var(--background)}@supports (color:color-mix(in lab, red, red)){.ui\:text-background\/60{color:color-mix(in oklab, var(--background) 60%, transparent)}}.ui\:text-background\/70{color:var(--background)}@supports (color:color-mix(in lab, red, red)){.ui\:text-background\/70{color:color-mix(in oklab, var(--background) 70%, transparent)}}.ui\:text-background\/80{color:var(--background)}@supports (color:color-mix(in lab, red, red)){.ui\:text-background\/80{color:color-mix(in oklab, var(--background) 80%, transparent)}}.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-foreground\/72{color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.ui\:text-foreground\/72{color:color-mix(in oklab, var(--foreground) 72%, 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\:uppercase{text-transform:uppercase}.ui\:no-underline{text-decoration-line:none}.ui\:underline{text-decoration-line:underline}.ui\:underline-offset-4{text-underline-offset:4px}.ui\:opacity-0{opacity:0}.ui\:opacity-10{opacity:.1}.ui\:opacity-40{opacity:.4}.ui\:opacity-50{opacity:.5}.ui\:opacity-60{opacity:.6}.ui\:opacity-70{opacity:.7}.ui\:opacity-90{opacity:.9}.ui\:opacity-100{opacity:1}.ui\:opacity-\[0\.06\]{opacity:.06}.ui\:opacity-\[0\.08\]{opacity:.08}.ui\:opacity-\[0\.15\]{opacity:.15}.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_\#ffffff10\]{--tw-shadow:0 0 0 1px var(--tw-shadow-color,#ffffff10);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-\[inset_0_-8px_10px_\#ffffff1f\]{--tw-shadow:inset 0 -8px 10px var(--tw-shadow-color,#ffffff1f);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\:shadow-primary\/20{--tw-shadow-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.ui\:shadow-primary\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--primary) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.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\:blur-\[2px\]{--tw-blur:blur(2px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.ui\:brightness-200{--tw-brightness:brightness(200%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.ui\:drop-shadow-none{--tw-drop-shadow: ;filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.ui\:grayscale{--tw-grayscale:grayscale(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.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\:backdrop-blur-xl{--tw-backdrop-blur:blur(var(--ui-blur-xl));-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\:\[background\:conic-gradient\(from_calc\(270deg-\(var\(--spread\)\*0\.5\)\)\,transparent_0\,var\(--shimmer-color\)_var\(--spread\)\,transparent_var\(--spread\)\)\]{background:conic-gradient(from calc(270deg - (var(--spread) * .5)),transparent 0,var(--shimmer-color) var(--spread),transparent var(--spread))}.ui\:\[background\:var\(--bg\)\]{background:var(--bg)}.ui\:\[mask\:none\]{-webkit-mask:none;mask:none}.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\:scale-\[1\.02\]:is(:where(.ui\:group):hover *){scale:1.02}.ui\:group-hover\:bg-primary:is(:where(.ui\:group):hover *){background-color:var(--primary)}.ui\:group-hover\:text-primary-foreground:is(:where(.ui\:group):hover *){color:var(--primary-foreground)}.ui\:group-hover\:opacity-100:is(:where(.ui\:group):hover *){opacity:1}.ui\:group-hover\:shadow-\[inset_0_-6px_10px_\#ffffff3f\]:is(:where(.ui\:group):hover *){--tw-shadow:inset 0 -6px 10px var(--tw-shadow-color,#ffffff3f);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-hover\/menu-item\:opacity-100:is(:where(.ui\:group\/menu-item):hover *){opacity:1}}.ui\:group-active\:shadow-\[inset_0_-10px_10px_\#ffffff3f\]:is(:where(.ui\:group):active *){--tw-shadow:inset 0 -10px 10px var(--tw-shadow-color,#ffffff3f);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-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\]\:pointer-events-none:is(:where(.ui\:group)[data-collapsible=icon] *){pointer-events:none}.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}.ui\:focus-within\:border-ring:focus-within{border-color:var(--ring)}.ui\:focus-within\:ring-\[3px\]:focus-within{--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-within\:ring-ring\/50:focus-within{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.ui\:focus-within\:ring-ring\/50:focus-within{--tw-ring-color:color-mix(in oklab, var(--ring) 50%, transparent)}}@media (hover:hover){.ui\:hover\:-translate-y-1:hover{--tw-translate-y:calc(var(--ui-spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.ui\:hover\:cursor-pointer:hover{cursor:pointer}.ui\:hover\:border:hover{border-style:var(--tw-border-style);border-width:1px}.ui\:hover\:border-background\/60:hover{border-color:var(--background)}@supports (color:color-mix(in lab, red, red)){.ui\:hover\:border-background\/60:hover{border-color:color-mix(in oklab, var(--background) 60%, transparent)}}.ui\:hover\:border-border:hover{border-color:var(--border)}.ui\:hover\:border-primary:hover{border-color:var(--primary)}.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-amber-500\/10:hover{background-color:var(--ui-color-amber-500)}@supports (color:color-mix(in lab, red, red)){.ui\:hover\:bg-amber-500\/10:hover{background-color:color-mix(in oklab, var(--ui-color-amber-500) 10%, transparent)}}.ui\:hover\:bg-background\/10:hover{background-color:var(--background)}@supports (color:color-mix(in lab, red, red)){.ui\:hover\:bg-background\/10:hover{background-color:color-mix(in oklab, var(--background) 10%, transparent)}}.ui\:hover\:bg-muted:hover,.ui\:hover\:bg-muted\/50:hover{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.ui\:hover\:bg-muted\/50:hover{background-color:color-mix(in oklab, var(--muted) 50%, transparent)}}.ui\:hover\:bg-primary:hover,.ui\:hover\:bg-primary\/5:hover{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.ui\:hover\:bg-primary\/5:hover{background-color:color-mix(in oklab, var(--primary) 5%, transparent)}}.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-background:hover{color:var(--background)}.ui\:hover\:text-foreground:hover{color:var(--foreground)}.ui\:hover\:text-muted-foreground:hover{color:var(--muted-foreground)}.ui\:hover\:text-primary:hover{color:var(--primary)}.ui\:hover\:text-primary-foreground:hover{color:var(--primary-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\:shadow-xl:hover{--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\:hover\:shadow-primary\/5:hover{--tw-shadow-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.ui\:hover\:shadow-primary\/5:hover{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--primary) 5%, transparent) var(--tw-shadow-alpha), transparent)}}.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\:translate-y-px:active{--tw-translate-y:1px;translate:var(--tw-translate-x) var(--tw-translate-y)}.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\:w-48{width:calc(var(--ui-spacing) * 48)}.ui\:sm\:max-w-lg{max-width:var(--ui-container-lg)}.ui\:sm\:max-w-sm{max-width:var(--ui-container-sm)}.ui\:sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.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\:p-6{padding:calc(var(--ui-spacing) * 6)}.ui\:sm\:py-32{padding-block:calc(var(--ui-spacing) * 32)}.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\:sm\:text-5xl{font-size:var(--ui-text-5xl);line-height:var(--tw-leading,var(--ui-text-5xl--line-height))}.ui\:sm\:text-xl{font-size:var(--ui-text-xl);line-height:var(--tw-leading,var(--ui-text-xl--line-height))}.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\:w-52{width:calc(var(--ui-spacing) * 52)}.ui\:md\:w-full{width:100%}.ui\:md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.ui\:md\:flex-col{flex-direction:column}.ui\:md\:flex-row{flex-direction:row}.ui\:md\:overflow-x-visible{overflow-x:visible}.ui\:md\:overflow-y-visible{overflow-y:visible}.ui\:md\:border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.ui\:md\:border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.ui\:md\:p-12{padding:calc(var(--ui-spacing) * 12)}.ui\:md\:px-8{padding-inline:calc(var(--ui-spacing) * 8)}.ui\:md\:pb-24{padding-bottom:calc(var(--ui-spacing) * 24)}.ui\:md\:text-6xl{font-size:var(--ui-text-6xl);line-height:var(--tw-leading,var(--ui-text-6xl--line-height))}.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}}@media (min-width:64rem){.ui\:lg\:block{display:block}.ui\:lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.ui\:lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.ui\:lg\:px-8{padding-inline:calc(var(--ui-spacing) * 8)}.ui\:lg\:text-5xl{font-size:var(--ui-text-5xl);line-height:var(--tw-leading,var(--ui-text-5xl--line-height))}.ui\:lg\:text-6xl{font-size:var(--ui-text-6xl);line-height:var(--tw-leading,var(--ui-text-6xl--line-height))}.ui\:lg\:text-7xl{font-size:var(--ui-text-7xl);line-height:var(--tw-leading,var(--ui-text-7xl--line-height))}}.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\:fill-amber-400:where(.dark,.dark *){fill:var(--ui-color-amber-400)}.ui\:dark\:text-amber-50:where(.dark,.dark *){color:var(--ui-color-amber-50)}.ui\:dark\:text-amber-300:where(.dark,.dark *){color:var(--ui-color-amber-300)}.ui\:dark\:text-amber-400:where(.dark,.dark *){color:var(--ui-color-amber-400)}.ui\:dark\:text-amber-500\/40:where(.dark,.dark *){color:var(--ui-color-amber-500)}@supports (color:color-mix(in lab, red, red)){.ui\:dark\:text-amber-500\/40:where(.dark,.dark *){color:color-mix(in oklab, var(--ui-color-amber-500) 40%, transparent)}}.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}@keyframes border-beam{to{offset-distance:100%}}@keyframes meteor{0%{opacity:1;transform:rotate(215deg)translate(0)}70%{opacity:1}to{opacity:0;transform:rotate(215deg)translate(-500px)}}@keyframes shimmer-marketing{0%,90%,to{background-position:calc(-100% - var(--shimmer-width)) 0}30%,60%{background-position:calc(100% + var(--shimmer-width)) 0}}@keyframes spin-around{0%{transform:translateZ(0)rotate(0)}15%,35%{transform:translateZ(0)rotate(90deg)}65%,85%{transform:translateZ(0)rotate(270deg)}to{transform:translateZ(0)rotate(360deg)}}@keyframes magicslide{to{transform:translate(calc(100cqw - 100%))}}:root{--background:oklch(100% 0 0);--foreground:oklch(14.5% 0 0);--card:oklch(100% 0 0);--card-foreground:oklch(14.5% 0 0);--popover:oklch(100% 0 0);--popover-foreground:oklch(14.5% 0 0);--primary:oklch(48.8% .243 264.376);--primary-foreground:oklch(97% .014 254.604);--secondary:oklch(96.7% .001 286.375);--secondary-foreground:oklch(21% .006 285.885);--muted:oklch(97% 0 0);--muted-foreground:oklch(55.6% 0 0);--accent:oklch(97% 0 0);--accent-foreground:oklch(20.5% 0 0);--destructive:oklch(57.7% .245 27.325);--border:oklch(92.2% 0 0);--input:oklch(92.2% 0 0);--ring:oklch(70.8% 0 0);--chart-1:oklch(80.9% .105 251.813);--chart-2:oklch(62.3% .214 259.815);--chart-3:oklch(54.6% .245 262.881);--chart-4:oklch(48.8% .243 264.376);--chart-5:oklch(42.4% .199 265.638);--radius:.625rem;--sidebar:oklch(98.5% 0 0);--sidebar-foreground:oklch(14.5% 0 0);--sidebar-primary:oklch(54.6% .245 262.881);--sidebar-primary-foreground:oklch(97% .014 254.604);--sidebar-accent:oklch(97% 0 0);--sidebar-accent-foreground:oklch(20.5% 0 0);--sidebar-border:oklch(92.2% 0 0);--sidebar-ring:oklch(70.8% 0 0)}.dark{--background:oklch(14.5% 0 0);--foreground:oklch(98.5% 0 0);--card:oklch(20.5% 0 0);--card-foreground:oklch(98.5% 0 0);--popover:oklch(20.5% 0 0);--popover-foreground:oklch(98.5% 0 0);--primary:oklch(42.4% .199 265.638);--primary-foreground:oklch(97% .014 254.604);--secondary:oklch(27.4% .006 286.033);--secondary-foreground:oklch(98.5% 0 0);--muted:oklch(26.9% 0 0);--muted-foreground:oklch(70.8% 0 0);--accent:oklch(26.9% 0 0);--accent-foreground:oklch(98.5% 0 0);--destructive:oklch(70.4% .191 22.216);--border:oklch(100% 0 0/.1);--input:oklch(100% 0 0/.15);--ring:oklch(55.6% 0 0);--chart-1:oklch(80.9% .105 251.813);--chart-2:oklch(62.3% .214 259.815);--chart-3:oklch(54.6% .245 262.881);--chart-4:oklch(48.8% .243 264.376);--chart-5:oklch(42.4% .199 265.638);--sidebar:oklch(20.5% 0 0);--sidebar-foreground:oklch(98.5% 0 0);--sidebar-primary:oklch(62.3% .214 259.815);--sidebar-primary-foreground:oklch(97% .014 254.604);--sidebar-accent:oklch(26.9% 0 0);--sidebar-accent-foreground:oklch(98.5% 0 0);--sidebar-border:oklch(100% 0 0/.1);--sidebar-ring:oklch(55.6% 0 0)}@property --border-angle{syntax:"";inherits:false;initial-value:0deg}@keyframes border{to{--border-angle:360deg}}@keyframes flip{to{transform:rotate(360deg)}}@keyframes kitrotate{to{transform:rotate(90deg)}}@keyframes shine{0%{background-position:0 0}to{background-position:-200% 0}}.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-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-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-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@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/apps/dashboard/static/sticky-test.html b/apps/dashboard/static/sticky-test.html new file mode 100644 index 00000000..922d4efe --- /dev/null +++ b/apps/dashboard/static/sticky-test.html @@ -0,0 +1,103 @@ + + + + + + Sticky Test + + + + +
Scroll the page. Header below should stick.
+ + +
+ +
+
Sidebar
+
+ + +
+ +
+
AppHeader (should stick)
+
+ + +
+
+

Page Content

+
+
+
+
+
+ + + + diff --git a/apps/dashboard/static/templates/bold.png b/apps/dashboard/static/templates/bold.png new file mode 100644 index 00000000..c434e869 Binary files /dev/null and b/apps/dashboard/static/templates/bold.png differ diff --git a/apps/dashboard/static/templates/classic.png b/apps/dashboard/static/templates/classic.png new file mode 100644 index 00000000..b2462849 Binary files /dev/null and b/apps/dashboard/static/templates/classic.png differ diff --git a/apps/dashboard/static/templates/minimal.png b/apps/dashboard/static/templates/minimal.png new file mode 100644 index 00000000..33c86070 Binary files /dev/null and b/apps/dashboard/static/templates/minimal.png differ diff --git a/apps/dashboard/static/templates/widget-layouts/card_grid.png b/apps/dashboard/static/templates/widget-layouts/card_grid.png new file mode 100644 index 00000000..f53cc911 Binary files /dev/null and b/apps/dashboard/static/templates/widget-layouts/card_grid.png differ diff --git a/apps/dashboard/static/templates/widget-layouts/carousel.png b/apps/dashboard/static/templates/widget-layouts/carousel.png new file mode 100644 index 00000000..e11f57ab Binary files /dev/null and b/apps/dashboard/static/templates/widget-layouts/carousel.png differ diff --git a/apps/dashboard/static/templates/widget-layouts/category_shelf.png b/apps/dashboard/static/templates/widget-layouts/category_shelf.png new file mode 100644 index 00000000..d134cf96 Binary files /dev/null and b/apps/dashboard/static/templates/widget-layouts/category_shelf.png differ diff --git a/apps/dashboard/static/templates/widget-layouts/compact_list.png b/apps/dashboard/static/templates/widget-layouts/compact_list.png new file mode 100644 index 00000000..78dc24b4 Binary files /dev/null and b/apps/dashboard/static/templates/widget-layouts/compact_list.png differ diff --git a/apps/dashboard/static/templates/widget-layouts/editorial_spotlight.png b/apps/dashboard/static/templates/widget-layouts/editorial_spotlight.png new file mode 100644 index 00000000..d1fdc19c Binary files /dev/null and b/apps/dashboard/static/templates/widget-layouts/editorial_spotlight.png differ diff --git a/apps/dashboard/static/templates/widget-layouts/primary_course.png b/apps/dashboard/static/templates/widget-layouts/primary_course.png new file mode 100644 index 00000000..13c3e45a Binary files /dev/null and b/apps/dashboard/static/templates/widget-layouts/primary_course.png differ diff --git a/apps/dashboard/static/templates/widget-layouts/tag_filter.png b/apps/dashboard/static/templates/widget-layouts/tag_filter.png new file mode 100644 index 00000000..71298bc9 Binary files /dev/null and b/apps/dashboard/static/templates/widget-layouts/tag_filter.png differ diff --git a/apps/dashboard/svelte.config.js b/apps/dashboard/svelte.config.js index 49f19e12..f7b70687 100644 --- a/apps/dashboard/svelte.config.js +++ b/apps/dashboard/svelte.config.js @@ -2,9 +2,9 @@ import 'dotenv/config'; import adapterAuto from '@sveltejs/adapter-auto'; import adapterNode from '@sveltejs/adapter-node'; +import { getCspDomains } from './src/lib/utils/csp-domains.js'; import path from 'path'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; -import { getCspDomains } from './src/lib/utils/csp-domains.js'; const isSelfHosted = process.env.PUBLIC_IS_SELFHOSTED === 'true'; const IS_CLOUDFLARE = process.env.CI_ENVIRONMENT === 'cloudflare'; @@ -54,7 +54,8 @@ const config = { 'object-src': ['none'], 'base-uri': ['self'], 'form-action': ['self'], - 'frame-ancestors': ['none'], + // 'self' allows same-origin iframes (e.g. widget preview at /widget-preview). 'none' blocks all embedding. + 'frame-ancestors': ['self'], 'upgrade-insecure-requests': true }, reportOnly: { @@ -78,7 +79,7 @@ const config = { 'object-src': ['none'], 'base-uri': ['self'], 'form-action': ['self'], - 'frame-ancestors': ['none'], + 'frame-ancestors': ['self'], 'report-uri': ['/csp-report'] } } diff --git a/apps/embeds/README.md b/apps/embeds/README.md index c6c85573..6546d5dd 100644 --- a/apps/embeds/README.md +++ b/apps/embeds/README.md @@ -128,3 +128,61 @@ pnpm build && npx serve . -p 5190 1. Add `src/widgets//widget.svelte` and a bootstrap entry (same pattern as `question-type-picker`). 2. Add a dedicated `build.lib.entry` or Vite config if you need a separate publishable script. + +## Course widget host split + +The course widget uses three different URL concerns: + +1. **Embed script host** + +- this is where the browser downloads `course-widget.js` +- production example: `https://assets.cdn.clsrio.com/embeds/course-widget/course-widget.js` +- configured on the API side with `PUBLIC_EMBED_BASE_URL` + +2. **Payload API host** + +- this is where the widget fetches `GET /widgets/:publicKey/payload` +- it reuses the existing API public URL via `PUBLIC_SERVER_URL` +- the generated embed snippet includes that host as `data-api-base-url` + +3. **Course destination host** + +- this is where end users are sent when they click a course +- it comes from the organization public site or custom domain, not the API host + +These are intentionally separate. The widget fetches data from the API host, but links users to the organization’s public course pages. + +## Course widget local development + +Local development should not depend on deployed CDN assets. + +### Expected local hosts + +- embed script host: `PUBLIC_EMBED_BASE_URL` +- payload API host: existing `PUBLIC_SERVER_URL` + +When `PUBLIC_EMBED_BASE_URL` is unset and the API runs in development, widget embed code falls back to: + +```text +http://localhost:5180 +``` + +That matches the `@cio/embeds` Vite dev server. + +When `PUBLIC_SERVER_URL` is set to your local API URL, the generated snippet will look like: + +```html +
+ +``` + +### Dev server path shape + +The embeds Vite dev server rewrites these stable production-style paths: + +- `/embeds/question-type-picker/question-type-picker.js` +- `/embeds/course-widget/course-widget.js` + +to the underlying source entry files during development. + +That means dashboard-generated widget embed snippets can use the same path shape in local development and production without requiring the CDN to exist first. diff --git a/apps/embeds/example-embed.html b/apps/embeds/example-embed.html index bf53e9e1..588c3639 100644 --- a/apps/embeds/example-embed.html +++ b/apps/embeds/example-embed.html @@ -13,12 +13,20 @@ } + -

+ + +

+ diff --git a/apps/embeds/package.json b/apps/embeds/package.json index 602415ee..b1f66f6d 100644 --- a/apps/embeds/package.json +++ b/apps/embeds/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "vite dev --port 5180", "prebuild": "pnpm --filter @cio/ui run build", - "build": "rm -rf dist && vite build", + "build:question-type-picker": "vite build --mode question-type-picker", + "build:course-widget": "vite build --mode course-widget", + "build": "rm -rf dist && pnpm build:question-type-picker && pnpm build:course-widget", "format": "prettier --write .", "preview": "vite preview --port 4180", "upload:embeds": "tsx scripts/upload-embeds.ts", @@ -26,6 +28,7 @@ }, "dependencies": { "@cio/ui": "workspace:*", + "@cio/utils": "workspace:*", "@cio/question-types": "workspace:*", "@lucide/svelte": "^0.562.0" } diff --git a/apps/embeds/scripts/upload-embeds.ts b/apps/embeds/scripts/upload-embeds.ts index 6d3617ad..0e92306c 100644 --- a/apps/embeds/scripts/upload-embeds.ts +++ b/apps/embeds/scripts/upload-embeds.ts @@ -16,8 +16,16 @@ import { readFileSync, readdirSync, statSync } from 'node:fs'; const BUCKET = 'assets'; -/** R2 object key prefix (no trailing slash). */ -const EMBED_WIDGET_PREFIX = 'embeds/question-type-picker'; +const EMBED_WIDGET_TARGETS = [ + { + buildDirName: 'question-type-picker', + prefix: 'embeds/question-type-picker' + }, + { + buildDirName: 'course-widget', + prefix: 'embeds/course-widget' + } +] as const; /** Cloudflare allows a limited number of URLs per purge request. */ const PURGE_URL_BATCH = 30; @@ -88,10 +96,10 @@ async function purgeCloudflareCdnForUrls(urls: string[]): Promise { class EmbedsUploader { private s3Client: S3Client | null = null; - private readonly buildDir: string; + private readonly buildRootDir: string; constructor() { - this.buildDir = join(process.cwd(), 'dist'); + this.buildRootDir = join(process.cwd(), 'dist'); this.initializeS3Client(); } @@ -138,7 +146,7 @@ class EmbedsUploader { if (filePath.match(/\.(js|mjs)$/)) { const baseName = filePath.split(/[/\\]/).pop() ?? filePath; // Stable bootstrap URL must revalidate quickly so new chunk hashes roll out after deploy. - if (baseName === 'question-type-picker.js') { + if (baseName === 'question-type-picker.js' || baseName === 'course-widget.js') { return 'public, max-age=300, must-revalidate'; } return 'public, max-age=31536000, immutable'; @@ -167,17 +175,17 @@ class EmbedsUploader { return files; } - async uploadFile(relativePath: string): Promise { + async uploadFile(targetPrefix: string, buildDir: string, relativePath: string): Promise { if (!this.s3Client) { throw new Error('S3 client not initialized'); } - const fullPath = join(this.buildDir, relativePath); + const fullPath = join(buildDir, relativePath); const content = readFileSync(fullPath); const contentType = this.getContentType(relativePath); const cacheControl = this.getCacheControl(relativePath); - const key = `${EMBED_WIDGET_PREFIX}/${relativePath.split('\\').join('/')}`; - + const normalizedRelativePath = relativePath.split('\\').join('/'); + const key = `${targetPrefix}/${normalizedRelativePath}`; const command = new PutObjectCommand({ Bucket: BUCKET, Key: key, @@ -191,36 +199,45 @@ class EmbedsUploader { } async uploadAll(): Promise { - try { - statSync(this.buildDir); - } catch { - throw new Error(`Build directory not found: ${this.buildDir}. Run pnpm build in apps/embeds first.`); - } - console.log('Uploading embeds to R2...'); - console.log(`Build directory: ${this.buildDir}`); - console.log(`Key prefix: ${EMBED_WIDGET_PREFIX}/`); - - const files = this.getAllFiles(this.buildDir); - if (files.length === 0) { - throw new Error('No files in dist/. Run pnpm build first.'); - } - - console.log(`Found ${files.length} files to upload`); + console.log(`Build root directory: ${this.buildRootDir}`); + const purgeUrls: string[] = []; + const publicBase = normalizePublicBaseUrl(process.env.EMBED_CDN_BASE_URL); const batchSize = 10; - for (let i = 0; i < files.length; i += batchSize) { - const batch = files.slice(i, i + batchSize); - await Promise.all(batch.map((file) => this.uploadFile(file))); - console.log(`Batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(files.length / batchSize)}`); + + for (const target of EMBED_WIDGET_TARGETS) { + const buildDir = join(this.buildRootDir, target.buildDirName); + + try { + statSync(buildDir); + } catch { + throw new Error(`Build directory not found: ${buildDir}. Run pnpm build in apps/embeds first.`); + } + + console.log(`Target prefix: ${target.prefix}`); + + const files = this.getAllFiles(buildDir); + if (files.length === 0) { + throw new Error(`No files found in ${buildDir}. Run pnpm build first.`); + } + + console.log(`Found ${files.length} file(s) for ${target.buildDirName}`); + + for (let i = 0; i < files.length; i += batchSize) { + const batch = files.slice(i, i + batchSize); + await Promise.all(batch.map((file) => this.uploadFile(target.prefix, buildDir, file))); + console.log( + `Batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(files.length / batchSize)} for ${target.buildDirName}` + ); + } + + purgeUrls.push( + ...files.map((relativePath) => `${publicBase}/${target.prefix}/${relativePath.split('\\').join('/')}`) + ); } console.log('Embeds upload completed.'); - - const publicBase = normalizePublicBaseUrl(process.env.EMBED_CDN_BASE_URL); - const purgeUrls = files.map( - (relativePath) => `${publicBase}/${EMBED_WIDGET_PREFIX}/${relativePath.split('\\').join('/')}` - ); await purgeCloudflareCdnForUrls(purgeUrls); } } @@ -232,7 +249,8 @@ async function main(): Promise { console.log(` Usage: tsx scripts/upload-embeds.ts -Uploads apps/embeds/dist/* to R2 under ${EMBED_WIDGET_PREFIX}/ +Uploads widget-specific embed builds under: +${EMBED_WIDGET_TARGETS.map((target) => ` - dist/${target.buildDirName} -> ${target.prefix}`).join('\n')} Environment (same as Storybook R2 upload): CLOUDFLARE_ACCESS_KEY @@ -264,4 +282,4 @@ if (isMain) { }); } -export { EmbedsUploader, EMBED_WIDGET_PREFIX }; +export { EmbedsUploader, EMBED_WIDGET_TARGETS }; diff --git a/apps/embeds/src/widgets/course-widget/bootstrap.ts b/apps/embeds/src/widgets/course-widget/bootstrap.ts new file mode 100644 index 00000000..0eb9a502 --- /dev/null +++ b/apps/embeds/src/widgets/course-widget/bootstrap.ts @@ -0,0 +1,127 @@ +const WIDGET_ATTR = 'data-cio-widget'; +const WIDGET_VALUE = 'course-widget'; +const WIDGET_KEY_ATTR = 'data-widget-key'; +const API_BASE_ATTR = 'data-api-base-url'; +const LOADING_CLASS = 'cio-embed-loading'; +const EMBED_MIN_HEIGHT_PX = 360; + +export type CourseWidgetEmbedApi = { + mount: (element: HTMLElement, options: { publicKey: string; apiBaseUrl?: string }) => { destroy: () => void }; +}; + +declare global { + interface Window { + CIO?: { + apiBaseUrl?: string; + courseWidget?: CourseWidgetEmbedApi; + }; + } +} + +let stylesInjected = false; + +function injectSpinnerStyles(): void { + if (stylesInjected) return; + stylesInjected = true; + + const css = ` +@keyframes cio-embed-spin { to { transform: rotate(360deg); } } +.${LOADING_CLASS} { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + min-height: ${EMBED_MIN_HEIGHT_PX}px; + padding: 1rem; + color: oklch(0.556 0 0); +} +.${LOADING_CLASS} svg { + animation: cio-embed-spin 0.8s linear infinite; + flex-shrink: 0; +} +`; + + const styleEl = document.createElement('style'); + styleEl.setAttribute('data-cio-embed', 'course-widget-loading'); + styleEl.textContent = css; + document.head.appendChild(styleEl); +} + +const LOADER_SVG = ``; + +function renderLoading(container: HTMLElement): void { + injectSpinnerStyles(); + + const root = document.createElement('div'); + root.className = LOADING_CLASS; + root.setAttribute('role', 'status'); + root.setAttribute('aria-live', 'polite'); + root.setAttribute('aria-label', 'Loading'); + root.innerHTML = LOADER_SVG; + + container.replaceChildren(root); +} + +function collectTargets(): Array<{ element: HTMLElement; publicKey: string; apiBaseUrl?: string }> { + return [...document.querySelectorAll(`[${WIDGET_ATTR}="${WIDGET_VALUE}"]`)] + .map((element) => ({ + element, + publicKey: element.getAttribute(WIDGET_KEY_ATTR) ?? '', + apiBaseUrl: element.getAttribute(API_BASE_ATTR) ?? window.CIO?.apiBaseUrl + })) + .filter((target) => Boolean(target.publicKey)); +} + +function scheduleMount(targets: Array<{ element: HTMLElement; publicKey: string; apiBaseUrl?: string }>): void { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + void import('./embed-mount.ts').then((mod) => { + mod.mountAllAuto(targets); + }); + }); + }); +} + +function manualMount( + element: HTMLElement, + options: { publicKey: string; apiBaseUrl?: string } +): { destroy: () => void } { + renderLoading(element); + + let cancelled = false; + let destroyWidget: (() => void) | null = null; + + void import('./embed-mount.ts').then((mod) => { + if (cancelled) return; + destroyWidget = mod.mountWidget(element, options).destroy; + }); + + return { + destroy: () => { + cancelled = true; + destroyWidget?.(); + element.replaceChildren(); + } + }; +} + +function init(): void { + const targets = collectTargets(); + + for (const target of targets) { + renderLoading(target.element); + } + + window.CIO = window.CIO ?? {}; + window.CIO.courseWidget = { + mount: manualMount + }; + + scheduleMount(targets); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init, { once: true }); +} else { + init(); +} diff --git a/apps/embeds/src/widgets/course-widget/embed-mount.ts b/apps/embeds/src/widgets/course-widget/embed-mount.ts new file mode 100644 index 00000000..f736f35e --- /dev/null +++ b/apps/embeds/src/widgets/course-widget/embed-mount.ts @@ -0,0 +1,51 @@ +import { mount, unmount } from 'svelte'; +import { widgetStyles } from '@cio/ui/custom/widget-layouts'; + +import Widget from './widget.svelte'; +import shellCss from './widget.css?raw'; + +function createShadowHost(target: HTMLElement): { shadowRoot: ShadowRoot; mountPoint: HTMLElement } { + target.replaceChildren(); + + const host = document.createElement('cio-course-widget'); + target.appendChild(host); + + const shadowRoot = host.attachShadow({ mode: 'open' }); + + const styleElement = document.createElement('style'); + styleElement.textContent = `${shellCss}\n${widgetStyles}`; + shadowRoot.appendChild(styleElement); + + const mountPoint = document.createElement('div'); + shadowRoot.appendChild(mountPoint); + + return { shadowRoot, mountPoint }; +} + +export function mountWidget( + target: HTMLElement, + options: { publicKey: string; apiBaseUrl?: string } +): { destroy: () => void } { + const { mountPoint } = createShadowHost(target); + + const instance = mount(Widget, { + target: mountPoint, + props: options + }); + + return { + destroy: () => { + unmount(instance); + target.replaceChildren(); + } + }; +} + +export function mountAllAuto(targets: Array<{ element: HTMLElement; publicKey: string; apiBaseUrl?: string }>): void { + for (const target of targets) { + mountWidget(target.element, { + publicKey: target.publicKey, + apiBaseUrl: target.apiBaseUrl + }); + } +} diff --git a/apps/embeds/src/widgets/course-widget/widget.css b/apps/embeds/src/widgets/course-widget/widget.css new file mode 100644 index 00000000..02c719fa --- /dev/null +++ b/apps/embeds/src/widgets/course-widget/widget.css @@ -0,0 +1,44 @@ +:host { + all: initial; + display: block; + width: 100%; +} + +.cio-widget-shell { + font-family: 'DM Sans', Inter, ui-sans-serif, system-ui, sans-serif; + color: rgba(15, 23, 42, 0.8); + background: transparent; + display: block; + width: 100%; + box-sizing: border-box; + padding: 16px; +} + +.cio-widget-loading, +.cio-widget-error { + min-height: 320px; + display: flex; + align-items: center; + justify-content: center; +} + +.cio-widget-error p { + margin: 0; + font-size: 14px; + color: #b91c1c; +} + +.cio-widget-spinner { + width: 24px; + height: 24px; + border: 2px solid rgba(15, 23, 42, 0.1); + border-top-color: rgba(15, 23, 42, 0.6); + border-radius: 999px; + animation: cio-widget-spin 0.8s linear infinite; +} + +@keyframes cio-widget-spin { + to { + transform: rotate(360deg); + } +} diff --git a/apps/embeds/src/widgets/course-widget/widget.svelte b/apps/embeds/src/widgets/course-widget/widget.svelte new file mode 100644 index 00000000..6d5934a2 --- /dev/null +++ b/apps/embeds/src/widgets/course-widget/widget.svelte @@ -0,0 +1,47 @@ + + +{#if errorMessage} +
+

{errorMessage}

+
+{:else if !payload} +
+ +
+{:else} + +{/if} diff --git a/apps/embeds/vite.config.ts b/apps/embeds/vite.config.ts index bc71cdb0..275a0621 100644 --- a/apps/embeds/vite.config.ts +++ b/apps/embeds/vite.config.ts @@ -7,44 +7,82 @@ import { defineConfig } from 'vite'; import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'; const dirname = path.dirname(fileURLToPath(import.meta.url)); +const embedEntries = { + 'question-type-picker': path.resolve(dirname, 'src/widgets/question-type-picker/bootstrap.ts'), + 'course-widget': path.resolve(dirname, 'src/widgets/course-widget/bootstrap.ts') +} as const; +const embedDevEntrypoints = { + '/embeds/question-type-picker/question-type-picker.js': '/src/widgets/question-type-picker/bootstrap.ts', + '/embeds/course-widget/course-widget.js': '/src/widgets/course-widget/bootstrap.ts' +} as const; -export default defineConfig(({ command }) => ({ - root: dirname, - plugins: [tailwindcss(), svelte(), cssInjectedByJsPlugin()], - resolve: { - alias: { - '@cio/ui': path.resolve(dirname, 'node_modules/@cio/ui/src'), - '@cio/ui/*': path.resolve(dirname, 'node_modules/@cio/ui/src/*'), - $src: path.resolve(dirname, 'node_modules/@cio/ui/src') +function embedDevEntryPlugin() { + return { + name: 'embed-dev-entry-plugin', + configureServer(server: { + middlewares: { use: (handler: (req: { url?: string }, res: unknown, next: () => void) => void) => void }; + }) { + server.middlewares.use((req, _res, next) => { + const requestUrl = req.url ?? ''; + const [pathname, search = ''] = requestUrl.split('?'); + const rewrittenPath = embedDevEntrypoints[pathname as keyof typeof embedDevEntrypoints]; + + if (rewrittenPath) { + req.url = search ? `${rewrittenPath}?${search}` : rewrittenPath; + } + + next(); + }); + } + }; +} + +export default defineConfig(({ command, mode }) => { + const buildTarget = mode in embedEntries ? (mode as keyof typeof embedEntries) : null; + const buildEntry = buildTarget ? { [buildTarget]: embedEntries[buildTarget] } : embedEntries; + + return { + root: dirname, + plugins: [embedDevEntryPlugin(), tailwindcss(), svelte(), cssInjectedByJsPlugin()], + resolve: { + alias: { + '@cio/ui': path.resolve(dirname, 'node_modules/@cio/ui/src'), + '@cio/ui/*': path.resolve(dirname, 'node_modules/@cio/ui/src/*'), + $src: path.resolve(dirname, 'node_modules/@cio/ui/src') + }, + conditions: ['svelte', 'browser', 'import'] }, - conditions: ['svelte', 'browser', 'import'] - }, - optimizeDeps: { - include: ['svelte-tiptap'], - exclude: ['@tiptap/core', '@tiptap/pm'] - }, - build: - command === 'build' - ? { - outDir: 'dist', - emptyOutDir: true, - cssCodeSplit: false, - lib: { - entry: path.resolve(dirname, 'src/widgets/question-type-picker/bootstrap.ts'), - name: 'CIOEmbeds', - formats: ['es'], - fileName: () => 'question-type-picker.js' - }, - rollupOptions: { - output: { - inlineDynamicImports: false, - // Hashed names so CDN/browser can cache chunks immutably; bootstrap entry stays `question-type-picker.js`. - chunkFileNames: 'question-type-picker-[name]-[hash].js' + optimizeDeps: { + include: ['svelte-tiptap'], + exclude: ['@tiptap/core', '@tiptap/pm'] + }, + build: + command === 'build' + ? { + outDir: buildTarget ? `dist/${buildTarget}` : 'dist', + emptyOutDir: false, + cssCodeSplit: false, + lib: { + entry: buildEntry, + name: 'CIOEmbeds', + formats: ['es'], + fileName: (_format, entryName) => `${entryName}.js` + }, + rollupOptions: { + output: { + inlineDynamicImports: false, + chunkFileNames: '[name]-[hash].js' + } } } - } - : {}, - server: { - port: 5180 - } -})); + : {}, + server: { + port: 5180, + cors: true + }, + preview: { + port: 4180, + cors: true + } + }; +}); diff --git a/apps/website/src/blog/students-analytics.md b/apps/website/src/blog/students-analytics.md index a99502b2..6a988a86 100644 --- a/apps/website/src/blog/students-analytics.md +++ b/apps/website/src/blog/students-analytics.md @@ -21,16 +21,7 @@ The audience page as you already know allows you to view the students in your **
- + ## Course Student Analytics @@ -43,17 +34,7 @@ Further more, when you go into any course and go to the people tab, you can view
- + ## What's Next? diff --git a/apps/website/src/lib/components/features-section.svelte b/apps/website/src/lib/components/features-section.svelte index ff2d3aa3..f0e60b32 100644 --- a/apps/website/src/lib/components/features-section.svelte +++ b/apps/website/src/lib/components/features-section.svelte @@ -1,34 +1,45 @@ @@ -49,8 +60,10 @@ {#each features as feature, i}
-
- {feature.icon} +
+

{feature.title}

{feature.description}

diff --git a/apps/website/src/lib/components/footer.svelte b/apps/website/src/lib/components/footer.svelte index 825e3ea6..179b0745 100644 --- a/apps/website/src/lib/components/footer.svelte +++ b/apps/website/src/lib/components/footer.svelte @@ -11,7 +11,7 @@

The Open Source Learning Management System for Companies
- Nail your compliance training and your certification program. + The LMS for compliance training and certification programs.

- + + + + + + + Document + + + +
+
+ + + + \ No newline at end of file diff --git a/package.json b/package.json index 24587e51..c7c2d53e 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "clean": "turbo clean && find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +", "clean:pnpm": "pnpm clean && pnpm store prune", "format": "turbo run format", - "format:check": "git diff --name-only -z --diff-filter=d HEAD~1 | xargs -0 prettier --check --ignore-unknown", + "format:check": "git diff --name-only -z --diff-filter=d HEAD~1 | xargs -0 -I{} sh -c '[ ! -L \"{}\" ] && printf \"%s\\0\" \"{}\"' | xargs -0 prettier --check --ignore-unknown", "postinstall": "test -d .git && lefthook install || true", "release": "standard-version", "db:reset": "pnpm --filter @cio/db db:reset", diff --git a/packages/ai-assistant/package.json b/packages/ai-assistant/package.json new file mode 100644 index 00000000..f3b84c29 --- /dev/null +++ b/packages/ai-assistant/package.json @@ -0,0 +1,47 @@ +{ + "name": "@cio/ai-assistant", + "version": "1.0.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./providers": { + "types": "./dist/providers/index.d.ts", + "default": "./dist/providers/index.js" + }, + "./tools": { + "types": "./dist/tools/index.d.ts", + "default": "./dist/tools/index.js" + }, + "./prompt": { + "types": "./dist/prompt/index.d.ts", + "default": "./dist/prompt/index.js" + } + }, + "scripts": { + "build": "tsc && tsc-alias", + "dev": "tsc --watch", + "format": "prettier --write \"src/**/*.ts\"" + }, + "dependencies": { + "@ai-sdk/anthropic": "^3.0.69", + "@ai-sdk/google": "^3.0.0", + "@ai-sdk/openai": "^3.0.52", + "ai": "^6.0.158", + "zod": "^3.24.4" + }, + "devDependencies": { + "@cio/tsconfig": "workspace:*", + "@types/node": "^24.10.1", + "prettier": "^3.2.5", + "tsc-alias": "^1.8.16", + "typescript": "^5.5.2" + } +} diff --git a/packages/ai-assistant/src/index.ts b/packages/ai-assistant/src/index.ts new file mode 100644 index 00000000..9f0b2712 --- /dev/null +++ b/packages/ai-assistant/src/index.ts @@ -0,0 +1,38 @@ +// Types +export { + AgentRole, + AIProvider, + ToolName, + CoursePlanSchema, + CoursePlanSectionSchema, + CoursePlanItemSchema, + MAX_DOCUMENT_TEXT_LENGTH, + MAX_AGENT_DOCUMENT_SIZE, + DOCUMENT_REDIS_TTL, + MAX_STEPS_PER_ROUND, + SUPPORTED_DOCUMENT_TYPES, + SUPPORTED_DOCUMENT_EXTENSIONS, + TOKEN_COST_ESTIMATES +} from './types'; + +export type { + AIProviderConfig, + AgentContext, + CoursePlan, + CoursePlanSection, + CoursePlanItem, + TokenUsage, + TokenBalance, + AgentStatus, + DocumentUploadResult +} from './types'; + +// Providers +export { createModel, getProviderConfigForProvider, pickAnyConfiguredProvider } from './providers'; + +// Tools +export { getToolSchemas } from './tools'; +export type { ToolSchema } from './tools'; + +// Prompts +export { buildSystemPrompt, buildTeacherSystemPrompt, buildStudentSystemPrompt } from './prompt'; diff --git a/packages/ai-assistant/src/prompt/index.ts b/packages/ai-assistant/src/prompt/index.ts new file mode 100644 index 00000000..d770d7bd --- /dev/null +++ b/packages/ai-assistant/src/prompt/index.ts @@ -0,0 +1,21 @@ +import { AgentRole, type AgentContext } from '../types'; +import { buildTeacherSystemPrompt } from './teacher'; +import { buildStudentSystemPrompt } from './student'; + +/** + * Builds a role-aware system prompt for the AI assistant. + * Teacher prompts include Plan/Agent mode instructions and content writing guidelines. + * Student prompts (v2) are read-only focused. + */ +export function buildSystemPrompt(context: AgentContext): string { + switch (context.role) { + case AgentRole.TEACHER: + return buildTeacherSystemPrompt(context); + case AgentRole.STUDENT: + return buildStudentSystemPrompt(context); + default: + return buildTeacherSystemPrompt(context); + } +} + +export { buildTeacherSystemPrompt, buildStudentSystemPrompt }; diff --git a/packages/ai-assistant/src/prompt/student.ts b/packages/ai-assistant/src/prompt/student.ts new file mode 100644 index 00000000..744cbf57 --- /dev/null +++ b/packages/ai-assistant/src/prompt/student.ts @@ -0,0 +1,25 @@ +import type { AgentContext } from '../types'; + +/** + * Student system prompt (v2 stub). + * Will be implemented in Phase 5 when student assistant is enabled. + */ +export function buildStudentSystemPrompt(context: AgentContext): string { + return `You are an AI assistant for ClassroomIO, helping a student learn course material. + +You can explain concepts, answer questions about the material, and help with exercises. You CANNOT modify any course content. + +## Rules + +- NEVER pretend to perform an action you don't have a tool for +- NEVER fabricate data you haven't retrieved via a tool +- Only use information from the course content to answer questions +- If the student asks about something not covered in the course, say so clearly + +## Current Context + +Course: "${context.courseTitle}" (ID: ${context.courseId}) +${context.courseDescription ? `Course description: ${context.courseDescription}` : ''} +${context.lessonId ? `Currently viewing lesson ID: ${context.lessonId}` : ''} +${context.exerciseId ? `Currently viewing exercise ID: ${context.exerciseId}` : ''}`; +} diff --git a/packages/ai-assistant/src/prompt/teacher.ts b/packages/ai-assistant/src/prompt/teacher.ts new file mode 100644 index 00000000..9ed37403 --- /dev/null +++ b/packages/ai-assistant/src/prompt/teacher.ts @@ -0,0 +1,214 @@ +import type { AgentContext } from '../types'; + +export function buildTeacherSystemPrompt(context: AgentContext): string { + const contextLines: string[] = []; + + contextLines.push(`Course: "${context.courseTitle}" (ID: ${context.courseId})`); + if (context.courseDescription) { + contextLines.push(`Course description: ${context.courseDescription}`); + } + + if (context.lessonId) { + const lessonInfo = context.lessonTitle + ? `The teacher is currently viewing lesson "${context.lessonTitle}" (ID: ${context.lessonId})` + : `The teacher is currently viewing lesson ID: ${context.lessonId}`; + contextLines.push(lessonInfo); + + if (context.lessonContent) { + contextLines.push(`Current lesson content:\n\n\n${context.lessonContent}\n`); + } else { + contextLines.push('This lesson has no content yet.'); + } + } + if (context.exerciseId) { + const exerciseInfo = context.exerciseTitle + ? `The teacher is currently viewing exercise "${context.exerciseTitle}" (ID: ${context.exerciseId})` + : `The teacher is currently viewing exercise ID: ${context.exerciseId}`; + contextLines.push(exerciseInfo); + } + if (context.documentText) { + contextLines.push( + `The teacher has uploaded a document. Use this content as the source material for course planning and content generation:\n\n\n${context.documentText}\n` + ); + } + if (context.existingSectionCount && context.existingSectionCount > 0) { + contextLines.push( + `This course already has ${context.existingSectionCount} sections. When creating new sections, set their order values starting after the existing sections.` + ); + } + + const currentContext = contextLines.length > 0 ? `\n\n## Current Context\n\n${contextLines.join('\n\n')}` : ''; + + return `You are an AI assistant for ClassroomIO, helping a teacher create and organize course content. + +## Your Capabilities + +You have access to specific tools listed below. Use them to read course content, create sections and lessons, update existing section metadata via update_section, update existing lesson metadata via update_lesson, write lesson content, create exercises with questions, edit exercise metadata (title, description, linked lesson, section, order, due date, lock state, allow-multiple-attempts) via update_exercise, add or update questions inside an existing exercise, update course landing-page copy/settings, check go-live readiness, and publish the course when it is ready. To change a question's text, points, options, or correct answer, use update_questions; to add new questions, use add_questions. + +## Plan Mode vs Agent Mode + +**Plan Mode** — When the teacher asks you to design a course structure, plan a course, or uploads a document: +1. Analyze the input (document content, topic description, or teacher's request). If the teacher has not provided additional details, use the course title (and description, if available) from the Current Context as the subject — do NOT ask the teacher what the course is about, you already have that information. +2. Use the generate_course_plan tool to propose a structured course plan with sections, lessons, and exercises +3. Each item has a type: "lesson" for content lessons, "exercise" for standalone quizzes/assessments +4. Place standalone exercises (like section quizzes) as separate items with type "exercise" at the end of a section — do NOT create them as lessons +5. For lessons that should also have a linked exercise, set hasExercise: true on the lesson item +6. Wait for the teacher to approve, request changes, or reject the plan +7. Do NOT create any sections, lessons, or exercises until the teacher explicitly approves + +**Agent Mode** — When the teacher approves a plan or asks you to perform a specific action: +1. Execute the requested actions using the appropriate tools +2. When implementing an approved plan: + - Create sections first + - For each section, iterate through its items in order + - Items with type "lesson": use create_lesson, then update_lesson_content to write content + - Items with type "exercise": use create_exercise with quiz questions (MCQ, true/false, etc.) + - Items with type "lesson" and hasExercise: true: create the lesson, write content, then also create a linked exercise +3. If the teacher asks to rename or otherwise edit an existing section or lesson, use update_section or update_lesson on the existing item instead of creating a new one +4. Report progress as you go +5. When implementing an approved plan or adding net-new content, append new sections after existing ones. Do not modify existing content unless the teacher explicitly asked you to edit, rename, or reorganize existing items. + +**Go-Live / Publishing** — When the teacher asks whether the course is ready, wants a launch checklist, or asks to go live: +1. Use check_course_go_live_readiness first to inspect required course details, landing-page fields, lessons, and exercises +2. If blockers are returned, explain the blockers clearly and use available tools to fix only the items the teacher asks you to fix +3. Use update_course_landing_page for course-level public copy, overview, goals, requirements, instructor metadata, pricing, and banner image fields +4. Use go_live_course only when the teacher explicitly asks to publish/go live; it runs the readiness checklist again and will fail if blockers remain +5. Never claim the course is live unless go_live_course returns success + +### Ordering within a section + +Lessons and exercises share the SAME 0-based order space within a section. When you create items in a section, increment \`order\` by 1 across both lessons and exercises in the sequence the teacher should encounter them. An end-of-section quiz must have a higher \`order\` than every lesson it follows — never reuse a lesson's order for an exercise, and never leave \`order\` unset when creating into a section. + +Example for a section that ends with a recap quiz: +- create_lesson "Intro" → order 0 +- create_lesson "Core concepts" → order 1 +- create_lesson "Examples" → order 2 +- create_exercise "Section quiz" → order 3 + +If a lesson has \`hasExercise: true\`, the linked exercise takes the next order after that lesson, and subsequent items shift up accordingly. + +**IMPORTANT**: Quizzes and assessments MUST be created using create_exercise (not create_lesson). An exercise contains questions with answer options. A lesson contains text content. Never confuse the two. + +## Updating Existing Metadata + +- If the teacher asks to change a section name or order, first call get_course_structure, then use update_section with the existing section ID. Do NOT create a replacement section. +- If the teacher asks to change a lesson name, move a lesson to another section, reorder it, schedule it, or change its visibility/unlock settings, first call get_course_structure, then use update_lesson with the existing lesson ID. Do NOT create a replacement lesson. +- If the teacher asks to change exercise metadata such as title, description, linked lesson, section, order, due date, lock state, or multiple-attempt behavior, use update_exercise. Do NOT create a replacement exercise. +- Only use create_section or create_lesson when the teacher clearly wants a brand-new item added to the course. + +## Editing Existing Questions + +To change an existing question (its text, points, order, correct answer, or options), use update_questions — never use add_questions for edits, as that creates duplicates. Where the correct answer lives depends on the question type: + +- RADIO, CHECKBOX, TRUE_FALSE: on \`options[].isCorrect\`. Include an option's \`id\` to edit it; omit \`id\` to add a new option. +- NUMERIC: on \`settings.correctValue\` (a number). Optional \`settings.tolerance\`. Do NOT add options to NUMERIC questions. +- STAR: on \`settings.correctValue\` (1..max stars). +- WORD_BANK: on \`settings.correctAnswers\` (array, one per \`___\` blank) and \`settings.template\`. + +Always call get_exercise_details first to read current ids and settings before patching. + +## IDs and Tool Arguments + +- UUIDs and database IDs must be copied EXACTLY from the most recent tool output that produced them. Never rewrite, shorten, reformat, "fix", or invent IDs from memory or pattern-matching. +- NEVER pass placeholder strings like \`"string"\`, \`"uuid"\`, \`""\`, or example IDs from this prompt as tool arguments. If you don't have a real ID in your context, call get_course_structure first to fetch one. +- Before calling any tool that takes a sectionId, lessonId, or exerciseId you didn't just create yourself, call get_course_structure and copy the exact UUID from its response. +- After create_section / create_lesson / create_exercise returns, the \`id\` it returns is the only valid ID for that new resource — use that exact value, never a guess. +- If a tool call fails with "does not exist in this course", "is not a valid UUID", or "belongs to a different course": stop, call get_course_structure, and use the IDs from its response. Do NOT retry with another guessed ID. +- Do not restate raw lesson, exercise, or section IDs in user-facing text unless the teacher explicitly asks for the IDs. +- If a tool call fails repeatedly with ID errors, surface that to the teacher rather than continuing to guess. + +## Content Writing Guidelines + +When generating lesson content with update_lesson_content: +- Put only the lesson body in the content. Do NOT include the lesson title — ClassroomIO already renders it separately in the UI +- Do NOT use

or

anywhere in lesson HTML. Start headings at

because that is the highest heading level allowed in lesson content +- Use only these HTML elements:

,

,

for section headings,

for paragraphs,