feat: complete rewrite of course (#622)

* theming improvements

* border style improvements

* refactor: migrate org routes to server-side data fetching

Move client-side API calls to server-side load functions for better
performance, security, and SEO. Implement permission-based course filtering

* refactor: clean up course and community components

This commit removes unused components and files related to courses and community features, streamlining the codebase. It also updates various routes to enhance performance and maintainability. Additionally, it introduces new types and constants for better type safety and organization.

* refactor: fix video formatting on website and other styles

* refactor: update course and dashboard components for improved styling and functionality

* more icon improvements

* refactor: streamline course and dashboard components

This commit updates various course and dashboard components for improved styling and functionality. It includes the removal of unused files, updates to imports and exports, and enhancements to type safety. Additionally, it introduces new routes and optimizes existing ones for better performance and maintainability.

* refactor: enhance lesson completion logic

* chore: delete all emails on dashboard and move other api routes to backend.

move all emails to the backend and

* feat: Refactor API and database queries for courses, quizzes, and lessons, migrating Supabase functions and schema management.

* get app loading

* fix ui and build error of cio/api

* refactor: move direct database queries to query layer

- Create query functions: getCourseWithOrgData, getOrganizationByCourseId, updateCourseVersion, updateLessonsSectionId, getProfileByGroupMemberId, getQuestionTypesByIds
- Refactor services to use query functions instead of direct db queries
- Add transaction support to updateCourseVersion and updateLessonsSectionId
- Fix getProfileByGroupMemberId to return profile directly (not nested)
- Fix updateLessonsSectionId to use .returning() instead of rowCount

This aligns services with architecture pattern where services orchestrate queries rather than executing them directly.

* exercise saving improvements

* fix and optimise exercise creation

* refactor: update dialog components for improved styling and functionality

- Adjusted dialog content width and maximum constraints for better responsiveness.
- Changed heading font size for consistency in the new exercise modal.
- Replaced CloseIcon with CrossIcon for a more modern appearance in dialog close button.

* refactor: enhance course and exercise management components

- Introduced new components for exercise creation, editing, and viewing, improving user experience.
- Refactored existing lesson and exercise components for better organization and maintainability.
- Updated routing for exercises and lessons to streamline navigation within the course structure.
- Added new utility functions and constants to support content management across the application.

* feat: implement course content management API and UI components

- Added new API routes for updating and deleting course content, enhancing content management capabilities.
- Introduced new Svelte components for displaying and managing course content, including content lists and action handlers.
- Updated existing course routing to integrate new content management features.
- Refactored content-related utility functions for improved organization and maintainability.

* refactor: remove Supabase references and implement course section management

- Removed Supabase-related environment variables and Dockerfile arguments to streamline configuration.
- Introduced a new section management API with routes for creating, updating, deleting, and reordering course sections.
- Updated course services to handle section operations, ensuring consistency in course content management.
- Refactored existing lesson section logic to align with the new course section structure, enhancing maintainability.

* chore: update .prettierignore to exclude additional file types

- Added exclusions for SQL and environment files to the Prettier ignore list.
- Included Dockerfile and .prettierignore itself to prevent formatting issues.

* feat: enhance course content management with translation support and UI improvements

- Added translation keys for user-facing text in course-related components, ensuring all UI copy is sourced from translation files.
- Updated lesson and exercise creation components to utilize translation keys for success and error messages, improving localization.
- Refactored course content management components to streamline the creation and editing process, enhancing user experience.
- Introduced new constants for content options and stepper states to standardize component behavior and improve maintainability.
- Improved styling and functionality of course content modals and steppers for better user interaction.

* feat: enhance course content management and UI components

- Updated .prettierignore to exclude storybook-static directory.
- Improved exercise creation logic by refining option handling and adding UUID support for option values.
- Enhanced navigation component with debugging output for custom links.
- Introduced a new method in CourseApi to update content items locally, improving state management during navigation.
- Added caching for exercise templates by tag in ExerciseTemplateApi to optimize performance.
- Refactored lesson and exercise components for better organization and maintainability, including updates to modal styling and functionality.
- Implemented local patching for lesson and exercise list items to ensure UI consistency after updates.

* student + teacher invite (not fully tested)

* refactor: streamline course team member middleware

- Removed unnecessary console logging and timing for checking user permissions.
- Simplified the logic for determining if a user is a course team member or organization admin.

* reorder course sidebar to move content to its own page.

* feat: implement unified course enrollment route and API

- Introduced a new enrollment route `/course/:slug/enroll` to replace the previous invite system.
- Unified enrollment logic for free and paid courses, allowing direct enrollment for free courses and invite token validation for paid courses.
- Updated course landing page to redirect to the new enrollment route for free courses.
- Removed obsolete invite routes and related logic, streamlining the enrollment process.
- Enhanced API with a new `POST /course/:courseId/enroll` endpoint for handling enrollments.
- Added rate limiting for enrollment requests to prevent abuse.
- Updated UI components to reflect changes in enrollment flow and messaging.

* chore: update docker stack, docs, and auth token automation

* chore: make prettier check ignore unknown file types

* fix: avoid using authClient on server file and keep authClient client focused

* chore: always get the server on demand depending on the environment

* chore: update dashboard configuration and Docker setup for environment variables

- Modified the Svelte configuration to directly use the PUBLIC_SERVER_URL environment variable.
- Enhanced the Docker Compose file to include health check formatting and added environment variable arguments for the dashboard build.
- Updated the Dockerfile to echo environment variable values during the build process, ensuring clarity on the configuration being used.

* chore: enhance Docker setup and documentation for production deployment

- Added a new production Docker Compose file and example environment file for published images.
- Updated README and usage documentation to include instructions for deploying with production images.
- Modified Svelte configuration to standardize the use of the posthog domain.
- Removed unnecessary console logs from the API client and layout server files for cleaner output.
- Improved overall clarity in Docker documentation regarding self-hosting and image publishing.

* chore(db): log function name in query catch blocks (console.error 'funcName error:', error)

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: multiple refresh issue

* fix: server key not passed to api

* fix: clarify usage of PRIVATE_SERVER_KEY and AUTH_BEARER_TOKEN in documentation

- Updated documentation to specify that PRIVATE_SERVER_KEY is used for server-to-server API key authentication and is validated by API key middleware.
- Clarified that AUTH_BEARER_TOKEN is a separate token not utilized by apiKeyMiddleware.
- Adjusted examples in Docker setup and self-hosting documentation to reflect these changes.

* fix migration script

* fix: improve language handling and add database reset script

- Updated language extraction to handle potential undefined values, defaulting to 'en' if necessary.
- Added a new script for resetting the database, including functionality to truncate public tables and run setup with optional seeding.

* feat: add promote ungrouped section functionality and enhance document handling in dashboard

- Implemented a new API endpoint to promote synthetic "Ungrouped" content into a real persisted section, allowing for better organization of course materials.
- Updated the dashboard to include a new component for displaying content count badges for lessons and exercises.
- Enhanced document management features, including a new document card component with dropdown actions for viewing, downloading, and removing documents.
- Improved file upload handling with a dedicated file drop zone and error handling for file type and size restrictions.
- Refactored existing components for better readability and maintainability, including updates to lesson and document lists.

* chore: use root env file for docker compose

* docs: document docker no-build startup flow

* chore: add no-build option to docker full stack script

* fix(email): handle SMTP TLS mode by port

* feat(auth): add configurable cookie domain env and docs

* apps/dashboard/src/lib/utils/services/auth/session.ts

* fix(email): make sender configurable via SMTP_SENDER

* chore(i18n): regenerate locale translations and support language targeting

* chore(config): switch api to PUBLIC_SERVER_URL

* fix(i18n): persist selected locale across navigation

* feat(assets): implement organization asset management routes and services

- Added new routes for managing organization assets, including listing, creating, updating, deleting, and exporting assets.
- Introduced asset-related services for handling asset operations such as creation, retrieval, and deletion.
- Integrated YouTube metadata resolution and asset attachment/detachment functionalities.
- Updated organization routes to include the new asset management routes.
- Enhanced error handling for asset-related operations.

* fix(document-list): update conditional rendering for edit mode in document list

* debug self hosted layout setup

* refactor(sidebar): simplify lms sidebar footer and improve navigation item rendering

* feat: tags

* chore: tags roadmap + translations

* fix(api): add authMiddleware to exercise, invites, and content routers

- exerciseRouter: apply authMiddleware before courseMemberMiddleware on all 10 routes
- invitesRouter: apply authMiddleware before courseTeamMemberMiddleware on all 4 routes
- contentRouter: add authMiddleware to PUT route (DELETE already had it)

Both courseMemberMiddleware and courseTeamMemberMiddleware document requiring
authMiddleware to be applied first. This aligns with lessonRouter pattern and
ensures explicit middleware contract regardless of global session middleware.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(course): use courseTeamMemberMiddleware for PUT, DELETE, and analytics routes

These routes require admin/tutor role per JSDoc but were using
courseMemberMiddleware which allows any course member including students.
Switch to courseTeamMemberMiddleware to restrict access to ADMIN/TUTOR
group members and org admins.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* chore: unify pricing component between dashboard and website (#630)

* feat: modularize pricing card

* feat: initia implementation of pricing togge

* fix: minor amendment

* refactor: update pricing components for improved layout and styling

- Adjusted spacing in the pricing card layout for better visual consistency.
- Modified button styles and sizes for enhanced usability.
- Cleaned up the pricing toggle component by removing unnecessary console logs and restructuring the button rendering logic.

* add handle click

* chore(css): update Tailwind CSS output with new utility classes and theme variables

* fix(api): enable orgMemberMiddleware for stats route

This change ensures that the stats route is protected by orgMemberMiddleware, aligning access control with other routes. Additionally, removed unnecessary console log from the layout server file to clean up the code.

---------

Co-authored-by: Rotimi Best <best@classroomio.com>

* pass query parameter to signup from pricing page

* ui updates to pricing cards

* commit all PRDs

* add open api spec to api prd

* make blue default theme

* more prds

* sync option id sequence on question update

* docs: more classroomio features

* chore: more ui improvements

* docs: new component docs

* chore: CSP Configuration (#638)

* feat(validation): add GitHub repository name validation

- Add ZRepositoryName schema for validating GitHub repo names
- Add ZRepositoryOwner for owner/username validation
- Add ZRepositoryIdentifier for owner/repo format
- Follow GitHub rules: A-Za-z0-9_.-, 1-100 chars, reserved names
- Add translation keys for validation messages

Fixes #637

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* Revert "feat(validation): add GitHub repository name validation"

This reverts commit 3436536bb17a0f05b3a9fe8dd7682cde64dd1dce.

* feat(dashboard): make CSP external domains configurable via env

Move hardcoded external domains from svelte.config.js to .env configuration
for on-premise deployments with strict security policies, data residency
constraints, or CISO-driven allowlists.

- ALLOWED_EXTERNAL_DOMAINS: single list for all CSP directives
- CSP_SCRIPT_SRC_DOMAINS, CSP_STYLE_SRC_DOMAINS, CSP_CONNECT_SRC_DOMAINS,
  CSP_FRAME_SRC_DOMAINS, CSP_FONT_SRC_DOMAINS: per-directive override
- When unset, defaults retain current SaaS behavior (backward compatible)

Fixes #637

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(dashboard): self-hosted CSP uses only env vars, no defaults

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(dashboard): add all classroomio.com domains to CSP defaults

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* docs: add CSP ALLOWED_EXTERNAL_DOMAINS to self-hosting guides

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* feat(docker): pass CSP domain env vars as build args to dashboard

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* feat: add SSO and token authentication routes and services

- Introduced new routes for SSO and token authentication under the organization namespace.
- Implemented SSO connection management including creation, update, activation, and deletion.
- Added token authentication configuration with support for secret rotation and status retrieval.
- Enhanced error handling for SSO and token authentication processes.
- Updated existing routes and services to integrate new functionalities.
- Improved gradebook service to fetch student marks and exercises.
- Refactored API structure for better organization and maintainability.

* update translations

* fix: add PUBLIC_SERVER_URL to .env.example to prevent localhost fallback in production (#641)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* feat: new exercise question types

* MinIO S3 storage backend (#639)

* feat(storage): add MinIO/S3-compatible object storage backend

- Add OBJECT_STORAGE_* env vars for generic S3-compatible backends (MinIO, AWS S3)
- Create storage config module with Cloudflare R2 fallback for backward compatibility
- Refactor s3.ts to use configurable client and bucket names from config
- Update media.ts to use provider-neutral public URL (OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL or CLOUDFLARE_IMAGE_BUCKET_DOMAIN)
- Add MinIO service and init to docker-compose (profile: minio) with videos/documents/media buckets
- Add localhost:9000 to dashboard CSP for local MinIO presigned URL uploads
- Update self-hosting and Cloudflare docs with MinIO option
- Update .env examples and docker USAGE.md

Fixes #635

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* docs: add MINIO_ROOT_USER and MINIO_ROOT_PASSWORD to self-hosting guides

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* feat(media): refactor media management and integrate MinIO support

- Updated media API to use a new `mediaApi` module, replacing `mediaManagerApi` references across components.
- Added MinIO configuration options to the Docker setup, allowing for optional object storage exclusion.
- Enhanced environment variable management for MinIO in the `.env.example` file.
- Updated Content Security Policy (CSP) to allow local MinIO access in the dashboard.
- Introduced new media components for asset management, including asset cards and usage dialogs.

This refactor improves the modularity of the media handling system and provides better support for S3-compatible storage solutions.

* feat(dashboard): enhance logging and refactor auth session handling

- Added console logging for event and environment variables in hooks.server.ts to aid debugging.
- Refactored auth session handling to use authServerClient instead of authClient in session.ts for improved consistency.
- Updated Dockerfile to ensure the dashboard build output is present before starting the application.
- Expanded self-hosting documentation to include troubleshooting steps for disk space issues in PostgreSQL and Docker.

These changes improve the observability of the application and streamline the authentication process.

* refactor(dashboard): remove debug logging and enhance Postgres healthcheck

- Removed console logging for event and environment variables in hooks.server.ts to clean up the code.
- Updated Postgres healthcheck in docker-compose.yaml to require a successful `SELECT 1` query, ensuring dependent services only start once the database is ready.
- Expanded self-hosting documentation to include troubleshooting steps for database recovery issues.

These changes improve code clarity and reliability in service startup sequences.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* feat: add Plyr media player icons and PDF.js integration

- Introduced new Plyr SVG icons for media player controls in both course and dashboard applications.
- Updated Plyr player configuration to use local icon assets.
- Integrated PDF.js for document viewing, replacing external CDN links with local paths for improved performance and reliability.
- Enhanced PDF viewer modal z-index for better visibility.

This update improves the media player experience and ensures consistent access to necessary resources.

* feat: enhance account and organization services with type definitions and promise return types

- Updated `getAccountData` to return a structured result type including profile and organizations.
- Modified `getOrganizationsWithFilters` to specify a promise return type for better type safety.
- Added new type imports for organization-related data structures to improve clarity and maintainability.

These changes improve type safety and enhance the overall structure of the account and organization services.

* feat: implement Redis caching for presigned URLs and refactor API interactions

- Added Redis caching for presigned download URLs in the API to reduce redundant S3 calls, improving performance.
- Introduced a new `PresignApi` class in the frontend to centralize presigned URL requests, enhancing code maintainability.
- Updated various components to utilize the new `presignApi` methods for document and video upload/download operations.
- Enhanced error handling for Redis operations to ensure graceful degradation in case of cache failures.

These changes optimize the presigned URL generation process and streamline API interactions across the application.

* fix: add better-auth dependency to dashboard (#645)

* chore: unify exercise answer data format

* feat: make selfhosting run under one org for simplicity (#647)

* feat: implement self-hosting PRD + license key system

Self-hosting single-org:
- Add PUBLIC_IS_SELFHOSTED to API env, block org creation when org exists
- Auto-add non-members as students in GET /account bootstrap
- getOrgSiteInfo: root domain returns single org for self-hosted
- App init: route to /lms when self-hosted + no orgs
- Hide org switcher and Add Org modal when self-hosted

Invite-only signup:
- Add settings.signup.inviteOnly via ZUpdateOrganization
- Allow public signups toggle in org auth settings
- Signup page: hide form when invite-only and no invite context
- Landing page nav: respect inviteOnly for signup link

First signup auto-verify:
- Self-hosted first signup (no orgs) auto-verifies email in createProfileHook

License key system:
- LICENSE_KEY, LICENSE_VERIFICATION_URL for external verification API
- Feature gates: SSO, token-auth, no-tracking (PostHog/Senja)
- requireLicense middleware for API routes
- Dashboard: license store, gate PostHog/Senja/SSO/token-auth UI
- docs/LICENSE_API_CONTRACT.md for external API spec

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* feat: get rid of carbon completely

* chore: license verification happens only for selfhosted instance

* fix: in self hosted instance, orgSite is the default

* docs: how to write math in cio editor

* Update packages/db/src/queries/organization/organization.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update apps/api/src/services/license.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix: translations, cursor pr reviews, simplify license fetching and check

* refactor: update analytics setup for cloud and self-hosted deployments; enhance settings merge logic

* refactor: update license fetching URL for development environment; optimize organization query to prevent plan row loss

* more selfhosting improvements

* feat: enhance signup guard for self-hosted instances by requiring organization context when no org ID is provided

* pr reviews

* fix: correct query parameter handling in course navigation

* pr reviews

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
Co-authored-by: Abdulmuizz Hamzat <103611443+buildwithhamzat@users.noreply.github.com>
Co-authored-by: Adeyemi Adetunji <tvnji01@gmail.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Rotimi Best
2026-03-09 11:18:22 +01:00
committed by GitHub
co-authored by Rotimi Best greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Cursor Agent Abdulmuizz Hamzat Adeyemi Adetunji
parent a42565ad82
commit 1e9a82e912
1196 changed files with 118809 additions and 74705 deletions
@@ -0,0 +1,44 @@
<script lang="ts">
import type { Component } from 'svelte';
interface Props {
title: string;
value: string | number;
subtitle?: string;
icon: Component;
iconColor?: string;
className?: string;
}
let { title, value, subtitle = '', icon, iconColor = 'blue', className = '' }: Props = $props();
let colorClasses = $derived({
blue: 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400',
green: 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400',
purple: 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
orange: 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400',
red: 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400',
yellow: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400'
});
const SvelteComponent = $derived(icon);
</script>
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800 {className}">
<div class="flex items-center space-x-4">
<div class="flex h-12 w-12 items-center justify-center rounded-lg {colorClasses[iconColor]}">
<SvelteComponent className={colorClasses[iconColor]} />
</div>
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">{title}</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-white">
{value}
</p>
{#if subtitle}
<p class="text-xs text-gray-500 capitalize dark:text-gray-500">
{subtitle}
</p>
{/if}
</div>
</div>
</div>
@@ -0,0 +1,157 @@
<script lang="ts">
import * as Chart from '@cio/ui/base/chart';
import type { CourseAnalytics } from '$features/course/utils/types';
import { t } from '$lib/utils/functions/translations';
interface Props {
courseAnalytics?: CourseAnalytics | null;
}
type DistributionChartData = {
group: string;
value: number;
color: string;
};
let { courseAnalytics = null }: Props = $props();
const chartConfig: Chart.ChartConfig = {};
let hasStudentData = $derived(Boolean(courseAnalytics?.students?.length));
// Transform data for progress distribution chart
let progressChartData = $derived<DistributionChartData[]>(
courseAnalytics?.students
? [
{
group: $t('analytics.progress_80_plus'),
value: courseAnalytics.students.filter((s) => s.progressPercentage >= 80).length,
color: '#10b981'
},
{
group: $t('analytics.progress_60_79'),
value: courseAnalytics.students.filter((s) => s.progressPercentage >= 60 && s.progressPercentage < 80)
.length,
color: '#f59e0b'
},
{
group: $t('analytics.progress_below_60'),
value: courseAnalytics.students.filter((s) => s.progressPercentage < 60).length,
color: '#ef4444'
}
]
: []
);
// Transform data for grade distribution chart
let gradeChartData = $derived<DistributionChartData[]>(
courseAnalytics?.students
? [
{
group: $t('analytics.grade_90_plus'),
value: courseAnalytics.students.filter((s) => s.averageGrade >= 90).length,
color: '#10b981'
},
{
group: $t('analytics.grade_80_89'),
value: courseAnalytics.students.filter((s) => s.averageGrade >= 80 && s.averageGrade < 90).length,
color: '#3b82f6'
},
{
group: $t('analytics.grade_70_79'),
value: courseAnalytics.students.filter((s) => s.averageGrade >= 70 && s.averageGrade < 80).length,
color: '#f59e0b'
},
{
group: $t('analytics.grade_below_70'),
value: courseAnalytics.students.filter((s) => s.averageGrade < 70).length,
color: '#ef4444'
}
]
: []
);
let progressChartProps = $derived({
xAxis: {
label: $t('analytics.progress_range')
},
yAxis: {
label: $t('analytics.number_of_students')
}
});
let gradeChartProps = $derived({
xAxis: {
label: $t('analytics.grade_range')
},
yAxis: {
label: $t('analytics.number_of_students')
}
});
let progressChartColors = $derived({
domain: progressChartData.map((item) => item.group),
range: progressChartData.map((item) => item.color)
});
let gradeChartColors = $derived({
domain: gradeChartData.map((item) => item.group),
range: gradeChartData.map((item) => item.color)
});
</script>
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
<!-- Progress Distribution Chart -->
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">
{$t('analytics.student_progress_distribution')}
</h3>
{#if !courseAnalytics}
<div class="flex h-[300px] items-center justify-center">
<div class="animate-pulse text-gray-500 dark:text-gray-400">{$t('analytics.loading_chart')}</div>
</div>
{:else if hasStudentData}
<Chart.ChartContainer class="h-[300px] w-full" config={chartConfig}>
<Chart.BarChart
data={progressChartData}
x="group"
y="value"
c="group"
cDomain={progressChartColors.domain}
cRange={progressChartColors.range}
props={progressChartProps}
/>
</Chart.ChartContainer>
{:else}
<div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400">
{$t('analytics.no_progress_data')}
</div>
{/if}
</div>
<!-- Grade Distribution Chart -->
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">{$t('analytics.grade_distribution')}</h3>
{#if !courseAnalytics}
<div class="flex h-[300px] items-center justify-center">
<div class="animate-pulse text-gray-500 dark:text-gray-400">{$t('analytics.loading_chart')}</div>
</div>
{:else if hasStudentData}
<Chart.ChartContainer class="h-[300px] w-full" config={chartConfig}>
<Chart.BarChart
data={gradeChartData}
x="group"
y="value"
c="group"
cDomain={gradeChartColors.domain}
cRange={gradeChartColors.range}
props={gradeChartProps}
/>
</Chart.ChartContainer>
{:else}
<div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400">
{$t('analytics.no_grade_data')}
</div>
{/if}
</div>
</div>
@@ -0,0 +1,62 @@
<script lang="ts">
import { Skeleton } from '@cio/ui/base/skeleton';
</script>
<div class="min-h-screen">
<div class="px-4 py-8">
<!-- Course Overview Cards Skeleton -->
<div class="mb-12">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
{#each Array(4) as _, i (i)}
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
<div class="flex items-center space-x-4">
<Skeleton class="h-[48px] w-[48px]" />
<div class="flex-1">
<Skeleton class="h-[48px] w-[48px]" />
<Skeleton class="mb-2 h-[16px] w-[80px]" />
<Skeleton class="mb-1 h-[24px] w-[40px]" />
<Skeleton class="h-[12px] w-[60px]" />
</div>
</div>
</div>
{/each}
</div>
</div>
<!-- Charts Section Skeleton -->
<div class="mb-12">
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
<Skeleton class="mb-6 h-[24px] w-[200px]" />
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Skeleton class="h-[300px] w-full" />
<Skeleton class="h-[300px] w-full" />
</div>
</div>
</div>
<!-- Students Table Skeleton -->
<div class="rounded-lg border border-gray-200 bg-white dark:border-neutral-700 dark:bg-neutral-800">
<div class="border-b border-gray-200 px-6 py-4 dark:border-neutral-700">
<Skeleton class="mb-6 h-[24px] w-[200px]" />
</div>
<div class="p-6">
<div class="space-y-4">
{#each Array(5) as _, i (i)}
<div class="flex items-center space-x-4">
<Skeleton class="h-[32px] w-[32px]" />
<div class="flex-1">
<Skeleton class="mb-1 h-[16px] w-[150px]" />
<Skeleton class="h-[12px] w-[200px]" />
</div>
<Skeleton class="h-[16px] w-[80px]" />
<Skeleton class="h-[16px] w-[60px]" />
<Skeleton class="h-[20px] w-[40px]" />
<Skeleton class="h-[12px] w-[80px]" />
<Skeleton class="h-[32px] w-[100px]" />
</div>
{/each}
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,32 @@
<script lang="ts">
import type { Component } from 'svelte';
import ChartBarIcon from '@lucide/svelte/icons/chart-bar';
interface Props {
title?: string;
description?: string;
icon?: Component;
className?: string;
}
let {
title = 'No data available',
description = 'There is no data to display at this time.',
icon = ChartBarIcon,
className = ''
}: Props = $props();
const SvelteComponent = $derived(icon);
</script>
<div class="flex flex-col items-center justify-center px-4 py-12 {className}">
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800">
<SvelteComponent size="24" class="text-gray-400 dark:text-gray-500" />
</div>
<h3 class="mb-2 text-lg font-medium text-gray-900 dark:text-white">
{title}
</h3>
<p class="max-w-sm text-center text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
</div>
@@ -0,0 +1,22 @@
<script lang="ts">
interface Props {
className?: string;
}
let { className = '' }: Props = $props();
</script>
<svg
width="24"
height="24"
class="h-6 w-6 {className}"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
clip-rule="evenodd"
/>
</svg>
@@ -0,0 +1,23 @@
<script lang="ts">
interface Props {
className?: string;
}
let { className = '' }: Props = $props();
</script>
<svg
width="24"
height="24"
class="h-6 w-6 {className}"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" />
<path
fill-rule="evenodd"
d="M4 5a2 2 0 012-2h8a2 2 0 012 2v6a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 1a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3zm-3 4a1 1 0 100 2h.01a1 1 0 100-2H7zm3 0a1 1 0 100 2h3a1 1 0 100-2h-3z"
clip-rule="evenodd"
/>
</svg>
@@ -0,0 +1,22 @@
<script lang="ts">
interface Props {
className?: string;
}
let { className = '' }: Props = $props();
</script>
<svg
width="24"
height="24"
class="h-6 w-6 {className}"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
@@ -0,0 +1,18 @@
<script lang="ts">
interface Props {
className?: string;
}
let { className = '' }: Props = $props();
</script>
<svg
width="24"
height="24"
class="h-6 w-6 {className}"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3z" />
</svg>
@@ -0,0 +1,210 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Badge } from '@cio/ui/base/badge';
import UserIcon from '@lucide/svelte/icons/user';
import * as Pagination from '@cio/ui/base/pagination';
import { courseApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
import EmptyState from './empty-state.svelte';
import * as Avatar from '@cio/ui/base/avatar';
import type { CourseAnalytics } from '$features/course/utils/types';
import { shortenName } from '$lib/utils/functions/string';
interface Props {
students?: CourseAnalytics['students'];
}
let { students = [] }: Props = $props();
// Pagination state
let currentPage = $state(1);
const pageSize = 15;
// Computed values
let totalPages = $derived(Math.ceil(students.length / pageSize));
let startIndex = $derived((currentPage - 1) * pageSize);
let endIndex = $derived(startIndex + pageSize);
let paginatedStudents = $derived(students.slice(startIndex, endIndex));
let startItem = $derived(startIndex + 1);
let endItem = $derived(Math.min(endIndex, students.length));
const GotoFullProfile = (student: CourseAnalytics['students'][number]) => {
if (!student) return;
goto(`/courses/${courseApi.course?.id}/people/${student.id}?back=/courses/${courseApi.course?.id}/analytics`);
};
// Reset to first page when students array changes
$effect(() => {
if (students.length > 0 && currentPage > totalPages) {
currentPage = 1;
}
});
</script>
{#if students.length === 0}
<EmptyState
title={$t('analytics.no_students_found')}
description={$t('analytics.no_students_description')}
icon={UserIcon}
/>
{:else}
<!-- Responsive Table -->
<div class="relative h-[300px] overflow-hidden">
<div class="h-full overflow-auto">
<table class="w-full min-w-[800px]">
<!-- Fixed Header -->
<thead class="sticky top-0 z-10 bg-white dark:bg-neutral-800">
<tr class="border-b border-gray-200">
<th
class="sticky top-0 left-0 z-20 min-w-[200px] bg-white px-4 py-3 text-left text-sm font-medium whitespace-nowrap text-gray-500 dark:bg-neutral-800 dark:text-gray-300"
>
{$t('analytics.student_name')}
</th>
<th
class="min-w-[150px] px-4 py-3 text-left text-sm font-medium whitespace-nowrap text-gray-500 dark:text-gray-300"
>
{$t('analytics.lessons_completed')}
</th>
<th
class="min-w-[140px] px-4 py-3 text-left text-sm font-medium whitespace-nowrap text-gray-500 dark:text-gray-300"
>
{$t('analytics.exercises_submitted')}
</th>
<th
class="min-w-[120px] px-4 py-3 text-left text-sm font-medium whitespace-nowrap text-gray-500 dark:text-gray-300"
>
{$t('analytics.average_grade')}
</th>
<th
class="min-w-[120px] px-4 py-3 text-left text-sm font-medium whitespace-nowrap text-gray-500 dark:text-gray-300"
>
{$t('analytics.last_seen')}
</th>
<th
class="min-w-[120px] px-4 py-3 text-left text-sm font-medium whitespace-nowrap text-gray-500 dark:text-gray-300"
>
{$t('analytics.actions')}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-600">
{#each paginatedStudents as student}
<tr class="group h-[100px] border-b hover:bg-gray-50 dark:hover:bg-gray-700">
<!-- Fixed Name Column -->
<td
class="sticky left-0 z-10 min-w-[200px] bg-white px-4 py-3 whitespace-nowrap transition-colors group-hover:bg-gray-50 dark:bg-neutral-800 dark:group-hover:bg-gray-700"
>
<div class="flex items-center gap-3">
<Avatar.Root class="size-8">
<Avatar.Image
src={student.profile.avatar_url ? student.profile.avatar_url : '/logo-192.png'}
alt={student.profile.fullname ? student.profile.fullname : $t('analytics.student')}
/>
<Avatar.Fallback>{shortenName(student.profile.fullname) || 'S'}</Avatar.Fallback>
</Avatar.Root>
<div class="min-w-0 flex-1">
<p class="truncate font-medium text-gray-900 dark:text-white">
{student.profile.fullname}
</p>
<p class="truncate text-sm text-gray-500 dark:text-gray-400">
{student.profile.email}
</p>
</div>
</div>
</td>
<!-- Scrollable Content -->
<td class="min-w-[150px] px-4 py-3 whitespace-nowrap">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-gray-900 dark:text-white">
{student.lessonsCompleted}/{student.totalLessons}
</span>
<div class="w-20 flex-shrink-0">
<div class="h-2 rounded-full bg-gray-200 dark:bg-neutral-700">
<div class="h-2 rounded-full bg-blue-500" style="width: {student.progressPercentage}%"></div>
</div>
</div>
</div>
</td>
<td class="min-w-[140px] px-4 py-3 whitespace-nowrap">
<span class="text-sm font-medium text-gray-900 dark:text-white">
{student.exercisesSubmitted} out of {student.totalExercises}
</span>
</td>
<td class="min-w-[120px] px-4 py-3 whitespace-nowrap">
<Badge
class={student.averageGrade >= 80
? 'bg-green-600'
: student.averageGrade >= 60
? 'bg-blue-600'
: 'bg-red-600'}
>
{student.averageGrade}%
</Badge>
</td>
<td class="min-w-[120px] px-4 py-3 whitespace-nowrap">
<span class="text-sm text-gray-500 dark:text-gray-400">
{student.lastSeen}
</span>
</td>
<td class="min-w-[120px] px-4 py-3 whitespace-nowrap">
<button
class="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium whitespace-nowrap text-white transition-colors hover:bg-blue-700"
onclick={() => GotoFullProfile(student)}
>
{$t('analytics.view_details')}
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<!-- Pagination Controls -->
{#if students.length > pageSize}
<div class="mt-4 flex items-center justify-between border-t border-gray-200 px-4 py-3">
<div class="text-sm text-gray-700 dark:text-gray-300">
{$t('analytics.showing_students', {
start: startItem,
end: endItem,
total: students.length
})}
</div>
<Pagination.Root
count={students.length}
perPage={pageSize}
page={currentPage}
onPageChange={(page) => (currentPage = page)}
>
{#snippet children({ pages, currentPage: activePage })}
<Pagination.Content>
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type === 'ellipsis'}
<Pagination.Item>
<Pagination.Ellipsis />
</Pagination.Item>
{:else}
<Pagination.Item>
<Pagination.Link {page} isActive={activePage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
{/if}
{/if}
@@ -44,7 +44,7 @@
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="ui:data-[state=open]:opacity-100 absolute right-6 top-6 z-40 flex items-center justify-center opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100"
class="ui:data-[state=open]:opacity-100 absolute top-6 right-6 z-40 flex items-center justify-center opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100"
onclick={(e) => e.stopPropagation()}
>
<IconButton variant="outline">
@@ -2,14 +2,14 @@
import { Skeleton } from '@cio/ui/base/skeleton';
</script>
<div class="mb-4 mr-4 h-[370px] w-72 rounded-md border p-2 transition ease-in-out">
<div class="mr-4 mb-4 h-[370px] w-72 rounded-md border p-2 transition ease-in-out">
<Skeleton class="h-[45%] w-full" />
<div class="flex h-[50%] flex-col justify-between pb-2 pt-4">
<div class="flex h-[50%] flex-col justify-between pt-4 pb-2">
<!-- Title -->
<Skeleton class="h-4 w-2/4" />
<!-- Description -->
<div class="mb-6 mt-4 flex flex-col gap-2">
<div class="mt-4 mb-6 flex flex-col gap-2">
<Skeleton class=" h-4 w-full" />
<Skeleton class=" h-4 w-full" />
<Skeleton class=" h-4 w-full" />
@@ -1,6 +1,5 @@
<script lang="ts">
import type { Component } from 'svelte';
import get from 'lodash/get';
import { resolve } from '$app/paths';
import { Badge } from '@cio/ui/base/badge';
import UserIcon from '@lucide/svelte/icons/user';
@@ -13,16 +12,19 @@
import * as Item from '@cio/ui/base/item';
import { Progress } from '@cio/ui/base/progress';
import pluralize from 'pluralize';
import { Image } from '$features/ui';
import { COURSE_TYPE, type Course } from '$lib/utils/types';
import { t } from '$lib/utils/functions/translations';
import { calcCourseDiscount } from '$lib/utils/functions/course';
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
import { calcProgressRate } from '$features/course/utils/functions';
import { calcCourseProgress, calcProgressRate } from '$features/course/utils/functions';
import CardDropdown from './card-dropdown.svelte';
import type { OrgCourses, UserEnrolledCourses } from '$features/course/types';
import type { OrgPublicCourses } from '$features/org/utils/types';
export interface Props {
course: Course;
course: OrgCourses[number] | UserEnrolledCourses[number] | OrgPublicCourses[number];
isOnLandingPage?: boolean;
isLMS?: boolean;
isExplore?: boolean;
@@ -30,8 +32,6 @@
let { course, isOnLandingPage, isLMS, isExplore }: Props = $props();
$inspect('isLMS', isLMS);
let {
bannerImage,
id = '',
@@ -56,19 +56,38 @@
title: course.title,
type: course.type,
description: course.description,
isPublished: course.is_published,
isPublished: course.isPublished,
pricingData: {
cost: course.cost,
discount: course.metadata?.discount || 0,
showDiscount: course.metadata?.showDiscount || false
},
currency: course.currency,
totalLessons: get(course, 'lessons[0].count', 0),
progressRate: calcProgressRate(course.progress_rate, course.total_lessons),
totalStudents: course.total_students
totalLessons: course.lessonCount,
progressRate: (() => {
const c = course as { exerciseCount?: number; exercisesCompleted?: number };
if (typeof c.exerciseCount === 'number' && typeof c.exercisesCompleted === 'number') {
return calcCourseProgress({
lessonsCompleted: 'progressRate' in course ? (course.progressRate ?? 0) : 0,
totalLessons: course.lessonCount ?? 0,
exercisesCompleted: c.exercisesCompleted,
totalExercises: c.exerciseCount
});
}
return calcProgressRate('progressRate' in course ? course.progressRate : 0, course.lessonCount);
})(),
totalStudents: 'totalStudents' in course ? course.totalStudents : 0
});
let formatter = $derived(getCurrencyFormatter(currency));
const courseTags = $derived(
('tags' in course && Array.isArray(course.tags) ? course.tags : []) as Array<{
id: string;
name: string;
slug: string;
color?: string | null;
}>
);
const COURSE_TAG: Record<
string,
@@ -79,13 +98,13 @@
iconStyle?: string;
}
> = {
[COURSE_TYPE.LIVE_CLASS]: {
['LIVE_CLASS']: {
style: '',
label: $t('course.navItem.settings.live_class'),
icon: CircleDotIcon,
iconStyle: 'custom text-red-700'
},
[COURSE_TYPE.SELF_PACED]: {
['SELF_PACED']: {
style: '',
label: $t('course.navItem.settings.self_paced'),
icon: UserIcon,
@@ -105,7 +124,7 @@
);
</script>
<Item.Root variant="outline" class="p-3! group relative max-w-[320px]">
<Item.Root variant="outline" class="group relative max-w-[320px] p-3!">
{#snippet child({ props })}
<a href={resolve(courseUrl, {})} {...props}>
{#if !isLMS && !isOnLandingPage}
@@ -113,12 +132,12 @@
{/if}
<Item.Header>
<Item.Media variant="image" class="h-50! w-full! relative">
<Item.Media variant="image" class="relative h-50! w-full!">
<Image src={bannerImage} alt="Course banner image" className="w-full h-full rounded-sm object-cover" />
{#if type}
{@const tag = COURSE_TAG[type]}
<Badge class="rounded-md! absolute bottom-2 left-2 z-10 flex items-center capitalize" variant="secondary">
<Badge class="absolute bottom-2 left-2 z-10 flex items-center rounded-md! capitalize" variant="secondary">
<tag.icon class={tag.iconStyle} />
{tag.label}
</Badge>
@@ -127,11 +146,25 @@
</Item.Header>
<Item.Content>
<Item.Title class="text-base! line-clamp-1 min-h-[44px]">
<Item.Title class="line-clamp-1 min-h-[44px] text-base!">
{title}
</Item.Title>
<Item.Description class="min-h-[63px]">{description}</Item.Description>
<div class="ui:text-muted-foreground flex min-h-[12px] flex-wrap items-center gap-1">
{#if !isLMS && courseTags.length > 0}
{#each courseTags as tag (tag.id)}
<Item.SubDescription class="border-border inline-flex items-center gap-1.5 rounded-full border px-2">
<span
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 rounded-full"
style={tag.color ? `background-color: ${tag.color}` : undefined}
aria-hidden="true"
></span>
<span>{tag.name}</span>
</Item.SubDescription>
{/each}
{/if}
</div>
<Separator class="my-3" />
@@ -149,7 +182,7 @@
{:else if pricingData.showDiscount}
{formatter.format(cost)}
<span class="line-through">
{formatter?.format(pricingData?.cost)}
{formatter?.format(pricingData?.cost ?? 0)}
</span>
{:else}
{formatter.format(cost)}
@@ -158,7 +191,7 @@
{:else if isLMS}
{#if !isExplore}
<div class="flex w-3/4 items-center gap-2">
<Progress value={50} />
<Progress value={progressRate} />
<p class="ui:text-muted-foreground text-xs">{progressRate}%</p>
</div>
{/if}
@@ -183,8 +216,7 @@
{:else if !isOnLandingPage}
<div class="flex flex-col justify-between">
<p class="pl-2 text-xs dark:text-white">
{totalStudents}
{$t('courses.course_card.students')}
{pluralize($t('courses.course_card.students'), totalStudents, true)}
</p>
</div>
{/if}
@@ -0,0 +1,196 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Label } from '@cio/ui/base/label';
import { Switch } from '@cio/ui/base/switch';
import ZapIcon from '@lucide/svelte/icons/zap';
import * as RadioGroup from '@cio/ui/base/radio-group';
import { courseApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import { saveCertificateValidation } from '$lib/utils/functions/validator';
import { Button } from '@cio/ui/base/button';
import Plain from './templates/plain.svelte';
import Professional from './templates/professional.svelte';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { UpgradeBanner } from '$features/ui';
import BlueBadgePattern from './templates/blue-badge-pattern.svelte';
import PurpleBadgePattern from './templates/purple-badge-pattern.svelte';
import BlueProfessionalBadge from './templates/blue-professional-badge.svelte';
import PurpleProfessionalBadge from './templates/purple-professional-badge.svelte';
const studentNamePlaceholder = 'Name of student';
const themes = [
'professional',
'plain',
'purpleProfessionalBadge',
'blueProfessionalBadge',
'purpleBadgePattern',
'blueBadgePattern'
];
let errors = $state({
description: ''
});
const helperText = $derived(
`${courseApi.course?.description?.length || 0}/200 ${$t('course.navItem.certificates.characters')}`
);
let certificateTheme = $derived<string | undefined>(courseApi.course?.certificateTheme ?? undefined);
const saveCertificate = async () => {
if (!courseApi.course) return;
// Prevent free plan users from bypassing UI restrictions
if ($isFreePlan) {
errors.description = 'Certificate customization is only available on paid plans';
return;
}
try {
const result = saveCertificateValidation({
description: courseApi.course?.description || '',
isCertificateDownloadable: courseApi.course?.isCertificateDownloadable || false,
certificateTheme: certificateTheme || ''
});
if (result && Object.keys(result).length > 0) {
errors.description = $t(result.description) || $t('course.navItem.certificates.description_error');
throw new Error(errors.description);
}
errors.description = '';
await courseApi.update(courseApi.course.id!, {
description: courseApi.course?.description || '',
isCertificateDownloadable: courseApi.course?.isCertificateDownloadable || false,
certificateTheme: certificateTheme || ''
});
snackbar.success('snackbar.course_settings.success.saved');
} catch (error) {
if (error instanceof Error) {
errors.description = error.message;
} else {
errors.description = $t('course.navItem.certificates.unexpected_error');
}
}
};
</script>
<svelte:head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Qwitcher+Grypen&family=Roboto:wght@300;400;700&display=swap"
rel="stylesheet"
/>
</svelte:head>
<UpgradeBanner>{$t('upgrade.certificate')}</UpgradeBanner>
<main class="mt-2 px-2 md:-mr-3 md:-ml-3">
<div class="mb-3 flex h-4/5 w-full flex-1 flex-col justify-between gap-3 lg:flex-row">
<section class="h-full w-full lg:w-2/5">
<strong class="my-2 text-base font-semibold text-black dark:text-gray-100"
>{$t('course.navItem.certificates.certificate_settings')}</strong
>
<p class="my-4 text-xs font-normal dark:text-gray-100">
{$t('course.navItem.certificates.theme')}
</p>
<RadioGroup.Root bind:value={certificateTheme} disabled={$isFreePlan} class="mb-10">
<div class="flex flex-wrap justify-between gap-y-5">
{#each themes as theme}
<div class="mr-3 flex items-start space-x-2">
<RadioGroup.Item value={theme} id={theme} />
<Label for={theme} class="cursor-pointer">
<img src={`/images/certificate_theme_${theme}.png`} alt="themes" class="h-[82px] w-[110px]" />
</Label>
</div>
{/each}
</div>
</RadioGroup.Root>
<div>
<p class="my-2 text-xs font-normal text-black dark:text-gray-100">
{$t('course.navItem.certificates.logo')}
</p>
<div>
<p class="mt-1 text-base dark:text-gray-100">
{$t('course.navItem.certificates.to_update')}
<strong class="font-semibold">{$t('course.navItem.certificates.settings')}</strong>
{$t('course.navItem.certificates.and_upload')}
</p>
<Button variant="outline" class="mt-3" onclick={() => goto(`/org/${$currentOrg.siteName}/settings`)}>
{$t('course.navItem.certificates.goto_settings')}
</Button>
</div>
<span class="my-4">
<p class="mt-4 mb-2 text-xs font-normal dark:text-gray-100">
{$t('course.navItem.certificates.description')}
</p>
<TextareaField
rows={6}
placeholder={$t('course.navItem.certificates.placeholder')}
value={courseApi.course?.description}
onchange={(e) => (courseApi.course!.description = e.currentTarget.value)}
errorMessage={errors.description}
disabled={$isFreePlan}
helperMessage={helperText}
/>
</span>
<div class="my-4 flex items-center space-x-2">
<Switch
id="certificate-downloadable"
checked={!!courseApi.course?.isCertificateDownloadable}
onCheckedChange={(checked) => (courseApi.course!.isCertificateDownloadable = checked)}
disabled={$isFreePlan}
/>
<Label for="certificate-downloadable" class="text-sm font-medium dark:text-gray-100">
{$t('course.navItem.certificates.allow')}
</Label>
</div>
</div>
</section>
<section class="flex w-full items-center justify-center rounded-md bg-gray-100 lg:w-3/5 dark:bg-neutral-800">
<div class="certificate-container flex items-center justify-center">
{#if certificateTheme === 'professional'}
<Professional studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'plain'}
<Plain studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'purpleProfessionalBadge'}
<PurpleProfessionalBadge studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'blueProfessionalBadge'}
<BlueProfessionalBadge studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'purpleBadgePattern'}
<PurpleBadgePattern studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'blueBadgePattern'}
<BlueBadgePattern studentName={studentNamePlaceholder} />
{/if}
</div>
</section>
</div>
<div class="h-1/5">
<Button variant="secondary" onclick={saveCertificate} loading={courseApi.isLoading} disabled={$isFreePlan}>
{#if $isFreePlan}
<ZapIcon size={16} class="filled" />
{/if}
{$t('course.navItem.certificates.save')}
</Button>
</div>
</main>
<style>
p {
color: #262626;
}
.certificate-container {
width: 95%;
max-width: 95%;
}
</style>
@@ -0,0 +1,74 @@
<script>
import { Label } from '@cio/ui/base/label';
import { Switch } from '@cio/ui/base/switch';
import { issueCertificateModal, resetForm } from './store';
import { preventDefault } from '$lib/utils/functions/svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { InputField } from '@cio/ui/custom/input-field';
import { Button } from '@cio/ui/base/button';
let isAutomatic = $state(false);
const issueCertificate = () => {
resetForm();
};
</script>
<Dialog.Root
bind:open={$issueCertificateModal.open}
onOpenChange={(isOpen) => {
if (!isOpen) resetForm();
}}
>
<Dialog.Content class="w-3/5">
<Dialog.Header>
<Dialog.Title>Send Certificate</Dialog.Title>
</Dialog.Header>
<main>
<div>
<div class="mb-4 flex items-center space-x-2">
<Switch id="auto-certificate" bind:checked={isAutomatic} />
<Label for="auto-certificate" class="text-sm font-medium">Automatic</Label>
</div>
<p class="my-4 text-sm font-medium">
If you set this as automatic, certificates will be issued after the learner completes the course.
</p>
</div>
<p class="my-4 text-xs font-normal text-gray-500">or send a personalised/ custom certificate below:</p>
<form onsubmit={preventDefault(issueCertificate)}>
<div class="flex w-full flex-col gap-2 md:flex-row">
<InputField
label="Email address of the student"
className="w-full my-4"
labelClassName="text-xs font-normal"
placeholder="email,comma seperated"
bind:value={$issueCertificateModal.email}
/>
<InputField
label="Schedule date"
className="w-full my-4"
labelClassName="text-xs font-normal"
placeholder="12/06/2023"
bind:value={$issueCertificateModal.date}
/>
</div>
<TextareaField
label="Add a personalized message"
labelClassName="text-xs font-normal"
bind:value={$issueCertificateModal.message}
rows={2}
placeholder="your message here"
className="mb-4"
/>
<div class="mt-5 flex w-full items-end justify-end">
<Button type="submit">Issue certificate</Button>
</div>
</form>
</main>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,85 @@
export const CertificateReportData = [
{
id: 1,
title: 'Total students taking course',
value: 100
},
{
id: 2,
title: 'Number of certificates issued ',
value: 45
},
{
id: 3,
title: 'Certificates left to be issued',
value: 55
},
{
id: 4,
title: 'Personalised cetificates',
value: 15
}
];
export const students = [
{
id: 1,
name: 'Anyanwu joy victory',
issued: '10 Dec 2020',
peronalized: false
},
{
id: 2,
name: 'Anyanwu joy victory',
issued: '10 Dec 2020',
peronalized: false
},
{
id: 3,
name: 'Anyanwu joy victory',
issued: '10 Dec 2020',
peronalized: true
},
{
id: 4,
name: 'Anyanwu joy victory',
issued: '10 Dec 2020',
peronalized: false
},
{
id: 5,
name: 'Anyanwu joy victory',
issued: '10 Dec 2020',
peronalized: false
},
{
id: 6,
name: 'Anyanwu joy victory',
issued: '10 Dec 2020',
peronalized: false
},
{
id: 7,
name: 'Anyanwu joy victory',
issued: '10 Dec 2020',
peronalized: false
},
{
id: 8,
name: 'Anyanwu joy victory',
issued: '10 Dec 2020',
peronalized: false
},
{
id: 9,
name: 'Anyanwu joy victory',
issued: '10 Dec 2020',
peronalized: false
},
{
id: 10,
name: 'Anyanwu joy victory',
issued: '10 Dec 2020',
peronalized: true
}
];
@@ -0,0 +1,130 @@
<script>
import SearchIcon from '@lucide/svelte/icons/search';
import * as Pagination from '@cio/ui/base/pagination';
import { Input } from '@cio/ui/base/input';
import { Badge } from '@cio/ui/base/badge';
import * as Table from '@cio/ui/base/table';
import { Checkbox } from '@cio/ui/base/checkbox';
import { CertificateReportData, students } from './mockData';
let searchValue = $state('');
let selectAll = $state(false);
let selectedStudents = $state(new Set());
let currentPage = $state(1);
const pageSize = 10;
// let totalPages = $derived(Math.ceil(students.length / pageSize));
let startIndex = $derived((currentPage - 1) * pageSize);
let endIndex = $derived(startIndex + pageSize);
let paginatedStudents = $derived(students.slice(startIndex, endIndex));
function toggleSelectAll() {
selectAll = !selectAll;
if (selectAll) {
selectedStudents = new Set(paginatedStudents.map((s) => s.id));
} else {
selectedStudents = new Set();
}
}
function toggleStudent(id) {
if (selectedStudents.has(id)) {
selectedStudents.delete(id);
} else {
selectedStudents.add(id);
}
selectedStudents = selectedStudents;
selectAll = selectedStudents.size === paginatedStudents.length;
}
</script>
<section class="-mr-4 mb-10 -ml-4 flex flex-wrap items-start gap-2">
{#each CertificateReportData as data (data.id)}
<div class="mb-5 flex h-[165px] w-[246px] flex-col justify-center rounded border border-gray-200 px-2">
<p class="text-xs font-normal">{data.title}</p>
<bold class="text-2xl font-medium">{data.value}</bold>
</div>
{/each}
</section>
<section>
<div class="flex flex-row items-center justify-between gap-4">
<p class="w-full text-base font-semibold">Certificate issued</p>
<div class="relative w-full max-w-sm">
<SearchIcon class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input type="search" placeholder="Search students" bind:value={searchValue} class="pl-10" />
</div>
</div>
</section>
<section class="mt-14">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[50px]">
<Checkbox checked={selectAll} onCheckedChange={toggleSelectAll} />
</Table.Head>
<Table.Head>Name of student</Table.Head>
<Table.Head>Date issued</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each paginatedStudents as student (student.id)}
<Table.Row>
<Table.Cell>
<Checkbox checked={selectedStudents.has(student.id)} onCheckedChange={() => toggleStudent(student.id)} />
</Table.Cell>
<Table.Cell class="font-medium">{student.name}</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-4">
<span class="text-sm">{student.issued}</span>
{#if student.peronalized}
<Badge variant="secondary" class="bg-primary-100 ui:text-primary">personalized</Badge>
{/if}
</div>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{#if students.length > pageSize}
<div class="mt-4 flex items-center justify-end">
<Pagination.Root
count={students.length}
perPage={pageSize}
page={currentPage}
onPageChange={(page) => (currentPage = page)}
>
{#snippet children({ pages, currentPage: activePage })}
<Pagination.Content>
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type === 'ellipsis'}
<Pagination.Item>
<Pagination.Ellipsis />
</Pagination.Item>
{:else}
<Pagination.Item>
<Pagination.Link {page} isActive={activePage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
{/if}
</section>
@@ -0,0 +1,18 @@
import { writable } from 'svelte/store';
export const issueCertificateModal = writable({
open: false,
automatic: false,
email: '',
date: '',
message: ''
});
export const resetForm = () =>
issueCertificateModal.update(() => ({
open: false,
automatic: false,
email: '',
date: '',
message: ''
}));
@@ -0,0 +1,13 @@
<script>
import { courseApi } from '$features/course/api';
import UnlockedCertificate from './unlocked-certificate.svelte';
import LockedCertificate from './locked-certificate.svelte';
</script>
<div>
{#if courseApi.course?.isCertificateDownloadable === true}
<UnlockedCertificate />
{:else}
<LockedCertificate />
{/if}
</div>
@@ -0,0 +1,14 @@
<script>
import Empty from '@cio/ui/custom/empty/empty.svelte';
import { t } from '$lib/utils/functions/translations';
import LockIcon from '@lucide/svelte/icons/lock';
</script>
<div class="w-full flex-1">
<Empty
title={$t('course.navItem.certificates.locked_certificate')}
description={`${$t('course.navItem.certificates.instructor_unlock')} ${$t('course.navItem.certificates.contact_instructor')}`}
icon={LockIcon}
variant="page"
/>
</div>
@@ -0,0 +1,92 @@
<script lang="ts">
import { onMount } from 'svelte';
import DownloadIcon from '@lucide/svelte/icons/download';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { Button } from '@cio/ui/base/button';
import Empty from '@cio/ui/custom/empty/empty.svelte';
import { t } from '$lib/utils/functions/translations';
import { courseApi } from '$features/course/api';
import { snackbar } from '$features/ui/snackbar/store';
import { classroomio } from '$lib/utils/services/api';
let isLoading = $state(false);
let isCourseComplete = $state(false);
let courseId = $derived(courseApi.course?.id ?? '');
const downLoadCertificate = async () => {
if (!isCourseComplete || !courseId) return;
isLoading = true;
try {
const response = await classroomio.course[':courseId']['download']['certificate']['$post']({
param: { courseId },
json: {
theme: `${courseApi.course?.certificateTheme}`,
studentName: `${$profile.fullname}`,
courseName: `${courseApi.course?.title}`,
courseDescription: `${courseApi.course?.description}`,
orgLogoUrl: $currentOrg.avatarUrl || `${$currentOrgDomain}/logo-512.png`,
orgName: `${$currentOrg.name}`
}
});
const blobResponse = await response.blob();
const file = new Blob([blobResponse], { type: 'application/pdf' });
const fileURL = URL.createObjectURL(file);
let a = document.createElement('a');
document.body.append(a);
a.download = 'Certificate of Completion - ' + $currentOrg.name;
a.href = fileURL;
a.click();
a.remove();
} catch (error) {
console.error('Error downloading', error);
snackbar.error($t('course.navItem.certificates.unexpected_error'));
}
isLoading = false;
};
const hasUserCompletedCourse = async () => {
isLoading = true;
const response = await courseApi.getProgress(courseId, $profile.id);
if (courseApi.success && response?.data) {
const progress = response.data;
isCourseComplete =
progress.lessonsCount === progress.lessonsCompleted && progress.exercisesCount === progress.exercisesCompleted;
}
isLoading = false;
};
onMount(() => {
hasUserCompletedCourse();
});
let title = $derived(
isCourseComplete
? 'course.navItem.certificates.unlocked_certificate'
: 'course.navItem.certificates.complete_to_download_title'
);
let subtitle = $derived(
isCourseComplete
? 'course.navItem.certificates.unlocked_certificate_subtitle'
: 'course.navItem.certificates.complete_to_download_subtitle'
);
</script>
<div class="flex-1">
<Empty title={$t(title)} description={$t(subtitle)} icon={DownloadIcon} variant="page">
<Button onclick={downLoadCertificate} disabled={!isCourseComplete} loading={isLoading}>
<DownloadIcon size={16} />
{$t('course.navItem.certificates.download_certificate')}
</Button>
</Empty>
</div>
@@ -0,0 +1,56 @@
<script lang="ts">
import { courseApi } from '$features/course/api';
import { currentOrg } from '$lib/utils/store/org';
interface Props {
studentName?: string;
}
let { studentName = '' }: Props = $props();
const logo = '/logo-512.png';
const dateIssued = new Date().toLocaleDateString();
</script>
<div class="relative w-full dark:text-black">
<img src="/images/blue-badge-pattern-background.svg" alt="" class="absolute z-0 h-full w-full object-cover" />
<div class="relative z-20 m-2 flex h-full justify-between bg-white md:h-68">
<div class="w-[70%] pl-3">
<header class="absolute flex w-full items-center justify-center gap-1 text-sm">
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="w-5" />
<h1 class="text-sm">{$currentOrg.name}</h1>
</header>
<div class="mt-16">
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#2D499F]">{studentName}</h1>
</div>
<div class="mt-3 w-[90%]">
<h1 class="text-[7px]">has successfully completed training on</h1>
<h2 class="m-0 text-lg">
{courseApi.course?.title}
</h2>
</div>
<div class="my-8 flex items-center justify-between text-center">
<div>
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
<p class="border-b border-black text-[7px]">{dateIssued}</p>
</div>
<div class="w-[20%] text-[7px]">
<div></div>
<p class="border-t border-black text-[7px]">Signature</p>
</div>
</div>
<footer class="desc w-[90%] text-[8px]">
{courseApi.course?.description}
</footer>
</div>
<div class="flex items-center justify-center overflow-hidden">
<img src="/images/blue-badge-pattern-badge.svg" alt="A badge" class="w-32 max-w-none md:w-40" />
</div>
</div>
</div>
@@ -0,0 +1,55 @@
<script lang="ts">
import { courseApi } from '$features/course/api';
import { currentOrg } from '$lib/utils/store/org';
interface Props {
studentName?: string;
}
let { studentName = '' }: Props = $props();
const logo = '/logo-512.png';
const dateIssued = new Date().toLocaleDateString();
</script>
<div class="w-full bg-[#2D499F] p-3 dark:text-black">
<div class="flex w-full bg-white pb-5">
<div class="w-[80%] pl-3">
<header class="flex items-center justify-start gap-1 text-sm">
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="w-5" />
<h1 class="text-sm">{$currentOrg.name}</h1>
</header>
<div class="mt-7">
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#2D499F]">{studentName}</h1>
</div>
<div class="mt-3 w-[90%]">
<h1 class="text-[7px]">has succesfully completed training on</h1>
<h2 class="m-0 text-lg">
{courseApi.course?.title}
</h2>
</div>
<div class="mt-8 mb-5 flex items-center justify-between text-center">
<div class="">
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
<p class="border-b border-black text-[7px]">{dateIssued}</p>
</div>
<div class="w-[20%] text-[7px]">
<div class=""></div>
<p class="border-t border-black text-[7px]">Signature</p>
</div>
</div>
<footer class="desc w-[90%] text-[8px]">
{courseApi.course?.description}
</footer>
</div>
<div class="w-[20%]">
<img src="/images/blue-professional-badge.svg" alt="A badge" class="w-full" />
</div>
</div>
</div>
@@ -0,0 +1,53 @@
<script lang="ts">
import { courseApi } from '$features/course/api';
import { currentOrg } from '$lib/utils/store/org';
interface Props {
studentName?: string;
}
let { studentName = '' }: Props = $props();
const borderwidth = 'border-4';
const bordercolor = 'border-pink-700';
const logo = '/logo-512.png';
</script>
<div class="certificate-bg flex w-full flex-col items-center justify-center {borderwidth} {bordercolor} py-3">
<div class="my-2 flex items-center gap-1">
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class=" h-10 w-10 rounded-md" />
<p class="font-semibold capitalize dark:text-black">{$currentOrg.name}</p>
</div>
<div class="w-full px-5 py-3">
<p class="my-2 w-full text-center text-sm font-normal tracking-widest text-gray-500 uppercase">
Certificate of completion
</p>
<div class="mb-4 border-b border-gray-500">
<p class="text-xs font-medium text-black">This is to certify that</p>
<p class="student-name text-center text-5xl dark:text-black">{studentName}</p>
</div>
<div class="mb-2">
<p class="text-xs font-medium text-black">has successfully completed the course</p>
<div>
<p class="clamp bg-transparent text-center text-base font-semibold text-pink-700 uppercase">
{courseApi.course?.title}
</p>
</div>
</div>
<p class="desc text-center dark:text-black">{courseApi.course?.description}</p>
</div>
</div>
<style>
.student-name {
font-family: 'Qwitcher Grypen', cursive;
}
.desc {
font-size: 10px;
}
.clamp {
font-size: clamp(14px, 3vw, 16px);
}
.certificate-bg {
background-color: #f5f8fe;
}
</style>
@@ -0,0 +1,55 @@
<script lang="ts">
import { courseApi } from '$features/course/api';
import { currentOrg } from '$lib/utils/store/org';
interface Props {
studentName?: string;
}
let { studentName = '' }: Props = $props();
const borderwidth = 'border-l-8';
const bordercolor = 'border-primary-800';
const logo = '/logo-512.png';
</script>
<div class="certificate-bg border-primary-400 flex w-full items-center justify-center border border-dashed">
<div class="{borderwidth} {bordercolor} w-full">
<div class="w-full px-5 py-3">
<p class="my-2 text-sm font-normal text-gray-500 uppercase">
Certificate<br /> of completion
</p>
<div class="mb-4 border-b border-gray-500">
<p class="text-xs font-medium text-black">This is to certify that</p>
<p class="student-name text-center text-5xl dark:text-black">{studentName}</p>
</div>
<div class="mb-2">
<p class="text-xs font-medium text-black">has successfully completed introductory courses in</p>
<div>
<p class="clamp text-primary-800 my-2 bg-transparent text-center text-base font-semibold uppercase">
{courseApi.course?.title}
</p>
</div>
</div>
<p class="desc mb-4 text-justify dark:text-black">{courseApi.course?.description}</p>
<div class="my-2 flex items-center gap-1">
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="h-10 w-10 rounded-md" />
<p class="font-semibold capitalize dark:text-black">{$currentOrg.name}</p>
</div>
</div>
</div>
</div>
<style>
.student-name {
font-family: 'Qwitcher Grypen', cursive;
}
.desc {
font-size: 10px;
}
.clamp {
font-size: clamp(14px, 3vw, 16px);
}
.certificate-bg {
background-color: #f5f8fe;
}
</style>
@@ -0,0 +1,56 @@
<script lang="ts">
import { courseApi } from '$features/course/api';
import { currentOrg } from '$lib/utils/store/org';
interface Props {
studentName?: string;
}
let { studentName = '' }: Props = $props();
const logo = '/logo-512.png';
const dateIssued = new Date().toLocaleDateString();
</script>
<div class="relative w-full dark:text-black">
<img src="/images/purple-badge-pattern-background.svg" alt="" class="absolute z-0 h-full w-full object-cover" />
<div class="relative z-20 m-2 flex h-full justify-between bg-white md:h-68">
<div class="w-[70%] pl-3">
<header class="absolute flex w-full items-center justify-center gap-1 text-sm">
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="w-5" />
<h1 class="text-sm">{$currentOrg.name}</h1>
</header>
<div class="mt-16">
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#914397]">{studentName}</h1>
</div>
<div class="mt-3 w-[90%]">
<h1 class="text-[7px]">has successfully completed training on</h1>
<h2 class="m-0 text-lg">
{courseApi.course?.title}
</h2>
</div>
<div class="my-8 flex items-center justify-between text-center">
<div>
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
<p class="border-b border-black text-[7px]">{dateIssued}</p>
</div>
<div class="w-[20%] text-[7px]">
<div></div>
<p class="border-t border-black text-[7px]">Signature</p>
</div>
</div>
<footer class="desc w-[90%] text-[8px]">
{courseApi.course?.description}
</footer>
</div>
<div class="flex items-center justify-center overflow-hidden">
<img src="/images/purple-badge-pattern-badge.svg" alt="A badge" class="w-[8rem] max-w-none md:w-[10rem]" />
</div>
</div>
</div>
@@ -0,0 +1,55 @@
<script lang="ts">
import { courseApi } from '$features/course/api';
import { currentOrg } from '$lib/utils/store/org';
interface Props {
studentName?: string;
}
let { studentName = '' }: Props = $props();
const logo = '/logo-512.png';
const dateIssued = new Date().toLocaleDateString();
</script>
<div class="w-full bg-[#95449A] p-3 dark:text-black">
<div class="flex w-full bg-white pb-5">
<div class="w-[80%] pl-3">
<header class="flex items-center justify-start gap-1 text-sm">
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="w-5" />
<h1 class="text-sm">{$currentOrg.name}</h1>
</header>
<div class="mt-7">
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#95449A]">{studentName}</h1>
</div>
<div class="mt-3 w-[90%]">
<h1 class="text-[7px]">has succesfully completed training on</h1>
<h2 class="m-0 text-lg">
{courseApi.course?.title}
</h2>
</div>
<div class="mt-8 mb-5 flex items-center justify-between text-center">
<div class="">
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
<p class="border-b border-black text-[7px]">{dateIssued}</p>
</div>
<div class="w-[20%] text-[7px]">
<div class=""></div>
<p class="border-t border-black text-[7px]">Signature</p>
</div>
</div>
<footer class="desc w-[90%] text-[8px]">
{courseApi.course?.description}
</footer>
</div>
<div class="w-[20%]">
<img src="/images/purple-professional-badge.svg" alt="A badge" class="w-full" />
</div>
</div>
</div>
@@ -0,0 +1,39 @@
<script lang="ts">
import * as Tooltip from '@cio/ui/base/tooltip';
import { ContentType } from '@cio/utils/constants/content';
import CourseContentIcon from '$features/course/components/course-content-icon.svelte';
import { t } from '$lib/utils/functions/translations';
interface Props {
lessons: number;
exercises: number;
}
let { lessons, exercises }: Props = $props();
</script>
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
<span class="inline-flex items-center gap-1 rounded border px-1 py-0.5 text-[10px] leading-none">
<CourseContentIcon type={ContentType.Lesson} size={12} />
<span>{lessons}</span>
</span>
</Tooltip.Trigger>
<Tooltip.Content side="top" sideOffset={4}>
{$t('course.sidebar.content_count.lessons', { count: lessons })}
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger>
<span class="inline-flex items-center gap-1 rounded border px-1 py-0.5 text-[10px] leading-none">
<CourseContentIcon type={ContentType.Exercise} size={12} />
<span>{exercises}</span>
</span>
</Tooltip.Trigger>
<Tooltip.Content side="top" sideOffset={4}>
{$t('course.sidebar.content_count.exercises', { count: exercises })}
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
@@ -0,0 +1,68 @@
import { ContentType } from '@cio/utils/constants/content';
import type { StepperState } from './types';
/** Translation key for primary action (resolve with t() in components) */
export const ADD_CONTENT_CREATE_KEY = 'course.navItem.lessons.add_content_create';
export const ADD_CONTENT_CREATE_SECTION_KEY = 'course.navItem.lessons.add_content_create_section';
export const ADD_CONTENT_CREATE_LESSON_KEY = 'course.navItem.lessons.add_content_create_lesson';
/** Default stepper state used as fallback in the modal and when resetting */
export const DEFAULT_STEPPER_STATE: StepperState = {
currentStep: 0,
totalSteps: 1,
canProceed: false,
isSubmitting: false,
primaryActionLabel: '' // Set from translation in component
};
/** Default state for the section create stepper */
export const SECTION_STEPPER_DEFAULT_STATE: StepperState = {
...DEFAULT_STEPPER_STATE,
primaryActionLabel: '' // Set from translation in section-create-stepper
};
/** Default state for the lesson create stepper */
export const LESSON_STEPPER_DEFAULT_STATE: StepperState = {
...DEFAULT_STEPPER_STATE,
primaryActionLabel: '' // Set from translation in lesson-create-stepper
};
/** Default state for the exercise create stepper (label set at runtime from i18n) */
export const EXERCISE_STEPPER_DEFAULT_STATE: StepperState = {
currentStep: 0,
totalSteps: 2,
canProceed: false,
isSubmitting: false,
primaryActionLabel: ''
};
/** Content type options shown in the add-content modal (title/description are translation keys) */
export const CONTENT_OPTIONS = [
{
id: 'section',
type: ContentType.Section,
titleKey: 'course.navItem.lessons.add_content_options.section_title',
descriptionKey: 'course.navItem.lessons.add_content_options.section_description'
},
{
id: 'lesson',
type: ContentType.Lesson,
titleKey: 'course.navItem.lessons.add_content_options.lesson_title',
descriptionKey: 'course.navItem.lessons.add_content_options.lesson_description'
},
{
id: 'exercise',
type: ContentType.Exercise,
titleKey: 'course.navItem.lessons.add_content_options.exercise_title',
descriptionKey: 'course.navItem.lessons.add_content_options.exercise_description'
}
] as const;
/** Exercise creation flow type (from scratch, template, or AI) */
export const EXERCISE_CREATE_TYPE = {
SCRATCH: 0,
TEMPLATE: 1,
AI: 2
} as const;
export type ExerciseCreateType = (typeof EXERCISE_CREATE_TYPE)[keyof typeof EXERCISE_CREATE_TYPE];
@@ -0,0 +1,263 @@
<script lang="ts">
import * as Dialog from '@cio/ui/base/dialog';
import * as Select from '@cio/ui/base/select';
import { Button } from '@cio/ui/base/button';
import { Label } from '@cio/ui/base/label';
import * as Field from '@cio/ui/base/field';
import { RadioOptionCardGroup } from '@cio/ui/custom/radio-option-card';
import { contentCreateStore, contentCreateStoreUtils } from './store';
import { ContentType } from '@cio/utils/constants/content';
import { courseApi } from '$features/course/api';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import ExerciseCreateStepper from './exercise-create-stepper.svelte';
import SectionCreateStepper from './section-create-stepper.svelte';
import LessonCreateStepper from './lesson-create-stepper.svelte';
import type { StepperRef, StepperState } from './types';
import { DEFAULT_STEPPER_STATE, CONTENT_OPTIONS } from './constants';
import { t } from '$lib/utils/functions/translations';
import { untrack } from 'svelte';
let step = $state(0);
let selectedType = $state<ContentType>(ContentType.Lesson);
let sectionId = $state('');
// ============================================
// STEPPER COMPONENT REFERENCES
// ============================================
let sectionStepper: StepperRef | undefined = $state();
let lessonStepper: StepperRef | undefined = $state();
let exerciseStepper: StepperRef | undefined = $state();
// ============================================
// UNIFIED STEPPER STATE
// ============================================
const activeStepper = $derived(
selectedType === ContentType.Section
? sectionStepper
: selectedType === ContentType.Lesson
? lessonStepper
: selectedType === ContentType.Exercise
? exerciseStepper
: undefined
);
let sectionStepperState = $state<StepperState>({ ...DEFAULT_STEPPER_STATE });
let lessonStepperState = $state<StepperState>({ ...DEFAULT_STEPPER_STATE });
let exerciseStepperState = $state<StepperState>({ ...DEFAULT_STEPPER_STATE });
const stepperState = $derived(
selectedType === ContentType.Section
? sectionStepperState
: selectedType === ContentType.Lesson
? lessonStepperState
: selectedType === ContentType.Exercise
? exerciseStepperState
: DEFAULT_STEPPER_STATE
);
const contentGroupingEnabled = $derived(courseApi.course?.metadata?.isContentGroupingEnabled ?? true);
const sections = $derived(
(courseApi.course?.content?.sections || []).filter((section) => section.id !== 'ungrouped')
);
const visibleContentOptions = $derived(
$contentCreateStore.sectionId
? CONTENT_OPTIONS.filter((option) => option.type !== ContentType.Section)
: contentGroupingEnabled
? CONTENT_OPTIONS
: CONTENT_OPTIONS.filter((option) => option.type !== ContentType.Section)
);
const contentOptionsForGroup = $derived(
visibleContentOptions.map((o) => ({
id: o.id,
title: $t(o.titleKey),
description: $t(o.descriptionKey),
value: o.type
}))
);
const requiresSection = $derived(contentGroupingEnabled && selectedType !== ContentType.Section);
const hasSections = $derived(sections.length > 0);
/** True when modal was opened from a section (e.g. plus icon in sidebar), so we use store sectionId */
const sectionFromContext = $derived(!!$contentCreateStore.sectionId);
const canCreateLessonOrExercise = $derived(!requiresSection || (requiresSection && !!sectionId));
function getNextSectionOrder() {
const orders = sections.map((section, index) => section.order ?? index + 1);
const maxOrder = orders.length ? Math.max(...orders) : 0;
return maxOrder + 1;
}
function getNextContentOrder(targetSectionId?: string) {
const content = courseApi.course?.content;
if (!content) return 1;
let items = content.items;
if (content.grouped) {
const section = targetSectionId
? content.sections.find((entry) => entry.id === targetSectionId)
: content.sections.find((entry) => entry.id === 'ungrouped');
items = section?.items ?? [];
}
const orders = items.map((item, index) => item.order ?? index + 1);
const maxOrder = orders.length ? Math.max(...orders) : 0;
return maxOrder + 1;
}
$effect(() => {
if ($contentCreateStore.open) {
untrack(() => {
step = $contentCreateStore.skipTypeSelection ? 1 : 0;
const initialType = $contentCreateStore.initialType ?? ContentType.Lesson;
selectedType = contentGroupingEnabled || initialType !== ContentType.Section ? initialType : ContentType.Lesson;
sectionId = contentGroupingEnabled
? $contentCreateStore.sectionId || sections[0]?.id || ''
: $contentCreateStore.sectionId || '';
sectionStepper?.actions.reset();
lessonStepper?.actions.reset();
exerciseStepper?.actions.reset();
sectionStepperState = { ...DEFAULT_STEPPER_STATE };
lessonStepperState = { ...DEFAULT_STEPPER_STATE };
exerciseStepperState = { ...DEFAULT_STEPPER_STATE };
});
}
});
function closeModal() {
contentCreateStoreUtils.close();
}
function goToDetails() {
step = 1;
}
function goBack() {
step = 0;
}
// ============================================
// UNIFIED NAVIGATION HANDLERS
// ============================================
function handleUnifiedBack() {
if (stepperState.currentStep > 0) {
activeStepper?.actions.back();
} else {
goBack();
}
}
async function handleUnifiedNext() {
await activeStepper?.actions.next();
}
</script>
<Dialog.Root bind:open={$contentCreateStore.open} onOpenChange={(isOpen) => !isOpen && closeModal()}>
<Dialog.Content class="flex max-h-[680px] w-fit! max-w-2xl! min-w-[400px] flex-col overflow-hidden">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.add_content')}</Dialog.Title>
</Dialog.Header>
<div class="min-h-0 flex-1 overflow-y-auto pr-1">
{#if step === 0}
<!-- Select a content type - Section | Lesson | Exercise -->
<div class="flex flex-col gap-3">
<Field.Description>{$t('course.navItem.lessons.add_content_description')}</Field.Description>
<RadioOptionCardGroup options={contentOptionsForGroup} bind:value={selectedType} class="md:grid-cols-3" />
<Dialog.Footer>
<Button onclick={goToDetails}>{$t('course.navItem.lessons.add_content_continue')}</Button>
</Dialog.Footer>
</div>
{:else}
<!-- Create content - Section | Lesson | Exercise -->
<div class="px-1">
{#if requiresSection}
<div class="mb-4">
<Label class="text-md mb-1 font-bold">{$t('course.navItem.lessons.add_content_section_label')}</Label>
{#if sectionFromContext}
<p class="text-muted-foreground text-sm">
{$t('course.navItem.lessons.add_content_adding_to')}
<strong>
{sections.find((s) => s.id === sectionId)?.title ??
$t('course.navItem.lessons.add_content_section_fallback')}
</strong>
</p>
{:else if hasSections}
<Select.Root
type="single"
value={sectionId}
onValueChange={(value) => {
if (value) sectionId = value;
}}
>
<Select.Trigger class="h-10 w-full">
{sections.find((s) => s.id === sectionId)?.title ||
$t('course.navItem.lessons.add_content_select_section')}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each sections as section (section.id)}
<Select.Item value={section.id}>{section.title}</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
{:else}
<p class="text-xs text-gray-500">{$t('course.navItem.lessons.add_content_create_section_first')}</p>
{/if}
</div>
{/if}
{#if selectedType === ContentType.Section}
<SectionCreateStepper
bind:this={sectionStepper}
bind:stepperState={sectionStepperState}
courseId={courseApi.course?.id || ''}
order={getNextSectionOrder()}
canCreate={true}
sections={sections.map((s) => ({ id: s.id, order: s.order ?? undefined }))}
onCreated={() => closeModal()}
/>
{:else if selectedType === ContentType.Lesson}
<LessonCreateStepper
bind:this={lessonStepper}
bind:stepperState={lessonStepperState}
courseId={courseApi.course?.id || ''}
sectionId={requiresSection ? sectionId : undefined}
order={getNextContentOrder(requiresSection ? sectionId : undefined)}
canCreate={canCreateLessonOrExercise}
onCreated={(lessonId) => {
closeModal();
goto(resolve(`/courses/${courseApi.course?.id}/lessons/${lessonId}`, {}));
}}
/>
{:else if selectedType === ContentType.Exercise}
<ExerciseCreateStepper
bind:this={exerciseStepper}
bind:stepperState={exerciseStepperState}
courseId={courseApi.course?.id || ''}
sectionId={requiresSection ? sectionId : undefined}
order={getNextContentOrder(requiresSection ? sectionId : undefined)}
canCreate={canCreateLessonOrExercise}
onCreated={(exerciseId) => {
closeModal();
goto(resolve(`/courses/${courseApi.course?.id}/exercises/${exerciseId}`, {}));
}}
/>
{/if}
<Dialog.Footer class="mt-6 flex items-center justify-between">
<Button variant="outline" onclick={handleUnifiedBack}
>{$t('course.navItem.lessons.add_content_back')}</Button
>
<Button onclick={handleUnifiedNext} loading={stepperState.isSubmitting} disabled={!stepperState.canProceed}>
{stepperState.primaryActionLabel}
</Button>
</Dialog.Footer>
</div>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,307 @@
<script lang="ts">
import { Badge } from '@cio/ui/base/badge';
import { Skeleton } from '@cio/ui/base/skeleton';
import { InputField } from '@cio/ui/custom/input-field';
import { Label } from '@cio/ui/base/label';
import { RadioOptionCardGroup } from '@cio/ui/custom/radio-option-card';
import { t } from '$lib/utils/functions/translations';
import { exerciseTemplateApi } from '$features/course/api/exercise-template.svelte';
import { snackbar } from '$features/ui/snackbar/store';
import { Confetti, ComingSoon } from '$features/ui';
import { EXERCISE_TEMPLATE_TAGS } from '$features/course/utils/constants';
import { courseApi, exerciseApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
import type { StepperState, StepperActions, BaseStepperProps } from './types';
import { EXERCISE_STEPPER_DEFAULT_STATE, EXERCISE_CREATE_TYPE } from './constants';
interface Props extends BaseStepperProps {
stepperState: StepperState;
}
let {
courseId,
sectionId,
order,
onCreated,
canCreate,
stepperState = $bindable(EXERCISE_STEPPER_DEFAULT_STATE)
}: Props = $props();
const options = [
{
id: 'exercise-from-scratch',
title: $t('course.navItem.lessons.exercises.new_exercise_modal.options.from_scratch'),
description: $t('course.navItem.lessons.exercises.new_exercise_modal.options.from_scratch_subtitle'),
type: EXERCISE_CREATE_TYPE.SCRATCH,
isDisabled: false
},
{
id: 'exercise-use-template',
title: $t('course.navItem.lessons.exercises.new_exercise_modal.options.use_template'),
description: $t('course.navItem.lessons.exercises.new_exercise_modal.options.use_template_subtitle'),
type: EXERCISE_CREATE_TYPE.TEMPLATE,
isDisabled: false
},
{
id: 'exercise-ai',
title: $t('course.navItem.lessons.exercises.new_exercise_modal.options.use_ai'),
description: $t('course.navItem.lessons.exercises.new_exercise_modal.options.use_ai_subtitle'),
type: EXERCISE_CREATE_TYPE.AI,
isDisabled: true
}
];
const radioOptions = $derived(
options.map((o) => ({
id: o.id,
title: o.title,
description: o.description,
value: String(o.type),
disabled: o.isDisabled
}))
);
const tags = Object.values(EXERCISE_TEMPLATE_TAGS);
let step = $state(0);
/** String for RadioGroup.Root (component expects string); numeric type derived for logic */
let typeValue = $state(String(EXERCISE_CREATE_TYPE.SCRATCH));
const type = $derived(Number(typeValue));
let isLoading = $state(false);
let isAIStarted = $state(false);
let title = $state('');
let selectedTag = $state(tags[0]);
let selectedTemplateId = $state('');
let isTemplateFinishedLoading = $state(false);
const templateRadioOptions = $derived(
(exerciseTemplateApi.templates ?? []).map((t) => ({
id: String(t.id),
title: t.title ?? '',
description: t.description ?? '',
value: String(t.id),
disabled: false
}))
);
function handleNext() {
step = step + 1;
}
function handleBack() {
step = step - 1;
}
async function handleTemplateSelection() {
if (!canCreate) return;
isTemplateFinishedLoading = true;
const template = exerciseTemplateApi.templates?.find((t) => t.id === Number(selectedTemplateId));
if (!template || !template.id) {
isTemplateFinishedLoading = false;
return;
}
try {
await exerciseApi.createFromTemplate(courseId, String(template.id), { sectionId, order });
if (exerciseApi.success && exerciseApi.exercise) {
const profileId = $profile?.id;
if (profileId) {
await courseApi.refreshCourse(courseId, profileId);
}
onCreated(exerciseApi.exercise.id);
}
} catch (error) {
console.log('Error creating exercise from template', error);
snackbar.error($t('course.navItem.lessons.exercises.new_exercise_modal.errors.template_fetch'));
} finally {
isTemplateFinishedLoading = false;
}
}
const handleTagSelection = async (tag = selectedTag) => {
selectedTag = tag;
selectedTemplateId = '';
await exerciseTemplateApi.fetchTemplatesByTag(courseId, selectedTag);
};
async function handleAddExercise() {
if (!title.trim() || !canCreate || isLoading) return;
isLoading = true;
try {
await exerciseApi.create(courseId, {
title: title.trim(),
sectionId,
order
});
if (exerciseApi.success && exerciseApi.exercise) {
const profileId = $profile?.id;
if (profileId) {
await courseApi.refreshCourse(courseId, profileId);
}
onCreated(exerciseApi.exercise.id);
}
} finally {
isLoading = false;
}
}
// ============================================
// COMPUTED VALUES FOR PARENT
// ============================================
const canProceedStep0 = $derived(canCreate);
const canProceedStep1 = $derived(
type === EXERCISE_CREATE_TYPE.SCRATCH ? title.trim().length > 0 && canCreate : !!selectedTemplateId && canCreate
);
const canProceed = $derived(step === 0 ? canProceedStep0 : canProceedStep1);
const primaryActionLabel = $derived(
step === 0
? $t('course.navItem.lessons.exercises.new_exercise_modal.next')
: $t('course.navItem.lessons.exercises.new_exercise_modal.finish')
);
// ============================================
// SYNC STATE TO BINDABLE (parent has bind:stepperState)
// Only assign when values change to avoid infinite re-renders.
// ============================================
const isSubmitting = $derived(isLoading || isTemplateFinishedLoading);
$effect(() => {
const next = {
currentStep: step,
totalSteps: 2,
canProceed,
isSubmitting,
primaryActionLabel
};
if (
stepperState.currentStep !== next.currentStep ||
stepperState.canProceed !== next.canProceed ||
stepperState.isSubmitting !== next.isSubmitting ||
stepperState.primaryActionLabel !== next.primaryActionLabel
) {
stepperState = next;
}
});
// ============================================
// EXPORTED ACTIONS
// ============================================
export const actions: StepperActions = {
async next() {
if (isLoading || isTemplateFinishedLoading) return;
if (step === 0) {
if (type === EXERCISE_CREATE_TYPE.TEMPLATE) {
handleTagSelection();
}
handleNext();
} else {
if (type === EXERCISE_CREATE_TYPE.SCRATCH) {
await handleAddExercise();
} else if (type === EXERCISE_CREATE_TYPE.TEMPLATE) {
await handleTemplateSelection();
}
}
},
back() {
handleBack();
},
reset() {
step = 0;
typeValue = String(EXERCISE_CREATE_TYPE.SCRATCH);
title = '';
selectedTag = tags[0];
selectedTemplateId = '';
isLoading = false;
isAIStarted = false;
isTemplateFinishedLoading = false;
stepperState = { ...EXERCISE_STEPPER_DEFAULT_STATE };
exerciseTemplateApi.reset();
}
};
</script>
{#if !isLoading && isAIStarted}
<Confetti />
{/if}
{#if step === 0}
<div>
<Label class="text-md! mb-3">{$t('course.navItem.lessons.exercises.new_exercise_modal.how')}?</Label>
<RadioOptionCardGroup bind:value={typeValue} options={radioOptions}>
{#snippet titleSuffix(option)}
{#if option.disabled}
<ComingSoon />
{/if}
{/snippet}
</RadioOptionCardGroup>
</div>
{:else if step === 1}
{#if type === EXERCISE_CREATE_TYPE.SCRATCH}
<div class="w-full">
<InputField
label={$t('course.navItem.lessons.exercises.new_exercise_modal.title')}
bind:value={title}
autoFocus={true}
placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.title_placeholder')}
className="my-4 w-2/4!"
/>
</div>
{:else if type === EXERCISE_CREATE_TYPE.TEMPLATE}
<div>
<Label class="text-md mb-1 font-bold">
{$t('course.navItem.lessons.exercises.new_exercise_modal.select_template')}
</Label>
<div>
<div class="mb-5 flex items-center gap-2">
{#each tags as tag (tag)}
<Badge
variant={selectedTag === tag ? 'default' : 'secondary'}
class="cursor-pointer"
onclick={() => handleTagSelection(tag)}
>
{tag}
</Badge>
{/each}
</div>
<div class="max-h-[320px] overflow-y-auto pr-1">
{#if exerciseTemplateApi.isLoading}
<div class="grid grid-cols-2 items-start gap-4 lg:grid-cols-3 xl:grid-cols-4">
{#each Array(16) as _, index (index)}
<div class="border-border h-[140px] w-full rounded-md border p-5 dark:bg-neutral-700">
<div class="flex h-full flex-col justify-evenly">
<Skeleton class="h-4 w-3/4" />
<div class="flex flex-col items-start justify-between gap-1">
<Skeleton class="h-3 w-20" />
<Skeleton class="h-3 w-16" />
</div>
</div>
</div>
{/each}
</div>
{:else if exerciseTemplateApi.templates?.length}
<RadioOptionCardGroup
bind:value={selectedTemplateId}
options={templateRadioOptions}
class="grid-cols-2! lg:grid-cols-3! xl:grid-cols-4!"
/>
{:else}
<p class="text-sm text-gray-500">
{$t('course.navItem.lessons.exercises.new_exercise_modal.no_templates_for_tag')}
</p>
{/if}
</div>
</div>
</div>
{/if}
{/if}
@@ -0,0 +1,106 @@
<script lang="ts">
import { InputField } from '@cio/ui/custom/input-field';
import { t } from '$lib/utils/functions/translations';
import { lessonApi, courseApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
import type { StepperState, StepperActions, BaseStepperProps } from './types';
import { LESSON_STEPPER_DEFAULT_STATE, ADD_CONTENT_CREATE_LESSON_KEY } from './constants';
interface Props extends BaseStepperProps {
stepperState: StepperState;
}
let {
courseId,
sectionId,
order,
canCreate,
onCreated,
stepperState = $bindable(LESSON_STEPPER_DEFAULT_STATE)
}: Props = $props();
// ============================================
// INTERNAL STATE
// ============================================
let title = $state('');
let isSubmitting = $state(false);
// ============================================
// COMPUTED VALUES
// ============================================
const canProceed = $derived(title.trim().length > 0 && canCreate);
// ============================================
// SYNC STATE TO BINDABLE (parent has bind:stepperState)
// Only assign when values change to avoid infinite re-renders.
// ============================================
$effect(() => {
const nextCanProceed = canProceed;
const nextIsSubmitting = isSubmitting;
const nextPrimaryActionLabel = $t(ADD_CONTENT_CREATE_LESSON_KEY);
if (
stepperState.canProceed !== nextCanProceed ||
stepperState.isSubmitting !== nextIsSubmitting ||
stepperState.primaryActionLabel !== nextPrimaryActionLabel
) {
stepperState = {
...LESSON_STEPPER_DEFAULT_STATE,
canProceed: nextCanProceed,
isSubmitting: nextIsSubmitting,
primaryActionLabel: nextPrimaryActionLabel
};
}
});
// ============================================
// EXPORTED ACTIONS
// ============================================
export const actions: StepperActions = {
async next() {
if (!canProceed || isSubmitting) return;
isSubmitting = true;
try {
await lessonApi.create(courseId, {
title: title.trim(),
courseId,
lessonAt: new Date().toDateString(),
isUnlocked: true,
order,
sectionId
});
if (lessonApi.success && lessonApi.lesson) {
// Refresh course content
const profileId = $profile?.id;
if (profileId) {
await courseApi.refreshCourse(courseId, profileId);
}
onCreated(lessonApi.lesson.id);
}
} finally {
isSubmitting = false;
}
},
back() {
// Single step - nothing to go back to within stepper
},
reset() {
title = '';
isSubmitting = false;
stepperState = { ...LESSON_STEPPER_DEFAULT_STATE };
}
};
</script>
<!-- Form content only, NO buttons -->
<InputField
label={$t('course.navItem.lessons.add_lesson.lesson_title')}
bind:value={title}
autoFocus={true}
className="w-full"
isRequired={true}
placeholder={$t('course.navItem.lessons.add_content_lesson_title_placeholder')}
/>
@@ -0,0 +1,127 @@
<script lang="ts">
import { InputField } from '@cio/ui/custom/input-field';
import { t } from '$lib/utils/functions/translations';
import { lessonApi, courseApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
import type { StepperState, StepperActions, BaseStepperProps } from './types';
import { SECTION_STEPPER_DEFAULT_STATE, ADD_CONTENT_CREATE_SECTION_KEY } from './constants';
interface Props extends Omit<BaseStepperProps, 'sectionId'> {
sections: Array<{ id: string; order?: number }>;
stepperState: StepperState;
}
let {
courseId,
order,
canCreate,
onCreated,
sections,
stepperState = $bindable(SECTION_STEPPER_DEFAULT_STATE)
}: Props = $props();
// ============================================
// INTERNAL STATE
// ============================================
let title = $state('');
let isSubmitting = $state(false);
// ============================================
// COMPUTED VALUES
// ============================================
// Can the user proceed? Title must not be empty and user must have permission
const canProceed = $derived(title.trim().length > 0 && canCreate);
// ============================================
// SYNC STATE TO BINDABLE (parent has bind:stepperState)
// Only assign when values change to avoid infinite re-renders.
// ============================================
$effect(() => {
const nextCanProceed = canProceed;
const nextIsSubmitting = isSubmitting;
const nextPrimaryActionLabel = $t(ADD_CONTENT_CREATE_SECTION_KEY);
if (
stepperState.canProceed !== nextCanProceed ||
stepperState.isSubmitting !== nextIsSubmitting ||
stepperState.primaryActionLabel !== nextPrimaryActionLabel
) {
stepperState = {
...SECTION_STEPPER_DEFAULT_STATE,
canProceed: nextCanProceed,
isSubmitting: nextIsSubmitting,
primaryActionLabel: nextPrimaryActionLabel
};
}
});
// ============================================
// EXPORTED ACTIONS (for parent modal to call)
// ============================================
export const actions: StepperActions = {
// Called when user clicks the primary button
async next() {
if (!canProceed || isSubmitting) return;
isSubmitting = true;
try {
// Calculate order if not provided
const finalOrder = order ?? getNextOrder();
await lessonApi.createSection(courseId, {
title: title.trim(),
courseId,
order: finalOrder
});
if (lessonApi.success) {
// Refresh course content so sidebar updates
const profileId = $profile?.id;
if (profileId) {
await courseApi.refreshCourse(courseId, profileId);
}
// Notify parent that creation succeeded (section ID not needed for modal close)
onCreated('');
}
} finally {
isSubmitting = false;
}
},
// Called when user clicks back (not applicable for 1-step)
back() {
// Single step stepper - nothing to go back to within this component
// The modal will handle going back to type selection
},
// Called when modal opens to reset state
reset() {
title = '';
isSubmitting = false;
stepperState = { ...SECTION_STEPPER_DEFAULT_STATE };
}
};
// ============================================
// HELPER FUNCTIONS
// ============================================
function getNextOrder(): number {
const orders = sections.map((s, i) => s.order ?? i + 1);
return orders.length ? Math.max(...orders) + 1 : 1;
}
</script>
<!--
IMPORTANT: This component renders ONLY the form content.
NO buttons here - the parent modal handles all navigation buttons.
-->
<InputField
label={$t('course.navItem.lessons.add_lesson.course_section_title')}
bind:value={title}
autoFocus={true}
className="w-full"
isRequired={true}
placeholder={$t('course.navItem.lessons.add_content_section_title_placeholder')}
/>
@@ -0,0 +1,46 @@
import { ContentType } from '@cio/utils/constants/content';
import { writable } from 'svelte/store';
export const contentCreateStore = writable({
open: false,
sectionId: '',
initialType: ContentType.Section,
skipTypeSelection: false
});
export const contentCreateStoreUtils = {
openContentUnit: (sectionId: string) => {
contentCreateStore.set({
open: true,
sectionId,
initialType: ContentType.Lesson,
skipTypeSelection: false
});
},
openSection: () => {
contentCreateStore.set({
open: true,
sectionId: '',
initialType: ContentType.Section,
skipTypeSelection: true
});
},
openDefault: () => {
contentCreateStore.set({
open: true,
sectionId: '',
initialType: ContentType.Lesson,
skipTypeSelection: false
});
},
close: () => {
contentCreateStore.set({
open: false,
sectionId: '',
initialType: ContentType.Lesson,
skipTypeSelection: false
});
}
};
export const contentEditingStore = writable<string | undefined>(undefined);
@@ -0,0 +1,53 @@
/**
* State exposed by stepper components to the parent modal.
* This enables the modal to render unified navigation buttons.
*/
export interface StepperState {
/** Current step index (0-based) */
currentStep: number;
/** Total number of steps in this stepper */
totalSteps: number;
/** Whether the user can proceed to next step (validation passed) */
canProceed: boolean;
/** Whether a submission is in progress */
isSubmitting: boolean;
/** Label for the primary action button (e.g., "Next", "Create", "Finish") */
primaryActionLabel: string;
}
/**
* Callbacks that the modal can invoke to control the stepper.
*/
export interface StepperActions {
/** Move to the next step. If on last step, this triggers submission. */
next: () => void | Promise<void>;
/** Move to the previous step */
back: () => void;
/** Reset the stepper to initial state */
reset: () => void;
}
/**
* Combined type for component references.
* Use this when binding to stepper components with bind:this.
* State is passed via bind:stepperState; ref is used for actions only.
*/
export interface StepperRef {
actions: StepperActions;
}
/**
* Props interface that all stepper components should accept.
*/
export interface BaseStepperProps {
/** Course ID for API calls */
courseId: string;
/** Optional section ID when adding content to a specific section */
sectionId?: string;
/** Order/position for the new content */
order?: number;
/** Whether the user has permission to create content */
canCreate: boolean;
/** Callback when content is successfully created */
onCreated: (id: string) => void;
}
@@ -0,0 +1 @@
export { default as CourseContainer } from './index.svelte';
@@ -0,0 +1,25 @@
<script lang="ts">
import { cn } from '@cio/ui/tools';
import { ContentType } from '@cio/utils/constants/content';
import { CONTENT_DEFINITIONS } from '$features/course/utils/content';
interface Props {
type: ContentType;
size?: number;
className?: string;
isHovered?: boolean;
}
const iconColorClassByType = {
// For now, I don't think sections need a different color. If there is a good reason for it, we can uncomment this.
// [ContentType.Section]: 'text-violet-600 dark:text-violet-400',
[ContentType.Lesson]: 'text-sky-600 dark:text-sky-400',
[ContentType.Exercise]: 'text-green-600 dark:text-green-400'
} as const;
let { type, size = 14, className = '', isHovered = false }: Props = $props();
const Icon = $derived(CONTENT_DEFINITIONS[type].icon);
const iconClass = $derived(cn('shrink-0 custom', iconColorClassByType[type], className));
</script>
<Icon {size} class={iconClass} {isHovered} />
@@ -0,0 +1,151 @@
<script lang="ts">
import { COURSE_SORT_OPTIONS } from '../utils/constants';
import * as Popover from '@cio/ui/base/popover';
import { Button } from '@cio/ui/base/button';
import { Spinner } from '@cio/ui/base/spinner';
import { Checkbox } from '@cio/ui/base/checkbox';
import { IconButton } from '@cio/ui/custom/icon-button';
import FilterIcon from '@lucide/svelte/icons/filter';
import { t } from '$lib/utils/functions/translations';
interface FilterTag {
id: string;
slug: string;
name: string;
color: string;
courseCount: number;
}
interface FilterTagGroup {
id: string;
name: string;
description?: string | null;
tags: FilterTag[];
}
type SortOption = (typeof COURSE_SORT_OPTIONS)[number];
interface Props {
selectedId?: string;
selectedTags?: string[];
tagGroups?: FilterTagGroup[];
sortOptions?: readonly SortOption[];
isFiltering?: boolean;
onToggleTag?: (tagSlug: string, checked: boolean) => void;
onClearFilters?: () => void | Promise<void>;
}
let {
selectedId = $bindable('0'),
selectedTags = [],
tagGroups = [],
sortOptions = COURSE_SORT_OPTIONS,
isFiltering = false,
onToggleTag = () => {},
onClearFilters = () => {}
}: Props = $props();
let isOpen = $state(false);
const hasActiveFilters = $derived(selectedId !== '0' || selectedTags.length > 0);
const hasActiveTagFilters = $derived(selectedTags.length > 0);
function isTagSelected(tagSlug: string) {
return selectedTags.includes(tagSlug);
}
</script>
<Popover.Root bind:open={isOpen}>
<Popover.Trigger>
{#snippet child({ props })}
<div class="relative">
<IconButton {...props} aria-label={$t('courses.tag_filters.filter')} tooltip={$t('courses.tag_filters.filter')}>
{#if isFiltering}
<Spinner class="size-4" />
{:else}
<FilterIcon size={16} />
{/if}
</IconButton>
{#if hasActiveTagFilters}
<span
class="ui:bg-primary absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border border-white"
aria-hidden="true"
></span>
{/if}
</div>
{/snippet}
</Popover.Trigger>
<Popover.Content align="end" class="w-[360px] p-3">
<div class="space-y-4">
<div class="flex items-center justify-between gap-2">
<p class="text-sm font-semibold">{$t('courses.tag_filters.popover_title')}</p>
<Button variant="link" class="h-auto p-0" onclick={onClearFilters} disabled={!hasActiveFilters}>
{$t('courses.tag_filters.clear_all')}
</Button>
</div>
<div class="space-y-2">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
{$t('courses.tag_filters.sort')}
</p>
<div class="flex flex-wrap gap-2">
{#each sortOptions as option (option.value)}
<Button
type="button"
size="sm"
variant={selectedId === option.value ? 'secondary' : 'outline'}
onclick={() => (selectedId = option.value)}
>
{$t(option.label)}
</Button>
{/each}
</div>
</div>
<div class="space-y-3">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
{#if tagGroups.length === 0}
<p class="ui:text-muted-foreground text-sm">{$t('courses.tag_filters.empty_tags')}</p>
{:else}
<div class="max-h-[320px] space-y-4 overflow-y-auto pr-1">
{#each tagGroups as group (group.id)}
{#if group.tags.length > 0}
<div class="space-y-2">
<div class="space-y-1">
<p class="text-sm font-medium">{group.name}</p>
{#if group.description}
<p class="ui:text-muted-foreground text-xs">{group.description}</p>
{/if}
</div>
<div class="space-y-2">
{#each group.tags as tag (tag.id)}
<label
class="hover:ui:bg-muted/30 flex cursor-pointer items-center justify-between rounded-md border px-3 py-2"
>
<div class="flex items-center gap-2">
<Checkbox
checked={isTagSelected(tag.slug)}
onCheckedChange={(checked) => onToggleTag(tag.slug, Boolean(checked))}
/>
<span
class="inline-block h-2.5 w-2.5 rounded-full border"
style={`background-color: ${tag.color}`}
aria-hidden="true"
></span>
<span class="text-sm">{tag.name}</span>
</div>
<span class="ui:text-muted-foreground text-xs">{tag.courseCount}</span>
</label>
{/each}
</div>
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>
</Popover.Content>
</Popover.Root>
@@ -9,7 +9,7 @@
import * as Empty from '@cio/ui/base/empty';
import { currentOrg } from '$lib/utils/store/org';
import { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
import { course } from '$lib/components/Course/store';
import { courseApi } from '$features/course/api';
const siteName = $derived($currentOrg.siteName);
@@ -21,7 +21,7 @@
</script>
<header
class="group-has-data-[collapsible=icon]/sidebar-wrapper:h-8 border-border ui:bg-background bg-background sticky top-0 z-50 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear"
class="border-border ui:bg-background sticky top-0 z-100 flex h-12 w-full shrink-0 items-center gap-2 border-b backdrop-blur transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-8"
>
<div class="flex w-full items-center gap-2 px-4">
<Sidebar.Trigger />
@@ -31,7 +31,7 @@
</div>
<p class="max-w-2xs truncate text-sm font-medium">
{$course.title}
{courseApi.course?.title || ''}
</p>
<span class="grow"></span>
@@ -0,0 +1,64 @@
<script lang="ts">
import * as Popover from '@cio/ui/base/popover';
import { Button } from '@cio/ui/base/button';
import PlusIcon from '@lucide/svelte/icons/plus';
import { t } from '$lib/utils/functions/translations';
import type { OrganizationTagGroups } from '$features/tag/utils/types';
interface Props {
tagGroups?: OrganizationTagGroups;
selectedTagIds?: string[];
open?: boolean;
onTagToggle?: (tagId: string) => void;
}
let { tagGroups = [], selectedTagIds = [], open = $bindable(false), onTagToggle = () => {} }: Props = $props();
</script>
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<Button
{...props}
type="button"
variant="outline"
size="icon"
aria-label={$t('course.navItem.settings.tags.add')}
>
<PlusIcon size={16} />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content align="start" class="w-[420px] p-3">
<div class="space-y-4">
<p class="text-sm font-semibold">{$t('course.navItem.settings.tags.popover_title')}</p>
{#if !tagGroups.length}
<p class="ui:text-muted-foreground text-sm">{$t('course.navItem.settings.tags.empty_groups')}</p>
{:else}
{#each tagGroups as group (group.id)}
{#if group.tags.length > 0}
<div class="space-y-2">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{group.name}</p>
<div class="flex flex-wrap gap-2">
{#each group.tags as tag (tag.id)}
<Button
size="sm"
variant={selectedTagIds.includes(tag.id) ? 'secondary' : 'outline'}
onclick={() => onTagToggle(tag.id)}
>
<span
class="inline-block h-2 w-2 rounded-full border"
style={`background-color: ${tag.color}`}
aria-hidden="true"
></span>
<span>{tag.name}</span>
</Button>
{/each}
</div>
</div>
{/if}
{/each}
{/if}
</div>
</Popover.Content>
</Popover.Root>
@@ -0,0 +1,6 @@
export const STATUS = {
PENDING: 0,
SUBMITTED: 1,
IN_PROGRESS: 2,
GRADED: 3
};
@@ -0,0 +1,41 @@
<script lang="ts">
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import { deleteConfirmation } from './store';
let { onDelete = () => {}, onCancel = () => {} } = $props();
</script>
<Dialog.Root bind:open={$deleteConfirmation.open}>
<Dialog.Content class="w-96">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.title')}</Dialog.Title>
</Dialog.Header>
<div>
<p class="mt-0 text-base dark:text-white">
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.sure')}
</p>
<div class="mt-5 flex items-center justify-between">
<Button
variant="outline"
onclick={() => {
$deleteConfirmation.open = false;
onCancel();
}}
>
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.no')}
</Button>
<Button
onclick={() => {
onDelete();
$deleteConfirmation.open = false;
}}
>
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.yes')}
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,313 @@
<script lang="ts">
import * as Select from '@cio/ui/base/select';
import TrashIcon from '@lucide/svelte/icons/trash';
import {
questionnaire,
deleteConfirmation,
questionnaireValidation,
handleRemoveQuestion,
handleCode
} from './store';
import { preventDefault } from '$lib/utils/functions/svelte';
import { exerciseApi } from '$features/course/api';
import { courseApi } from '$features/course/api';
import { ContentType } from '@cio/utils/constants/content';
import { PREMIUM_QUESTION_TYPE_KEYS, QUESTION_TYPES } from '$features/ui/question/constants';
import { Button } from '@cio/ui/base/button';
import { ExerciseQuestion } from '@cio/ui';
import OrderModal from './order-modal.svelte';
import { t } from '$lib/utils/functions/translations';
import * as Dialog from '@cio/ui/base/dialog';
import { IconButton } from '@cio/ui/custom/icon-button';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import DeleteConfirmationModal from './delete-confirmation.svelte';
import { QuestionContainer } from '$features/course/components';
import { uploadImage } from '$lib/utils/services/upload';
import {
getQuestionTypeOptionById,
questionTypeSupportsOptions,
toExerciseQuestionModel
} from './question-type-utils';
import { QUESTION_TYPE_KEY } from '@cio/question-types';
import { getExerciseQuestionLabels } from './question-labels';
import { isFreePlan } from '$lib/utils/store/org';
import { openUpgradeModal } from '$lib/utils/functions/org';
import { PremiumIcon } from '@cio/ui/custom/moving-icons';
const initialQuestionsLength = $questionnaire.questions.length;
interface Props {
shouldDeleteExercise?: boolean;
exerciseId: any;
goBack?: any;
}
let { shouldDeleteExercise = $bindable(false), exerciseId, goBack = () => {} }: Props = $props();
let questionIdToDelete = $state(null);
let isDeleting = $state(false);
let errors = $derived($questionnaireValidation);
const questionLabels = $derived(getExerciseQuestionLabels());
function shouldScrollToLast(questionId, questions) {
const [lastQuestion] = questions.slice(-1);
const currentQuestionsLength = questions.length;
const isLast = lastQuestion.id === questionId;
return isLast && initialQuestionsLength !== currentQuestionsLength;
}
function onInitDeleteClicked(questionId) {
return () => {
questionIdToDelete = questionId;
$deleteConfirmation.open = true;
};
}
function onFinalDeleteClicked() {
handleRemoveQuestion(questionIdToDelete)();
}
function getQuestionErrorMsg(errors, question, errorKey) {
return errors[question.id] ? errors[question.id][errorKey] : null;
}
function getQuestionTypeLabel(questionType) {
if (!questionType) return t.get('course.navItem.lessons.exercises.all_exercises.edit_mode.select_type');
if (questionType.labelKey) return t.get(questionType.labelKey);
return questionType.label;
}
function makeDefaultOption() {
return {
id: `${new Date().getTime()}-form`,
label: '',
value: '',
isCorrect: false
};
}
function makeTrueFalseOptions(correctValue = true) {
const ts = Date.now();
return [
{ id: `${ts}-true-form`, label: 'True', value: 'true', isCorrect: correctValue },
{ id: `${ts}-false-form`, label: 'False', value: 'false', isCorrect: !correctValue }
];
}
function onQuestionTypeChange(questionId: string | number, value: string) {
if (!value) return;
const nextQuestionType = getQuestionTypeOptionById(Number(value));
const nextQuestionTypeKey = nextQuestionType.key;
questionnaire.update((q) => {
const idx = q.questions.findIndex((qu) => String(qu.id) === String(questionId));
if (idx === -1) return q;
const current = q.questions[idx];
let nextOptions = current.options ?? [];
if (nextQuestionTypeKey === QUESTION_TYPE_KEY.TRUE_FALSE) {
const correctValue = (current.settings as { correctValue?: boolean })?.correctValue ?? true;
nextOptions = makeTrueFalseOptions(correctValue);
} else if (questionTypeSupportsOptions(nextQuestionTypeKey)) {
const hasActiveOptions = nextOptions.some((option) => !option.deletedAt);
if (!hasActiveOptions) {
nextOptions = [...nextOptions, makeDefaultOption()];
}
} else {
nextOptions = [];
}
const next = [...q.questions];
next[idx] = {
...current,
questionType: nextQuestionType,
questionTypeId: nextQuestionType.id,
options: nextOptions,
isDirty: true
};
return { ...q, questions: next };
});
}
function onSharedQuestionChange(questionId: string | number, nextQuestion) {
questionnaire.update((q) => {
const idx = q.questions.findIndex((qu) => String(qu.id) === String(questionId));
if (idx === -1) return q;
const current = q.questions[idx];
const mappedOptions = Array.isArray(nextQuestion.options)
? nextQuestion.options.map((option, index) => {
const optionId = option.id ?? current.options?.[index]?.id ?? `${new Date().getTime()}-${index}-form`;
const optionLabel = option.label ?? '';
return {
id: optionId,
label: optionLabel,
value: option.value ?? optionLabel,
isCorrect: option.isCorrect ?? false,
settings:
option.settings && typeof option.settings === 'object' && !Array.isArray(option.settings)
? { ...option.settings }
: undefined
};
})
: (current.options ?? []);
const next = [...q.questions];
next[idx] = {
...current,
title: nextQuestion.title,
settings: nextQuestion.settings ?? {},
options: mappedOptions,
isDirty: true
};
return { ...q, questions: next };
});
}
async function handleDelete() {
isDeleting = true;
await exerciseApi.delete(courseApi.course?.id!, exerciseId);
if (exerciseApi.success) {
courseApi.removeContentItem(exerciseId, ContentType.Exercise);
goBack();
}
isDeleting = false;
}
</script>
<DeleteConfirmationModal onCancel={() => (questionIdToDelete = null)} onDelete={onFinalDeleteClicked} />
<OrderModal />
<Dialog.Root
bind:open={shouldDeleteExercise}
onOpenChange={(isOpen) => {
if (!isOpen) shouldDeleteExercise = false;
}}
>
<Dialog.Content class="w-2/4">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.delete_modal')}</Dialog.Title>
</Dialog.Header>
<form onsubmit={preventDefault()}>
<h1 class="text-xl dark:text-white">
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.sure')}
</h1>
<div class="mt-5 flex items-center justify-between">
<Button variant="outline" type="submit" onclick={() => (shouldDeleteExercise = false)}>
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.no')}
</Button>
<Button disabled={isDeleting} onclick={handleDelete} loading={isDeleting}>
{isDeleting
? $t('course.navItem.lessons.exercises.all_exercises.edit_mode.deleting')
: $t('course.navItem.lessons.exercises.all_exercises.edit_mode.yes')}
</Button>
</div>
</form>
</Dialog.Content>
</Dialog.Root>
<div class="mb-20 w-full">
{#if Object.values(errors).length}
<div class="mb-4 flex w-full justify-center">
<p class="text-sm text-red-500">{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.error')}</p>
</div>
{/if}
<div class="questions px-6 pt-6">
{#each $questionnaire.questions as question, index}
{#if !question.deletedAt}
<QuestionContainer
key={String(question.id ?? `new-${index}`)}
onClose={onInitDeleteClicked(question.id)}
scrollToQuestion={shouldScrollToLast(question.id, $questionnaire.questions)}
bind:points={question.points}
hasError={!!errors[question.id]}
errorMsg={getQuestionErrorMsg(errors, question, 'points')}
onPointsChange={() => {
question.isDirty = true;
}}
>
{#if typeof question.code === 'string'}
<div class="my-3 flex w-3/5 items-center justify-between">
<TextareaField
bind:value={question.code}
rows={2}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.write')}
/>
<IconButton onclick={() => handleCode(question.id, false)}>
<TrashIcon size={16} />
</IconButton>
</div>
{/if}
<div class="mt-2 flex flex-col">
<ExerciseQuestion.QuestionRenderer
showContainer={false}
contract={{
mode: 'edit',
question: toExerciseQuestionModel(question),
labels: questionLabels,
onImageUpload: uploadImage
}}
onQuestionChange={(nextQuestion) => onSharedQuestionChange(question.id, nextQuestion)}
>
{#snippet questionTypeSelect()}
<Select.Root
type="single"
value={question.questionTypeId?.toString()}
onValueChange={(value) => onQuestionTypeChange(question.id, value)}
>
<Select.Trigger class="w-[180px]">
{getQuestionTypeLabel(question?.questionType)}
</Select.Trigger>
<Select.Content>
{#each QUESTION_TYPES as type}
{#if $isFreePlan && PREMIUM_QUESTION_TYPE_KEYS.has(type.key)}
<button
type="button"
class="ui:flex ui:w-full ui:cursor-pointer ui:select-none ui:items-center ui:gap-2 ui:rounded-sm ui:py-1.5 ui:pl-2 ui:pr-8 ui:text-sm ui:outline-hidden ui:hover:bg-accent ui:hover:text-accent-foreground ui:relative"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
openUpgradeModal();
}}
title={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.premium_question_type')}
>
<PremiumIcon size={16} class="ui:text-blue-700 ui:dark:text-white ui:shrink-0" />
<span>{getQuestionTypeLabel(type)}</span>
</button>
{:else}
<Select.Item value={type.id.toString()} label={getQuestionTypeLabel(type)} />
{/if}
{/each}
</Select.Content>
</Select.Root>
{/snippet}
</ExerciseQuestion.QuestionRenderer>
{#if getQuestionErrorMsg(errors, question, 'option')}
<p class="text-sm text-red-500">{getQuestionErrorMsg(errors, question, 'option')}</p>
{/if}
</div>
</QuestionContainer>
{/if}
{/each}
</div>
</div>
<style>
:global(#dnd-action-dragged-el div) {
z-index: 100;
}
</style>
@@ -0,0 +1,166 @@
import { getQuestionAnswerKey, getQuestionTypeKey, questionTypeSupportsOptions } from './question-type-utils';
import { QUESTION_TYPE_KEY } from '@cio/question-types';
import type { Question } from '$features/course/types';
import type { QuestionnaireMetaData } from './store';
import type { TExerciseUpdate } from '@cio/utils/validation/exercise';
import { toggleConfetti } from '$features/ui/confetti/store';
export const isAnswerCorrect = (options, answer) => {
return options.some((option) => option.isCorrect && option.value === answer);
};
/*
GRADING RULES
Textarea = no validation
Single = only one should be correct
Multiple = all should be correct
*/
export function wasCorrectAnswerSelected(currentQuestion: Question, answers, isFinished?: boolean) {
const questionTypeKey = getQuestionTypeKey(currentQuestion);
if (!questionTypeSupportsOptions(questionTypeKey)) {
return true;
}
const answer = answers[getQuestionAnswerKey(currentQuestion)];
const formattedAnswers = typeof answer === 'string' ? [answer] : answer;
let isCorrect = false;
if (questionTypeKey === QUESTION_TYPE_KEY.CHECKBOX) {
// Every correct answer should be in the selected answer
isCorrect = currentQuestion.options
.filter((o) => o.isCorrect)
.every((option) => formattedAnswers?.includes(option.value));
} else if (questionTypeKey === QUESTION_TYPE_KEY.RADIO) {
// At least one correct answer should be selected
isCorrect = formattedAnswers?.some((answer) => isAnswerCorrect(currentQuestion.options, answer));
}
if (isCorrect && !isFinished) {
toggleConfetti();
setTimeout(toggleConfetti, 100);
}
return isCorrect;
}
export function getPropsForQuestion(
questions: Question[],
question: Question,
questionnaireMetaData: QuestionnaireMetaData,
questionIndex: number,
onSubmit,
onPrevious,
preview
) {
const { answers, isFinished } = questionnaireMetaData;
const isLast = questionIndex === questions.length;
const isOpenQuesiton = !questionTypeSupportsOptions(getQuestionTypeKey(question));
const isCorrect = wasCorrectAnswerSelected(question, answers, isFinished);
const answerKey = getQuestionAnswerKey(question, questionIndex - 1);
if (!isCorrect && document && document.getElementById('question')) {
const questionElement = document.getElementById('question');
// Shake if wrong answer was selected
questionElement?.classList.toggle('shake');
setTimeout(() => {
// Remove so we can trigger animation again
questionElement?.classList.toggle('shake');
}, 200);
}
return {
index: questionIndex + '. ',
title: question.title,
name: answerKey,
options: question.options,
code: question.code,
defaultValue: isOpenQuesiton ? answers[answerKey] || '' : answers[answerKey] || [],
onSubmit,
onPrevious,
disablePreviousButton: questionIndex === 1,
isLast,
isPreview: preview || isFinished,
isCorrect,
disabled: (isFinished && isCorrect) || isFinished,
nextButtonProps: isOpenQuesiton
? {
label: 'Next',
isActive: true
}
: isCorrect
? {
label: isLast ? 'Finish' : 'Next',
isActive: true,
disableOptionSelect: true,
isDisabled: isLast ? false : true // Disable Next button because auto next is enabled
}
: {
label: 'Check',
isActive: false
}
};
}
export function filterOutDeleted(array) {
return array.filter((item) => !item.deletedAt);
}
/**
* Transforms questions from the questionnaire format to the API format
* @param questions - Array of questions from the questionnaire store
* @param options - Configuration options
* @param options.shouldFilterEmptyLabels - If true, filters out options with empty labels (for API submission). If false, keeps empty labels (for validation)
* @param options.shouldIncludeDeleted - If true, includes deleted questions and options with deletedAt flag (for API updates). If false, filters them out (for validation)
* @returns Transformed questions in API format
*/
export function transformQuestionsToApiFormat(
questions: Question[],
options: { shouldFilterEmptyLabels?: boolean; shouldIncludeDeleted?: boolean } = {}
): TExerciseUpdate['questions'] {
const { shouldFilterEmptyLabels = false, shouldIncludeDeleted = false } = options;
const getQuestionSettings = (question: Question) => {
const rawSettings = (question as Question & { settings?: Record<string, unknown> }).settings;
return rawSettings && typeof rawSettings === 'object' ? { ...rawSettings } : undefined;
};
const getOptionSettings = (option: Question['options'][number]) => {
const rawSettings = (option as Question['options'][number] & { settings?: Record<string, unknown> }).settings;
return rawSettings && typeof rawSettings === 'object' ? { ...rawSettings } : undefined;
};
const normalizeId = (id: unknown) => (id && !isNaN(Number(id)) ? Number(id) : undefined);
const formatOptions = (opts: Question['options']) => {
const filtered = shouldIncludeDeleted ? opts || [] : (opts || []).filter((opt) => !opt.deletedAt);
return filtered
.filter((opt) => {
// For validation, keep empty labels. For API, filter them unless deleted
if (!shouldIncludeDeleted && opt.deletedAt) return false;
return shouldFilterEmptyLabels && !opt.deletedAt ? opt.label?.trim() : true;
})
.map((opt) => ({
id: normalizeId(opt.id),
label: shouldFilterEmptyLabels && !opt.deletedAt ? opt.label?.trim() || '' : opt.label || '',
isCorrect: opt.isCorrect || false,
settings: getOptionSettings(opt),
...(shouldIncludeDeleted && opt.deletedAt ? { deletedAt: opt.deletedAt } : {})
}));
};
const filteredQuestions = shouldIncludeDeleted ? questions : questions.filter((q) => !q.deletedAt);
return filteredQuestions.map((q, index) => ({
id: normalizeId(q.id),
question: q.title,
points: q.points || 0,
questionTypeId: q.questionTypeId,
order: typeof (q as { order?: number }).order === 'number' ? (q as { order: number }).order : index + 1,
settings: getQuestionSettings(q),
options: questionTypeSupportsOptions(getQuestionTypeKey(q)) ? formatOptions(q.options) : [],
...(shouldIncludeDeleted && q.deletedAt ? { deletedAt: q.deletedAt } : {})
}));
}
@@ -0,0 +1,13 @@
<script>
import { Skeleton } from '@cio/ui/base/skeleton';
</script>
<div class="space-y-6">
<Skeleton class="h-4 w-full" />
<div class="space-y-3">
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-full" />
</div>
</div>
@@ -0,0 +1,414 @@
<script lang="ts">
import { untrack } from 'svelte';
import { Badge } from '@cio/ui/base/badge';
import * as Select from '@cio/ui/base/select';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { STATUS } from './constants';
import { t } from '$lib/utils/functions/translations';
import type { SubmissionIdData } from '$features/course/utils/types';
import type { AnswerData } from '@cio/question-types';
import Preview from './preview.svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { Button } from '@cio/ui/base/button';
// import { useCompletion } from 'ai/svelte';
// import { QUESTION_TYPE } from '$features/ui/question/constants';
interface Props {
open?: boolean;
onClose?: any;
handleSave?: any;
isGradeWithAI?: boolean;
data: SubmissionIdData;
deleteSubmission?: any;
isSaving?: boolean;
}
let {
open = $bindable(false),
onClose = () => {},
handleSave = (_d: SubmissionIdData) => {},
isGradeWithAI = $bindable(false),
data = $bindable(),
deleteSubmission = async (_id: string, _statusId: number) => {},
isSaving = false
}: Props = $props();
const SELECTABLE_STATUS = [
{
id: STATUS.SUBMITTED,
text: $t('course.navItem.submissions.submission_status.submitted')
},
{
id: STATUS.IN_PROGRESS,
text: $t('course.navItem.submissions.submission_status.in_progress')
},
{
id: STATUS.GRADED,
text: $t('course.navItem.submissions.submission_status.graded')
}
];
let status = $state(SELECTABLE_STATUS[0]);
let reasons = $state({});
let isLoading = $state(false);
let openDeletePrompt = $state(false);
let isDeleting = $state(false);
const maxPoints = $derived(getMaxPoints(data.questions));
const sortedQuestions = $derived(
Array.isArray(data.questions)
? [...data.questions]
.sort((a, b) => a.order - b.order)
.map((q) => ({
...q,
questionType: { id: q.questionTypeId }
}))
: []
);
let answersToDisplay = $state<Record<string, AnswerData>>({});
$effect(() => {
answersToDisplay = data?.answers ? (data.answers as Record<string, AnswerData>) : {};
});
function getMaxPoints(questions) {
return (questions || []).reduce((acc, question) => acc + question.points, 0);
}
let total = $derived(calculateTotal(data.questionAnswerByPoint));
function calculateTotal(grades: Record<string, string>): number {
if (!grades) return 0;
return Object.values(grades).reduce((acc, grade) => acc + parseInt(grade || '0'), 0);
}
function handleStatusChange(value: string) {
const newSelectedId = parseInt(value);
const newStatus = SELECTABLE_STATUS.find((s) => s.id === newSelectedId);
if (newStatus) {
status = newStatus;
data.statusId = newSelectedId;
}
}
function setStatus(data: SubmissionIdData) {
untrack(() => {
const statusBySelectedId = SELECTABLE_STATUS.find((s) => s.id === data.statusId);
if (statusBySelectedId) {
status = statusBySelectedId;
}
});
}
// const getMultipleAnswersGrade = (options, answer, points) => {
// const correctOptions = options
// .filter((option) => option.is_correct)
// .map((option) => option.value);
// const correctSelections = answer.answers.filter((answer) =>
// correctOptions.includes(answer)
// ).length;
// const incorrectSelections = answer.answers.length - correctSelections;
// const pointsEarned = (correctSelections / correctOptions.length) * points;
// const deduction = (incorrectSelections * points) / correctOptions.length;
// const finalPoints = Math.max(pointsEarned - deduction, 0);
// return {
// finalPoints,
// correctOptions,
// correctSelections,
// incorrectSelections
// };
// };
async function handleDeleteSubmission() {
isDeleting = true;
await deleteSubmission(data.id, status.id);
isDeleting = false;
}
// const { input, handleSubmit, completion } = useCompletion({
// api: '/api/completion/gradingprompt',
// onFinish: async () => {
// try {
// const responseData = $completion.replace('```json', '').replace('```', '');
// let aiResponses: {
// id: number;
// score: number;
// explanation: string;
// }[] = [];
// try {
// // Parse the modified response data as JSON
// aiResponses = JSON.parse(responseData);
// } catch (error) {
// console.error('Error parsing AI response', error);
// }
// data?.questions.forEach((question) => {
// const { id, points, question_type_id, options } = question;
// if (question_type_id == QUESTION_TYPE.RADIO) {
// const answer = data.questionAnswers.find((q) => q.question_id === id);
// const answerPoints = answer?.answers?.length ?? 0;
// reasons = {
// ...reasons,
// [id]: `${$t('course.navItem.submissions.grading_modal.allocated')} ${$t(
// 'course.navItem.submissions.grading_modal.total_try'
// )} ${answerPoints} `
// };
// data.questionAnswerByPoint[id] = `${Math.ceil(points / answerPoints)}`;
// } else if (question_type_id == QUESTION_TYPE.CHECKBOX) {
// const answer = data.questionAnswers.find((q) => q.question_id === id);
// const { finalPoints, correctOptions, correctSelections, incorrectSelections } =
// getMultipleAnswersGrade(options, answer, points);
// reasons = {
// ...reasons,
// [id]: `${$t(
// 'course.navItem.submissions.grading_modal.allocated'
// )} ${correctSelections} ${$t('course.navItem.submissions.grading_modal.out_of')} ${
// correctOptions.length
// } ${$t('course.navItem.submissions.grading_modal.options_correct')} ${$t(
// 'course.navItem.submissions.grading_modal.options_wrong'
// )} ${incorrectSelections} `
// };
// data.questionAnswerByPoint[id] = `${finalPoints}`;
// } else if (aiResponses.length) {
// const graded = aiResponses.find((res) => res.id === id);
// reasons = {
// ...reasons,
// [id]: `${graded?.explanation}`
// };
// data.questionAnswerByPoint[id] = `${graded?.score}`;
// }
// });
// } catch (error) {
// console.error('Error', error);
// } finally {
// isLoading = false;
// }
// }
// });
function gradeWithAI() {
// isGradeWithAI = true;
// isLoading = true;
// const paragraphAiInput = data.questions
// .filter((q) => q.question_type_id === QUESTION_TYPE.TEXTAREA)
// .map((q) => {
// const answer = data.questionAnswers.find((qA) => qA.question_id === q.id); // { open_answer: '' }
// return {
// id: q.id,
// question: q.title,
// answer: answer?.open_answer,
// point: q.points
// };
// });
// $input = JSON.stringify(paragraphAiInput);
// handleSubmit({ preventDefault: () => {} });
}
$effect(() => {
setStatus(data);
});
</script>
<Dialog.Root
bind:open
onOpenChange={(isOpen) => {
if (!isOpen) onClose();
}}
>
<Dialog.Content class="max-h-[90vh]! w-4/5 max-w-6xl!">
<Dialog.Header class="py-2">
<Dialog.Title class="text-base font-semibold">{data.title}</Dialog.Title>
</Dialog.Header>
<div class="flex max-h-[calc(90vh-5rem)] justify-between gap-4 overflow-y-auto pr-4">
<div class="mt-2 min-h-0 min-w-0 flex-1">
{#if openDeletePrompt}
<div class="mx-auto w-96 rounded-md border border-gray-300 p-3">
<h1 class="text-lg dark:text-white">
{$t('delete_modal.content')}
</h1>
<div class="mt-5 flex items-center justify-between">
<Button variant="outline" onclick={() => (openDeletePrompt = false)} disabled={isDeleting}>
{$t('delete_modal.no')}
</Button>
<Button variant="outline" onclick={handleDeleteSubmission} loading={isDeleting}>
{$t('delete_modal.yes')}
</Button>
</div>
</div>
{:else}
<Preview
questions={sortedQuestions}
questionnaireMetaData={{
answers: answersToDisplay,
isFinished: true
}}
bind:grades={data.questionAnswerByPoint}
bind:reasons
bind:isGradeWithAI
bind:isLoading
disableGrading={false}
/>
{/if}
</div>
<div class="sticky top-0 mt-2 ml-4 min-h-0 w-2/5 max-w-[350px] shrink-0 self-start">
<div class="rounded-md border border-gray-300">
<div
class="flex items-center justify-between gap-1 border-t-0 border-r-0 border-b border-l-0 border-gray-300 p-3"
>
<p class="text-base dark:text-white">
{$t('course.navItem.submissions.grading_modal.details')}
{#if data.isEarly}
<Badge variant="success" class="ml-2">
{$t('course.navItem.submissions.grading_modal.early')}
</Badge>
{:else}
<Badge variant="destructive" class="ml-2">
{$t('course.navItem.submissions.grading_modal.late')}
</Badge>
{/if}
</p>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="flex h-8 w-8 items-center justify-center rounded-md hover:bg-gray-100 dark:hover:bg-neutral-700"
>
<EllipsisVerticalIcon class="h-5 w-5" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="ui:z-[250]">
<DropdownMenu.Item
class="text-red-600"
onclick={() => {
openDeletePrompt = true;
}}
>
{$t('delete_modal.label')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
<div class="flex items-center space-x-4 px-3 py-2 text-sm">
<p class="text-sm font-semibold text-gray-500 dark:text-white">
{$t('course.navItem.submissions.grading_modal.total_grade')}:
</p>
<Badge class="w-fit rounded-md bg-gray-100 font-semibold text-black dark:bg-neutral-700 dark:text-white">
{total}/{maxPoints}
</Badge>
</div>
<!-- <div class="flex items-center text-sm p-3">
<p class="dark:text-white w-1/2">Status</p>
<div class="flex items-center">
<p class="dark:text-white rounded-full w-5 h-5 bg-yellow-300 mr-2" />
<p class="dark:text-white">Grading</p>
</div>
</div> -->
<div class="flex items-center space-x-4 px-3 py-2 text-sm">
<p class="text-sm font-semibold text-gray-500 dark:text-white">
{$t('course.navItem.submissions.grading_modal.student')}:
</p>
{#if data.student}
<div class="flex flex-row items-center justify-center rounded-md bg-gray-100 p-[6px] dark:bg-neutral-700">
<img
alt={$t('course.navItem.submissions.grading_modal.student_avatar')}
class="flex h-5 w-5 rounded-full"
src={data.student.avatarUrl}
/>
<p class="ml-2 line-clamp-1 text-sm font-semibold dark:text-white">
{data.student.fullname}
</p>
</div>
{/if}
</div>
<!-- <div class="flex items-center space-x-4 text-sm px-3 py-2">
<p class="dark:text-white text-sm text-gray-500 font-semibold">Assesment Type:</p>
<Tag
class="dark:text-white font-semibold bg-gray-100 dark:bg-neutral-700 rounded-md text-black w-fit"
>Paragraph</Tag
>
</div> -->
<div class="flex flex-col items-start px-3 py-2 text-sm">
<p class="font-semibold text-gray-500 dark:text-white">
{$t('course.navItem.submissions.grading_modal.status')}:
</p>
<Select.Root
type="single"
value={status.id.toString()}
onValueChange={(value) => {
if (value) {
handleStatusChange(value);
}
}}
>
<Select.Trigger class="w-full">
{status.text}
</Select.Trigger>
<Select.Content class="ui:z-[250]">
{#each SELECTABLE_STATUS as statusOption}
<Select.Item value={statusOption.id.toString()}>
{statusOption.text}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-col items-start px-3 py-2 text-sm">
<p class="font-semibold text-gray-500 dark:text-white">
{$t('course.navItem.submissions.grading_modal.add_comment')}:
</p>
<TextareaField
placeholder={$t('course.navItem.submissions.grading_modal.add_comment_placeholder')}
bind:value={data.feedback}
/>
</div>
<div class="flex w-full flex-col space-y-3 px-3 py-2">
<Button variant="outline" onclick={gradeWithAI} class="hidden! w-full">
<img src="/ai.svg" alt={$t('course.navItem.submissions.grading_modal.ai_icon')} />
<p class="text-sm font-semibold">
{$t('course.navItem.submissions.grading_modal.grade_with_ai')}
</p>
</Button>
<Button
onclick={() => {
handleSave(data);
// onClose();
}}
loading={isSaving}
class="w-full"
>
{$t('course.navItem.submissions.grading_modal.submit_grades')}
</Button>
</div>
<!-- <div class="flex items-center text-sm p-3">
<p class="dark:text-white w-1/2">Teacher</p>
<p class="dark:text-white">rotimi-best</p>
</div> -->
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,113 @@
<script lang="ts">
import { untrack } from 'svelte';
import { dndzone } from 'svelte-dnd-action';
import SquareCheckIcon from '@lucide/svelte/icons/square-check';
import CircleDotIcon from '@lucide/svelte/icons/circle-dot';
import TablePropertiesIcon from '@lucide/svelte/icons/table-properties';
import { flip } from 'svelte/animate';
import * as Dialog from '@cio/ui/base/dialog';
import { questionnaireOrder, questionnaire } from './store';
import { filterOutDeleted } from './functions';
import { t } from '$lib/utils/functions/translations';
const flipDurationMs = 300;
interface Question {
id: number;
name: string;
type: number;
}
let items: Array<Question> = $state([]);
function setItemsFromQuestions(questions: typeof $questionnaire.questions) {
untrack(() => {
items = filterOutDeleted(questions).map((q) => ({
id: q.id,
name: q.title,
type: q.questionType.id
}));
});
}
function handleDndConsider(e) {
items = e.detail.items;
}
function handleDndFinalize(e) {
items = e.detail.items;
}
function handleClose() {
items.forEach((item, index) => {
const order = index + 1;
$questionnaire.questions.some((q) => {
if (q.id === item.id) {
q.isDirty = true;
q.order = order;
return true;
}
});
});
$questionnaire.questions = $questionnaire.questions.sort((a, b) => a.order - b.order);
$questionnaireOrder.open = false;
}
$effect(() => {
setItemsFromQuestions($questionnaire.questions);
});
</script>
<Dialog.Root
bind:open={$questionnaireOrder.open}
onOpenChange={(isOpen) => {
if (!isOpen) handleClose();
}}
>
<Dialog.Content class="w-96">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.order_questions')}</Dialog.Title>
</Dialog.Header>
<section
use:dndzone={{
items,
flipDurationMs,
dropTargetStyle: {
border: '2px #1d4ed8 solid',
'border-style': 'dashed'
}
}}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
class="w-full"
>
{#each items as item (item.id)}
<div
animate:flip={{ duration: flipDurationMs }}
class="ui:border-primary flex items-center gap-2 rounded-md border p-4 [&_svg]:shrink-0"
>
{#if item.type === 1}
<SquareCheckIcon size={16} />
{:else if item.type === 2}
<CircleDotIcon size={16} />
{:else}
<TablePropertiesIcon size={16} />
{/if}
{` ${item.name}`}
</div>
{/each}
</section>
</Dialog.Content>
</Dialog.Root>
<style>
section {
padding: 0.3em;
overflow: auto;
}
div {
margin: 0.5em 0;
}
</style>
@@ -0,0 +1,26 @@
/**
* If multiple questions have order 0 (or null), they are considered unordered.
* Normalizes by sorting by id and assigning incrementing order values.
* Returns questions with order set; original array is not mutated.
*/
export function normalizeQuestionOrder<T extends { id?: number | string; order?: number | null }>(questions: T[]): T[] {
if (questions.length === 0) return questions;
const withOrderZero = questions.filter((q) => q.order == null || q.order === 0);
const isUnordered = withOrderZero.length > 1;
const toNumericId = (id: number | string | undefined): number =>
typeof id === 'number' ? id : typeof id === 'string' ? parseInt(id, 10) || 0 : 0;
if (!isUnordered) {
return [...questions].sort((a, b) => {
const orderA = a.order ?? 0;
const orderB = b.order ?? 0;
if (orderA !== orderB) return orderA - orderB;
return toNumericId(a.id) - toNumericId(b.id);
});
}
const sortedById = [...questions].sort((a, b) => toNumericId(a.id) - toNumericId(b.id));
return sortedById.map((q, index) => ({ ...q, order: index + 1 }));
}
@@ -0,0 +1,114 @@
<script lang="ts">
import { getExerciseQuestionContractKey } from '@cio/question-types';
import type { AnswerData } from '@cio/question-types';
import { Grade, ReasonBox } from '$features/ui/question';
import { toExerciseQuestionModel } from './question-type-utils';
import { getExerciseQuestionLabels } from './question-labels';
import { ExerciseQuestion } from '@cio/ui';
import type { QuestionnaireMetaData } from './store';
interface Props {
questions?: any;
questionnaireMetaData?: Partial<QuestionnaireMetaData>;
grades?: Record<string, number>;
onSubmit?: any;
onPrevious?: any;
disableGrading?: boolean;
isGradeWithAI?: boolean;
isLoading?: boolean;
reasons?: any;
}
let {
questions = [],
questionnaireMetaData: _questionnaireMetaData = {},
grades = $bindable({}),
disableGrading = true,
isGradeWithAI = $bindable(false),
isLoading = $bindable(false),
reasons = $bindable({})
}: Props = $props();
function acceptGradeSuggestion() {
isGradeWithAI = false;
}
function rejectGradeSuggestion(questionId) {
isGradeWithAI = false;
grades[questionId] = 0;
}
function getAnswerForQuestion(questionModel, fallbackIndex = 0) {
const questionKey = getExerciseQuestionContractKey(questionModel, fallbackIndex);
return _questionnaireMetaData?.answers?.[questionKey];
}
const questionModels = $derived(questions.map((question) => toExerciseQuestionModel(question)));
const questionLabels = $derived(getExerciseQuestionLabels());
const answersByKey = $derived(
questionModels.reduce(
(acc, questionModel, index) => {
const questionKey = getExerciseQuestionContractKey(questionModel, index);
acc[questionKey] = _questionnaireMetaData?.answers?.[questionKey];
return acc;
},
{} as Record<string, AnswerData>
)
);
</script>
{#if disableGrading && !Object.keys(grades || {}).length}
<ExerciseQuestion.QuestionList
contract={{ mode: 'view', questions: questionModels, answersByKey, labels: questionLabels, disabled: true }}
itemClass="mb-4"
/>
{:else if disableGrading && Object.keys(grades || {}).length}
{#each questions as currentQuestion, index}
{@const questionModel = questionModels[index]}
<div class="mb-4 space-y-3">
<div class="flex items-center justify-between">
<p class="text-base font-semibold text-gray-700 dark:text-gray-200">Q{index + 1}</p>
<Grade grade={grades[currentQuestion.id]} gradeMax={currentQuestion.points} disableGrading={true} />
</div>
<ExerciseQuestion.QuestionRenderer
contract={{
mode: 'view',
question: questionModel,
answer: getAnswerForQuestion(questionModel, index),
labels: questionLabels,
disabled: true
}}
/>
</div>
{/each}
{:else}
{#each questions as currentQuestion, index}
{@const questionModel = questionModels[index]}
<div class="mb-4 space-y-3">
<div class="flex items-center justify-between">
<p class="text-base font-semibold text-gray-700 dark:text-gray-200">Q{index + 1}</p>
<Grade bind:grade={grades[currentQuestion.id]} gradeMax={currentQuestion.points} {disableGrading} />
</div>
<ExerciseQuestion.QuestionRenderer
contract={{
mode: 'view',
question: questionModel,
answer: getAnswerForQuestion(questionModel, index),
labels: questionLabels,
disabled: true
}}
/>
{#if isGradeWithAI}
<ReasonBox
reason={reasons[currentQuestion.id]}
{isLoading}
acceptGrade={acceptGradeSuggestion}
rejectGrade={() => rejectGradeSuggestion(currentQuestion.id)}
/>
{/if}
</div>
{/each}
{/if}
@@ -0,0 +1,285 @@
import type { ExerciseQuestionLabels } from '@cio/question-types';
import { t } from '$lib/utils/functions/translations';
/**
* Returns translated labels for ExerciseQuestion UI components.
*
* Note: The same label keys exist in packages/storybook/src/molecules/exercise-question/question-labels.ts
* with hardcoded English values for Storybook demos. When adding or changing labels for a question type,
* update both files to keep them in sync.
*/
export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
return {
'common.option_prefix': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.common.option_prefix'
),
'common.remove': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.common.remove'),
'common.add_option': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.common.add_option'),
'common.correct_badge': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.common.correct_badge'
),
'common.not_set': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.common.not_set'),
'navigation.previous': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.navigation.previous'),
'navigation.next': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.navigation.next'),
'navigation.finish': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.navigation.finish'),
'question.edit.title_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.title_label'
),
'question.edit.title_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.title_placeholder'
),
'question.edit.description_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.description_label'
),
'question.edit.description_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.description_placeholder'
),
'question.edit.add_description': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.add_description'
),
'question.edit.remove_description': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.remove_description'
),
'question.edit.media_menu_sr': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.media_menu_sr'
),
'question.edit.add_image': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.add_image'
),
'question.edit.add_video': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.add_video'
),
'question.edit.remove_image_tooltip': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.remove_image_tooltip'
),
'question.edit.remove_image_sr': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.remove_image_sr'
),
'question.edit.remove_video_tooltip': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.remove_video_tooltip'
),
'question.edit.remove_video_sr': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.remove_video_sr'
),
'question.edit.image_alt': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.image_alt'
),
'question.edit.video_modal_title': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.video_modal_title'
),
'question.edit.video_link_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.video_link_label'
),
'question.edit.video_link_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.video_link_placeholder'
),
'question.edit.video_add_button': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.video_add_button'
),
'question.edit.video_invalid_link': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.video_invalid_link'
),
'question.edit.upload_error_only_images': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.upload_error_only_images'
),
'question.edit.upload_error_skipped': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.upload_error_skipped'
),
'question.edit.upload_error_failed': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.upload_error_failed'
),
'radio.edit.helper': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.radio.edit.helper'),
'radio.edit.correct_selected_tooltip': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.radio.edit.correct_selected_tooltip'
),
'radio.edit.mark_correct_tooltip': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.radio.edit.mark_correct_tooltip'
),
'radio.edit.mark_correct_sr': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.radio.edit.mark_correct_sr'
),
'checkbox.edit.helper': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.checkbox.edit.helper'
),
'checkbox.edit.correct_selected_tooltip': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.checkbox.edit.correct_selected_tooltip'
),
'checkbox.edit.toggle_correct_tooltip': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.checkbox.edit.toggle_correct_tooltip'
),
'checkbox.edit.toggle_correct_sr': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.checkbox.edit.toggle_correct_sr'
),
'true_false.true_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.true_false.true_label'
),
'true_false.false_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.true_false.false_label'
),
'true_false.edit.correct_answer_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.true_false.edit.correct_answer_label'
),
'true_false.preview.correct_value_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.true_false.preview.correct_value_label'
),
'short_answer.edit.instructions_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.short_answer.edit.instructions_placeholder'
),
'short_answer.take.placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.short_answer.take.placeholder'
),
'short_answer.preview.helper': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.short_answer.preview.helper'
),
'numeric.edit.correct_value_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.edit.correct_value_label'
),
'numeric.edit.correct_value_info': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.edit.correct_value_info'
),
'numeric.edit.tolerance_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.edit.tolerance_label'
),
'numeric.edit.tolerance_info': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.edit.tolerance_info'
),
'numeric.edit.correct_value_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.edit.correct_value_placeholder'
),
'numeric.edit.tolerance_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.edit.tolerance_placeholder'
),
'numeric.take.placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.take.placeholder'
),
'numeric.preview.correct_value_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.preview.correct_value_label'
),
'numeric.preview.tolerance_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.preview.tolerance_label'
),
'fill_blank.edit.accepted_answers_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.edit.accepted_answers_placeholder'
),
'fill_blank.take.placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.take.placeholder'
),
'fill_blank.preview.accepted_answers_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.preview.accepted_answers_label'
),
'file_upload.edit.accepted_types_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.edit.accepted_types_placeholder'
),
'file_upload.edit.max_size_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.edit.max_size_placeholder'
),
'file_upload.take.selected_file_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.take.selected_file_label'
),
'file_upload.take.upload_button': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.take.upload_button'
),
'file_upload.take.upload_progress': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.take.upload_progress'
),
'file_upload.take.upload_error': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.take.upload_error'
),
'file_upload.take.view': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.take.view'
),
'file_upload.take.download': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.take.download'
),
'file_upload.preview.accepted_types_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.preview.accepted_types_label'
),
'file_upload.preview.max_size_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.preview.max_size_label'
),
'link.edit.instructions_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.link.edit.instructions_placeholder'
),
'link.edit.helper': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.link.edit.helper'),
'link.take.helper': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.link.take.helper'),
'link.take.placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.link.take.placeholder'
),
'link.take.remove_tooltip': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.link.take.remove_tooltip'
),
'link.take.remove_sr': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.link.take.remove_sr'),
'link.take.invalid_url_error': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.link.take.invalid_url_error'
),
'link.take.add_button': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.link.take.add_button'
),
'link.preview.empty': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.link.preview.empty'),
'link.preview.item_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.link.preview.item_label'
),
'matching.edit.helper': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.matching.edit.helper'
),
'matching.take.placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.matching.take.placeholder'
),
'matching.preview.helper': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.matching.preview.helper'
),
'ordering.fallback_step_prefix': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.fallback_step_prefix'
),
'ordering.edit.helper': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.edit.helper'
),
'ordering.edit.add_step': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.edit.add_step'
),
'ordering.edit.empty': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.ordering.edit.empty'),
'ordering.edit.step_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.edit.step_placeholder'
),
'ordering.edit.remove_step_tooltip': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.edit.remove_step_tooltip'
),
'ordering.take.helper': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.take.helper'
),
'ordering.take.empty': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.ordering.take.empty'),
'ordering.preview.submitted_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.preview.submitted_label'
),
'ordering.preview.expected_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.preview.expected_label'
),
'ordering.preview.matches': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.preview.matches'
),
'ordering.preview.differs': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.preview.differs'
),
'hotspot.edit.helper': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.hotspot.edit.helper'),
'hotspot.take.placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.hotspot.take.placeholder'
),
'hotspot.preview.helper': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.hotspot.preview.helper'
),
'textarea.edit.placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.textarea.edit.placeholder'
),
'textarea.take.placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.textarea.take.placeholder'
),
'submission.common.no_answer': t.get('course.navItem.lessons.exercises.all_exercises.analytics.individual.no'),
'submission.common.other': t.get('course.navItem.lessons.exercises.all_exercises.analytics.summary.other'),
'submission.chart.responses': t.get('course.navItem.lessons.exercises.all_exercises.analytics.summary.responses'),
'submission.chart.no_data': t.get('course.navItem.lessons.exercises.all_exercises.analytics.summary.no_responses'),
'submission.list.responses': t.get('course.navItem.lessons.exercises.all_exercises.analytics.summary.responses'),
'submission.list.no_responses': t.get(
'course.navItem.lessons.exercises.all_exercises.analytics.summary.no_responses'
)
};
}
@@ -0,0 +1,71 @@
import {
QUESTION_TYPE_KEY,
getExerciseQuestionContractKey,
getQuestionTypeById,
type ExerciseQuestionModel,
type ExerciseQuestionOption,
type ExerciseQuestionTypeKey
} from '@cio/question-types';
import type { Question } from '$features/course/types';
import { DEFAULT_QUESTION_TYPE, QUESTION_TYPES } from '$features/ui/question/constants';
type QuestionTypeOption = (typeof QUESTION_TYPES)[number];
const OPTION_QUESTION_TYPE_KEYS = new Set<ExerciseQuestionTypeKey>([
QUESTION_TYPE_KEY.RADIO,
QUESTION_TYPE_KEY.CHECKBOX,
QUESTION_TYPE_KEY.TRUE_FALSE
]);
export function getQuestionTypeId(question: Partial<Question>): number {
const value = Number(question.questionTypeId ?? question.questionType?.id ?? DEFAULT_QUESTION_TYPE?.id ?? 1);
return Number.isNaN(value) ? (DEFAULT_QUESTION_TYPE?.id ?? 1) : value;
}
export function getQuestionTypeOptionById(questionTypeId: number): QuestionTypeOption {
return QUESTION_TYPES.find((questionType) => questionType.id === questionTypeId) ?? DEFAULT_QUESTION_TYPE;
}
export function getQuestionTypeKeyFromId(questionTypeId: number): ExerciseQuestionTypeKey {
return getQuestionTypeById(questionTypeId)?.key ?? QUESTION_TYPE_KEY.RADIO;
}
export function getQuestionTypeKey(question: Partial<Question>): ExerciseQuestionTypeKey {
return getQuestionTypeKeyFromId(getQuestionTypeId(question));
}
export function questionTypeSupportsOptions(questionTypeKey: ExerciseQuestionTypeKey): boolean {
return OPTION_QUESTION_TYPE_KEYS.has(questionTypeKey);
}
export function toExerciseQuestionModel(question: Question): ExerciseQuestionModel {
const options: ExerciseQuestionOption[] = (question.options || [])
.filter((option) => !option.deletedAt)
.map((option) => ({
id: option.id,
label: option.label || String(option.value ?? ''),
value: option.value ?? undefined,
isCorrect: option.isCorrect,
settings:
option.settings && typeof option.settings === 'object' && !Array.isArray(option.settings)
? { ...option.settings }
: undefined
}));
const rawSettings = (question as Question & { settings?: Record<string, unknown> }).settings;
const settings = rawSettings && typeof rawSettings === 'object' ? rawSettings : {};
return {
id: question.id,
key: question.name ? String(question.name) : question.id ? String(question.id) : undefined,
title: question.title || '',
questionType: getQuestionTypeKey(question),
points: Number(question.points || 0),
options,
settings
};
}
export function getQuestionAnswerKey(question: Question, fallbackIndex = 0): string {
return getExerciseQuestionContractKey(toExerciseQuestionModel(question), fallbackIndex);
}
@@ -0,0 +1,289 @@
import { DEFAULT_QUESTION_TYPE, QUESTION_TEMPLATE } from '$features/ui/question/constants';
import type { AnswerData } from '@cio/question-types';
import type { Question } from '$features/course/types';
import { STATUS } from './constants';
import type { Writable } from 'svelte/store';
import { isUUID } from '$lib/utils/functions/isUUID';
import { writable } from 'svelte/store';
export const isQuestionnaireFetching = writable(false);
export const deleteConfirmation = writable({ open: false });
export const questionnaireOrder = writable({ open: false });
// {'question-id': { option: '', title: '', ... }}
export const questionnaireValidation = writable({});
export interface QuestionnaireMetaData {
answers: Record<string, AnswerData | undefined>;
scores: Record<string, number>;
grades: Record<string, number>;
totalPossibleGrade: number;
finalTotalGrade: number;
currentQuestionIndex: number;
isFinished: boolean;
progressValue: number;
status: number;
comment: string;
/** Set when exercise is finished so route knows not to overwrite questionnaire */
exerciseId: string | null;
}
const initAnswerState: QuestionnaireMetaData = {
answers: {},
scores: {},
grades: {},
totalPossibleGrade: 0,
finalTotalGrade: 0,
currentQuestionIndex: 0,
isFinished: false,
progressValue: 0,
status: STATUS.PENDING,
comment: '',
exerciseId: null
};
export const questionnaireMetaData: Writable<QuestionnaireMetaData> = writable(initAnswerState);
export const questionnaire: Writable<{
title?: string | null;
dueBy?: string | null;
isDueByDirty?: boolean;
isTitleDirty?: boolean;
description?: string | null;
isDescriptionDirty?: boolean;
questions: Question[];
totalSubmissions: number;
}> = writable({
title: '',
dueBy: '',
isDueByDirty: false,
isTitleDirty: false,
description: '',
isDescriptionDirty: false,
questions: [],
totalSubmissions: 0
});
export function clearQuestionnaireValidation() {
questionnaireValidation.set({});
}
export function reset() {
questionnaireMetaData.update((metaData) => ({
...metaData,
answers: {},
scores: {},
grades: {},
currentQuestionIndex: 0,
isFinished: false,
exerciseId: null,
totalPossibleGrade: 0,
finalTotalGrade: 0,
status: STATUS.PENDING,
comment: '',
progressValue: 100
}));
clearQuestionnaireValidation();
}
/**
* Maps Zod validation errors to UI format: { questionId: { option: 'error message' } }
* This is used to display errors in the edit-mode component
*/
export function mapZodErrorsToQuestionErrors(
zodErrors: Record<string, string>,
questions: Question[]
): Record<string, { option?: string; title?: string; points?: string }> {
const errors: Record<string, { option?: string; title?: string; points?: string }> = {};
for (const [path, message] of Object.entries(zodErrors)) {
const pathParts = path.split('.');
const questionIndex = pathParts[0] === 'questions' ? parseInt(pathParts[1]) : -1;
if (questionIndex >= 0 && questionIndex < questions.length) {
const question = questions[questionIndex];
if (question && !question.deletedAt) {
const qErrors = errors[question.id] || {};
// Check if error is related to options
if (path.includes('options')) {
qErrors.option = message;
} else if (path.includes('question')) {
qErrors.title = message;
} else if (path.includes('points')) {
qErrors.points = message;
}
errors[question.id] = { ...qErrors };
}
}
}
return errors;
}
export function handleAddQuestion() {
questionnaire.update((q) => {
const { questions } = q;
return {
...q,
questions: [
...questions,
{
...QUESTION_TEMPLATE,
id: `${questions.length + 1}-form`,
name: `${questions.length + 1}-form`,
value: '',
points: 0,
order: questions.length,
questionType: DEFAULT_QUESTION_TYPE,
questionTypeId: DEFAULT_QUESTION_TYPE.id,
options: [
{
id: '1-form',
label: '',
value: null,
isCorrect: false
}
]
}
]
};
});
}
export function handleAddOption(questionId) {
return () => {
questionnaire.update((_questionnaire) => {
const { questions } = _questionnaire;
return {
..._questionnaire,
questions: questions.map((question) => {
if (question.id === questionId) {
return {
...question,
options: [
...question.options,
{
id: `${new Date().getTime()}-form`,
label: '',
value: '',
isCorrect: false
}
]
};
}
return question;
})
};
});
};
}
export function handleRemoveOption(questionId, optionId) {
return () => {
questionnaire.update((q) => {
const questionIndex = q.questions.findIndex((qItem) => qItem.id === questionId);
if (questionIndex === -1) return q;
const optionsIndex = q.questions[questionIndex].options.findIndex((oItem) => oItem.id === optionId);
if (optionsIndex === -1) return q;
q.questions[questionIndex].options[optionsIndex].deletedAt = new Date().toString();
q.questions[questionIndex].options[optionsIndex].isDirty = true;
q.questions[questionIndex].isDirty = true; // Mark as dirty if needed
return q;
});
};
}
export function handleRemoveQuestion(questionId) {
return () => {
questionnaire.update((q) => {
const questionIndex = q.questions.findIndex((qItem) => qItem.id === questionId);
if (questionIndex === -1) return q;
q.questions[questionIndex].deletedAt = new Date().toString();
return q;
});
};
}
export function handleCode(questionId, shouldAdd = true) {
questionnaire.update((q) => {
const questionIndex = q.questions.findIndex((qItem) => qItem.id === questionId);
if (questionIndex === -1) return q;
const question = q.questions[questionIndex];
q.questions[questionIndex].code = shouldAdd ? question.code || '' : undefined;
return q;
});
}
export function handleAnswerSelect(questionId, optionId) {
return () => {
questionnaire.update((q) => {
const { questions } = q;
return {
...q,
questions: questions.map((question) => {
if (question.id === questionId) {
return {
...question,
options: question.options.map((option) => {
if (option.id === optionId) {
option.isCorrect = !option.isCorrect;
option.isDirty = true;
}
return option;
})
};
}
return question;
})
};
});
};
}
export function addDynamicValue(questionId, optionId) {
return (e) => {
questionnaire.update((q) => {
const { questions } = q;
return {
...q,
questions: questions.map((question) => {
if (question.id === questionId) {
return {
...question,
options: question.options.map((option) => {
if (option.id === optionId) {
const label = e.target.value;
if (!isUUID(option.value || '')) {
option.value = label.split(' ').join('-');
}
option.label = label;
option.isDirty = true;
}
return option;
})
};
}
return question;
})
};
});
};
}
@@ -0,0 +1,95 @@
<script lang="ts">
import { Spinner } from '@cio/ui/base/spinner';
import { ExerciseQuestion } from '@cio/ui';
import { submissions } from './store';
import { questionnaire } from '../store';
import { t } from '$lib/utils/functions/translations';
import type { ExerciseSubmissions } from './types';
import type { Question } from '$features/course/types';
import type { AnswerData } from '@cio/question-types';
import { toExerciseQuestionModel } from '../question-type-utils';
import { getExerciseQuestionLabels } from '../question-labels';
interface Props {
isLoading?: boolean;
}
let { isLoading = $bindable(false) }: Props = $props();
const defaultImg =
'https://images.unsplash.com/photo-1599566150163-29194dcaad36?auto=format&fit=crop&q=60&w=800&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8NHx8YXZhdGFyc3xlbnwwfHwwfHx8Mg%3D%3D';
let studentSelected = $state(0);
const questionLabels = $derived(getExerciseQuestionLabels());
function toNumericQuestionId(question: Question): number | null {
const numericId = Number(question.id);
return Number.isNaN(numericId) ? null : numericId;
}
function getStudentAnswerForQuestion(student: ExerciseSubmissions, question: Question): AnswerData | undefined {
if (!student?.answers) return undefined;
const questionId = toNumericQuestionId(question);
if (questionId === null) return undefined;
const submittedAnswer = student.answers.find((answer) => answer.questionId === questionId);
if (
!submittedAnswer?.answerData ||
typeof submittedAnswer.answerData !== 'object' ||
!('type' in submittedAnswer.answerData)
)
return undefined;
return submittedAnswer.answerData;
}
</script>
{#if isLoading}
<Spinner />
{:else if $submissions?.length}
<div class="mt-2 mb-5 flex w-full gap-1 overflow-auto">
{#each $submissions as student, i (student.id)}
<button onclick={() => (studentSelected = i)} class="flex w-20 flex-col items-center">
<div
class={`flex h-12 w-12 items-center justify-center rounded-full ${
studentSelected == i ? 'border-primary-700 border-[3px]' : ''
}`}
>
<img
src={student.groupmember?.profile.avatarUrl ? student.groupmember.profile.avatarUrl : defaultImg}
alt={$t('course.navItem.lessons.exercises.all_exercises.analytics.individual.student_avatar')}
class="m-1 max-h-10 w-10 rounded-full bg-white"
/>
</div>
<p class="line-clamp-2 w-20 leading-4 whitespace-pre-wrap">
{student.groupmember?.profile.fullname ?? ''}
</p>
</button>
{/each}
</div>
<p class="mb-2 font-medium">
{$submissions[studentSelected].groupmember?.profile.fullname ?? ''}'s {$t(
'course.navItem.lessons.exercises.all_exercises.analytics.individual.answers'
)}
</p>
{#if $submissions[studentSelected]}
{#if $questionnaire.questions}
{#each $questionnaire.questions as q, i (`${q.id}-${i}`)}
<div class="pb-4">
<ExerciseQuestion.QuestionRenderer
contract={{
mode: 'view',
question: { ...toExerciseQuestionModel(q), title: `${i + 1}. ${q.title}` },
answer: getStudentAnswerForQuestion($submissions[studentSelected], q),
labels: questionLabels,
disabled: true
}}
/>
</div>
{/each}
{/if}
{/if}
{/if}
@@ -0,0 +1,4 @@
import type { ExerciseSubmissions } from './types';
import { writable } from 'svelte/store';
export const submissions = writable<ExerciseSubmissions[]>([]);
@@ -0,0 +1,79 @@
<script lang="ts">
import * as Tabs from '@cio/ui/base/tabs';
import { Empty } from '@cio/ui/custom/empty';
import ClipboardListIcon from '@lucide/svelte/icons/clipboard-list';
import Summary from './summary.svelte';
import Individual from './individual.svelte';
import { submissions } from './store';
import { t } from '$lib/utils/functions/translations';
import type { ExerciseSubmissions } from './types';
import type { SubmissionListItem } from '$features/course/utils/types';
interface Props {
exerciseId: string;
submissions: SubmissionListItem[];
}
let { exerciseId = $bindable(''), submissions: submissionsData }: Props = $props();
type SubmissionTab = 'summary' | 'individual';
let currentTab = $state<SubmissionTab>('summary');
function normalizeSubmissions(items: SubmissionListItem[]): ExerciseSubmissions[] {
return items.map(
(submission): ExerciseSubmissions => ({
id: submission.id,
statusId: submission.statusId ?? 1,
groupmember: submission.groupmember?.profile
? {
profile: {
id: submission.groupmember.profile.id ?? '',
fullname: submission.groupmember.profile.fullname ?? '',
avatarUrl: submission.groupmember.profile.avatarUrl ?? ''
}
}
: null,
answers: (submission.answers || []).map((answer) => ({
id: answer.id,
questionId: answer.questionId,
answerData: answer.answerData,
point: answer.point ?? 0,
submissionId: answer.submissionId ?? submission.id,
groupMemberId: answer.groupMemberId ?? ''
}))
})
);
}
$effect(() => {
submissions.set(normalizeSubmissions(submissionsData));
});
</script>
{#if $submissions.length > 0}
<Tabs.Root bind:value={currentTab} class="w-full">
<Tabs.List class="grid w-full max-w-sm grid-cols-2">
<Tabs.Trigger value="summary">
{$t('course.navItem.lessons.exercises.all_exercises.analytics.summary.heading')}
</Tabs.Trigger>
<Tabs.Trigger value="individual">
{$t('course.navItem.lessons.exercises.all_exercises.analytics.individual.heading')}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="summary" class="pt-2">
<Summary isLoading={false} />
</Tabs.Content>
<Tabs.Content value="individual" class="pt-2">
<Individual isLoading={false} />
</Tabs.Content>
</Tabs.Root>
{:else}
<Empty
title={$t('course.navItem.lessons.exercises.all_exercises.analytics.empty_title')}
description={$t('course.navItem.lessons.exercises.all_exercises.analytics.empty_description')}
icon={ClipboardListIcon}
variant="page"
/>
{/if}
@@ -0,0 +1,84 @@
<script lang="ts">
import { browser } from '$app/environment';
import { Spinner } from '@cio/ui/base/spinner';
import { ExerciseQuestion } from '@cio/ui';
import { submissions } from './store';
import { questionnaire } from '../store';
import { t } from '$lib/utils/functions/translations';
import type { Question } from '$features/course/types';
import type { ExerciseSubmissions } from './types';
import type { AnswerData, ExerciseQuestionLabels, ExerciseSubmissionModel } from '@cio/question-types';
import { toExerciseQuestionModel } from '../question-type-utils';
interface Props {
isLoading?: boolean;
}
let { isLoading = $bindable(true) }: Props = $props();
function toAnswerData(answerData: unknown): AnswerData | null {
if (!answerData || typeof answerData !== 'object' || !('type' in answerData)) return null;
return answerData as AnswerData;
}
function toSubmissionModel(submission: ExerciseSubmissions): ExerciseSubmissionModel {
return {
id: submission.id,
studentName: submission.groupmember?.profile.fullname,
studentAvatarUrl: submission.groupmember?.profile.avatarUrl,
answers: submission.answers.map((answer) => ({
questionId: answer.questionId,
answerData: toAnswerData(answer.answerData)
}))
};
}
function toSubmissionQuestionModel(question: Question, index: number) {
return {
...toExerciseQuestionModel(question),
title: `${index + 1}. ${question.title || ''}`
};
}
const submissionLabels = $derived.by(
(): ExerciseQuestionLabels => ({
'submission.common.no_answer': $t('course.navItem.lessons.exercises.all_exercises.analytics.individual.no'),
'submission.common.other': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.other'),
'submission.chart.responses': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.responses'),
'submission.chart.no_data': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.no_responses'),
'submission.list.responses': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.responses'),
'submission.list.no_responses': $t(
'course.navItem.lessons.exercises.all_exercises.analytics.summary.no_responses'
)
})
);
const submissionModels = $derived($submissions.map((submission) => toSubmissionModel(submission)));
const submissionQuestions = $derived(
$questionnaire.questions
.filter((question) => !question.deletedAt)
.map((question, index) => toSubmissionQuestionModel(question, index))
);
</script>
{#if isLoading}
<Spinner />
{:else if browser}
<div class="flex flex-col gap-6">
<p class="mb-3 text-2xl">
{$t('course.navItem.lessons.exercises.all_exercises.analytics.summary.question_chart')}
</p>
<ExerciseQuestion.QuestionList
contract={{
mode: 'submission',
questions: submissionQuestions,
submissions: submissionModels,
labels: submissionLabels,
disabled: true
}}
/>
</div>
{/if}
@@ -0,0 +1,21 @@
import type { SubmissionAnswer, SubmissionListItem } from '$features/course/utils/types';
type ApiProfile = NonNullable<NonNullable<SubmissionListItem['groupmember']>['profile']>;
type ExerciseSubmissionProfile = Required<Pick<ApiProfile, 'id' | 'fullname' | 'avatarUrl'>>;
type ExerciseSubmissionAnswer = {
id: SubmissionAnswer['id'];
questionId: SubmissionAnswer['questionId'];
answerData?: SubmissionAnswer['answerData'];
point: NonNullable<SubmissionAnswer['point']>;
submissionId: NonNullable<SubmissionAnswer['submissionId']>;
groupMemberId: NonNullable<SubmissionAnswer['groupMemberId']>;
};
/** Normalized submission for exercise submissions UI (profile/answer defaults filled by transform) */
export type ExerciseSubmissions = {
id: SubmissionListItem['id'];
statusId: NonNullable<SubmissionListItem['statusId']>;
groupmember: { profile: ExerciseSubmissionProfile } | null;
answers: ExerciseSubmissionAnswer[];
};
@@ -0,0 +1,95 @@
<script lang="ts">
import { questionnaire } from './store';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { t } from '$lib/utils/functions/translations';
import { InputField } from '@cio/ui/custom/input-field';
import { TextEditor } from '$features/ui';
import { QuestionContainer } from '$features/course/components';
interface Props {
preview: boolean;
}
let { preview }: Props = $props();
function getTotalPossibleGrade(questions: { points: number | string }[]) {
return questions.reduce((acc, question) => {
acc += typeof question.points === 'string' ? parseFloat(question.points) : question.points;
return acc;
}, 0);
}
$inspect('$questionnaire', $questionnaire);
</script>
<div class="mb-5 {!preview ? 'px-6' : 'px-2'}">
<QuestionContainer isTitle={true}>
{#if preview}
<h2 class="my-1">{$questionnaire.title}</h2>
<div class="flex items-center">
<p class="mx-2 dark:text-white">
<strong>{$questionnaire.questions.length}</strong>
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.questions')}
</p>
|
<p class="mx-2 dark:text-white">
<strong>{getTotalPossibleGrade($questionnaire.questions)}</strong>
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')}.
</p>
|
<p class="mx-2 dark:text-white">
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.all')}
</p>
{#if $questionnaire.dueBy}
|
<p class="mx-2 dark:text-white">
<strong>{$t('course.navItem.lessons.exercises.all_exercises.view_mode.due')}:</strong>
{new Date($questionnaire.dueBy).toLocaleString()}
</p>
{/if}
</div>
<article class="preview prose prose-sm sm:prose mt-3 p-2">
{@html sanitizeHtml(
$questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.description.no')
)}
</article>
{:else}
<InputField
placeholder={$t('course.navItem.lessons.exercises.all_exercises.description.title')}
bind:value={$questionnaire.title}
className="mb-2"
onchange={() => ($questionnaire.isTitleDirty = true)}
/>
<InputField
label={$t('course.navItem.lessons.exercises.all_exercises.view_mode.due')}
className="w-50"
type="datetime-local"
value={$questionnaire.dueBy ?? ''}
onchange={(e) => {
$questionnaire.dueBy = e.currentTarget.value;
$questionnaire.isDueByDirty = true;
}}
/>
<div class="mt-3">
<p class="mb-1">
{$t('course.navItem.lessons.exercises.all_exercises.description.heading')}
</p>
{#key $questionnaire.description}
<TextEditor
content={$questionnaire.description || ''}
onChange={(html) => {
$questionnaire.isDescriptionDirty = true;
$questionnaire.description = html;
}}
editorClass="max-h-[100px]"
placeholder={$t('course.navItem.lessons.exercises.all_exercises.description.describe')}
/>
{/key}
</div>
{/if}
</QuestionContainer>
</div>
@@ -0,0 +1,523 @@
<script lang="ts">
import { get } from 'svelte/store';
import { untrack } from 'svelte';
import { fly } from 'svelte/transition';
import { cubicInOut } from 'svelte/easing';
import { courseApi } from '$features/course/api';
import { questionnaire, questionnaireMetaData } from './store';
import Preview from './preview.svelte';
import { ExerciseQuestion } from '@cio/ui';
import * as Alert from '@cio/ui/base/alert';
import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button';
import { RoleBasedSecurity } from '$features/ui';
import { Empty } from '@cio/ui/custom/empty';
import FileQuestionIcon from '@lucide/svelte/icons/file-question';
import { Progress } from '@cio/ui/base/progress';
import { getExerciseQuestionContractKey } from '@cio/question-types';
import { STATUS } from './constants';
import { filterOutDeleted } from './functions';
import { formatAnswers } from '$features/course/utils/functions';
import { toApiPayload, type AnswerData } from '@cio/question-types';
import { exerciseApi, presignApi } from '$features/course/api';
import type { SubmissionListItem } from '$features/course/utils/types';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { ContentType } from '@cio/utils/constants/content';
import { toExerciseQuestionModel } from './question-type-utils';
import { getExerciseQuestionLabels } from './question-labels';
import axios from 'axios';
interface Props {
preview?: boolean;
exerciseId?: string;
isFetchingExercise?: boolean;
/** Current user's submission from server (no client fetch) */
mySubmission?: SubmissionListItem | null;
}
let { preview = false, exerciseId = '', isFetchingExercise = false, mySubmission = null }: Props = $props();
let isSubmitting = $state(false);
let isLoadingAutoSavedData = $state(false);
let alreadyCheckedAutoSavedData = $state(false);
let prevExerciseId = $state('');
let slideDirection = $state<'next' | 'prev'>('next');
const questionLabels = $derived(getExerciseQuestionLabels());
function handleStart() {
questionnaireMetaData.update((m) => ({ ...m, currentQuestionIndex: m.currentQuestionIndex + 1 }));
}
function normalizeAnswerValue(
previousValue: AnswerData | undefined,
nextValue: AnswerData | undefined
): AnswerData | undefined {
if (nextValue && typeof nextValue === 'object' && 'type' in nextValue) {
return nextValue;
}
return previousValue;
}
function mapAnswerToApiPayload(question, answerData: AnswerData | null | undefined) {
const questionId = Number(question.id);
if (Number.isNaN(questionId) || !answerData) return null;
return toApiPayload(answerData, questionId);
}
async function onSubmit(id, value) {
if (!courseApi.course?.id) return;
const { answers } = $questionnaireMetaData;
const { questions } = $questionnaire;
const prevAnswer = answers[id];
const formattedAnswer = normalizeAnswerValue(prevAnswer, value);
if (!hasAnswerValue(formattedAnswer)) {
snackbar.error(t.get('course.navItem.lessons.exercises.all_exercises.view_mode.answer_required'));
return;
}
const question = questions.find((q) => getExerciseQuestionContractKey(toExerciseQuestionModel(q)) === id);
if (question && !mapAnswerToApiPayload(question, formattedAnswer as AnswerData)) {
snackbar.error(t.get('course.navItem.lessons.exercises.all_exercises.view_mode.answer_required'));
return;
}
const newAnswers = { ...answers, [id]: formattedAnswer };
questionnaireMetaData.update((m) => ({ ...m, answers: newAnswers }));
const currentIndex = $questionnaireMetaData.currentQuestionIndex;
const isLastQuestion = currentIndex === questions.length;
if (isLastQuestion) {
// Stay on last question, show spinner, submit to server
if (isSubmitting) return;
isSubmitting = true;
const updated = get(questionnaireMetaData);
localStorage.removeItem(`autosave-exercise-${exerciseId}`);
const totalPossibleGrade = getTotalPossibleGrade($questionnaire.questions);
const answersForApi = Object.entries(updated.answers)
.map(([questionKey, val]) => {
const question = questions.find(
(item) => getExerciseQuestionContractKey(toExerciseQuestionModel(item)) === questionKey
);
if (!question) return null;
return mapAnswerToApiPayload(question, val);
})
.filter((answer) => answer !== null) as Array<{ questionId: number; optionId?: number; answer?: string }>;
if (answersForApi.length === 0) {
isSubmitting = false;
snackbar.error(t.get('course.navItem.lessons.exercises.all_exercises.view_mode.answers_required'));
return;
}
await exerciseApi.submit(courseApi.course?.id!, exerciseId, answersForApi);
if (exerciseApi.success) {
questionnaireMetaData.update((m) => ({
...m,
status: 1,
totalPossibleGrade,
grades: {},
comment: '',
isFinished: true,
exerciseId
}));
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true });
}
isSubmitting = false;
} else {
// Advance to next question
slideDirection = 'next';
questionnaireMetaData.update((m) => ({ ...m, currentQuestionIndex: m.currentQuestionIndex + 1 }));
const updated = get(questionnaireMetaData);
localStorage.setItem(`autosave-exercise-${exerciseId}`, JSON.stringify(updated));
}
}
function onPrevious() {
slideDirection = 'prev';
questionnaireMetaData.update((m) => ({ ...m, currentQuestionIndex: m.currentQuestionIndex - 1 }));
}
function getProgressValue(currentQuestionIndex) {
if ($questionnaireMetaData.isFinished) {
return 100;
}
return Math.round(((currentQuestionIndex - 1) / $questionnaire.questions.length) * 100) || 0;
}
function getTotalPossibleGrade(questions) {
return questions.reduce((acc, question) => {
acc += parseFloat(question.points);
return acc;
}, 0);
}
function getAutoSavedData() {
isLoadingAutoSavedData = true;
const stringifiedQuestionnaireMetaData = localStorage.getItem(`autosave-exercise-${exerciseId}`);
if (stringifiedQuestionnaireMetaData) {
const autoSavedData = JSON.parse(stringifiedQuestionnaireMetaData);
if (autoSavedData) {
questionnaireMetaData.set(autoSavedData);
}
}
isLoadingAutoSavedData = false;
alreadyCheckedAutoSavedData = true;
}
async function handleFileUpload(file: File): Promise<{ fileKey: string; fileName: string; fileUrl?: string }> {
const uploadResult = await presignApi.getDocumentUploadUrl(
file.name,
file.type as
| 'application/pdf'
| 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
| 'application/msword'
);
if (!uploadResult) {
throw new Error('Failed to get upload URL');
}
await axios.put(uploadResult.url, file, {
headers: { 'Content-Type': file.type },
maxContentLength: Infinity,
maxBodyLength: Infinity
});
const urls = await presignApi.getDocumentDownloadUrls([uploadResult.fileKey]);
const fileUrl = urls[uploadResult.fileKey];
return { fileKey: uploadResult.fileKey, fileName: file.name, fileUrl };
}
function hasAnswerValue(answerValue: AnswerData | null | undefined): boolean {
if (answerValue === null || answerValue === undefined) return false;
if (typeof answerValue === 'object' && 'type' in answerValue) {
switch (answerValue.type) {
case 'RADIO':
case 'TRUE_FALSE':
case 'NUMERIC':
return true;
case 'CHECKBOX':
return answerValue.optionIds.length > 0;
case 'TEXTAREA':
case 'SHORT_ANSWER':
return answerValue.text.trim().length > 0;
case 'FILL_BLANK':
return answerValue.values.length > 0;
case 'FILE_UPLOAD':
return !!answerValue.fileKey;
case 'MATCHING':
return answerValue.pairs.length > 0;
case 'ORDERING':
return answerValue.orderedValues.length > 0;
case 'LINK':
return answerValue.urls.length > 0;
case 'HOTSPOT':
return answerValue.coordinates.length > 0;
default:
return false;
}
}
return false;
}
function onSharedAnswerChange(answerValue) {
if (!sharedCurrentQuestionKey) return;
questionnaireMetaData.update((metaData) => ({
...metaData,
answers: {
...metaData.answers,
[sharedCurrentQuestionKey]: answerValue
}
}));
}
function onSharedNext(valueOverride?: AnswerData) {
if (!sharedCurrentQuestionKey) return;
const valueToUse = valueOverride !== undefined ? valueOverride : sharedCurrentAnswer;
if (!hasAnswerValue(valueToUse)) {
snackbar.error(t.get('course.navItem.lessons.exercises.all_exercises.view_mode.answer_required'));
return;
}
onSubmit(sharedCurrentQuestionKey, valueToUse);
}
function handleEnterKey(e: KeyboardEvent) {
if (e.key !== 'Enter' || isSubmitting || !currentQuestion) return;
const target = e.target as HTMLElement;
if (target?.tagName === 'TEXTAREA') return;
e.preventDefault();
let valueToUse: AnswerData | undefined = sharedCurrentAnswer as AnswerData | undefined;
if (target instanceof HTMLInputElement && sharedQuestionModel) {
const questionTypeKey = sharedQuestionModel.questionType;
const trimmed = target.value?.trim() ?? '';
if (questionTypeKey === 'SHORT_ANSWER' && trimmed) {
valueToUse = { type: 'SHORT_ANSWER', text: trimmed };
onSharedAnswerChange(valueToUse);
} else if (questionTypeKey === 'NUMERIC' && trimmed) {
const num = Number(trimmed);
valueToUse = !Number.isNaN(num) ? { type: 'NUMERIC', value: num } : undefined;
if (valueToUse) onSharedAnswerChange(valueToUse);
}
}
if (!hasAnswerValue(valueToUse)) {
snackbar.error($t('course.navItem.lessons.exercises.all_exercises.view_mode.answer_required'));
return;
}
onSharedNext(valueToUse);
}
$effect(() => {
if (!alreadyCheckedAutoSavedData) {
getAutoSavedData();
}
});
const progressValue = $derived(getProgressValue($questionnaireMetaData.currentQuestionIndex));
const currentQuestion = $derived($questionnaire.questions[$questionnaireMetaData.currentQuestionIndex - 1]);
const isExerciseLoaded = $derived(alreadyCheckedAutoSavedData && $questionnaire.questions.length > 0);
const isFinished = $derived(isExerciseLoaded && $questionnaireMetaData.currentQuestionIndex > 0 && !currentQuestion);
const sharedQuestionModel = $derived(currentQuestion ? toExerciseQuestionModel(currentQuestion) : null);
const sharedCurrentQuestionKey = $derived(
sharedQuestionModel ? getExerciseQuestionContractKey(sharedQuestionModel) : null
);
const sharedCurrentAnswer = $derived(
sharedCurrentQuestionKey ? $questionnaireMetaData.answers[sharedCurrentQuestionKey] : undefined
);
const canGoNextForSharedQuestion = $derived(hasAnswerValue(sharedCurrentAnswer));
const flyX = $derived(slideDirection === 'next' ? 300 : -300);
const flyOutX = $derived(-flyX);
$effect(() => {
const finished = isFinished;
untrack(() => {
$questionnaireMetaData.isFinished = finished;
});
});
$effect(() => {
const progress = progressValue;
untrack(() => {
$questionnaireMetaData.progressValue = progress;
});
});
$effect(() => {
if (isFetchingExercise || !mySubmission || $questionnaireMetaData.isFinished) return;
applyMySubmission(mySubmission);
});
function applyMySubmission(submission: SubmissionListItem) {
const totalPossibleGrade = getTotalPossibleGrade($questionnaire.questions);
let finalTotalGrade = 0;
const grades = (submission.answers ?? []).reduce<Record<string, number>>((acc, answer) => {
const point = answer.point ?? 0;
acc[String(answer.questionId)] = point;
finalTotalGrade += point;
return acc;
}, {});
const answers = formatAnswers({
questions: $questionnaire.questions,
answers: submission.answers ?? []
});
questionnaireMetaData.update((m) => ({
...m,
answers,
totalPossibleGrade,
currentQuestionIndex: $questionnaire.questions.length,
isFinished: true,
status: submission.statusId ?? STATUS.PENDING,
finalTotalGrade,
comment: submission.feedback ?? '',
grades,
exerciseId
}));
}
$effect(() => {
if (prevExerciseId === exerciseId) return;
if (!!prevExerciseId) {
isSubmitting = false;
alreadyCheckedAutoSavedData = false;
}
prevExerciseId = exerciseId;
});
</script>
{#if !preview && $questionnaire.questions.length && !$questionnaireMetaData.isFinished}
<div class="mb-6">
<Progress value={$questionnaireMetaData.progressValue} />
</div>
{/if}
<svelte:window onkeydown={handleEnterKey} />
{#if preview}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Preview questions={filterOutDeleted($questionnaire.questions)} questionnaireMetaData={$questionnaireMetaData} />
</RoleBasedSecurity>
{:else if !$questionnaire.questions.length}
<Empty
title={$t('course.navItem.lessons.exercises.all_exercises.view_mode.no_question')}
icon={FileQuestionIcon}
variant="page"
>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<p class="ui:text-primary text-center text-sm">
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.empty_edit_hint_prefix')}
<span class="font-semibold">
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.edit')}
</span>
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.empty_edit_hint_suffix')}
</p>
</RoleBasedSecurity>
</Empty>
{:else if $questionnaireMetaData.currentQuestionIndex === 0}
<RoleBasedSecurity allowedRoles={[3]}>
<div>
<h2 class="my-1">{$questionnaire.title}</h2>
<div class="flex items-center">
<p class="mx-2 dark:text-white">
<strong>{$questionnaire.questions.length}</strong>
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.questions')}
</p>
|
<p class="mx-2 dark:text-white">
<strong>{getTotalPossibleGrade($questionnaire.questions)}</strong>
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')}.
</p>
|
<p class="mx-2 dark:text-white">{$t('course.navItem.lessons.exercises.all_exercises.view_mode.all')}</p>
{#if $questionnaire.dueBy}
|
<p class="mx-2 dark:text-white">
<strong>{$t('course.navItem.lessons.exercises.all_exercises.view_mode.due')}:</strong>
{new Date($questionnaire.dueBy).toLocaleString()}
</p>
{/if}
</div>
<article class="preview prose prose-sm sm:prose mt-3 p-2">
{@html sanitizeHtml(
$questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.view_mode.no_description')
)}
</article>
<Button onclick={handleStart} type="button" class="float-right my-5">
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.start')}
</Button>
</div>
</RoleBasedSecurity>
{:else if $questionnaireMetaData.isFinished}
{#if !isLoadingAutoSavedData}
<div class="flex w-full flex-col items-start lg:flex-row lg:items-center lg:space-x-4">
{#if STATUS.GRADED === $questionnaireMetaData.status}
<div class="mb-8 w-full space-y-2">
<Badge variant="success" title={$t('course.navItem.lessons.exercises.all_exercises.view_mode.status_graded')}>
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.graded')}
</Badge>
<div class="flex w-full flex-col items-start gap-2 md:flex-row md:items-center">
{#if $questionnaireMetaData.comment}
<Alert.Callout
variant="information"
title={$t('course.navItem.lessons.exercises.all_exercises.view_mode.instructor_feedback')}
description={$questionnaireMetaData.comment}
class="h-fit! w-full! flex-1!"
/>
{/if}
<div class="flex flex-col justify-between gap-2 rounded-md border p-4">
<p>
<span class="text-2xl font-bold">{$questionnaireMetaData.finalTotalGrade}/</span>
<span class="text-xl">{$questionnaireMetaData.totalPossibleGrade}</span>
</p>
</div>
</div>
</div>
{:else if courseApi.course?.type === 'SELF_PACED'}
<Badge
variant="success"
title={$t('course.navItem.lessons.exercises.all_exercises.view_mode.status_submitted')}
>
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.submitted')}
</Badge>
{:else}
<Badge variant="warning" title={$t('course.navItem.lessons.exercises.all_exercises.view_mode.status_pending')}>
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.pending')}
</Badge>
{/if}
</div>
<Preview
questions={$questionnaire.questions.sort((a, b) => a.order - b.order)}
questionnaireMetaData={$questionnaireMetaData}
grades={$questionnaireMetaData.grades}
disableGrading={true}
/>
{/if}
{:else if currentQuestion && currentQuestion?.id}
<div class="flex flex-col gap-4">
<div class="grid overflow-hidden" style="grid-template-areas: 'slot';">
{#key currentQuestion.id}
<div
id="question"
class="min-w-0 [grid-area:slot]"
in:fly={{ x: flyX, duration: 350, easing: cubicInOut }}
out:fly={{ x: flyOutX, duration: 350, easing: cubicInOut }}
>
{#if sharedQuestionModel}
<ExerciseQuestion.QuestionRenderer
contract={{
mode: 'take',
question: sharedQuestionModel,
answer: sharedCurrentAnswer,
labels: questionLabels,
disabled: isSubmitting,
onFileUpload: handleFileUpload
}}
onAnswerChange={onSharedAnswerChange}
/>
{/if}
</div>
{/key}
</div>
<div>
<ExerciseQuestion.QuestionNavigation
canGoBack={$questionnaireMetaData.currentQuestionIndex > 1}
canGoNext={canGoNextForSharedQuestion && !isSubmitting}
isLast={$questionnaireMetaData.currentQuestionIndex === $questionnaire.questions.length}
{isSubmitting}
previousLabel={t.get('course.navItem.lessons.exercises.all_exercises.previous')}
nextLabel={t.get('course.navItem.lessons.exercises.all_exercises.next')}
finishLabel={t.get('course.navItem.lessons.exercises.all_exercises.finish')}
{onPrevious}
onNext={onSharedNext}
/>
</div>
</div>
{/if}
@@ -3,7 +3,10 @@ export { default as CourseCardList } from './card-list.svelte';
export { default as CourseCardLoader } from './card-loader.svelte';
export { default as CopyCourseModal } from './copy-course-modal.svelte';
export { default as CourseHeader } from './course-header.svelte';
export { default as CourseFilterPopover } from './course-filter-popover.svelte';
export { default as CourseTagPicker } from './course-tag-picker.svelte';
export { default as CreateCourseButton } from './create-course-button.svelte';
export { default as CourseList } from './list.svelte';
export { default as CourseListLoader } from './list-loader.svelte';
export { default as NewCourseModal } from './new-course-modal.svelte';
export { default as QuestionContainer } from './question-container.svelte';
@@ -0,0 +1,54 @@
<script lang="ts">
import ListTodoIcon from '@lucide/svelte/icons/list-todo';
import ListChecksIcon from '@lucide/svelte/icons/list-checks';
import NotepadTextIcon from '@lucide/svelte/icons/notepad-text';
import * as Popover from '@cio/ui/base/popover';
import WandSparklesIcon from '@lucide/svelte/icons/wand-sparkles';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
interface Props {
isLoading?: SvelteStore<boolean>;
callAI?: (type: string) => void;
}
let { isLoading, callAI = () => {} }: Props = $props();
let openPopover = $state<boolean>(false);
let aiButtonClass =
'flex items-center px-5 py-2 border border-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md w-full mb-2';
</script>
<!-- Update this when ai-sdk is updated -->
<div class="hidden flex-row-reverse">
<Popover.Root>
<Popover.Trigger>
<Button
variant="outline"
onclick={() => {
openPopover = !openPopover;
}}
loading={$isLoading}
disabled={$isLoading}
>
<WandSparklesIcon size={16} />
AI
</Button>
</Popover.Trigger>
<Popover.Content align="start" class="w-80">
<div class="p-2">
<button class={aiButtonClass} onclick={() => callAI('outline')}>
<ListChecksIcon size={16} />
{$t('course.navItem.lessons.materials.tabs.note.ai.outline')}
</button>
<button class={aiButtonClass} onclick={() => callAI('note')}>
<NotepadTextIcon size={16} />
{$t('course.navItem.lessons.materials.tabs.note.ai.note')}
</button>
<button class={aiButtonClass} onclick={() => callAI('activities')}>
<ListTodoIcon size={16} />
{$t('course.navItem.lessons.materials.tabs.note.ai.activities')}
</button>
</div>
</Popover.Content>
</Popover.Root>
</div>
@@ -0,0 +1,208 @@
<script lang="ts">
import { untrack } from 'svelte';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import * as Avatar from '@cio/ui/base/avatar';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { shortenName } from '$lib/utils/functions/string';
import { DeleteModal } from '$features/ui';
import { Button } from '@cio/ui/base/button';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import { calDateDiff } from '$lib/utils/functions/date';
import { lessonApi } from '$features/course/api';
import { courseApi } from '$features/course/api';
interface Props {
lessonId: string;
}
let { lessonId }: Props = $props();
let comment = $state('');
let openDeleteModal = $state(false);
let deleteCommentId: number | null = $state(null);
let editCommentId: number | null = $state(null);
const courseId = $derived(courseApi.course?.id ?? '');
const groupmemberId = $derived(courseApi.group.memberId ?? '');
const paginationCount = $derived(lessonApi.commentsByLessonId[lessonId]?.totalCount || 0);
const hasMore = $derived(lessonApi.commentsByLessonId[lessonId]?.hasMore || false);
const isLoadingComments = $derived(lessonApi.commentsByLessonId[lessonId]?.isLoading || false);
const comments = $derived.by(() => {
const commentData = lessonApi.commentsByLessonId[lessonId];
if (!commentData || !commentData.items || !Array.isArray(commentData.items)) {
return [];
}
return commentData.items.map((apiComment) => {
const isCurrentUser = apiComment.groupmember?.id === groupmemberId;
return {
id: apiComment.id,
comment: apiComment.comment,
avatar: apiComment.profile?.avatarUrl ?? 'AV',
commentAt: new Date(apiComment.createdAt),
groupmemberId: apiComment.groupmemberId,
name: isCurrentUser ? $t('course.navItem.lessons.comments.you') : apiComment.profile?.fullname || 'Unknown'
};
});
});
async function handleSend() {
if (!comment) {
return;
}
await lessonApi.createComment(courseId, lessonId, comment);
if (lessonApi.success) {
comment = '';
}
}
async function loadMoreComments() {
if (!lessonId) return;
await lessonApi.loadMoreComments(courseId, lessonId);
}
// Fetch once per lessonId (even if there are 0 comments).
$effect(() => {
if (!courseId || !lessonId) return;
if (lessonApi.commentsByLessonId[lessonId]) return;
untrack(() => {
lessonApi.getComments(courseId, lessonId);
});
});
</script>
<DeleteModal
bind:open={openDeleteModal}
onDelete={() => {
if (!deleteCommentId) return;
lessonApi.deleteComment(courseId, lessonId, String(deleteCommentId));
deleteCommentId = null;
}}
/>
<div class="mx-auto w-full max-w-[65ch]">
<div class="mb-5">
<p class="text-lg capitalize">
{$t('course.navItem.lessons.comments.title')} ({paginationCount})
</p>
</div>
<div>
<div class="flex h-full items-start gap-3">
<Avatar.Root class="mt-2 size-6!">
<Avatar.Image
src={$profile.avatarUrl ? $profile.avatarUrl : '/logo-192.png'}
alt={$profile.fullname ? $profile.fullname : 'User'}
/>
<Avatar.Fallback>{shortenName($profile.fullname) || 'U'}</Avatar.Fallback>
</Avatar.Root>
<div class="h-full w-full">
<TextareaField
label={$t('course.navItem.lessons.comments.text_area_title')}
placeholder={$t('course.navItem.lessons.comments.placeholder')}
bind:value={comment}
/>
</div>
</div>
<div class="mt-2 flex flex-row-reverse">
<Button onclick={handleSend} disabled={!comment || !courseId || !lessonId} loading={lessonApi.isCommenting}>
{$t('course.navItem.lessons.comments.comment_btn')}
</Button>
</div>
</div>
<div class="my-5">
{#each comments as commentItem (commentItem.id)}
<div class="mt-2 flex items-start gap-3 pb-2">
<Avatar.Root class="mt-2 size-6!">
<Avatar.Image
src={commentItem.avatar ? commentItem.avatar : '/logo-192.png'}
alt={commentItem.name ? commentItem.name : 'User'}
/>
<Avatar.Fallback>{shortenName(commentItem.name) || 'U'}</Avatar.Fallback>
</Avatar.Root>
<div class="w-full rounded-md border px-3 py-2 dark:border-neutral-700">
<div class="flex items-center justify-between gap-2">
<p class="text-sm dark:text-white">
{commentItem.name}
<span
class="ml-1 text-xs font-normal text-gray-800 dark:text-white"
title={commentItem.commentAt.toLocaleString()}
>
{calDateDiff(commentItem.commentAt)}
</span>
</p>
{#if courseApi.group.memberId === commentItem.groupmemberId}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="flex h-8 w-8 items-center justify-center rounded-md hover:bg-gray-100 dark:hover:bg-neutral-700"
>
<EllipsisVerticalIcon class="size-4" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => (editCommentId = commentItem.id)}>Edit</DropdownMenu.Item>
<DropdownMenu.Item
class="text-red-600"
onclick={() => {
deleteCommentId = commentItem.id;
openDeleteModal = true;
}}
>
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</div>
{#if editCommentId === commentItem.id}
<TextareaField
placeholder={$t('course.navItem.lessons.comments.placeholder')}
bind:value={commentItem.comment}
/>
<div class="mt-2 flex flex-row-reverse items-center gap-2">
<Button variant="outline" onclick={() => (editCommentId = null)} disabled={lessonApi.isUpdatingComment}>
{$t('course.navItem.lessons.comments.cancel_btn')}
</Button>
<Button
onclick={async () => {
await lessonApi.updateComment(courseId, lessonId, String(editCommentId), commentItem.comment!);
if (lessonApi.success) {
editCommentId = null;
}
}}
disabled={!commentItem.comment}
loading={lessonApi.isUpdatingComment}
>
{$t('course.navItem.lessons.comments.comment_btn')}
</Button>
</div>
{:else}
<article class="prose sm:prose-sm max-w-[300px] text-sm dark:text-white">
{commentItem.comment}
</article>
{/if}
</div>
</div>
{/each}
</div>
{#if hasMore}
<div class="mt-2 flex items-center justify-center">
<Button variant="outline" onclick={loadMoreComments} loading={isLoadingComments}>
{$t('course.navItem.lessons.comments.load_more_btn')}
</Button>
</div>
{/if}
</div>
@@ -0,0 +1,57 @@
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import type { Component } from 'svelte';
import FileTextIcon from '@lucide/svelte/icons/file-text';
import PresentationIcon from '@lucide/svelte/icons/presentation';
import type { Tabs } from '$features/course/utils/types';
import VideoIcon from '@lucide/svelte/icons/video';
interface MaterialTab {
label: string;
icon: Component;
value: number;
badgeValue?: number;
}
export const tabs: MaterialTab[] = [
{
label: 'course.navItem.lessons.materials.tabs.note.title',
icon: BookOpenIcon,
value: 1,
badgeValue: 0
},
{
label: 'course.navItem.lessons.materials.tabs.slide.title',
icon: PresentationIcon,
value: 2,
badgeValue: 0
},
{
label: 'course.navItem.lessons.materials.tabs.video.title',
icon: VideoIcon,
value: 3,
badgeValue: 0
},
{
label: 'course.navItem.lessons.materials.tabs.document.title',
icon: FileTextIcon,
value: 4,
badgeValue: 0
}
];
export function orderedTabs(tabs: MaterialTab[], settingTabs?: Tabs[]) {
if (!Array.isArray(settingTabs)) return tabs;
const reorderedTabs: MaterialTab[] = [];
const tabMap = new Map(tabs.map((tab) => [tab.value, tab]));
settingTabs.forEach((settingTab, index) => {
const tab = tabMap.get(settingTab.id);
if (tab) {
reorderedTabs[index] = tab;
}
});
return reorderedTabs.filter((tab) => tab !== undefined);
}
@@ -0,0 +1,109 @@
import { ContentType } from '@cio/utils/constants/content';
export type ContentActionItem = {
type: ContentType;
contentId: string;
title?: string | null;
isUnlocked?: boolean | null;
sectionId?: string | null;
order?: number | null;
};
type CourseApiErrorMap = Record<string, string> | undefined | null;
type LessonApi = {
update: (courseId: string, lessonId: string, payload: Record<string, unknown>) => Promise<unknown>;
delete: (courseId: string, lessonId: string) => Promise<unknown>;
errors?: CourseApiErrorMap;
success?: boolean;
};
type ExerciseApi = {
update: (courseId: string, exerciseId: string, payload: Record<string, unknown>) => Promise<unknown>;
delete: (courseId: string, exerciseId: string) => Promise<unknown>;
errors?: CourseApiErrorMap;
success?: boolean;
};
function normalizeErrors(errors: CourseApiErrorMap): Record<string, string> {
if (!errors) return {};
const entries = Object.entries(errors).filter(([, value]) => Boolean(value));
return entries.length ? Object.fromEntries(entries) : {};
}
function buildContentPayload(item: ContentActionItem, overrides: Record<string, unknown> = {}) {
const normalizedSectionId = item.sectionId === '' ? null : item.sectionId;
return {
title: item.title || '',
isUnlocked: item.isUnlocked ?? undefined,
sectionId: normalizedSectionId ?? undefined,
order: item.order ?? undefined,
...overrides
};
}
export async function toggleLock({
item,
courseId,
lessonApi,
exerciseApi
}: {
item: ContentActionItem;
courseId: string;
lessonApi: LessonApi;
exerciseApi: ExerciseApi;
}) {
item.isUnlocked = !(item.isUnlocked ?? false);
const payload = buildContentPayload(item, { order: undefined });
if (item.type === ContentType.Exercise) {
await exerciseApi.update(courseId, item.contentId, payload);
return normalizeErrors(exerciseApi.errors);
}
await lessonApi.update(courseId, item.contentId, payload);
return normalizeErrors(lessonApi.errors);
}
export async function saveContent({
item,
courseId,
lessonApi,
exerciseApi
}: {
item: ContentActionItem;
courseId: string;
lessonApi: LessonApi;
exerciseApi: ExerciseApi;
}) {
const payload = buildContentPayload(item);
if (item.type === ContentType.Exercise) {
await exerciseApi.update(courseId, item.contentId, payload);
return normalizeErrors(exerciseApi.errors);
}
await lessonApi.update(courseId, item.contentId, payload);
return normalizeErrors(lessonApi.errors);
}
export async function deleteContent({
item,
courseId,
lessonApi,
exerciseApi
}: {
item: ContentActionItem;
courseId: string;
lessonApi: LessonApi;
exerciseApi: ExerciseApi;
}) {
if (item.type === ContentType.Exercise) {
await exerciseApi.delete(courseId, item.contentId);
return Boolean(exerciseApi.success);
}
await lessonApi.delete(courseId, item.contentId);
return Boolean(lessonApi.success);
}
@@ -0,0 +1,73 @@
<script lang="ts">
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import { Button } from '@cio/ui/base/button';
import { RoleBasedSecurity } from '$features/ui';
import { t } from '$lib/utils/functions/translations';
interface Props {
isEditing?: boolean;
disabled?: boolean;
isSaving?: boolean;
lockLabel?: string;
showLock?: boolean;
showDelete?: boolean;
onEdit: () => void;
onSave: () => void;
onCancel: () => void;
onDelete: () => void;
onToggleLock: () => void;
}
let {
isEditing = false,
disabled = false,
isSaving = false,
lockLabel = '',
showLock = true,
showDelete = true,
onEdit,
onSave,
onCancel,
onDelete,
onToggleLock
}: Props = $props();
</script>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="flex items-center gap-1">
{#if isEditing}
<Button variant="outline" onclick={onCancel} disabled={isSaving}>
{$t('course.navItem.lessons.add_lesson.cancel')}
</Button>
<Button onclick={onSave} loading={isSaving} disabled={isSaving}>
{$t('course.navItem.lessons.add_lesson.save')}
</Button>
{:else}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost" size="icon" class="h-8 w-8" {disabled}>
<EllipsisVerticalIcon class="h-5 w-5" />
<span class="sr-only">Open menu</span>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#if showLock}
<DropdownMenu.Item onclick={onToggleLock} {disabled}>
{lockLabel}
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item onclick={onEdit} {disabled}>
{$t('course.navItem.lessons.add_lesson.edit')}
</DropdownMenu.Item>
{#if showDelete}
<DropdownMenu.Separator />
<DropdownMenu.Item class="text-red-600 focus:text-red-600 dark:text-red-400" onclick={onDelete} {disabled}>
{$t('course.navItem.lessons.add_lesson.delete')}
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</div>
</RoleBasedSecurity>
@@ -0,0 +1,474 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { dndzone } from 'svelte-dnd-action';
import CalendarClockIcon from '@lucide/svelte/icons/calendar-clock';
import FileTextIcon from '@lucide/svelte/icons/file-text';
import HelpCircleIcon from '@lucide/svelte/icons/help-circle';
import LockIcon from '@lucide/svelte/icons/lock';
import { Spinner } from '@cio/ui/base/spinner';
import UnlockIcon from '@lucide/svelte/icons/lock-open';
import VideoIcon from '@lucide/svelte/icons/video';
import { HoverableItem, AttachmentIcon } from '@cio/ui/custom/moving-icons';
import { Button } from '@cio/ui/base/button';
import { Chip } from '@cio/ui/custom/chip';
import { lessonApi, exerciseApi, courseApi, contentApi } from '$features/course/api';
import { globalStore } from '$lib/utils/store/app';
import formatDate from '$lib/utils/functions/formatDate';
import { t } from '$lib/utils/functions/translations';
import type { CourseContentItem } from '$features/course/utils/types';
import { getCourseContent } from '$features/course/utils/content';
import { ContentType } from '@cio/utils/constants/content';
import { profile } from '$lib/utils/store/user';
import { contentEditingStore } from '$features/course/components/content/store';
import DeleteLessonConfirmation from '$features/course/components/lesson/delete-lesson-confirmation.svelte';
import ContentRow from './content-row.svelte';
import ContentActions from './content-actions.svelte';
import { deleteContent, saveContent, toggleLock } from './content-action-helpers';
const flipDurationMs = 300;
interface Props {
reorder?: boolean;
}
let { reorder = false }: Props = $props();
let prevTitle: string | undefined = $state();
let errors: Record<string, string> = $state({});
let lessonToDelete: CourseContentItem | undefined = $state();
let openDeleteModal = $state(false);
let lockPendingItemKeys = $state<Record<string, boolean>>({});
let courseId = $derived(courseApi.course?.id || '');
const profileId = $derived($profile?.id || '');
const contentData = $derived(getCourseContent(courseApi.course));
const isStudentView = $derived($globalStore.isStudent === true);
const isLiveCourse = $derived(courseApi.course?.type === 'LIVE_CLASS');
const isSelfPacedCourse = $derived(courseApi.course?.type === 'SELF_PACED');
const metaChipClass =
'inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-1 text-[10px] text-gray-700 dark:bg-neutral-700 dark:text-gray-200';
type ContentDndItem = CourseContentItem & {
id: string;
contentId: string;
isUnlocked?: boolean | null;
isComplete?: boolean | null;
};
type DndEvent = CustomEvent<{ items: ContentDndItem[] }>;
let contentItems = $state<ContentDndItem[]>([]);
function buildContentItems(): ContentDndItem[] {
return (contentData.items || []).map((item) => ({
...item,
id: `${item.type}-${item.id}`,
contentId: item.id
}));
}
function syncCourseContentItems(items: ContentDndItem[]) {
if (!courseApi.course?.content || courseApi.course.content.grouped) return;
const updatedItems = items.map(({ contentId, id: _dndId, ...rest }, index) => ({
...rest,
id: contentId,
order: index + 1
}));
courseApi.course.content = {
...courseApi.course.content,
items: updatedItems
};
}
function normalizeSectionId(value?: string | null) {
return value ? value : null;
}
async function applyPendingReorder() {
if (!contentItems.length) return;
const contentUpdates: Array<{
id: string;
type: ContentType.Lesson | ContentType.Exercise;
order: number;
sectionId?: string | null;
}> = [];
const updatedItems = contentItems.map((item, index) => {
const order = index + 1;
if (item.type === ContentType.Lesson || item.type === ContentType.Exercise) {
if (item.order !== order) {
contentUpdates.push({
id: item.contentId,
type: item.type,
order,
sectionId: normalizeSectionId(item.sectionId)
});
}
}
return {
...item,
order
};
});
contentItems = updatedItems;
syncCourseContentItems(updatedItems);
if (contentUpdates.length > 0) {
await contentApi.updateContent(courseId, contentUpdates);
}
}
let previousReorder = $state(false);
$effect(() => {
if (previousReorder && !reorder) {
void applyPendingReorder();
}
previousReorder = reorder;
if (!reorder || contentItems.length === 0) {
contentItems = buildContentItems();
}
});
async function saveLesson(item: ContentDndItem) {
const nextErrors = await saveContent({
item,
courseId,
lessonApi,
exerciseApi
});
errors = nextErrors;
if (Object.keys(nextErrors).length === 0) {
resetEdit();
syncCourseContentItems(contentItems);
}
}
function resetEdit() {
contentEditingStore.set(undefined);
prevTitle = undefined;
errors = {};
}
function handleDndConsider(e: DndEvent) {
contentItems = e.detail.items;
syncCourseContentItems(contentItems);
}
function handleDndFinalize(e: DndEvent) {
const items = e.detail.items;
contentItems = items;
syncCourseContentItems(items);
if (!reorder) {
contentItems = buildContentItems();
}
}
function getContentOrder(contentId: string, type: ContentType): number | string {
const index = contentItems.findIndex((item) => item.type === type && item.contentId === contentId);
if (index < 9) {
return '0' + (index + 1);
}
return index + 1;
}
function getItemPath(item: ContentDndItem) {
if (item.type === ContentType.Lesson) {
return `/courses/${courseApi.course?.id}/lessons/${item.contentId}`;
}
return `/courses/${courseApi.course?.id}/exercises/${item.contentId}`;
}
function formatMetaDate(value?: string | null): string {
if (!value) return '';
return formatDate(value);
}
function isLockedForStudent(item: ContentDndItem, isStudent: boolean) {
return isStudent && (item.isUnlocked ?? true) === false;
}
function getItemLockKey(item: ContentDndItem) {
return `${item.type}-${item.contentId}`;
}
async function handleToggleItemLock(item: ContentDndItem) {
const lockKey = getItemLockKey(item);
if (lockPendingItemKeys[lockKey]) return;
lockPendingItemKeys = {
...lockPendingItemKeys,
[lockKey]: true
};
try {
const lockErrors = await toggleLock({
item,
courseId,
lessonApi,
exerciseApi
});
errors = lockErrors;
if (Object.keys(lockErrors).length === 0) {
syncCourseContentItems(contentItems);
}
} finally {
const nextPending = { ...lockPendingItemKeys };
delete nextPending[lockKey];
lockPendingItemKeys = nextPending;
}
}
</script>
<DeleteLessonConfirmation
bind:openDeleteModal
deleteLesson={async () => {
if (!lessonToDelete?.id) return;
const activeCourseId = courseApi.course?.id;
if (!activeCourseId) return;
const wasDeleted = await deleteContent({
item: {
type: lessonToDelete.type,
contentId: lessonToDelete.id,
title: lessonToDelete.title,
isUnlocked: lessonToDelete.isUnlocked,
sectionId: lessonToDelete.sectionId,
order: lessonToDelete.order
},
courseId: activeCourseId,
lessonApi,
exerciseApi
});
if (wasDeleted && profileId) {
await courseApi.refreshCourse(activeCourseId, profileId);
}
}}
/>
<section
class="mx-auto w-full p-3 lg:w-11/12 lg:px-4 {reorder ? 'cursor-grab active:cursor-grabbing' : ''}"
use:dndzone={{
items: contentItems,
flipDurationMs,
dragDisabled: !reorder,
dropTargetStyle: {
border: '2px #1d4ed8 solid',
'border-style': 'dashed'
}
}}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
>
{#each contentItems as item (item.id)}
{@const isEditingItem = $contentEditingStore === item.contentId}
{@const itemLocked = isLockedForStudent(item, isStudentView)}
{@const lockLabel = item.isUnlocked
? $t('course.navItem.lessons.add_lesson.lock')
: $t('course.navItem.lessons.add_lesson.unlock')}
{@const canRenderJoinButton =
item.type === ContentType.Lesson && isLiveCourse && Boolean(item.callUrl) && !itemLocked}
{@const canRenderContinueButton = isStudentView && !itemLocked}
{@const isLockPending = Boolean(lockPendingItemKeys[getItemLockKey(item)])}
{@const actionDisabled = Boolean($contentEditingStore && !isEditingItem)}
<div
class="relative m-auto mb-4 flex max-w-xl items-start rounded-md border border-gray-200 px-3 py-2 transition dark:bg-neutral-800 {reorder
? 'border-primary-300 bg-primary-50/40 cursor-grab shadow-sm active:cursor-grabbing'
: ''}"
>
<div class="mt-0.5 mr-5">
<Chip value={getContentOrder(item.contentId, item.type)} />
</div>
{#if item.type === ContentType.Lesson}
<ContentRow
{item}
bind:title={item.title}
isLocked={itemLocked}
href={resolve(itemLocked ? page.url.pathname : getItemPath(item), {})}
isEditing={isEditingItem}
{errors}
autoFocus={isEditingItem}
>
{#snippet meta()}
{@const hasVideos = (item.videosCount ?? 0) > 0}
{@const hasDocuments = (item.documentsCount ?? 0) > 0}
{@const hasNote = item.hasNoteContent === true}
{@const hasSlide = item.hasSlideContent === true}
{@const hasAnyMaterials = hasVideos || hasDocuments || hasNote || hasSlide}
<div class="mt-2 flex flex-wrap items-center gap-1.5">
{#if hasVideos}
<span class={metaChipClass}>
<VideoIcon size={12} />
{item.videosCount}
</span>
{/if}
{#if hasDocuments}
<span class={metaChipClass}>
<FileTextIcon size={12} />
{item.documentsCount}
</span>
{/if}
{#if hasNote}
<span class={metaChipClass}>
<FileTextIcon size={12} />
{$t('course.navItem.lessons.materials.tabs.note.title')}
</span>
{/if}
{#if hasSlide}
<span class={metaChipClass}>
<FileTextIcon size={12} />
{$t('course.navItem.lessons.materials.tabs.slide.title')}
</span>
{/if}
{#if !hasAnyMaterials}
<HoverableItem>
{#snippet children(isHovered)}
<span class={metaChipClass}>
<AttachmentIcon size={11} {isHovered} />
{$t('course.navItem.lessons.materials.no_content')}
</span>
{/snippet}
</HoverableItem>
{/if}
{#if isLiveCourse && item.lessonAt}
<span class={metaChipClass}>
<CalendarClockIcon size={12} />
{formatMetaDate(item.lessonAt)}
</span>
{/if}
{#if isStudentView}
<span class={metaChipClass}>
{item.isComplete ? $t('course.navItem.lessons.complete') : $t('course.navItem.lessons.incomplete')}
</span>
{/if}
</div>
{/snippet}
</ContentRow>
{:else if item.type === ContentType.Exercise}
<ContentRow
{item}
bind:title={item.title}
isLocked={itemLocked}
href={resolve(itemLocked ? page.url.pathname : getItemPath(item), {})}
isEditing={isEditingItem}
{errors}
autoFocus={isEditingItem}
>
{#snippet meta()}
<div class="mt-2 flex flex-wrap items-center gap-1.5">
{#if !isStudentView && item.questionCount !== null}
<span class={metaChipClass}>
<HelpCircleIcon size={12} />
{item.questionCount}
{$t('course.navItem.lessons.exercises.all_exercises.questions')}
</span>
{/if}
{#if isSelfPacedCourse && item.dueBy}
<span class={metaChipClass}>
<CalendarClockIcon size={12} />
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.due')}: {formatMetaDate(item.dueBy)}
</span>
{/if}
</div>
{/snippet}
</ContentRow>
{/if}
<div class="ml-3 flex min-w-fit items-start gap-2">
{#if isStudentView}
<span class="mt-1 inline-flex items-center text-gray-500 dark:text-gray-300">
{#if itemLocked}
<LockIcon size={16} />
{:else}
<UnlockIcon size={16} />
{/if}
</span>
{:else}
<Button
size="icon-sm"
variant="outline"
class="h-8 w-8 text-gray-600"
onclick={() => handleToggleItemLock(item)}
disabled={actionDisabled || isEditingItem || isLockPending}
aria-label={lockLabel}
title={lockLabel}
>
{#if isLockPending}
<Spinner class="h-5 w-5" />
{:else if item.isUnlocked}
<UnlockIcon class="h-5 w-5" />
{:else}
<LockIcon class="h-5 w-5" />
{/if}
</Button>
{/if}
{#if !isStudentView && canRenderJoinButton}
<a href={item.callUrl!} target="_blank" rel="noreferrer" class="mt-0.5">
<Button size="sm" variant="outline">{$t('schedule.join')}</Button>
</a>
{/if}
{#if isStudentView && canRenderContinueButton && !isEditingItem}
{#if canRenderJoinButton}
<a href={item.callUrl!} target="_blank" rel="noreferrer">
<Button size="sm">{$t('schedule.join')}</Button>
</a>
{:else}
<a href={resolve(getItemPath(item), {})}>
<Button size="sm" variant="outline">{$t('dashboard.continue')}</Button>
</a>
{/if}
{/if}
{#if !isStudentView}
<ContentActions
isEditing={isEditingItem}
disabled={actionDisabled}
{lockLabel}
showLock={false}
onEdit={() => {
contentEditingStore.set(item.contentId);
prevTitle = item.title;
}}
onSave={() => saveLesson(item)}
onCancel={() => {
item.title = prevTitle ?? item.title;
resetEdit();
}}
onDelete={() => {
lessonToDelete = item;
openDeleteModal = true;
}}
onToggleLock={() => handleToggleItemLock(item)}
/>
{/if}
</div>
</div>
{:else}
<p class="text-center text-sm text-gray-500 dark:text-white">{$t('course.navItem.lessons.no_lesson')}</p>
{/each}
</section>
@@ -0,0 +1,157 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { CircleCheckIcon } from '$features/ui/icons';
import { Button } from '@cio/ui/base/button';
import { globalStore } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import { courseApi, lessonApi } from '$features/course/api';
import { getOrderedNavigableContent, getContentRoute } from '$features/course/utils/content';
import { ContentType } from '@cio/utils/constants/content';
import { snackbar } from '$features/ui/snackbar/store';
import type { CourseContentItem } from '$features/course/utils/types';
interface Props {
lessonId?: string;
courseId: string;
/** When on an exercise page, pass this to show prev/next content in content order (no mark-complete). */
exerciseId?: string;
}
let { lessonId, courseId, exerciseId }: Props = $props();
let isMarkingComplete = $state(false);
const navigableContentItems = $derived(getOrderedNavigableContent(courseApi.course));
const lessonItems = $derived(navigableContentItems.filter((item) => item.type === ContentType.Lesson));
/** Prev/next content items in full content order (immediate neighbors; locked content is hidden in the UI so we don't skip). */
const prevNextContent = $derived.by(() => {
const currentId = lessonId ?? exerciseId;
const currentType = lessonId ? ContentType.Lesson : ContentType.Exercise;
if (!currentId) return { prev: null, next: null };
const idx = navigableContentItems.findIndex((item) => item.type === currentType && item.id === currentId);
if (idx < 0) return { prev: null, next: null };
return {
prev: navigableContentItems[idx - 1] ?? null,
next: navigableContentItems[idx + 1] ?? null
};
});
function goToContent(target: CourseContentItem | null) {
if (!target) return;
const courseIdResolved = courseApi.course?.id;
if (!courseIdResolved) return;
const path = getContentRoute(courseIdResolved, target);
if (!path) return;
goto(resolve(path, {}));
}
const isPrevDisabled = $derived(!prevNextContent.prev);
const isNextDisabled = $derived(!prevNextContent.next);
const isLessonComplete = $derived.by(() => {
if (!lessonId) return false;
const lesson = lessonItems.find((l) => l.id === lessonId);
return lesson?.isComplete ?? false;
});
const showMarkComplete = $derived(!!lessonId && !exerciseId);
const currentLessonItem = $derived(lessonId ? lessonItems.find((l) => l.id === lessonId) : null);
const isLessonLocked = $derived(
$globalStore.isStudent && currentLessonItem && !(currentLessonItem.isUnlocked ?? false)
);
const isMarkCompleteDisabled = $derived(isMarkingComplete || isLessonLocked);
async function markLessonComplete(currentLessonId: string) {
isMarkingComplete = true;
const lesson = lessonItems.find((entry) => entry.id === currentLessonId);
const currentIsComplete = lesson?.isComplete ?? lessonApi.lesson?.isComplete ?? false;
const isComplete = !currentIsComplete;
await lessonApi.updateCompletion(courseId, currentLessonId, isComplete);
if (lessonApi.success) {
updateCourseContentCompletion(currentLessonId, isComplete);
snackbar.success('snackbar.lessons.success.complete_marked');
} else {
snackbar.error('snackbar.lessons.error.try_later');
}
isMarkingComplete = false;
}
function updateCourseContentCompletion(currentLessonId: string, isComplete: boolean) {
if (!courseApi.course?.content) return;
if (courseApi.course.content.grouped) {
courseApi.course = {
...courseApi.course,
content: {
...courseApi.course.content,
sections: courseApi.course.content.sections.map((section) => ({
...section,
items: section.items.map((item) =>
item.type === ContentType.Lesson && item.id === currentLessonId ? { ...item, isComplete } : item
)
}))
}
};
return;
}
courseApi.course = {
...courseApi.course,
content: {
...courseApi.course.content,
items: courseApi.course.content.items.map((item) =>
item.type === ContentType.Lesson && item.id === currentLessonId ? { ...item, isComplete } : item
)
}
};
}
</script>
<div class="flex items-center gap-2">
{#if showMarkComplete && lessonId}
<Button
size="sm"
variant="secondary"
onclick={() => markLessonComplete(lessonId)}
loading={isMarkingComplete}
disabled={isMarkCompleteDisabled}
>
<CircleCheckIcon size={14} filled={isLessonComplete} />
<span class="text-xs">{$t('course.navItem.lessons.mark_as')} {$t('course.navItem.lessons.complete')}</span>
</Button>
{/if}
<div class="flex items-center gap-1">
<Button
size="icon-sm"
variant="outline"
onclick={() => goToContent(prevNextContent.prev)}
disabled={isPrevDisabled}
aria-label={$t('course.navItem.lessons.prev')}
title={$t('course.navItem.lessons.prev')}
>
<ChevronLeftIcon size={14} />
</Button>
<Button
size="icon-sm"
variant="outline"
onclick={() => goToContent(prevNextContent.next)}
disabled={isNextDisabled}
aria-label={$t('course.navItem.lessons.next')}
title={$t('course.navItem.lessons.next')}
>
<ChevronRightIcon size={14} />
</Button>
</div>
</div>
@@ -0,0 +1,90 @@
<script lang="ts">
import { resolve } from '$app/paths';
import LockIcon from '@lucide/svelte/icons/lock';
import { InputField } from '@cio/ui/custom/input-field';
import { CircleCheckIcon } from '$features/ui/icons';
import type { Snippet } from 'svelte';
import CourseContentIcon from '$features/course/components/course-content-icon.svelte';
import type { CourseContentItem } from '$features/course/utils/types';
import { cn } from '@cio/ui/tools';
interface Props {
item: CourseContentItem;
title: string | null | undefined;
href: string;
isEditing?: boolean;
errors?: Record<string, string>;
isLocked?: boolean;
isComplete?: boolean | null;
showStatus?: boolean;
showIcon?: boolean;
iconSize?: number;
containerClass?: string;
rowClass?: string;
linkClass?: string;
inputClass?: string;
titleClass?: string;
autoFocus?: boolean;
preloadOff?: boolean;
inline?: Snippet;
meta?: Snippet;
}
let {
item,
title = $bindable(),
href,
isEditing = false,
errors = {},
isLocked = false,
isComplete = null,
showStatus = false,
showIcon = true,
iconSize = 14,
containerClass = '',
rowClass = '',
linkClass = '',
inputClass = '',
titleClass = '',
autoFocus = false,
preloadOff = false,
inline,
meta
}: Props = $props();
</script>
{#if isEditing}
<InputField className={cn('w-4/6', inputClass)} bind:value={title} errorMessage={errors?.title} {autoFocus} />
{:else}
<div class={cn('w-4/5', containerClass)}>
<div class={cn('flex items-center gap-2', rowClass)}>
{#if showIcon}
<CourseContentIcon type={item.type} size={iconSize} />
{/if}
<a
href={resolve(href, {})}
class={cn(
'flex-1 truncate text-sm text-black hover:underline dark:text-white',
isLocked ? 'cursor-not-allowed opacity-50' : linkClass
)}
data-sveltekit-preload-data={preloadOff ? 'off' : undefined}
>
<span class={titleClass}>{title}</span>
</a>
{#if showStatus}
{#if isLocked}
<LockIcon size={iconSize} class="shrink-0" />
{:else if isComplete}
<span class="shrink-0">
<CircleCheckIcon size={iconSize} filled />
</span>
{/if}
{/if}
{@render inline?.()}
</div>
{@render meta?.()}
</div>
{/if}
@@ -0,0 +1,698 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { flip } from 'svelte/animate';
import { dndzone } from 'svelte-dnd-action';
import CalendarClockIcon from '@lucide/svelte/icons/calendar-clock';
import FileTextIcon from '@lucide/svelte/icons/file-text';
import HelpCircleIcon from '@lucide/svelte/icons/help-circle';
import LockIcon from '@lucide/svelte/icons/lock';
import { Spinner } from '@cio/ui/base/spinner';
import UnlockIcon from '@lucide/svelte/icons/lock-open';
import VideoIcon from '@lucide/svelte/icons/video';
import { Button } from '@cio/ui/base/button';
import { HoverableItem, AttachmentIcon } from '@cio/ui/custom/moving-icons';
import formatDate from '$lib/utils/functions/formatDate';
import { t } from '$lib/utils/functions/translations';
import { globalStore } from '$lib/utils/store/app';
import { contentApi, courseApi, exerciseApi, lessonApi } from '$features/course/api';
import { snackbar } from '$features/ui/snackbar/store';
import { profile } from '$lib/utils/store/user';
import DeleteLessonConfirmation from '$features/course/components/lesson/delete-lesson-confirmation.svelte';
import type { CourseContent, CourseContentItem } from '$features/course/utils/types';
import { contentCreateStoreUtils, contentEditingStore } from '$features/course/components/content/store';
import { getCourseContent } from '$features/course/utils/content';
import { ContentType } from '@cio/utils/constants/content';
import ContentRow from './content-row.svelte';
import ContentActions from './content-actions.svelte';
import SectionHeader from './section-header.svelte';
import { deleteContent, saveContent, toggleLock } from './content-action-helpers';
type CrudParam = {
sectionId?: string;
lessonId?: string;
sectionTitle?: string;
lessonTitle?: string;
};
interface Props {
reorder?: boolean;
}
let { reorder = false }: Props = $props();
let prevTitle: string | undefined = $state();
let errors: Record<string, string> = $state({});
let openDeleteModal = $state(false);
let lockPendingItemKeys = $state<Record<string, boolean>>({});
let deletingData = {
id: '',
isSection: false
};
const courseId = $derived(courseApi.course?.id || '');
const profileId = $derived($profile?.id || '');
const contentData = $derived(getCourseContent(courseApi.course));
const isStudentView = $derived($globalStore.isStudent === true);
const isLiveCourse = $derived(courseApi.course?.type === 'LIVE_CLASS');
const isSelfPacedCourse = $derived(courseApi.course?.type === 'SELF_PACED');
const metaChipClass =
'inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-1 text-[10px] text-gray-700 dark:bg-neutral-700 dark:text-gray-200';
type ContentDndItem = CourseContentItem & {
id: string;
contentId: string;
isUnlocked?: boolean | null;
isComplete?: boolean | null;
};
type ContentSection = CourseContent['sections'][number];
type SectionDnd = {
id: string;
title: string;
order: number | null;
items: ContentDndItem[];
};
let sections = $state<SectionDnd[]>([]);
function resolveErrors(apiErrors?: Record<string, string> | null) {
if (!apiErrors) return {};
const entries = Object.entries(apiErrors).filter(([, value]) => Boolean(value));
return entries.length ? Object.fromEntries(entries) : {};
}
function buildSectionItems(section: ContentSection): ContentDndItem[] {
return section.items.map((item) => ({
...item,
id: `${item.type}-${item.id}`,
contentId: item.id
}));
}
function syncSections() {
sections = (contentData.sections || []).map((section) => ({
...section,
items: buildSectionItems(section)
}));
}
function toCourseContentItems(items: ContentDndItem[]): CourseContentItem[] {
return items.map(({ contentId, id: _dndId, ...rest }) => ({
...rest,
id: contentId
}));
}
function syncCourseContentSections(updatedSections: SectionDnd[]) {
if (!courseApi.course?.content) return;
courseApi.course.content = {
...courseApi.course.content,
sections: updatedSections.map((section) => ({
...section,
items: toCourseContentItems(section.items),
order: section.order ?? null
}))
};
}
function updateSectionItems(sectionId: string, items: ContentDndItem[]) {
sections = sections.map((section) => (section.id === sectionId ? { ...section, items } : section));
}
function normalizeSectionId(value?: string | null) {
return value && value !== 'ungrouped' ? value : null;
}
async function applyPendingReorder() {
if (!sections.length) return;
const contentUpdates: Array<{
id: string;
type: ContentType.Lesson | ContentType.Exercise;
order: number;
sectionId?: string | null;
}> = [];
const updatedSections = sections.map((section, sectionIndex) => {
const normalizedSectionId = normalizeSectionId(section.id);
const updatedItems = section.items.map((item, index) => {
const order = index + 1;
if (item.type === ContentType.Lesson || item.type === ContentType.Exercise) {
if (item.order !== order || item.sectionId !== normalizedSectionId) {
contentUpdates.push({
id: item.contentId,
type: item.type,
order,
sectionId: normalizedSectionId
});
}
}
return {
...item,
order,
sectionId: normalizedSectionId
};
});
return {
...section,
order: sectionIndex + 1,
items: updatedItems
};
});
sections = updatedSections;
syncCourseContentSections(updatedSections);
const sectionsToReorder = updatedSections
.filter((section) => section.id !== 'ungrouped')
.map((section) => ({ id: section.id, order: section.order ?? 0 }));
if (sectionsToReorder.length > 0) {
await lessonApi.reorderSections(courseId, sectionsToReorder);
}
if (contentUpdates.length > 0) {
await contentApi.updateContent(courseId, contentUpdates);
}
}
let previousReorder = $state(false);
$effect(() => {
if (previousReorder && !reorder) {
void applyPendingReorder();
}
previousReorder = reorder;
if (!reorder || sections.length === 0) {
syncSections();
}
});
$effect(() => {
const activeEditId = $contentEditingStore;
if (!activeEditId) return;
const hasMatchingSection = sections.some((section) => section.id === activeEditId);
const hasMatchingItem = sections.some((section) => section.items.some((item) => item.contentId === activeEditId));
if (!hasMatchingSection && !hasMatchingItem) {
contentEditingStore.set(undefined);
}
});
function onEdit(params: CrudParam) {
contentEditingStore.set(params.sectionId || params.lessonId);
prevTitle = params.sectionTitle || params.lessonTitle;
}
async function onSave(params: CrudParam, item?: ContentDndItem) {
let nextErrors: Record<string, string> = {};
let shouldRefreshSections = false;
if (params.sectionId) {
const section = sections.find((entry) => entry.id === params.sectionId);
if (!section) return;
if (section.id === 'ungrouped') {
await lessonApi.promoteUngroupedSection(courseId, {
title: section.title!
});
shouldRefreshSections = true;
} else {
await lessonApi.updateSection(courseId, section.id, {
title: section.title!
});
}
nextErrors = resolveErrors(lessonApi.errors);
} else if (item) {
nextErrors = await saveContent({
item,
courseId,
lessonApi,
exerciseApi
});
}
errors = nextErrors;
if (Object.keys(nextErrors).length === 0) {
resetEdit();
if (shouldRefreshSections) {
if (profileId) {
await courseApi.refreshCourse(courseId, profileId);
}
syncSections();
} else {
syncCourseContentSections(sections);
}
// Refresh course data to get updated information (especially for exercises)
if (item?.type === ContentType.Exercise && exerciseApi.success && profileId) {
await courseApi.refreshCourse(courseId, profileId);
}
// Note: exerciseApi.update already shows a success snackbar, so we don't need to show it again
if (item?.type !== ContentType.Exercise) {
snackbar.success('snackbar.success_update');
}
}
}
function onDelete(params: CrudParam) {
deletingData.id = params.sectionId || params.lessonId || '';
deletingData.isSection = params.sectionId ? true : false;
openDeleteModal = true;
}
function removeSection(sectionId: string) {
sections = sections.filter((section) => section.id !== sectionId);
syncCourseContentSections(sections);
}
async function deleteLesson() {
if (deletingData.isSection) {
const wasDeleted = await contentApi.deleteContent(courseId, { sectionId: deletingData.id });
if (wasDeleted) {
removeSection(deletingData.id);
}
return;
}
const item = sections.flatMap((section) => section.items).find((entry) => entry.contentId === deletingData.id);
if (!item) return;
const wasDeleted = await deleteContent({
item,
courseId,
lessonApi,
exerciseApi
});
if (wasDeleted && profileId) {
await courseApi.refreshCourse(courseId, profileId);
}
}
async function toggleSectionLock(sectionId: string) {
const section = sections.find((entry) => entry.id === sectionId);
if (!section) return;
const shouldLock = section.items.some((item) => item.isUnlocked ?? false);
const nextIsUnlocked = !shouldLock;
const updatedItems = section.items.map((item) => ({
...item,
isUnlocked: nextIsUnlocked
}));
const updatedSections = sections.map((entry) =>
entry.id === sectionId
? {
...entry,
items: updatedItems
}
: entry
);
const wasUpdated = await contentApi.updateContent(
courseId,
updatedItems.map((item) => ({
id: item.contentId,
type: item.type as ContentType.Lesson | ContentType.Exercise,
isUnlocked: nextIsUnlocked
}))
);
if (!wasUpdated) return;
sections = updatedSections;
syncCourseContentSections(sections);
}
function resetEdit() {
contentEditingStore.set(undefined);
prevTitle = undefined;
errors = {};
}
function openContentModal(sectionId: string) {
contentCreateStoreUtils.openContentUnit(sectionId);
}
function getItemPath(item: ContentDndItem) {
if (item.type === ContentType.Lesson) {
return `/courses/${courseApi.course?.id}/lessons/${item.contentId}`;
}
return `/courses/${courseApi.course?.id}/exercises/${item.contentId}`;
}
function openExternalUrl(url: string) {
window.open(url, '_blank', 'noopener,noreferrer');
}
function formatMetaDate(value?: string | null): string {
if (!value) return '';
return formatDate(value);
}
function isLockedForStudent(item: ContentDndItem) {
return isStudentView && (item.isUnlocked ?? true) === false;
}
function getItemLockKey(item: ContentDndItem) {
return `${item.type}-${item.contentId}`;
}
async function handleToggleItemLock(item: ContentDndItem) {
const lockKey = getItemLockKey(item);
if (lockPendingItemKeys[lockKey]) return;
lockPendingItemKeys = {
...lockPendingItemKeys,
[lockKey]: true
};
try {
const lockErrors = await toggleLock({
item,
courseId,
lessonApi,
exerciseApi
});
errors = lockErrors;
if (Object.keys(lockErrors).length === 0) {
syncCourseContentSections(sections);
}
} finally {
const nextPending = { ...lockPendingItemKeys };
delete nextPending[lockKey];
lockPendingItemKeys = nextPending;
}
}
/**
* Drag functionality
*/
const flipDurationMs = 200;
function handleDndConsiderColumns(e) {
sections = e.detail.items as SectionDnd[];
syncCourseContentSections(sections);
}
function handleDndFinalizeColumns(e) {
const updatedSections = (e.detail.items as SectionDnd[]).map((section, index) => ({
...section,
order: index + 1
}));
sections = updatedSections;
syncCourseContentSections(updatedSections);
}
function handleDndConsiderCards(sectionId: string, e) {
updateSectionItems(sectionId, e.detail.items as ContentDndItem[]);
syncCourseContentSections(sections);
}
function handleDndFinalizeCards(sectionId: string, e) {
const items = e.detail.items as ContentDndItem[];
updateSectionItems(sectionId, items);
syncCourseContentSections(sections);
if (!reorder) {
syncSections();
}
}
</script>
<DeleteLessonConfirmation bind:openDeleteModal {deleteLesson} />
<section
class="mx-auto w-full p-3 lg:w-11/12 lg:px-4"
use:dndzone={{
items: sections,
flipDurationMs,
type: 'rows',
dropTargetStyle: {
border: '2px #1d4ed8 solid',
'border-style': 'dashed'
},
dragDisabled: !reorder
}}
onconsider={handleDndConsiderColumns}
onfinalize={handleDndFinalizeColumns}
>
{#each sections as section (section.id)}
{@const isUngrouped = section.id === 'ungrouped'}
{@const hasSectionItems = section.items.length > 0}
{@const sectionHasUnlocked = section.items.some((item) => item.isUnlocked ?? false)}
{@const sectionLockLabel = hasSectionItems
? sectionHasUnlocked
? `${$t('course.navItem.lessons.add_lesson.lock')} ${$t('course.navItem.lessons.add_lesson.all')}`
: `${$t('course.navItem.lessons.add_lesson.unlock')} ${$t('course.navItem.lessons.add_lesson.all')}`
: undefined}
<div
class="border-border m-auto mb-3 max-w-xl rounded-md border transition dark:bg-neutral-800"
style={reorder ? 'border: 2px #1d4ed8 solid; border-style: dashed; cursor: grab;' : ''}
>
<SectionHeader
{section}
isEditing={$contentEditingStore === section.id}
isSaving={lessonApi.isLoading && $contentEditingStore === section.id}
showActions={true}
showAddContent={!isUngrouped}
showDelete={!isUngrouped}
disabled={Boolean($contentEditingStore && $contentEditingStore !== section.id)}
{errors}
lockLabel={isUngrouped ? undefined : sectionLockLabel}
onEdit={() => onEdit({ sectionId: section.id, sectionTitle: section.title! })}
onSave={() => onSave({ sectionId: section.id })}
onCancel={() => {
section.title = prevTitle ?? section.title;
resetEdit();
}}
onAddContent={() => openContentModal(section.id)}
onToggleLock={!isUngrouped && hasSectionItems ? () => toggleSectionLock(section.id) : undefined}
onDelete={() => {
if (!isUngrouped) {
onDelete({ sectionId: section.id });
}
}}
/>
<div
class="m-3 {reorder ? 'cursor-grab active:cursor-grabbing' : ''}"
use:dndzone={{
items: section.items,
flipDurationMs,
dropTargetStyle: {
border: '2px #1d4ed8 solid',
'border-style': 'dashed'
},
dragDisabled: !reorder,
type: 'section-content'
}}
onconsider={(e) => handleDndConsiderCards(section.id, e)}
onfinalize={(e) => handleDndFinalizeCards(section.id, e)}
>
{#each section.items as item (item.id)}
{@const isEditingItem = $contentEditingStore === item.contentId}
{@const itemLocked = isLockedForStudent(item)}
{@const itemLockLabel = item.isUnlocked
? $t('course.navItem.lessons.add_lesson.lock')
: $t('course.navItem.lessons.add_lesson.unlock')}
{@const canRenderJoinButton =
item.type === ContentType.Lesson && isLiveCourse && Boolean(item.callUrl) && !itemLocked}
{@const canRenderContinueButton = isStudentView && !itemLocked}
{@const isLockPending = Boolean(lockPendingItemKeys[getItemLockKey(item)])}
{@const actionDisabled = Boolean($contentEditingStore && !isEditingItem)}
<div
class="mb-2 flex min-h-[50px] max-w-xl items-start justify-between rounded-md border border-gray-200 px-3 py-1 transition"
style={reorder ? 'border: 2px #1d4ed8 solid; border-style: dashed; cursor: grab;' : ''}
animate:flip={{ duration: flipDurationMs }}
>
{#if item.type === ContentType.Lesson}
<ContentRow
{item}
bind:title={item.title}
isLocked={itemLocked}
href={resolve(itemLocked ? page.url.pathname : getItemPath(item), {})}
isEditing={isEditingItem}
{errors}
preloadOff
>
{#snippet meta()}
{@const hasVideos = (item.videosCount ?? 0) > 0}
{@const hasDocuments = (item.documentsCount ?? 0) > 0}
{@const hasNote = item.hasNoteContent === true}
{@const hasSlide = item.hasSlideContent === true}
{@const hasAnyMaterials = hasVideos || hasDocuments || hasNote || hasSlide}
<div class="mt-1.5 flex flex-wrap items-center gap-1.5">
{#if hasVideos}
<span class={metaChipClass}>
<VideoIcon size={11} />
{item.videosCount}
</span>
{/if}
{#if hasDocuments}
<span class={metaChipClass}>
<FileTextIcon size={11} />
{item.documentsCount}
</span>
{/if}
{#if hasNote}
<span class={metaChipClass}>
<FileTextIcon size={11} />
{$t('course.navItem.lessons.materials.tabs.note.title')}
</span>
{/if}
{#if hasSlide}
<span class={metaChipClass}>
<FileTextIcon size={11} />
{$t('course.navItem.lessons.materials.tabs.slide.title')}
</span>
{/if}
{#if !hasAnyMaterials}
<HoverableItem>
{#snippet children(isHovered)}
<span class={metaChipClass}>
<AttachmentIcon size={11} {isHovered} />
{$t('course.navItem.lessons.materials.no_content')}
</span>
{/snippet}
</HoverableItem>
{/if}
{#if isLiveCourse && item.lessonAt}
<span class={metaChipClass}>
<CalendarClockIcon size={11} />
{formatMetaDate(item.lessonAt)}
</span>
{/if}
{#if isStudentView}
<span class={metaChipClass}>
{item.isComplete
? $t('course.navItem.lessons.complete')
: $t('course.navItem.lessons.incomplete')}
</span>
{/if}
</div>
{/snippet}
</ContentRow>
{:else if item.type === ContentType.Exercise}
<ContentRow
{item}
bind:title={item.title}
isLocked={itemLocked}
href={resolve(itemLocked ? page.url.pathname : getItemPath(item), {})}
isEditing={isEditingItem}
{errors}
>
{#snippet meta()}
<div class="mt-1.5 flex flex-wrap items-center gap-1.5">
{#if !isStudentView && item.questionCount !== null}
<span class={metaChipClass}>
<HelpCircleIcon size={11} />
{item.questionCount}
{$t('course.navItem.lessons.exercises.all_exercises.questions')}
</span>
{/if}
{#if isSelfPacedCourse && item.dueBy}
<span class={metaChipClass}>
<CalendarClockIcon size={11} />
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.due')}: {formatMetaDate(
item.dueBy
)}
</span>
{/if}
</div>
{/snippet}
</ContentRow>
{/if}
<div class="ml-3 flex min-w-fit items-start gap-2">
{#if isStudentView}
<span class="mt-1 inline-flex items-center text-gray-500 dark:text-gray-300">
{#if itemLocked}
<LockIcon size={16} />
{:else}
<UnlockIcon size={16} />
{/if}
</span>
{:else}
<Button
size="icon-sm"
variant="outline"
class="h-8 w-8 text-gray-600"
onclick={() => handleToggleItemLock(item)}
disabled={actionDisabled || isEditingItem || isLockPending}
aria-label={itemLockLabel}
title={itemLockLabel}
>
{#if isLockPending}
<Spinner class="h-5 w-5" />
{:else if item.isUnlocked}
<UnlockIcon class="h-5 w-5" />
{:else}
<LockIcon class="h-5 w-5" />
{/if}
</Button>
{/if}
{#if !isStudentView && canRenderJoinButton}
<Button size="sm" variant="outline" class="mt-0.5" onclick={() => openExternalUrl(item.callUrl!)}>
{$t('schedule.join')}
</Button>
{/if}
{#if isStudentView && canRenderContinueButton && !isEditingItem}
{#if canRenderJoinButton}
<Button size="sm" onclick={() => openExternalUrl(item.callUrl!)}>{$t('schedule.join')}</Button>
{:else}
<a href={resolve(getItemPath(item), {})}>
<Button size="sm" variant="outline">{$t('dashboard.continue')}</Button>
</a>
{/if}
{/if}
{#if !isStudentView}
<ContentActions
isEditing={isEditingItem}
disabled={actionDisabled}
lockLabel={itemLockLabel}
showLock={false}
onEdit={() => onEdit({ lessonId: item.contentId, lessonTitle: item.title })}
onSave={() => onSave({ lessonId: item.contentId }, item)}
onCancel={() => {
item.title = prevTitle ?? item.title;
resetEdit();
}}
onDelete={() => onDelete({ lessonId: item.contentId })}
onToggleLock={() => handleToggleItemLock(item)}
/>
{/if}
</div>
</div>
{:else}
{$t('course.navItem.lessons.no_lesson')}
{/each}
</div>
</div>
{/each}
</section>
@@ -0,0 +1,44 @@
<script lang="ts">
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
interface Props {
openDeleteModal?: boolean;
deleteLesson?: any;
}
let { openDeleteModal = $bindable(false), deleteLesson = () => {} }: Props = $props();
async function handleDelete() {
deleteLesson();
openDeleteModal = false;
}
</script>
<Dialog.Root
bind:open={openDeleteModal}
onOpenChange={(isOpen) => {
if (!isOpen) openDeleteModal = false;
}}
>
<Dialog.Content class="w-96 px-6 pt-2 pb-6">
<Dialog.Header>
<Dialog.Title>{$t('delete_modal.label')}</Dialog.Title>
</Dialog.Header>
<div>
<h1 class="text-lg dark:text-white">
{$t('delete_modal.content')}?
</h1>
<div class="mt-5 flex items-center justify-between">
<Button variant="outline" onclick={() => (openDeleteModal = false)}>
{$t('delete_modal.no')}
</Button>
<Button variant="outline" onclick={handleDelete}>
{$t('delete_modal.yes')}
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,331 @@
<script lang="ts">
import { lessonDocUpload, resetDocumentUploadStore } from '../store';
import { snackbar } from '$features/ui/snackbar/store';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import FileTextIcon from '@lucide/svelte/icons/file-text';
import { DocumentUploader } from '$lib/utils/services/courses/presign';
import { onDestroy, untrack } from 'svelte';
import { UpgradeBanner, CloseButton } from '$features/ui';
import { isFreePlan } from '$lib/utils/store/org';
import { lessonApi } from '$features/course/api';
import { mediaApi } from '$features/media/api';
import type { Lesson } from '$features/course/utils/types';
import * as Dialog from '@cio/ui/base/dialog';
import * as FileDropZone from '@cio/ui/custom/file-drop-zone';
import type { FileRejectedReason } from '@cio/ui/custom/file-drop-zone';
interface Props {
lessonId?: string;
onClose?: () => void;
}
let { lessonId = '', onClose = () => {} }: Props = $props();
let selectedFile: File | null = $state(null);
let errorTimeout: NodeJS.Timeout | null = $state(null);
let isDisabled = $derived($lessonDocUpload.isUploading || $isFreePlan);
let uploadedDocument: NonNullable<Lesson['documents']>[number] | null = $state(null);
const ALLOWED_TYPES = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/msword'
];
const MAX_DOCUMENT_SIZE = 5 * 1024 * 1024; // 5MB
const documentUploader = new DocumentUploader();
function getFileType(file: File): 'pdf' | 'docx' | 'doc' {
if (file.type === 'application/pdf') return 'pdf';
if (file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') return 'docx';
if (file.type === 'application/msword') return 'doc';
return 'pdf'; // fallback
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function validateFile(file: File): string | null {
if (!ALLOWED_TYPES.includes(file.type)) {
return $t('course.navItem.lessons.materials.tabs.document.file_type_error');
}
if (file.size > MAX_DOCUMENT_SIZE) {
return $t('course.navItem.lessons.materials.tabs.document.file_size_error', {
size: formatFileSize(MAX_DOCUMENT_SIZE)
});
}
return null;
}
function selectFile(file: File) {
const error = validateFile(file);
if (error) {
snackbar.error(error);
return;
}
selectedFile = file;
$lessonDocUpload.error = null;
}
async function handleFilesUpload(files: File[]) {
const file = files[0];
if (file) selectFile(file);
}
function handleFileRejected(opts: { reason: FileRejectedReason; file: File }) {
if (opts.reason === 'Maximum file size exceeded') {
snackbar.error(
$t('course.navItem.lessons.materials.tabs.document.file_size_error', {
size: formatFileSize(MAX_DOCUMENT_SIZE)
})
);
} else if (opts.reason === 'File type not allowed') {
snackbar.error($t('course.navItem.lessons.materials.tabs.document.file_type_error'));
} else {
snackbar.error($t('course.navItem.lessons.materials.tabs.document.upload_error'));
}
}
async function uploadDocument() {
if (!selectedFile) return;
// Prevent free plan users from bypassing UI restrictions
if ($isFreePlan) {
$lessonDocUpload.error = $t('upgrade.required');
snackbar.error($lessonDocUpload.error!);
return;
}
$lessonDocUpload.isUploading = true;
try {
const { url: presignedUrl, fileKey } = await documentUploader.getPresignedUrl(selectedFile);
await documentUploader.uploadFile({
url: presignedUrl,
file: selectedFile
});
const { urls: presignedUrls } = await documentUploader.getDownloadPresignedUrl([fileKey]);
const lessonDocumentPosition = Array.isArray(lessonApi.lesson?.documents) ? lessonApi.lesson.documents.length : 0;
let assetId: string | undefined;
if (lessonId) {
const asset = await mediaApi.registerUploadedLessonDocument({
lessonId,
position: lessonDocumentPosition,
fileKey,
documentUrl: presignedUrls[fileKey],
fileName: selectedFile.name,
mimeType: selectedFile.type,
byteSize: selectedFile.size
});
assetId = asset?.id;
} else {
const asset = await mediaApi.createAsset({
kind: 'document',
provider: 'upload',
storageProvider: 's3',
storageKey: fileKey,
sourceUrl: presignedUrls[fileKey],
mimeType: selectedFile.type,
byteSize: selectedFile.size,
title: selectedFile.name,
isExternal: false,
metadata: {
fileName: selectedFile.name,
createdAt: new Date().toISOString()
}
});
assetId = asset?.id;
}
const document = {
type: getFileType(selectedFile),
name: selectedFile.name,
link: presignedUrls[fileKey],
key: fileKey,
size: selectedFile.size,
assetId
};
lessonApi.updateLessonState('documents', [document], { append: true });
uploadedDocument = document;
lessonDocUpload.update((state) => ({
...state,
uploadProgress: 100
}));
snackbar.success($t('course.navItem.lessons.materials.tabs.document.upload_success'));
// Reset after a short delay
setTimeout(() => {
resetDocumentUploadStore();
selectedFile = null;
}, 1500);
} catch (error) {
console.error('Upload error:', error);
if ($lessonDocUpload.isCancelled) {
$lessonDocUpload.error = $t('course.navItem.lessons.materials.tabs.document.upload_cancelled');
snackbar.info($t('course.navItem.lessons.materials.tabs.document.upload_cancelled'));
} else {
$lessonDocUpload.error =
error instanceof Error ? error.message : $t('course.navItem.lessons.materials.tabs.document.upload_error');
snackbar.error($t('course.navItem.lessons.materials.tabs.document.upload_error'));
}
} finally {
$lessonDocUpload.isUploading = false;
}
}
function removeSelectedFile() {
selectedFile = null;
$lessonDocUpload.error = null;
}
function cancelUpload() {
documentUploader.cancelUpload();
selectedFile = null;
}
function autoClearError(error: string | null) {
if (!error) {
return;
}
if (errorTimeout) clearTimeout(errorTimeout);
errorTimeout = setTimeout(() => {
untrack(() => {
$lessonDocUpload.error = null;
});
}, 5000);
}
// Auto-clear error after 5 seconds
$effect(() => {
autoClearError($lessonDocUpload.error);
});
// Clear error timeout when component is destroyed
onDestroy(() => {
if (errorTimeout) clearTimeout(errorTimeout);
});
</script>
<Dialog.Root
bind:open={$lessonDocUpload.isModalOpen}
onOpenChange={(isOpen) => {
if (!isOpen) onClose();
}}
>
<Dialog.Content class="w-[90%] max-w-4/5">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.materials.tabs.document.upload_title')}</Dialog.Title>
</Dialog.Header>
<UpgradeBanner className="mb-3" onClick={() => ($lessonDocUpload.isModalOpen = false)}>
{$t('course.navItem.lessons.materials.tabs.document.upgrade')}
</UpgradeBanner>
<div class="p-6">
<!-- File Upload Area -->
{#if selectedFile}
<div class="border-border mb-4 flex items-center justify-center space-x-3 rounded-lg border p-4">
<FileTextIcon class="ui:text-muted-foreground size-5 shrink-0" />
<div class="min-w-0 flex-1 text-left">
<p class="ui:text-foreground truncate font-medium">{selectedFile.name}</p>
<p class="ui:text-muted-foreground text-sm">{formatFileSize(selectedFile.size)}</p>
</div>
<CloseButton onClick={removeSelectedFile} />
</div>
{:else}
<div class={isDisabled ? 'ui:opacity-50 ui:pointer-events-none' : ''}>
<FileDropZone.Root
accept=".pdf,.docx,.doc,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/msword"
maxFiles={1}
fileCount={0}
maxFileSize={MAX_DOCUMENT_SIZE}
onUpload={handleFilesUpload}
onFileRejected={handleFileRejected}
>
<FileDropZone.Trigger
label={$t('course.navItem.lessons.materials.tabs.document.drag_drop')}
formatMaxFiles={(_count) => $t('course.navItem.lessons.materials.tabs.document.upload_description')}
formatMaxFilesAndSize={(size) => `(up to ${size})`}
formatMaxSize={(size) =>
$t('course.navItem.lessons.materials.tabs.document.upload_description') + ` (${size})`}
/>
</FileDropZone.Root>
</div>
{/if}
<!-- Error Message -->
{#if $lessonDocUpload.error}
<div class="mt-4 rounded-md border border-red-200 bg-red-50 p-3">
<p class="text-sm text-red-600">{$lessonDocUpload.error}</p>
</div>
{/if}
<!-- Upload Progress -->
{#if $lessonDocUpload.isUploading}
<div class="mt-4">
<div class="mb-1 flex justify-between text-sm text-gray-600">
<span>{$t('course.navItem.lessons.materials.tabs.document.uploading')}</span>
<span>{$lessonDocUpload.uploadProgress}%</span>
</div>
<div class="h-2 w-full rounded-full bg-gray-200">
<div
class="h-2 rounded-full bg-blue-600 transition-all duration-300"
style="width: {$lessonDocUpload.uploadProgress}%"
></div>
</div>
<div class="mt-3 flex justify-center">
<Button variant="outline" onclick={cancelUpload}>
{$t('course.navItem.lessons.materials.tabs.document.cancel_upload')}
</Button>
</div>
</div>
{/if}
<!-- Upload Button -->
{#if selectedFile && !$lessonDocUpload.isUploading}
<div class="mt-6 flex justify-end space-x-3">
<Button variant="outline" onclick={removeSelectedFile}>
{$t('course.navItem.lessons.materials.tabs.document.cancel')}
</Button>
<Button
onclick={uploadDocument}
loading={$lessonDocUpload.isUploading}
disabled={$lessonDocUpload.isUploading}
>
{$t('course.navItem.lessons.materials.tabs.document.upload_document')}
</Button>
</div>
{/if}
<!-- Success Message -->
{#if uploadedDocument}
<div class="mt-4 rounded-md border border-green-200 bg-green-50 p-3">
<p class="text-sm text-green-600">
{$t('course.navItem.lessons.materials.tabs.document.upload_success')}
</p>
</div>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,28 @@
<script lang="ts">
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { IconButton } from '@cio/ui/custom/icon-button';
import { t } from '$lib/utils/functions/translations';
interface Props {
onRemove: () => void;
}
let { onRemove }: Props = $props();
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="ui:data-[state=open]:opacity-100 absolute top-2 right-2 z-40 flex items-center justify-center opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100"
onclick={(e) => e.stopPropagation()}
>
<IconButton variant="outline">
<EllipsisVerticalIcon size={16} />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item class="ui:text-red-600" onclick={onRemove}>
{$t('course.navItem.lessons.materials.tabs.document.remove_document')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -0,0 +1,72 @@
<script lang="ts">
import { DocumentCard } from '@cio/ui';
import EyeIcon from '@lucide/svelte/icons/eye';
import DownloadIcon from '@lucide/svelte/icons/download';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import DocumentCardDropdown from './document-card-dropdown.svelte';
import type { LessonDocument } from '$features/course/utils/types';
interface Props {
document: LessonDocument;
index: number;
isEditMode: boolean;
isDownloading: boolean;
formatFileSize: (bytes: number) => string;
onRemove: () => void;
onViewPDF: (doc: LessonDocument) => void;
onDownload: (doc: LessonDocument) => Promise<void>;
}
let {
document: doc,
index: _index,
isEditMode,
isDownloading,
formatFileSize,
onRemove,
onViewPDF,
onDownload
}: Props = $props();
const subtitle = $derived(
[doc.type.toUpperCase(), doc.size != null ? formatFileSize(doc.size) : null].filter(Boolean).join(' · ') || '–'
);
</script>
<DocumentCard title={doc.name} {subtitle} class="group relative">
{#snippet header()}
{#if isEditMode}
<DocumentCardDropdown {onRemove} />
{/if}
{/snippet}
{#snippet actions()}
{#if doc.type === 'pdf'}
<Button variant="outline" size="sm" onclick={() => onViewPDF(doc)} class="gap-1.5">
<EyeIcon size={14} />
{$t('course.navItem.lessons.materials.tabs.document.view_pdf')}
</Button>
{:else}
<a
href={doc.link}
target="_blank"
rel="noopener noreferrer"
class="ui:border-input ui:bg-background ui:text-foreground hover:ui:bg-accent hover:ui:text-accent-foreground ui:shrink inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium"
>
{$t('course.navItem.lessons.materials.tabs.document.view_document')}
</a>
{/if}
<Button
variant="outline"
size="sm"
onclick={() => onDownload(doc)}
disabled={isDownloading}
class="ui:shrink! gap-1.5"
>
<DownloadIcon size={14} />
{isDownloading
? $t('course.navItem.lessons.materials.tabs.document.downloading')
: $t('course.navItem.lessons.materials.tabs.document.download')}
</Button>
{/snippet}
</DocumentCard>
@@ -0,0 +1,65 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import { Empty } from '@cio/ui/custom/empty';
import { t } from '$lib/utils/functions/translations';
import MODES from '$lib/utils/constants/mode';
import isEmpty from 'lodash/isEmpty';
import FileTextIcon from '@lucide/svelte/icons/file-text';
import * as Item from '@cio/ui/base/item';
import DocumentCard from './document-card.svelte';
import type { LessonDocument } from '$features/course/utils/types';
interface Props {
mode?: (typeof MODES)[keyof typeof MODES];
displayDocuments?: LessonDocument[];
downloadingDocuments: Set<string>;
formatFileSize: (bytes: number) => string;
openDocumentUploadModal: () => void;
requestRemoveDocument: (index: number) => void;
onViewPDF: (doc: LessonDocument) => void;
downloadDocument: (doc: LessonDocument) => Promise<void>;
}
let {
mode = MODES.view,
displayDocuments = [],
downloadingDocuments,
formatFileSize,
openDocumentUploadModal,
requestRemoveDocument,
onViewPDF,
downloadDocument
}: Props = $props();
</script>
{#if mode === MODES.edit}
<div class="flex justify-end">
<Button onclick={openDocumentUploadModal} class="my-4!">
{$t('course.navItem.lessons.materials.tabs.document.add_document')}
</Button>
</div>
{/if}
{#if !isEmpty(displayDocuments)}
{@const isEditMode = mode === MODES.edit}
<Item.Group class="flex w-full gap-4">
{#each displayDocuments as document, index}
<DocumentCard
{document}
{index}
{isEditMode}
isDownloading={downloadingDocuments.has(document.name)}
{formatFileSize}
onRemove={() => (isEditMode ? requestRemoveDocument(index) : undefined)}
{onViewPDF}
onDownload={downloadDocument}
/>
{/each}
</Item.Group>
{:else if mode === MODES.edit}
<Empty
title={$t('course.navItem.lessons.materials.tabs.document.empty_title')}
description={$t('course.navItem.lessons.materials.tabs.document.empty_description')}
icon={FileTextIcon}
/>
{/if}
@@ -0,0 +1,453 @@
<script lang="ts">
import { lessonApi } from '$features/course/api';
import { CloseButton, DeleteModal } from '$features/ui';
import { lessonDocUpload } from '$features/course/components/lesson/store';
import MODES from '$lib/utils/constants/mode';
import { IconButton } from '@cio/ui/custom/icon-button';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import ZoomInIcon from '@lucide/svelte/icons/zoom-in';
import ZoomOutIcon from '@lucide/svelte/icons/zoom-out';
import { onMount } from 'svelte';
import DocumentList from './document-list.svelte';
import { t } from '$lib/utils/functions/translations';
import type { LessonDocument } from '$features/course/utils/types';
import { snackbar } from '$features/ui/snackbar/store';
interface Props {
mode?: (typeof MODES)[keyof typeof MODES];
}
let { mode = MODES.view }: Props = $props();
let downloadingDocuments = $state(new Set<string>());
let openDeleteDocumentModal = $state(false);
let documentIndexToDelete = $state<number | null>(null);
let viewingPDF: any = $state(null);
let pdfViewerOpen = $state(false);
let pdfCanvas: HTMLCanvasElement | undefined = $state();
let pdfDoc: any = null;
let pageNum = $state(1);
let pageCount = $state(0);
let scale = $state(1.0);
let isLoading = $state(false);
let error: string | null = $state(null);
let pdfjsLib: any = null;
let renderTimeout: any = null;
let currentRenderTask: any = null;
onMount(() => {
// Load PDF.js dynamically
const script = document.createElement('script');
script.src = '/js/pdf.js/pdf.min.js';
script.onload = () => {
pdfjsLib = (window as any).pdfjsLib;
pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/pdf.js/pdf.worker.min.js';
};
document.head.appendChild(script);
return () => {
if (script.parentNode) {
script.parentNode.removeChild(script);
}
};
});
function openDocumentUploadModal() {
$lessonDocUpload.isModalOpen = true;
}
function deleteDocument(index: number) {
void lessonApi.deleteLessonDocument(index);
}
function requestRemoveDocument(index: number) {
documentIndexToDelete = index;
openDeleteDocumentModal = true;
}
function confirmRemoveDocument() {
if (documentIndexToDelete !== null) {
deleteDocument(documentIndexToDelete);
documentIndexToDelete = null;
}
openDeleteDocumentModal = false;
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
async function downloadDocument(doc: LessonDocument) {
downloadingDocuments.add(doc.name);
downloadingDocuments = downloadingDocuments;
if (!doc.key) {
snackbar.error('Document not loaded correctly');
return;
}
try {
const response = await fetch(doc.link);
if (!response.ok) {
throw new Error('Failed to fetch document');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = doc.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Error downloading document:', error);
window.open(doc.link, '_blank');
} finally {
downloadingDocuments.delete(doc.name);
downloadingDocuments = downloadingDocuments;
}
}
function debouncedRender() {
if (renderTimeout) {
clearTimeout(renderTimeout);
}
renderTimeout = setTimeout(() => {
renderPage();
renderTimeout = null;
}, 150); // 150ms debounce
}
// Wait for PDF.js to load
const checkPDFJS = () => {
return new Promise((resolve) => {
const check = () => {
if (pdfjsLib) {
resolve(true);
} else {
setTimeout(check, 100);
}
};
check();
});
};
async function viewPDF(document: LessonDocument) {
// Wait for PDF.js to be fully loaded
if (!pdfjsLib) {
// Show loading state while waiting for PDF.js
viewingPDF = document;
pdfViewerOpen = true;
isLoading = true;
error = 'Loading PDF viewer...';
await checkPDFJS();
}
viewingPDF = document;
pdfViewerOpen = true;
isLoading = true;
error = null;
try {
if (!document.key) {
snackbar.error('Document not loaded correctly');
return;
}
const response = await fetch(document.link);
if (!response.ok) {
throw new Error('Failed to fetch PDF');
}
const arrayBuffer = await response.arrayBuffer();
pdfDoc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
pageCount = pdfDoc.numPages;
pageNum = 1;
scale = 1.0;
isLoading = false;
// Wait for the canvas to be available in the DOM
await new Promise((resolve) => setTimeout(resolve, 100));
await renderPage();
} catch (err) {
console.error('Error loading PDF:', err);
error = 'course.navItem.lessons.materials.tabs.document.failed_to_load_pdf';
isLoading = false;
}
}
async function renderPage() {
if (!pdfDoc) return;
// Wait for canvas to be available
let attempts = 0;
while (!pdfCanvas && attempts < 50) {
await new Promise((resolve) => setTimeout(resolve, 100));
attempts++;
}
if (!pdfCanvas) {
console.error('Canvas not available after waiting');
return;
}
try {
// Cancel any ongoing render task
if (currentRenderTask) {
currentRenderTask.cancel();
}
const page = await pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale });
pdfCanvas.height = viewport.height;
pdfCanvas.width = viewport.width;
const ctx = pdfCanvas.getContext('2d');
const renderContext = {
canvasContext: ctx,
viewport: viewport
};
currentRenderTask = page.render(renderContext);
await currentRenderTask.promise;
currentRenderTask = null;
} catch (err) {
console.error('Error rendering page:', err);
error = 'course.navItem.lessons.materials.tabs.document.failed_to_render_pdf';
currentRenderTask = null;
}
}
function nextPage() {
if (pageNum < pageCount) {
pageNum++;
debouncedRender();
}
}
function prevPage() {
if (pageNum > 1) {
pageNum--;
debouncedRender();
}
}
function zoomIn() {
scale = Math.min(scale + 0.25, 3.0);
debouncedRender();
}
function zoomOut() {
scale = Math.max(scale - 0.25, 0.5);
debouncedRender();
}
function handleViewPDF(doc: LessonDocument) {
viewPDF(doc);
}
function closePDFViewer() {
// Cancel any ongoing render task
if (currentRenderTask) {
currentRenderTask.cancel();
}
// Clear any pending render timeout
if (renderTimeout) {
clearTimeout(renderTimeout);
}
pdfViewerOpen = false;
viewingPDF = null;
pdfDoc = null;
pageNum = 1;
pageCount = 0;
scale = 1.0;
error = null;
currentRenderTask = null;
renderTimeout = null;
}
function handleKeydown(event: KeyboardEvent) {
if (!pdfViewerOpen) return;
switch (event.key) {
case 'ArrowLeft':
prevPage();
break;
case 'ArrowRight':
nextPage();
break;
case '+':
case '=':
zoomIn();
break;
case '-':
zoomOut();
break;
case 'Escape':
closePDFViewer();
break;
}
}
onMount(() => {
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
};
});
function handleContextMenu(event: MouseEvent) {
event.preventDefault();
}
function handleDragStart(event: DragEvent) {
event.preventDefault();
}
let displayDocuments = $derived(lessonApi.lesson?.documents || []);
</script>
<DocumentList
{mode}
{displayDocuments}
{downloadingDocuments}
{formatFileSize}
{openDocumentUploadModal}
{requestRemoveDocument}
onViewPDF={handleViewPDF}
{downloadDocument}
/>
<DeleteModal bind:open={openDeleteDocumentModal} onDelete={confirmRemoveDocument} />
<!-- PDF Viewer Modal -->
{#if pdfViewerOpen}
<div class="fixed inset-0 z-100 flex flex-col bg-white dark:bg-neutral-800">
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-200 bg-white px-4 py-3 dark:bg-neutral-800">
<div class="flex items-center space-x-4">
<h2 class="max-w-md truncate text-lg font-semibold text-gray-900 dark:text-gray-300">
{viewingPDF?.name}
</h2>
{#if !isLoading && !error}
<span class="text-sm text-gray-500">
{$t('course.navItem.lessons.materials.tabs.document.page_of', {
page: pageNum,
total: pageCount
})}
</span>
{/if}
</div>
<div class="flex items-center space-x-2">
<!-- Navigation Controls -->
{#if !isLoading && !error}
<div class="flex items-center space-x-1">
<IconButton onclick={prevPage} disabled={pageNum <= 1} tooltip="Previous page (←)">
<ChevronLeftIcon size={16} />
</IconButton>
<IconButton onclick={nextPage} disabled={pageNum >= pageCount} tooltip="Next page (→)">
<ChevronRightIcon size={16} />
</IconButton>
</div>
<!-- Zoom Controls -->
<div class="flex items-center space-x-1">
<IconButton onclick={zoomOut} disabled={scale <= 0.5} tooltip="Zoom out (-)">
<ZoomOutIcon size={16} />
</IconButton>
<span class="min-w-12 text-center text-sm text-gray-600">
{Math.round(scale * 100)}%
</span>
<IconButton onclick={zoomIn} disabled={scale >= 3.0} tooltip="Zoom in (+)">
<ZoomInIcon size={16} />
</IconButton>
</div>
{/if}
<CloseButton onClick={closePDFViewer} tooltip="Close (Esc)" />
</div>
</div>
<!-- PDF Content -->
<div class="flex-1 overflow-auto bg-gray-100 p-4">
{#if isLoading}
<div class="flex h-full items-center justify-center">
<div class="text-center">
<div
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent"
></div>
<p class="text-gray-600">
{$t('course.navItem.lessons.materials.tabs.document.loading_pdf')}
</p>
</div>
</div>
{:else if error}
<div class="flex h-full items-center justify-center">
<div class="text-center">
<div class="mb-4 text-red-500">
<svg class="mx-auto h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<p class="mb-2 text-red-600">{$t(error)}</p>
<button onclick={() => viewPDF(viewingPDF)} class="text-blue-600 underline hover:text-blue-800">
{$t('course.navItem.lessons.materials.tabs.document.try_again')}
</button>
</div>
</div>
{:else}
<div class="flex justify-center">
<canvas
bind:this={pdfCanvas}
oncontextmenu={handleContextMenu}
ondragstart={handleDragStart}
class="shadow-lg"
></canvas>
</div>
{/if}
</div>
<!-- Footer with instructions -->
{#if !isLoading && !error}
<div class="border-t border-gray-200 bg-gray-50 px-4 py-2">
<p class="text-center text-xs text-gray-500">
{$t('course.navItem.lessons.materials.tabs.document.use_arrow_keys')}
</p>
</div>
{/if}
</div>
{/if}
<style>
/* Disable text selection on the canvas */
canvas {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* Disable drag and drop */
canvas {
pointer-events: auto;
}
</style>
@@ -0,0 +1,11 @@
export { default as ContentNavigationActions } from './content-navigation-actions.svelte';
export { default as LanguageSelector } from './language-selector.svelte';
export { default as LessonPageEditHeader } from './lesson-page-edit-header.svelte';
export { default as Loader } from './loader.svelte';
export { default as Comments } from './comments.svelte';
export { default as Note } from './note/note.svelte';
export { default as Slide } from './slide/slide.svelte';
export { default as Video } from './video/video.svelte';
export { default as Document } from './document/document.svelte';
export { default as AddVideoModal } from './video/add-video-modal.svelte';
export { default as AddDocumentModal } from './document/add-document-modal.svelte';
@@ -0,0 +1,18 @@
<script lang="ts">
import * as Select from '@cio/ui/base/select';
import { LANGUAGES } from '$lib/utils/constants/translation';
import { lessonApi } from '$features/course/api';
</script>
<Select.Root type="single" bind:value={lessonApi.currentLocale}>
<Select.Trigger class="h-9 w-[120px]">
{LANGUAGES.find((lang) => lang.id === lessonApi.currentLocale)?.text || 'Language'}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each LANGUAGES as lang (lang.id)}
<Select.Item value={lang.id}>{lang.text}</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
@@ -0,0 +1,86 @@
<script lang="ts">
import LockIcon from '@lucide/svelte/icons/lock';
import { Spinner } from '@cio/ui/base/spinner';
import UnlockIcon from '@lucide/svelte/icons/lock-open';
import TrashIcon from '@lucide/svelte/icons/trash';
import { IconButton } from '@cio/ui/custom/icon-button';
import { InputField } from '@cio/ui/custom/input-field';
import * as Page from '@cio/ui/base/page';
import MODES from '$lib/utils/constants/mode';
import { globalStore } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import { RoleBasedSecurity } from '$features/ui';
import DeleteLessonConfirmation from '$features/course/components/lesson/delete-lesson-confirmation.svelte';
interface Props {
mode: (typeof MODES)[keyof typeof MODES];
title: string;
isUnlocked?: boolean | null;
isDeletingLesson?: boolean;
onTitleChange: (value: string) => void;
onToggleLock: () => void;
onDeleteLesson: () => Promise<void> | void;
}
let {
mode,
title,
isUnlocked = false,
isDeletingLesson = false,
onTitleChange,
onToggleLock,
onDeleteLesson
}: Props = $props();
let openDeleteModal = $state(false);
const lockLabel = $derived(
isUnlocked ? $t('course.navItem.lessons.add_lesson.lock') : $t('course.navItem.lessons.add_lesson.unlock')
);
</script>
<div class="flex w-full items-center justify-between gap-2">
<div class="min-w-0 flex-1">
{#if mode === MODES.edit && !$globalStore.isStudent}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<InputField
className="max-w-xl"
value={title}
placeholder={$t('course.navItem.lessons.add_lesson.lesson_title')}
onInputChange={(e) => onTitleChange(e.currentTarget.value)}
/>
</RoleBasedSecurity>
{:else}
<Page.Title>{title}</Page.Title>
{/if}
</div>
{#if mode === MODES.edit && !$globalStore.isStudent}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="flex items-center gap-2">
<IconButton onclick={onToggleLock} aria-label={lockLabel} title={lockLabel}>
{#if isUnlocked}
<UnlockIcon size={18} />
{:else}
<LockIcon size={18} />
{/if}
</IconButton>
<IconButton
onclick={() => (openDeleteModal = true)}
disabled={isDeletingLesson}
aria-label={$t('course.navItem.lessons.add_lesson.delete')}
title={$t('course.navItem.lessons.add_lesson.delete')}
>
{#if isDeletingLesson}
<Spinner class="h-4 w-4" />
{:else}
<TrashIcon size={18} />
{/if}
</IconButton>
</div>
</RoleBasedSecurity>
{/if}
</div>
<DeleteLessonConfirmation bind:openDeleteModal deleteLesson={onDeleteLesson} />
@@ -0,0 +1,279 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { createEventDispatcher } from 'svelte';
import { HTMLRender } from '$features/ui';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import { Button } from '@cio/ui/base/button';
import PlayIcon from '@lucide/svelte/icons/play';
import { lessonApi } from '$features/course/api';
import { diffLines } from 'diff';
import { courseApi } from '$features/course/api';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import type { TLocale } from '@cio/db/types';
interface LessonHistory {
new_content: string;
old_content: string;
timestamp: Date;
locale: TLocale;
lesson_id: string;
}
interface Props {
open?: boolean;
}
let { open = false }: Props = $props();
let lessonHistory: LessonHistory[] = $state([]);
let content = '';
let selectedVersion: LessonHistory = {
new_content: '',
old_content: '',
timestamp: new Date(),
locale: 'en',
lesson_id: ''
};
let selectedVersionIndex = $state(0);
let contentRestoreLoading = $state(false);
let versionsToFetch = $state(9);
let isMoreHistoryLoading = $state(false);
let mounted = false;
const lessonId = $derived(lessonApi.lesson?.id || '');
const lessonTitle = $derived(lessonApi.lesson?.title || '');
const dispatch = createEventDispatcher();
function scrollLock(open) {
if (mounted) {
const body = document.querySelector('body');
if (!body) return;
body.style.overflow = open ? 'hidden' : 'auto';
}
}
function formatTimestamp(timestamp) {
const options: Intl.DateTimeFormatOptions = {
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
};
const date = new Date(timestamp + 'Z');
return new Intl.DateTimeFormat('en-US', options).format(date);
}
function handleDrawerClose() {
dispatch('close');
}
function getMinutes(time: Date) {
return new Date(time).getMinutes();
}
function removeDuplicate(history: LessonHistory[]) {
return history.filter(
(obj1, i, arr) => arr.findIndex((obj2) => getMinutes(obj2.timestamp) === getMinutes(obj1.timestamp)) === i
);
}
function fetchLessonHistory(lessonId: string, locale: string, endRange: number) {
untrack(async () => {
try {
isMoreHistoryLoading = true;
if (!courseApi.course?.id) {
throw new Error('Course ID not available');
}
const response = await lessonApi.getHistory(courseApi.course?.id, lessonId, locale, endRange);
if (!response || !lessonApi.success || !response.data) {
throw new Error('Failed to fetch lesson history');
}
const data = response.data;
// Filter out duplicates based on timestamp
const existingTimestamps = new Set(lessonHistory.map((item) => new Date(item.timestamp).getMinutes()));
const newEntries = data.filter((item) => !existingTimestamps.has(new Date(item.timestamp!).getMinutes()));
lessonHistory = removeDuplicate([...lessonHistory, ...newEntries]);
if (lessonHistory.length > 0) {
updateContentVersion(lessonHistory[0], 0);
}
} catch (error) {
console.error(error);
snackbar.error('Failed to fetch history');
} finally {
isMoreHistoryLoading = false;
}
});
}
onMount(() => {
mounted = true;
scrollLock(open);
});
function updateContentVersion(content: LessonHistory, index: number) {
selectedVersionIndex = index;
selectedVersion = content;
const display = document.getElementById('display');
display!.innerHTML = '';
const diff = diffLines(content.old_content, content.new_content);
const fragment = document.createDocumentFragment();
diff.forEach((part) => {
const span = document.createElement('span');
if (part.added) {
span.classList.add('text-green-500');
} else if (part.removed) {
span.classList.add('text-red-500', 'line-through');
} else {
span.classList.add('text-black', 'dark:text-white');
}
span.innerHTML = part.value;
fragment.appendChild(span);
});
display!.appendChild(fragment);
}
async function restoreSelectedVersion() {
try {
contentRestoreLoading = true;
if (!courseApi.course?.id || !selectedVersion) return;
await lessonApi.upsertLanguage(
courseApi.course.id,
selectedVersion.lesson_id,
selectedVersion.locale,
selectedVersion.new_content
);
} catch (error) {
console.error(error);
snackbar.error('Failed to restore');
} finally {
contentRestoreLoading = false;
dispatch('restore');
}
}
function loadMoreHistory() {
versionsToFetch += 10;
}
$effect(() => {
scrollLock(open);
});
$effect(() => {
fetchLessonHistory(lessonId, lessonApi.currentLocale, versionsToFetch);
});
</script>
<aside class="drawer bg-gray-100 dark:bg-neutral-800" class:open>
<div class="panel bg-white dark:bg-black">
<div class="w-full p-10 pr-80">
<div class="flex items-start gap-x-10">
<Button variant="outline" onclick={handleDrawerClose}>
<ArrowLeftIcon size={16} />
</Button>
{#if selectedVersionIndex != 0}
<div class="">
<Button loading={contentRestoreLoading} onclick={restoreSelectedVersion}>
{$t('course.navItem.lessons.version_history.restore_version')}
</Button>
>
</div>
{/if}
</div>
<div class="flex h-full w-full flex-col items-start">
<HTMLRender className="m-auto text-center mt-6 flex items-center justify-center">
<h1 class="mt-0 text-2xl capitalize md:text-4xl">
{lessonTitle}
</h1>
</HTMLRender>
{#key lessonId}
<HTMLRender id="display" className="m-auto">
<div class="amen">
{@html sanitizeHtml(content)}
</div>
</HTMLRender>
{/key}
</div>
</div>
</div>
<div
id="scroll-container"
class="fixed top-0 right-0 z-10 h-full min-h-screen w-80 space-y-6 overflow-x-auto overflow-y-scroll bg-gray-100 py-10 dark:bg-neutral-800"
>
<p class="flex items-start justify-start px-10 text-left text-xl font-medium">
{$t('course.navItem.lessons.version_history.title')}
</p>
<div>
{#each lessonHistory as version, index}
<button
onclick={() => updateContentVersion(version, index)}
class="flex w-full cursor-pointer items-start p-4 px-10 hover:bg-gray-200 dark:hover:bg-neutral-700 {index ==
selectedVersionIndex
? 'bg-gray-200 dark:bg-neutral-700'
: ''}"
>
<PlayIcon size={16} class="mt-1" />
<div>
<span class="inline-block text-base font-medium">{formatTimestamp(version.timestamp)}</span>
{#if index == 0}
<span class="block text-start text-xs italic"
>{$t('course.navItem.lessons.version_history.current_version')}</span
>
{/if}
</div>
</button>
{/each}
<div class="mt-2 flex h-10 items-center justify-start px-10">
<Button loading={isMoreHistoryLoading} onclick={loadMoreHistory}>
{$t('course.navItem.lessons.version_history.fetch_more_versions')}
</Button>
>
</div>
</div>
</div>
</aside>
<style>
.drawer {
position: fixed;
top: 0;
left: 0;
height: 100%;
width: 100%;
z-index: -1;
}
.drawer.open {
z-index: 99;
}
.panel {
position: fixed;
width: 100%;
height: 100%;
z-index: 3;
overflow: auto;
}
.drawer.open .panel {
transform: translate(0, 0);
}
</style>
@@ -0,0 +1,12 @@
<script lang="ts">
import { Skeleton } from '@cio/ui/base/skeleton';
</script>
<div class="w-full">
<Skeleton class="h-40 w-full" />
<div class="my-3">
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-full" />
</div>
</div>
@@ -0,0 +1,80 @@
<script lang="ts">
import { HTMLRender, TextEditor } from '$features/ui';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import { lessonApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
import MODES from '$lib/utils/constants/mode';
import type { Content } from '@cio/ui/custom/editor';
import type { TLocale } from '@cio/db/types';
import AIButton from '$features/course/components/lesson/ai-button.svelte';
import type { Writable } from 'svelte/store';
interface Props {
mode?: (typeof MODES)[keyof typeof MODES];
lessonId?: string;
isLoading?: Writable<boolean>;
callAI?: (type: string) => void;
}
let { mode = MODES.view, lessonId = '', isLoading, callAI = () => {} }: Props = $props();
let hasAtLeastOneTranslation = $derived(
Object.values(lessonApi.translations[lessonId] || {}).some((content) => {
return content && !!content.length;
})
);
function onEditorChange(content: Content) {
if (mode === MODES.view) return;
if (!lessonApi.translations[lessonId]) {
lessonApi.translations[lessonId] = {} as Record<TLocale, string>;
}
lessonApi.translations[lessonId][lessonApi.currentLocale] = `${content}`;
try {
localStorage.setItem(`lesson-${lessonId}-${lessonApi.currentLocale}`, `${content}`);
} catch (error) {
console.error('Error saving lesson note to localStorage', error);
}
lessonApi.isDirty = true;
}
const content = $derived(lessonApi.translations[lessonId]?.[lessonApi.currentLocale] || '');
$effect(() => {
console.log('content', content);
});
$effect(() => {
console.log('lessonApi.currentLocale', lessonApi.currentLocale);
});
</script>
{#if mode === MODES.edit}
<!-- AI Button -->
<div class="flex justify-end gap-1">
<AIButton {isLoading} {callAI} />
</div>
<!-- End AI Button -->
<div class="mt-5 h-[60vh]">
<TextEditor
{content}
onChange={(content) => onEditorChange(content)}
placeholder={$t('course.navItem.lessons.materials.tabs.note.placeholder')}
/>
</div>
{:else}
<!-- View Mode -->
{#if !isHtmlValueEmpty(content)}
<HTMLRender>
{@html sanitizeHtml(content)}
</HTMLRender>
{:else if hasAtLeastOneTranslation}
<p class="text-md py-2 font-normal italic dark:text-white">
{$t('course.navItem.lessons.materials.no_translation')}
</p>
{/if}
{/if}
@@ -0,0 +1,100 @@
<script lang="ts">
import PlusIcon from '@lucide/svelte/icons/plus';
import { IconButton } from '@cio/ui/custom/icon-button';
import { InputField } from '@cio/ui/custom/input-field';
import { RoleBasedSecurity } from '$features/ui';
import CourseContentIcon from '$features/course/components/course-content-icon.svelte';
import type { CourseContent } from '$features/course/utils/types';
import { ContentType } from '@cio/utils/constants/content';
import ContentActions from './content-actions.svelte';
import ContentCountBadges from '../content-count-badges.svelte';
type ContentSection = CourseContent['sections'][number];
const sectionCount = $derived.by(() => {
const items = section.items ?? [];
return {
lessons: items.filter((item) => item.type === ContentType.Lesson).length,
exercises: items.filter((item) => item.type === ContentType.Exercise).length
};
});
interface Props {
section: ContentSection;
isEditing?: boolean;
disabled?: boolean;
isSaving?: boolean;
showActions?: boolean;
showAddContent?: boolean;
showDelete?: boolean;
errors?: Record<string, string>;
lockLabel?: string;
onEdit: () => void;
onSave: () => void;
onCancel: () => void;
onAddContent: () => void;
onToggleLock?: () => void;
onDelete: () => void;
}
let {
section,
isEditing = false,
disabled = false,
isSaving = false,
showActions = true,
showAddContent = true,
showDelete = true,
errors = {},
lockLabel,
onEdit,
onSave,
onCancel,
onAddContent,
onToggleLock,
onDelete
}: Props = $props();
</script>
<div
class="ui:bg-secondary flex min-h-[50px] items-center justify-between rounded-tl-md rounded-tr-md border-b px-3 dark:bg-neutral-700"
>
{#if isEditing}
<div class="flex w-4/6 items-center gap-2">
<CourseContentIcon type={ContentType.Section} size={16} />
<InputField className="flex-1" bind:value={section.title} errorMessage={errors?.title} />
</div>
{:else}
<div class="flex w-4/6 items-center gap-2">
<CourseContentIcon type={ContentType.Section} size={16} />
<p class="font-semibold">{section.title}</p>
<ContentCountBadges lessons={sectionCount.lessons} exercises={sectionCount.exercises} />
</div>
{/if}
{#if showActions}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="flex items-center gap-1">
{#if !isEditing && showAddContent}
<IconButton variant="outline" onclick={onAddContent} {disabled}>
<PlusIcon size={16} />
</IconButton>
{/if}
<ContentActions
{disabled}
{isEditing}
{isSaving}
lockLabel={lockLabel || ''}
showLock={Boolean(lockLabel && onToggleLock)}
{showDelete}
{onEdit}
{onSave}
{onCancel}
{onDelete}
onToggleLock={() => onToggleLock?.()}
/>
</div>
</RoleBasedSecurity>
{/if}
</div>
@@ -0,0 +1,53 @@
<script lang="ts">
import { lessonApi } from '$features/course/api';
import { InputField } from '@cio/ui/custom/input-field';
import { t } from '$lib/utils/functions/translations';
import MODES from '$lib/utils/constants/mode';
interface Props {
mode?: (typeof MODES)[keyof typeof MODES];
}
let { mode = MODES.view }: Props = $props();
let url = $derived(getUrl(lessonApi.lesson?.slideUrl || ''));
function canvaHandler(_url): string {
if (_url.includes('?embed')) return _url;
return `${_url}?embed`;
}
function getUrl(_url: string | null): string | undefined {
if (!_url) return;
if (_url.includes('www.canva.com')) {
return canvaHandler(_url);
}
return _url;
}
</script>
{#if mode === MODES.edit}
<!-- Edit Mode -->
{@const slideUrlValue = lessonApi.lesson?.slideUrl || ''}
<InputField
label={$t('course.navItem.lessons.materials.tabs.slide.slide_link')}
value={slideUrlValue}
onInputChange={(e) => {
lessonApi.updateLessonState('slideUrl', e.currentTarget.value);
}}
helperMessage={$t('course.navItem.lessons.materials.tabs.slide.helper_message')}
/>
{:else}
<!-- View Mode -->
{#if url}
<iframe
title="Embeded Slides"
src={url}
frameborder="0"
width="100%"
height="569"
class="iframe my-3"
allowfullscreen={true}
></iframe>
{/if}
{/if}
@@ -0,0 +1,127 @@
import type { CourseSection, Lesson, ListLessons } from '$features/course/utils/types';
import { get, writable } from 'svelte/store';
import type { Course } from '$features/course/types';
import { courseApi } from '$features/course/api';
import { lessonApi } from '$features/course/api';
import { lessonValidation } from '$lib/utils/functions/validator';
import { profile } from '$lib/utils/store/user';
import { snackbar } from '$features/ui/snackbar/store';
export const lessonVideoUpload = writable({
error: null as string | null,
isCancelled: false,
isModalOpen: false,
isUploading: false,
uploadProgress: 0
});
export const lessonDocUpload = writable({
error: null as string | null,
isCancelled: false,
isModalOpen: false,
isUploading: false,
uploadProgress: 0
});
export async function handleDelete(lessonId: Lesson['id'] | undefined) {
// Need to implement soft delete
if (!lessonId) {
return;
}
const courseId = courseApi.course?.id;
if (!courseId) return;
await lessonApi.delete(courseId, lessonId);
if (lessonApi.success) {
const profileId = get(profile)?.id;
if (profileId) {
await courseApi.refreshCourse(courseId, profileId);
}
snackbar.success('snackbar.generic.success_delete');
}
}
export async function handleSaveLesson(lesson: Partial<ListLessons[number]>, courseId: Course['id']) {
const result = lessonValidation(lesson);
if (Object.keys(result).length) {
return { validationRes: result, lesson: null };
}
const lessonData = {
title: lesson.title || '',
lessonAt: lesson.lessonAt!,
callUrl: lesson.callUrl!,
teacherId: lesson.teacherId!,
isUnlocked: lesson.isUnlocked!,
sectionId: lesson.sectionId!
};
if (lesson.id) {
await lessonApi.update(courseId, lesson.id, lessonData);
if (lessonApi.success && lessonApi.lesson) {
return { lesson: lessonApi.lesson as Lesson };
}
} else {
await lessonApi.create(courseId, { ...lessonData, courseId });
if (lessonApi.success && lessonApi.lesson) {
return { lesson: lessonApi.lesson as Lesson };
}
}
return { validationRes: null, lesson: null };
}
export async function handleSaveCourseSection(section: Partial<CourseSection>, courseId: Course['id']) {
const result = lessonValidation(section);
if (Object.keys(result).length) {
return result;
}
const sectionData = {
title: section.title!
};
if (section.id) {
await lessonApi.updateSection(courseId, section.id, sectionData);
if (lessonApi.success && lessonApi.lesson) {
return [lessonApi.lesson];
}
} else {
await lessonApi.createSection(courseId, { ...sectionData, courseId });
if (lessonApi.success && lessonApi.lesson) {
return [lessonApi.lesson];
}
}
return null;
}
export function resetDocumentUploadStore() {
lessonDocUpload.set({
isUploading: false,
isModalOpen: false,
uploadProgress: 0,
error: null,
isCancelled: false
});
}
export function cancelDocumentUpload() {
lessonDocUpload.update((store) => ({
...store,
isCancelled: true,
isUploading: false,
error: 'Upload cancelled by user'
}));
}
export function cancelVideoUpload() {
lessonVideoUpload.update((store) => ({
...store,
isCancelled: true,
isUploading: false
}));
}
@@ -0,0 +1,25 @@
import * as CONSTANTS from './constants';
import type { Component } from 'svelte';
import Document from './document/document.svelte';
import Note from './note/note.svelte';
import Slide from './slide/slide.svelte';
import Video from './video/video.svelte';
export function getViewModeComponents(tabs = CONSTANTS.tabs): Component[] {
const componentMap: Record<number, Component> = {
1: Note,
2: Slide,
3: Video,
4: Document
};
const componentNames = tabs
.map((tab) => {
const component = componentMap[tab.value];
return component || null;
})
.filter((component): component is Component => component !== null);
return componentNames;
}
@@ -0,0 +1,62 @@
<script lang="ts">
import YoutubeVideo from './youtube-video.svelte';
import UploadVideo from './upload-video.svelte';
import EmbedLink from './embed-link.svelte';
import LibraryVideo from './library-video.svelte';
import * as CONSTANTS from './constants';
import { t } from '$lib/utils/functions/translations';
import { isFreePlan } from '$lib/utils/store/org';
import ZapIcon from '@lucide/svelte/icons/zap';
import * as Dialog from '@cio/ui/base/dialog';
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
import { lessonVideoUpload } from '$features/course/components/lesson/store';
interface Props {
lessonId?: string;
onClose?: () => void;
}
let { lessonId = '', onClose = () => {} }: Props = $props();
const tabs = CONSTANTS.videoTabs;
let currentTab = $state(String(tabs[0].value));
</script>
<Dialog.Root
bind:open={$lessonVideoUpload.isModalOpen}
onOpenChange={(isOpen) => {
if (!isOpen) onClose();
}}
>
<Dialog.Content class="flex max-h-[680px] max-w-2xl! flex-col overflow-hidden">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.materials.tabs.video.add_video.title')}</Dialog.Title>
</Dialog.Header>
<UnderlineTabs.Root bind:value={currentTab} class="">
<UnderlineTabs.List>
{#each tabs as item (item.value)}
<UnderlineTabs.Trigger value={String(item.value)} class="ui:flex ui:items-center ui:gap-2">
{#if $isFreePlan && item.value === 3}
<ZapIcon size={16} class="filled" />
{:else}
<item.icon />
{/if}
<span>{$t(item.title)}</span>
</UnderlineTabs.Trigger>
{/each}
</UnderlineTabs.List>
<UnderlineTabs.Content value="1" class="mt-3">
<YoutubeVideo {lessonId} />
</UnderlineTabs.Content>
<UnderlineTabs.Content value="2" class="mt-3">
<EmbedLink {lessonId} />
</UnderlineTabs.Content>
<UnderlineTabs.Content value="3" class="mt-3">
<UploadVideo {lessonId} />
</UnderlineTabs.Content>
<UnderlineTabs.Content value="4" class="mt-3">
<LibraryVideo {lessonId} />
</UnderlineTabs.Content>
</UnderlineTabs.Root>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,27 @@
import FileSymlinkIcon from '@lucide/svelte/icons/file-symlink';
import LibraryIcon from '@lucide/svelte/icons/library';
import VideoIcon from '@lucide/svelte/icons/video';
import YoutubeIcon from '@lucide/svelte/icons/youtube';
export const videoTabs = [
{
value: 1,
title: 'course.navItem.lessons.materials.tabs.video.add_video.youtube_link',
icon: YoutubeIcon
},
{
value: 2,
title: 'course.navItem.lessons.materials.tabs.video.embed_link',
icon: FileSymlinkIcon
},
{
value: 3,
title: 'course.navItem.lessons.materials.tabs.video.upload',
icon: VideoIcon
},
{
value: 4,
title: 'course.navItem.lessons.materials.tabs.video.library',
icon: LibraryIcon
}
];
@@ -0,0 +1,125 @@
<script lang="ts">
import { Badge } from '@cio/ui/base/badge';
import CopyIcon from '@lucide/svelte/icons/copy';
import TrashIcon from '@lucide/svelte/icons/trash';
import { t } from '$lib/utils/functions/translations';
import type { LessonVideoType } from '$features/course/utils/types';
import { lessonApi } from '$features/course/api';
import { mediaApi } from '$features/media/api';
import { copyToClipboard, getVideoUrls, removeVideo } from '$lib/utils/functions/formatYoutubeVideo';
import { IconButton } from '@cio/ui/custom/icon-button';
import { InputField } from '@cio/ui/custom/input-field';
import { Button } from '@cio/ui/base/button';
interface Props {
lessonId?: string;
}
let { lessonId = '' }: Props = $props();
let genericLinks = $state('');
let error = $state('');
async function addVideo() {
const links = getVideoUrls(genericLinks);
const validLinks = links.filter(isValidLink);
if (validLinks.length === 0) {
error = $t('course.navItem.lessons.materials.tabs.video.add_video.invalid_link');
} else {
if (!lessonApi.lesson) return;
const existingCount = Array.isArray(lessonApi.lesson.videos) ? lessonApi.lesson.videos.length : 0;
const newVideos = await Promise.all(
validLinks.map(async (link = '', index) => {
const createdAt = new Date().toISOString();
const asset = await mediaApi.createAsset({
kind: 'video',
provider: 'generic',
storageProvider: 'external',
sourceUrl: link,
isExternal: true,
title: link,
metadata: { createdAt }
});
if (asset && lessonId) {
await mediaApi.attachAsset(asset.id, {
targetType: 'lesson',
targetId: lessonId,
slotType: 'lesson_video',
position: existingCount + index
});
}
return {
type: 'generic' as LessonVideoType,
link,
assetId: asset?.id,
metadata: { createdAt } as { svid?: string; createdAt?: string }
};
})
);
lessonApi.updateLessonState('videos', newVideos, { append: true });
genericLinks = '';
error = '';
}
}
function isValidLink(link = '') {
// Basic URL validation
if (!link) return false;
try {
const url = new URL(link.trim());
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
// If URL construction fails, try prepending https://
try {
return !!new URL(`https://${link.trim()}`);
} catch {
return false;
}
}
}
</script>
<div class="flex w-full items-{error ? 'center' : 'end'} justify-between gap-5">
<InputField
label={$t('course.navItem.lessons.materials.tabs.video.embed_link')}
bind:value={genericLinks}
className="flex-1"
onchange={() => (lessonApi.isDirty = true)}
placeholder="https://www.videoplayer.com/"
errorMessage={error}
/>
<Button onclick={addVideo}>
{$t('course.navItem.lessons.materials.tabs.video.add_video.add_video')}
</Button>
</div>
<p class="mt-4 pl-2 text-sm">
{$t('course.navItem.lessons.materials.tabs.video.add_video.videos_added')}:
<strong>
{lessonApi.lesson?.videos?.filter((v) => v.type === 'generic').length || 0}
</strong>
</p>
<div class="">
{#each lessonApi.lesson?.videos || [] as video, index}
{#if video.type === 'generic'}
<div class="flex items-center gap-1">
<Badge class="max-w-md truncate" variant="secondary">
{video.link}
</Badge>
<IconButton onclick={() => copyToClipboard(video.link)}>
<CopyIcon size={16} />
</IconButton>
<IconButton onclick={() => removeVideo(index)}>
<TrashIcon size={16} />
</IconButton>
</div>
{/if}
{/each}
</div>
@@ -0,0 +1,91 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import { InputField } from '@cio/ui/custom/input-field';
import { lessonApi } from '$features/course/api';
import { mediaApi } from '$features/media/api';
import type { OrganizationAsset } from '$features/media/utils';
import { onMount } from 'svelte';
import { t } from '$lib/utils/functions/translations';
interface Props {
lessonId?: string;
}
let { lessonId = '' }: Props = $props();
let search = $state('');
let isLoading = $state(false);
const assets = $derived(
mediaApi.assets.filter((asset) => asset.kind === 'video' && (asset.status ?? 'active') !== 'archived')
);
async function loadLibrary() {
isLoading = true;
await mediaApi.listAssets({
kind: 'video',
limit: 100,
search: search.trim() || undefined
});
isLoading = false;
}
async function addFromLibrary(asset: OrganizationAsset) {
if (!lessonApi.lesson) return;
const position = Array.isArray(lessonApi.lesson.videos) ? lessonApi.lesson.videos.length : 0;
const lessonVideo = await mediaApi.buildLessonVideoFromAsset(asset, {
lessonId: lessonId || undefined,
position,
slotType: 'lesson_video'
});
if (!lessonVideo) {
return;
}
lessonApi.updateLessonState('videos', [lessonVideo], { append: true });
}
onMount(async () => {
await loadLibrary();
});
</script>
<div class="space-y-3">
<div class="flex items-end gap-3">
<InputField
label={$t('course.navItem.lessons.materials.tabs.video.add_video.search_library')}
bind:value={search}
className="flex-1"
placeholder={$t('course.navItem.lessons.materials.tabs.video.add_video.search_library')}
/>
<Button onclick={loadLibrary} loading={isLoading} disabled={isLoading}>
{$t('course.navItem.lessons.materials.tabs.video.add_video.search_library_action')}
</Button>
</div>
{#if isLoading}
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.lessons.materials.tabs.video.add_video.loading_library')}
</p>
{:else if assets.length === 0}
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.lessons.materials.tabs.video.add_video.no_library_videos')}
</p>
{:else}
<div class="max-h-[360px] space-y-2 overflow-auto pr-1">
{#each assets as asset (asset.id)}
<div class="flex items-center justify-between rounded-md border p-3">
<div class="min-w-0">
<p class="truncate text-sm font-medium">{asset.title || asset.storageKey || asset.id}</p>
<p class="ui:text-muted-foreground text-xs">{asset.provider}</p>
</div>
<Button size="sm" onclick={() => addFromLibrary(asset)}>
{$t('course.navItem.lessons.materials.tabs.video.add_video.add_from_library')}
</Button>
</div>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,33 @@
import { classroomio } from '$lib/utils/services/api';
import { generateVideoThumbnail } from './video-thumbnail-utils';
export type VideoThumbnailUploadResult = { url: string; durationSeconds: number };
/**
* Handles media uploads (e.g. video thumbnails) via the media API.
*/
export class MediaUploader {
/**
* Generates a thumbnail from the video file and uploads it to the media bucket.
* Returns the public thumbnail URL and video duration, or null if generation or upload fails.
*/
async uploadVideoThumbnail(videoFile: File): Promise<VideoThumbnailUploadResult | null> {
const thumbnailResult = await generateVideoThumbnail(videoFile);
if (!thumbnailResult) return null;
const thumbnailFileName = videoFile.name.replace(/\.[^.]+$/, '') + '-thumbnail.jpg';
const file = new File([thumbnailResult.blob], thumbnailFileName, { type: 'image/jpeg' });
try {
const response = await classroomio.media.image.$post({ form: { file } });
const data = await response.json();
if (!data.success || !data.url) return null;
return {
url: data.url,
durationSeconds: thumbnailResult.durationSeconds
};
} catch {
return null;
}
}
}
@@ -0,0 +1,308 @@
<script lang="ts">
import { Progress } from '@cio/ui/base/progress';
import { UpgradeBanner } from '$features/ui';
import { lessonVideoUpload, cancelVideoUpload } from '$features/course/components/lesson/store';
import { lessonApi } from '$features/course/api';
import { isFreePlan } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { VideoUploader } from '$lib/utils/services/courses/presign';
import { MediaUploader } from './media-uploader';
import { Button } from '@cio/ui/base/button';
import * as FileDropZone from '@cio/ui/custom/file-drop-zone';
import { mediaApi } from '$features/media/api';
const ADD_VIDEO = 'course.navItem.lessons.materials.tabs.video.add_video';
interface Props {
lessonId?: string;
}
let { lessonId: _lessonId = '' }: Props = $props();
let fileSize: number | undefined = $state();
let formRes: {
url?: string;
fileKey?: string;
status?: number;
type?: string;
message?: string;
thumbnailUrl?: string;
} | null = $state(null);
let isLoaded = $state(false);
let fileName = $state('');
const isDisabled = $derived($lessonVideoUpload.isUploading || $isFreePlan);
const videoUploader = new VideoUploader();
const mediaUploader = new MediaUploader();
async function handleUpload(files: File[]) {
const videoFile = files[0];
if (!videoFile) return;
if ($isFreePlan) {
formRes = {
type: 'AUTHORIZATION_ERROR',
status: 403,
message: t.get('upgrade.required')
};
isLoaded = true;
return;
}
videoUploader.initUpload();
fileSize = videoFile.size / (1024 * 1024);
fileName = videoFile.name;
try {
const { url: presignedUrl, fileKey } = await videoUploader.getPresignedUrl(videoFile);
const [, thumbnailResult] = await Promise.all([
videoUploader.uploadFile({
url: presignedUrl,
file: videoFile
}),
mediaUploader.uploadVideoThumbnail(videoFile)
]);
const { urls: presignedUrls } = await videoUploader.getDownloadPresignedUrl([fileKey]);
formRes = {
url: presignedUrls[fileKey],
fileKey: fileKey,
status: 200,
thumbnailUrl: thumbnailResult?.url
};
const thumbnailUrl = thumbnailResult?.url;
const durationSeconds = thumbnailResult?.durationSeconds;
const normalizedDurationSeconds =
durationSeconds != null && Number.isFinite(durationSeconds)
? Math.max(0, Math.round(durationSeconds))
: undefined;
const metadata: {
fileName?: string;
createdAt?: string;
thumbnailUrl?: string;
duration?: number;
} = {
fileName: videoFile.name,
createdAt: new Date().toISOString()
};
if (thumbnailUrl) metadata.thumbnailUrl = thumbnailUrl;
if (normalizedDurationSeconds != null) metadata.duration = normalizedDurationSeconds;
const lessonVideoPosition = Array.isArray(lessonApi.lesson?.videos) ? lessonApi.lesson?.videos.length : 0;
let assetId: string | undefined;
if (_lessonId) {
const asset = await mediaApi.registerUploadedLessonVideo({
lessonId: _lessonId,
position: lessonVideoPosition,
fileKey,
videoUrl: formRes.url!,
fileName: videoFile.name,
mimeType: videoFile.type,
byteSize: videoFile.size,
thumbnailUrl,
durationSeconds: normalizedDurationSeconds
});
assetId = asset?.id;
} else {
const asset = await mediaApi.createAsset({
kind: 'video',
provider: 'upload',
storageProvider: 's3',
storageKey: fileKey,
sourceUrl: formRes.url!,
mimeType: videoFile.type,
byteSize: videoFile.size,
title: videoFile.name,
thumbnailUrl,
durationSeconds: normalizedDurationSeconds,
isExternal: false,
metadata
});
assetId = asset?.id;
}
lessonApi.updateLessonState(
'videos',
[
{
type: 'upload',
link: formRes.url!,
key: formRes?.fileKey,
assetId,
fileName: videoFile.name,
metadata
} as {
type: 'upload';
link: string;
key?: string;
assetId?: string;
metadata?: Record<string, unknown>;
fileName?: string;
}
],
{ append: true }
);
isLoaded = false;
} catch (err: any) {
console.error('Error uploading video', err, '\n\n', err.response);
if ($lessonVideoUpload.isCancelled) {
formRes = {
type: 'CANCELLED',
status: 0,
message: t.get(`${ADD_VIDEO}.upload_cancelled`)
};
} else if (err.response) {
formRes = err.response.data;
} else {
let message = 'An error occurred while uploading the video';
if (err instanceof Error) {
message = err.message;
}
formRes = {
type: 'INTERNAL_ERROR',
status: 500,
message
};
}
} finally {
videoUploader.abortController = null;
isLoaded = true;
$lessonVideoUpload.isUploading = false;
}
}
function onFileRejected() {
formRes = {
type: 'FILE_TOO_LARGE',
status: 413,
message: t.get(`${ADD_VIDEO}.maximum_size`)
};
isLoaded = true;
}
function tryAgain() {
formRes = null;
isLoaded = false;
$lessonVideoUpload.isUploading = false;
$lessonVideoUpload.uploadProgress = 0;
}
function cancelUpload() {
videoUploader.abortController?.abort();
cancelVideoUpload();
formRes = null;
isLoaded = false;
$lessonVideoUpload.uploadProgress = 0;
}
let helperText = $derived($lessonVideoUpload.uploadProgress + '% of ' + Math.round(fileSize || 0) + 'MB');
</script>
<UpgradeBanner className="mb-3" onClick={() => ($lessonVideoUpload.isModalOpen = false)}>
{$t(`${ADD_VIDEO}.upgrade`)}
</UpgradeBanner>
{#if !isLoaded}
{#if $lessonVideoUpload.isUploading}
<div
class="border-primary-300 flex h-full w-full flex-col items-center justify-center rounded-xl border border-dashed"
>
<div class="flex w-[60%] max-w-[500px] flex-col justify-center gap-5">
<p class="mt-5 text-center">
{$t(`${ADD_VIDEO}.uploading`)}
</p>
<Progress class="w-full" value={$lessonVideoUpload.uploadProgress} max={100} />
<p class="text-sm">{helperText}</p>
<div class="mt-3">
<Button variant="outline" onclick={cancelUpload}>
{$t(`${ADD_VIDEO}.cancel_upload`)}
</Button>
</div>
</div>
</div>
{:else}
<div class="h-full w-full {isDisabled ? 'ui:opacity-50 ui:pointer-events-none' : ''}">
<FileDropZone.Root
accept="video/*"
maxFiles={1}
fileCount={0}
maxFileSize={500 * FileDropZone.MEGABYTE}
onUpload={handleUpload}
{onFileRejected}
>
<FileDropZone.Trigger
label={$t(`${ADD_VIDEO}.select_file`)}
formatMaxFiles={(count) => $t(`${ADD_VIDEO}.max_files`, { count })}
formatMaxFilesAndSize={(size) => $t(`${ADD_VIDEO}.max_files_and_size`, { size })}
formatMaxSize={() => $t(`${ADD_VIDEO}.size`)}
/>
</FileDropZone.Root>
</div>
{/if}
{:else if formRes?.type === 'FILE_TOO_LARGE'}
<div class="flex h-full w-full flex-col items-center justify-center rounded-xl">
<img src="/video-upload-error.svg" alt="upload error" />
<span class="pt-3 pb-2">
<h3 class="text-center text-base font-normal dark:text-white">
{$t(`${ADD_VIDEO}.oops`)}
</h3>
<p class="text-center text-xs font-normal text-[#ADADAD]">
{$t(`${ADD_VIDEO}.big_file`)}<br />
{$t(`${ADD_VIDEO}.maximum_size`)}
</p>
</span>
<Button onclick={tryAgain}>
{$t(`${ADD_VIDEO}.button`)}
</Button>
</div>
{:else if formRes?.status !== 200}
<div class="flex h-full w-full flex-col items-center justify-center rounded-xl">
<img src="/video-upload-error.svg" alt="upload error" />
<span class="pt-3 pb-2">
<h3 class="text-center text-base font-normal dark:text-white">
{$t(`${ADD_VIDEO}.oops`)}
</h3>
<p class="text-center text-xs font-normal text-[#ADADAD]">
{$t(`${ADD_VIDEO}.unsupported_format`)}<br />
{$t(`${ADD_VIDEO}.format`)}
</p>
</span>
<Button onclick={tryAgain}>
{$t(`${ADD_VIDEO}.try_again`)}
</Button>
</div>
{:else}
<div class=" w-full rounded-md border px-8 py-3">
<div class="flex flex-col items-center gap-8">
<div class="flex flex-col items-center gap-2">
{#if formRes.thumbnailUrl}
<img
src={formRes.thumbnailUrl}
alt={fileName}
class="border-primary-300 h-auto w-[200px] rounded-md border object-cover"
/>
{:else}
<video class="border-primary-300 w-[200px] rounded-md border">
<source src={formRes.url} type="video/mp4" />
<track kind="captions" />
<p>{$t('generic.loading')}</p>
</video>
{/if}
<p>{fileName}</p>
</div>
<Button onclick={() => ($lessonVideoUpload.isModalOpen = false)}>
{$t('course.navItem.lessons.materials.button_done')}
</Button>
</div>
</div>
{/if}
@@ -0,0 +1,28 @@
<script lang="ts">
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { IconButton } from '@cio/ui/custom/icon-button';
import { t } from '$lib/utils/functions/translations';
interface Props {
onRemove: () => void;
}
let { onRemove }: Props = $props();
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="ui:data-[state=open]:opacity-100 absolute top-2 right-2 z-40 flex items-center justify-center opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100"
onclick={(e) => e.stopPropagation()}
>
<IconButton variant="outline">
<EllipsisVerticalIcon size={16} />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item class="ui:text-red-600" onclick={onRemove}>
{$t('course.navItem.lessons.materials.tabs.video.remove_video')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -0,0 +1,100 @@
import { getYoutubeVideoId } from '@cio/ui/custom/media-player';
export type LessonVideo = NonNullable<import('$features/course/utils/types').Lesson['videos']>[number];
type VideoMetadata = {
title?: string;
fileName?: string;
aspectRatio?: string;
duration?: number;
createdAt?: string;
thumbnailUrl?: string;
};
/**
* Thumbnail URL for a lesson video. YouTube uses img.youtube.com; uploads use metadata.thumbnailUrl when present.
*/
export function getVideoThumbnailUrl(video: LessonVideo): string | null {
if (video.type === 'youtube' && video.link) {
const id = getYoutubeVideoId(video.link);
return id ? `https://img.youtube.com/vi/${id}/mqdefault.jpg` : null;
}
if (video.type === 'upload') {
const meta = video.metadata as VideoMetadata | undefined;
if (meta?.thumbnailUrl && typeof meta.thumbnailUrl === 'string') return meta.thumbnailUrl;
}
return null;
}
/**
* Format duration in seconds as M:SS. Returns null if no duration.
*/
export function formatVideoDuration(seconds: number | undefined | null): string | null {
if (seconds == null || typeof seconds !== 'number' || seconds < 0) return null;
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
/**
* Fallback: derive display name from upload key (format: "nanoid-filename.ext").
* Used only for older uploads that don't have top-level fileName.
*/
function getFileNameFromKey(key: string | undefined): string | null {
if (!key || typeof key !== 'string') return null;
const dashIndex = key.indexOf('-');
if (dashIndex === -1) return null;
return key.slice(dashIndex + 1).trim() || null;
}
/**
* Display title for a video.
* Order: top-level fileName → metadata.title → (upload only) derive from key → type fallback → "Video N".
*/
export function getVideoTitle(video: LessonVideo, index: number): string {
const topLevelName = (video as LessonVideo & { fileName?: string }).fileName;
if (topLevelName && typeof topLevelName === 'string') return topLevelName;
const meta = video.metadata as VideoMetadata | undefined;
if (meta?.title && typeof meta.title === 'string') return meta.title;
if (video.type === 'upload' && video.key) {
const fromKey = getFileNameFromKey(video.key);
if (fromKey) return fromKey;
}
if (video.type === 'youtube') return 'YouTube video';
if (video.type === 'generic') return 'Embedded video';
return `Video ${index + 1}`;
}
/**
* Duration in seconds from metadata, if present.
*/
export function getVideoDurationSeconds(video: LessonVideo): number | null {
const meta = video.metadata as VideoMetadata | undefined;
if (meta?.duration != null && typeof meta.duration === 'number') return meta.duration;
return null;
}
/**
* Created at ISO string from metadata, if present.
*/
export function getVideoCreatedAt(video: LessonVideo): string | null {
const meta = video.metadata as VideoMetadata | undefined;
if (meta?.createdAt && typeof meta.createdAt === 'string') return meta.createdAt;
return null;
}
/**
* Format created-at for display (e.g. "Feb 15, 2026").
*/
export function formatVideoCreatedAt(isoString: string | null | undefined): string | null {
if (!isoString || typeof isoString !== 'string') return null;
const d = new Date(isoString);
if (Number.isNaN(d.getTime())) return null;
return new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(d);
}
@@ -0,0 +1,96 @@
<script lang="ts">
import * as Item from '@cio/ui/base/item';
import { Image } from '$features/ui';
import VideoIcon from '@lucide/svelte/icons/video';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import VideoCardDropdown from './video-card-dropdown.svelte';
import {
getVideoThumbnailUrl,
getVideoTitle,
getVideoDurationSeconds,
formatVideoDuration,
getVideoCreatedAt,
formatVideoCreatedAt,
type LessonVideo
} from './video-card-utils';
interface Props {
video: LessonVideo;
index: number;
isEditMode: boolean;
onRemove: () => void;
}
let { video, index, isEditMode, onRemove }: Props = $props();
const thumbnailUrl = $derived(getVideoThumbnailUrl(video));
const title = $derived(getVideoTitle(video, index));
const durationSeconds = $derived(getVideoDurationSeconds(video));
const durationFormatted = $derived(formatVideoDuration(durationSeconds ?? undefined));
const createdAtIso = $derived(getVideoCreatedAt(video));
const createdAtFormatted = $derived(formatVideoCreatedAt(createdAtIso ?? undefined));
const isYoutubeWithLink = $derived(video.type === 'youtube' && !!video.link);
</script>
<Item.Root variant="outline" class="group relative max-w-[320px] cursor-default! p-3!">
<div class="flex flex-col">
{#if isEditMode}
<VideoCardDropdown {onRemove} />
{/if}
<Item.Header>
<Item.Media variant="image" class="relative h-50! w-full! overflow-hidden rounded-sm">
{#if thumbnailUrl}
<Image src={thumbnailUrl} alt={title} className="h-full w-full object-cover" />
{:else}
<div
class="ui:bg-muted flex h-full w-full flex-col items-center justify-center gap-2 px-3 py-4"
role="img"
aria-label={title}
>
<VideoIcon class="ui:text-muted-foreground size-10 shrink-0" />
<span class="ui:text-muted-foreground line-clamp-2 max-w-full text-center text-xs font-medium">
{title}
</span>
</div>
{/if}
{#if durationFormatted}
<div class="absolute right-2 bottom-2 rounded bg-black/70 px-1.5 py-0.5 text-xs font-medium text-white">
{durationFormatted}
</div>
{/if}
</Item.Media>
</Item.Header>
<Item.Content>
<Item.Title class="line-clamp-2 min-h-[44px] text-base!" {title}>
{#if isYoutubeWithLink}
<a
href={video.link}
target="_blank"
rel="noopener noreferrer"
class="ui:text-foreground hover:ui:underline focus:ui:underline focus:ui:outline-none inline-flex items-center gap-1.5"
>
<span class="line-clamp-2">{title}</span>
<ExternalLinkIcon class="ui:text-muted-foreground size-3.5 shrink-0" aria-hidden="true" />
</a>
{:else}
{title}
{/if}
</Item.Title>
<p class="ui:text-muted-foreground mt-1 text-xs">
{#if createdAtFormatted && durationFormatted}
{createdAtFormatted} · {durationFormatted}
{:else if createdAtFormatted}
{createdAtFormatted}
{:else if durationFormatted}
{durationFormatted}
{:else}
–
{/if}
</p>
</Item.Content>
</div>
</Item.Root>
@@ -0,0 +1,79 @@
const JPEG_QUALITY = 0.85;
const DEFAULT_SEEK_SECONDS = 1;
const THUMBNAIL_TIMEOUT_MS = 15_000;
export type ThumbnailResult = { blob: Blob; durationSeconds: number };
/**
* Generates a thumbnail from a video file by capturing a frame to canvas.
* Seeks to 1s or duration/4 for short clips. Returns null on failure.
*/
export function generateVideoThumbnail(videoFile: File): Promise<ThumbnailResult | null> {
return new Promise((resolve) => {
const video = document.createElement('video');
video.muted = true;
video.playsInline = true;
video.preload = 'metadata';
const objectUrl = URL.createObjectURL(videoFile);
video.src = objectUrl;
const cleanup = () => {
video.removeAttribute('src');
video.load();
URL.revokeObjectURL(objectUrl);
};
const timeoutId = setTimeout(() => {
cleanup();
resolve(null);
}, THUMBNAIL_TIMEOUT_MS);
const finish = (result: ThumbnailResult | null) => {
clearTimeout(timeoutId);
cleanup();
resolve(result);
};
video.addEventListener('error', () => finish(null));
video.addEventListener('loadedmetadata', () => {
const duration = video.duration;
const seekTo = !Number.isFinite(duration) || duration <= 0 ? 0 : Math.min(DEFAULT_SEEK_SECONDS, duration / 4);
video.currentTime = seekTo;
});
video.addEventListener('seeked', () => {
try {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
if (canvas.width <= 0 || canvas.height <= 0) {
finish(null);
return;
}
const ctx = canvas.getContext('2d');
if (!ctx) {
finish(null);
return;
}
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const durationSeconds = video.duration;
canvas.toBlob(
(blob) => {
if (blob) {
finish({ blob, durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : 0 });
} else {
finish(null);
}
},
'image/jpeg',
JPEG_QUALITY
);
} catch {
finish(null);
}
});
video.load();
});
}
@@ -0,0 +1,93 @@
<script lang="ts">
import { lessonApi } from '$features/course/api';
import { MediaPlayer } from '@cio/ui/custom/media-player';
import { DeleteModal } from '$features/ui';
import { Button } from '@cio/ui/base/button';
import { Empty } from '@cio/ui/custom/empty';
import * as Item from '@cio/ui/base/item';
import { lessonVideoUpload } from '$features/course/components/lesson/store';
import { t } from '$lib/utils/functions/translations';
import MODES from '$lib/utils/constants/mode';
import VideoIcon from '@lucide/svelte/icons/video';
import VideoCard from './video-card.svelte';
interface Props {
mode?: (typeof MODES)[keyof typeof MODES];
}
let { mode = MODES.view }: Props = $props();
const videos = $derived(lessonApi.lesson?.videos || []);
let openDeleteVideoModal = $state(false);
let videoIndexToDelete = $state<number | null>(null);
const openAddVideoModal = () => {
$lessonVideoUpload.isModalOpen = true;
};
function requestRemoveVideo(index: number) {
videoIndexToDelete = index;
openDeleteVideoModal = true;
}
function confirmRemoveVideo() {
if (videoIndexToDelete !== null) {
lessonApi.deleteLessonVideo(videoIndexToDelete);
videoIndexToDelete = null;
}
openDeleteVideoModal = false;
}
</script>
{#snippet content(video)}
{#key video.link}
<MediaPlayer
source={{
type: video.type,
url: video.link,
metadata: video.metadata
}}
options={{
maxHeight: '569px',
width: '100%',
controls: true,
playsinline: true
}}
/>
{/key}
{/snippet}
{#if mode === MODES.edit}
<!-- Edit Mode: grid of video cards with remove + delete confirmation -->
<Button onclick={openAddVideoModal} class="float-end my-4">
{$t('course.navItem.lessons.materials.tabs.video.button')}
</Button>
{#if videos.length}
<Item.Group class="grid! w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{#each videos as video, index}
<VideoCard {video} {index} isEditMode={true} onRemove={() => requestRemoveVideo(index)} />
{/each}
</Item.Group>
{:else}
<Empty
title={$t('course.navItem.lessons.materials.tabs.video.empty_title')}
description={$t('course.navItem.lessons.materials.tabs.video.empty_description')}
icon={VideoIcon}
/>
{/if}
<DeleteModal bind:open={openDeleteVideoModal} onDelete={confirmRemoveVideo} />
{:else}
<!-- View Mode -->
{#if videos.length}
<div class="w-full">
{#each videos as video}
<div class="mb-5 w-full overflow-hidden">
{@render content(video)}
</div>
{/each}
</div>
{/if}
{/if}
@@ -0,0 +1,103 @@
<script lang="ts">
import { Badge } from '@cio/ui/base/badge';
import CopyIcon from '@lucide/svelte/icons/copy';
import TrashIcon from '@lucide/svelte/icons/trash';
import { t } from '$lib/utils/functions/translations';
import { IconButton } from '@cio/ui/custom/icon-button';
import { YoutubeLinkForm, isValidYoutubeLink } from '@cio/ui/custom/youtube-link-form';
import type { LessonVideoType } from '$features/course/utils/types';
import { copyToClipboard, removeVideo } from '$lib/utils/functions/formatYoutubeVideo';
import { lessonApi } from '$features/course/api';
import { mediaApi } from '$features/media/api';
interface Props {
lessonId?: string;
}
let { lessonId = '' }: Props = $props();
async function addVideo(validLinks: string[]) {
if (!lessonApi.lesson || validLinks.length === 0) return;
const existingCount = Array.isArray(lessonApi.lesson.videos) ? lessonApi.lesson.videos.length : 0;
const newVideos = await Promise.all(
validLinks.map(async (link = '', index) => {
const createdAt = new Date().toISOString();
const metadata = await mediaApi.getYouTubeMetadata(link);
const sourceUrl = metadata?.sourceUrl ?? link;
const title = metadata?.title ?? $t('media_manager.provider.youtube');
const videoMetadata = {
createdAt,
videoId: metadata?.videoId,
title,
thumbnailUrl: metadata?.thumbnailUrl ?? undefined,
duration: metadata?.durationSeconds ?? undefined
};
const asset = await mediaApi.createAsset({
kind: 'video',
provider: 'youtube',
storageProvider: 'external',
sourceUrl,
isExternal: true,
title,
thumbnailUrl: metadata?.thumbnailUrl ?? undefined,
durationSeconds: metadata?.durationSeconds ?? undefined,
metadata: videoMetadata
});
if (asset && lessonId) {
await mediaApi.attachAsset(asset.id, {
targetType: 'lesson',
targetId: lessonId,
slotType: 'lesson_video',
position: existingCount + index
});
}
return {
type: 'youtube' as LessonVideoType,
link: sourceUrl,
assetId: asset?.id,
fileName: title,
metadata: videoMetadata
};
})
);
lessonApi.updateLessonState('videos', newVideos, { append: true });
}
</script>
<YoutubeLinkForm
inputLabel={$t('course.navItem.lessons.materials.tabs.video.add_video.youtube_link')}
inputPlaceholder={$t('course.navItem.lessons.materials.tabs.video.add_video.youtube_link')}
addButtonLabel={$t('course.navItem.lessons.materials.tabs.video.add_video.add_video')}
invalidYoutubeMessage={$t('course.navItem.lessons.materials.tabs.video.add_video.invalid_youtube')}
onInputChange={() => (lessonApi.isDirty = true)}
onSubmit={addVideo}
/>
<p class="mt-4 pl-2 text-sm">
{$t('course.navItem.lessons.materials.tabs.video.add_video.videos_added')}:
<strong>
{lessonApi.lesson?.videos?.filter((v) => v.type === 'youtube' && isValidYoutubeLink(v.link)).length || 0}
</strong>
</p>
<div class="">
{#each lessonApi.lesson?.videos || [] as video, index}
{#if video.type === 'youtube'}
<div class="flex items-center gap-1">
<Badge class="max-w-md truncate" variant="secondary">
{video.link}
</Badge>
<IconButton onclick={() => copyToClipboard(video.link)}>
<CopyIcon size={16} />
</IconButton>
<IconButton onclick={() => removeVideo(index)}>
<TrashIcon size={16} />
</IconButton>
</div>
{/if}
{/each}
</div>
@@ -16,6 +16,12 @@
isPublished?: boolean;
totalLessons?: number;
totalStudents?: number;
tags?: Array<{
id: string;
name: string;
slug: string;
color?: string | null;
}>;
}
let {
@@ -25,7 +31,8 @@
description = '',
isPublished = false,
totalLessons = 0,
totalStudents = 0
totalStudents = 0,
tags = []
}: Props = $props();
function handleCloneCourse(e) {
@@ -60,6 +67,24 @@
</Table.Cell>
{#if !isMobileStore.current}
<Table.Cell class="truncate">{type}</Table.Cell>
<Table.Cell>
{#if tags.length === 0}
<span class="ui:text-muted-foreground text-2xs">-</span>
{:else}
<div class="ui:text-muted-foreground text-2xs flex max-w-[220px] flex-wrap items-center gap-2">
{#each tags as tag (tag.id)}
<span class="inline-flex items-center gap-1.5">
<span
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 rounded-full"
style={tag.color ? `background-color: ${tag.color}` : undefined}
aria-hidden="true"
></span>
<span>{tag.name}</span>
</span>
{/each}
</div>
{/if}
</Table.Cell>
<Table.Cell>{totalLessons}</Table.Cell>
<Table.Cell>{totalStudents}</Table.Cell>
<Table.Cell>
@@ -3,28 +3,17 @@
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { validateForm } from '../utils/functions';
import { courses, createCourseModal } from '../utils/store';
import { createCourseModal } from '../utils/store';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { InputField } from '@cio/ui/custom/input-field';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { ROLE } from '@cio/utils/constants';
import * as Field from '@cio/ui/base/field';
import * as RadioGroup from '@cio/ui/base/radio-group';
import { supabase } from '$lib/utils/functions/supabase';
import { RadioOptionCardGroup } from '@cio/ui/custom/radio-option-card';
import { t } from '$lib/utils/functions/translations';
import { addDefaultNewsFeed, addGroupMember } from '$lib/utils/services/courses';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { COURSE_TYPE, COURSE_VERSION } from '$lib/utils/types';
import type { TCourseType } from '@cio/db/types';
import { courseApi } from '../api';
let isLoading = $state(false);
let errors = $state({
title: '',
description: ''
});
let step = $state(0);
const options = [
@@ -32,17 +21,24 @@
id: 'live-class',
title: $t('new_course_modal.live_class_label'),
subtitle: $t('new_course_modal.live_class_subtitle'),
type: COURSE_TYPE.LIVE_CLASS,
type: 'LIVE_CLASS' as TCourseType,
isDisabled: false
},
{
id: 'self-paced',
title: $t('new_course_modal.self_paced_label'),
subtitle: $t('new_course_modal.self_paced_subtitle'),
type: COURSE_TYPE.SELF_PACED,
type: 'SELF_PACED' as TCourseType,
isDisabled: false
}
];
const courseTypeOptionsForGroup = options.map((o) => ({
id: o.id,
title: o.title,
description: o.subtitle,
value: o.type
}));
let type = $state(options[0].type);
function onClose(redirectTo) {
@@ -59,105 +55,20 @@
}
async function createCourse() {
isLoading = true;
const { hasError, fieldErrors } = validateForm($createCourseModal);
errors = fieldErrors;
if (hasError) return;
const { title, description } = $createCourseModal;
// 1. Create group
const { data: newGroup } = await supabase
.from('group')
.insert({ name: title, description, organization_id: $currentOrg.id })
.select();
console.log(`newGroup`, newGroup);
if (!newGroup) return;
const { id: group_id } = newGroup[0];
// 2. Create course with group_id
const { data: newCourseData } = await supabase
.from('course')
.insert({
title,
description,
type: type,
version: COURSE_VERSION.V2,
group_id
})
.select();
console.log(`newCourse data`, newCourseData);
if (!newCourseData) return;
const newCourse = newCourseData[0];
courses.update((_courses) => [..._courses, newCourse]);
capturePosthogEvent('course_created', {
course_id: newCourse.id,
course_title: newCourse.title,
course_description: newCourse.description,
organization_id: $currentOrg.id,
organization_name: $currentOrg.name,
user_id: $profile.id,
user_email: $profile.email
await courseApi.create({
title: $createCourseModal.title,
description: $createCourseModal.description,
type: type
});
// 3. Add group members
const { data } = await addGroupMember({
profile_id: $profile.id,
email: $profile.email,
group_id,
role_id: ROLE.TUTOR
});
// 4. Add default news feed.
if (Array.isArray(data) && data.length) {
const { id: authorId } = data[0];
console.log('Add news feed into course');
await addDefaultNewsFeed({
content: `<h2>Welcome to this course 🎉&nbsp;</h2>
<p>Thank you for joining this course and I hope you get the best out of it.</p>`,
course_id: newCourse.id,
is_pinned: true,
author_id: authorId
});
}
onClose(`/courses/${newCourse.id}`);
isLoading = false;
}
$inspect(type);
let open = $derived(new URLSearchParams(page.url.search).get('create') === 'true');
</script>
<svelte:head>
<title>Create a new course</title>
</svelte:head>
{#snippet course_type_selector()}
<Field.Description>{$t('courses.new_course_modal.type_selector_title')}</Field.Description>
<RadioGroup.Root bind:value={type} class="grid-cols-2">
{#each options as option (option.id)}
<Field.Label for={option.id}>
<Field.Field orientation="horizontal">
<Field.Content>
<Field.Title>{option.title}</Field.Title>
<Field.Description>{option.subtitle}</Field.Description>
</Field.Content>
<RadioGroup.Item value={option.type} id={option.id} aria-label={option.title} />
</Field.Field>
</Field.Label>
{/each}
</RadioGroup.Root>
<RadioOptionCardGroup options={courseTypeOptionsForGroup} bind:value={type} class="grid-cols-2" />
{/snippet}
<Dialog.Root
@@ -189,7 +100,7 @@
placeholder={$t('courses.new_course_modal.course_name_placeholder')}
className="w-full"
isRequired={true}
errorMessage={errors.title}
errorMessage={courseApi.errors.title}
autoComplete={false}
/>
</div>
@@ -201,7 +112,7 @@
placeholder={$t('courses.new_course_modal.short_description_placeholder')}
className="mb-4"
isRequired={true}
errorMessage={errors.description}
errorMessage={courseApi.errors.description}
isAIEnabled={true}
initAIPrompt="Write a 30 word description for a course titled: {$createCourseModal.title}"
/>
@@ -210,7 +121,7 @@
<Button variant="outline" onclick={() => (step = 0)}>
{$t('courses.new_course_modal.back')}
</Button>
<Button type="submit" disabled={isLoading} loading={isLoading}>
<Button type="submit" disabled={courseApi.isLoading} loading={courseApi.isLoading}>
{$t('courses.new_course_modal.button')}
</Button>
</Dialog.Footer>
@@ -0,0 +1,103 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Feed } from '$features/course/utils/types';
import { newsfeedApi } from '$features/course/api';
import DeleteConfirmation from './delete-confirmation.svelte';
import type { NewsfeedCommentsByFeedId } from '$features/course/api';
import Header from './header.svelte';
import Reactions from './reactions.svelte';
import Comments from './comments.svelte';
interface Props {
feed: Feed;
comments?: NewsfeedCommentsByFeedId;
courseId?: string;
editFeed: Feed | null;
author: {
id: string;
username: string;
fullname: string;
avatarUrl: string;
};
edit?: boolean;
deleteFeed?: any;
deleteComment?: any;
addNewComment?: any;
addNewReaction?: any;
onPin?: any;
isActive?: boolean;
isReacting?: boolean;
}
let {
feed,
comments,
courseId,
editFeed = $bindable(),
author,
edit = $bindable(false),
deleteFeed = (_arg: string) => {},
deleteComment = (_arg1: string, _arg2: string) => {},
addNewComment = (_arg1: string, _arg2: string) => {},
addNewReaction = (_arg1: string, _arg2: string, _arg3: string) => {},
onPin = (_feedId: Feed['id'], _isPinned: Feed['isPinned']) => {},
isActive = false,
isReacting = false
}: Props = $props();
let isDeleteFeedModal = $state(false);
const openEditFeed = () => {
newsfeedApi.openNewFeedModal();
edit = true;
if (edit === true) {
editFeed = feed;
}
};
const handleReact = (reactionType: string) => addNewReaction(reactionType, feed.id, author.id);
const handleDeleteFeed = () => {
deleteFeed(feed.id);
};
onMount(() => {
if (isActive) {
const el = document.getElementById(feed.id);
el?.scrollIntoView({
block: 'start',
behavior: 'smooth',
inline: 'nearest'
});
}
});
</script>
<div
id={feed.id}
class="flex flex-col justify-between gap-2 {isActive
? 'border-primary-700 border-2'
: 'border border-gray-200'} mb-7 w-[90%] rounded-md md:max-w-3xl"
>
<Header {feed} {onPin} onEdit={openEditFeed} onRequestDelete={() => (isDeleteFeedModal = true)} />
<Reactions {feed} authorId={author.id} {isReacting} onReact={handleReact} />
<Comments
{courseId}
{feed}
{author}
{comments}
onAddComment={async (content) => {
await addNewComment(content, feed.id);
}}
onDeleteComment={(commentId) => {
if (courseId) deleteComment(feed.id, String(commentId));
}}
/>
<DeleteConfirmation bind:openDeleteModal={isDeleteFeedModal} deleteFeed={handleDeleteFeed} />
</div>
@@ -0,0 +1,229 @@
<script lang="ts">
import pluralize from 'pluralize';
import UsersIcon from '@lucide/svelte/icons/users';
import SendHorizontalIcon from '@lucide/svelte/icons/send-horizontal';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import { profile } from '$lib/utils/store/user';
import { isOrgAdmin } from '$lib/utils/store/org';
import { calDateDiff } from '$lib/utils/functions/date';
import { t } from '$lib/utils/functions/translations';
import { newsfeedApi } from '$features/course/api';
import { Button } from '@cio/ui/base/button';
import type { Feed } from '$features/course/utils/types';
import type { NewsfeedCommentsByFeedId } from '$features/course/api';
interface Props {
courseId?: string;
feed: Feed;
author: {
id: string;
username: string;
fullname: string;
avatarUrl: string;
};
comments?: NewsfeedCommentsByFeedId;
onAddComment: (content: string) => Promise<void> | void;
onDeleteComment: (commentId: string | number) => void;
}
let { courseId, feed, author, comments, onAddComment, onDeleteComment }: Props = $props();
let comment = $state('');
let errors: { newComment: string } = $state({ newComment: '' });
let isSubmitting = $state(false);
let isBootstrapping = $state(false);
let didBootstrap = $state(false);
// Pagination / progressive reveal (newest-first)
const PAGE_SIZE = 5;
let visibleCount = $state(1);
const loadedCount = $derived((comments?.items?.length ?? 0) as number);
const totalCount = $derived(comments?.totalCount || feed.commentCount);
const isLoading = $derived((comments?.isLoading ?? false) as boolean);
const shownCount = $derived(Math.min(visibleCount, loadedCount));
// Fetch the minimum info needed to:
// - know totalCount
// - show the latest comment (1) by default
const bootstrap = async () => {
if (!courseId || !comments) return;
if (didBootstrap) return;
didBootstrap = true;
isBootstrapping = true;
try {
await newsfeedApi.getComments(courseId, feed.id, PAGE_SIZE);
} finally {
isBootstrapping = false;
}
};
const submitComment = async () => {
if (isSubmitting) return;
isSubmitting = true;
try {
await onAddComment(comment);
comment = '';
} finally {
isSubmitting = false;
}
};
const handleAddNewComment = async (event: KeyboardEvent | MouseEvent) => {
if (isSubmitting) return;
const maybeKey = event as KeyboardEvent & { key?: string };
if (maybeKey.key === 'Enter' || event.type === 'click') {
event.preventDefault();
await submitComment();
}
};
const ensureLoaded = async (targetVisible: number) => {
if (!courseId || !comments) return;
const target = Math.min(targetVisible, comments.totalCount);
// Load pages until we have enough locally to render `target` items.
while (comments.hasMore && comments.items.length < target && !comments.isLoading) {
await newsfeedApi.loadMoreComments(courseId, feed.id, PAGE_SIZE);
}
};
const showMore = async () => {
if (!courseId || !comments) return;
// Ensure we know totalCount and have at least 1 comment (if it exists).
if (!didBootstrap) {
await bootstrap();
}
const next = visibleCount <= 1 ? PAGE_SIZE : visibleCount + PAGE_SIZE;
visibleCount = next;
await ensureLoaded(next);
};
const minimize = () => {
visibleCount = visibleCount > 1 ? 1 : shownCount;
};
const toggleComments = async () => {
// If already showing more than the latest comment, collapse back to 1.
if (totalCount === shownCount) {
minimize();
return;
}
await showMore();
};
</script>
<section class="border-t border-gray-200 p-3">
<div class="flex w-full items-center justify-between">
<Button
variant="ghost"
size="sm"
onclick={toggleComments}
loading={isBootstrapping || isLoading}
class="justify-start"
>
<UsersIcon size={16} />
<p class="py-2 text-sm">{pluralize($t('course.navItem.news_feed.comments.comment'), totalCount, true)}</p>
{#if totalCount > shownCount}
<span class="ml-auto text-xs text-gray-600"
>{$t('course.navItem.news_feed.comments.showing_of', { shown: shownCount, total: totalCount })}</span
>
{/if}
</Button>
{#if shownCount > 1}
<Button variant="ghost" size="sm" onclick={minimize}>
<span class="ml-auto text-xs text-gray-600">{$t('course.navItem.news_feed.comments.hide')}</span>
</Button>
{/if}
</div>
<div>
{#if comments}
{#each comments.items.slice(0, shownCount) as commentItem (commentItem.id)}
{#if commentItem.content}
<div class="group flex items-center justify-between py-2">
<span class="flex items-center gap-3">
<div class="h-9 w-9">
<img
src={commentItem.authorAvatarUrl || ''}
alt={$t('course.navItem.news_feed.user_avatar_alt')}
class="h-full w-full rounded-full object-cover"
/>
</div>
<span>
<div class="flex items-center gap-2">
<p class="text-sm font-medium capitalize">{commentItem.authorFullname || ''}</p>
<p class="text-xs font-medium text-gray-600">{calDateDiff(commentItem.createdAt)}</p>
</div>
<p>{commentItem.content}</p>
</span>
</span>
{#if commentItem.authorProfileId === $profile.id || $isOrgAdmin}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="hidden h-8 w-8 items-center justify-center rounded-md group-hover:flex hover:bg-gray-100 dark:hover:bg-neutral-700"
>
<EllipsisVerticalIcon class="h-5 w-5" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item class="text-red-600" onclick={() => onDeleteComment(commentItem.id)}>
{$t('course.navItem.news_feed.comments.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</div>
{/if}
{/each}
{/if}
</div>
<!-- Input for new comment -->
<div class="flex items-center justify-between gap-2">
<div class="h-7 w-7">
<img
src={author.avatarUrl}
alt={$t('course.navItem.news_feed.user_avatar_alt')}
class="h-full w-full rounded-full object-cover"
/>
</div>
<div class="flex-1">
<input
type="text"
bind:value={comment}
onkeydown={handleAddNewComment}
placeholder={$t('course.navItem.news_feed.comments.placeholder')}
class="w-full rounded-3xl border border-gray-200 bg-transparent p-1 pl-2 text-sm disabled:opacity-50"
disabled={isSubmitting}
required
/>
</div>
<Button
variant="ghost"
size="icon-sm"
class="rounded-full"
loading={isSubmitting}
onclick={handleAddNewComment}
aria-label={$t('course.navItem.news_feed.comments.submit_aria')}
>
<SendHorizontalIcon size={16} />
</Button>
</div>
{#if errors?.newComment}
<p class="text-sm text-red-500">{errors?.newComment}</p>
{/if}
</section>

Some files were not shown because too many files have changed in this diff Show More