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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

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

Co-authored-by: Rotimi Best <[email protected]>

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

Co-authored-by: Rotimi Best <[email protected]>

* docs: add CSP ALLOWED_EXTERNAL_DOMAINS to self-hosting guides

Co-authored-by: Rotimi Best <[email protected]>

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

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

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

Co-authored-by: Rotimi Best <[email protected]>

* 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 <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
Co-authored-by: Abdulmuizz Hamzat <[email protected]>
Co-authored-by: Adeyemi Adetunji <[email protected]>
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
@@ -4,7 +4,7 @@
// // import { useCompletion } from 'ai/svelte';
// import WandSparklesIcon from '@lucide/svelte/icons/wand-sparkles';
// import { IconButton } from '$lib/components/IconButton';
// import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
// import { HTMLRender } from '$features/ui';
// import { t } from '$lib/utils/functions/translations';
// interface Props {
@@ -85,7 +85,7 @@
class="my-1 h-[82%] w-full overflow-y-auto bg-white p-2 text-start text-sm dark:bg-neutral-700"
>
{#if isHTML}
<HtmlRender>{plainText}</HtmlRender>
<HTMLRender>{plainText}</HTMLRender>
{:else}
<p class="font-normal">
{plainText}
@@ -1,164 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import type { CourseAnalytics } from '$lib/utils/types/analytics';
import { ScaleTypes } from '@carbon/charts-svelte';
import '@carbon/charts-svelte/styles.css';
interface Props {
courseAnalytics?: CourseAnalytics | null;
}
let { courseAnalytics = null }: Props = $props();
let BarChartSimple: any = $state();
// Transform data for progress distribution chart
let progressChartData = $derived(
courseAnalytics?.students
? [
{
group: '80%+ Progress',
value: courseAnalytics.students.filter((s) => s.progressPercentage >= 80).length
},
{
group: '60-79% Progress',
value: courseAnalytics.students.filter((s) => s.progressPercentage >= 60 && s.progressPercentage < 80)
.length
},
{
group: 'Below 60% Progress',
value: courseAnalytics.students.filter((s) => s.progressPercentage < 60).length
}
]
: []
);
// Transform data for grade distribution chart
let gradeChartData = $derived(
courseAnalytics?.students
? [
{
group: '90%+ Grade',
value: courseAnalytics.students.filter((s) => s.averageGrade >= 90).length
},
{
group: '80-89% Grade',
value: courseAnalytics.students.filter((s) => s.averageGrade >= 80 && s.averageGrade < 90).length
},
{
group: '70-79% Grade',
value: courseAnalytics.students.filter((s) => s.averageGrade >= 70 && s.averageGrade < 80).length
},
{
group: 'Below 70% Grade',
value: courseAnalytics.students.filter((s) => s.averageGrade < 70).length
}
]
: []
);
onMount(async () => {
if (browser) {
const charts = await import('@carbon/charts-svelte');
BarChartSimple = charts.BarChartSimple;
}
});
// Chart options for progress distribution
let progressChartOptions = $derived({
axes: {
left: {
mapsTo: 'value',
title: 'Number of Students'
},
bottom: {
mapsTo: 'group',
scaleType: ScaleTypes.LABELS,
title: 'Progress Range'
}
},
height: '300px',
data: {
loading: !courseAnalytics
},
color: {
scale: {
'80%+ Progress': '#10b981', // green
'60-79% Progress': '#f59e0b', // yellow
'Below 60% Progress': '#ef4444' // red
}
}
});
// Chart options for grade distribution
let gradeChartOptions = $derived({
axes: {
left: {
mapsTo: 'value',
title: 'Number of Students'
},
bottom: {
mapsTo: 'group',
scaleType: ScaleTypes.LABELS,
title: 'Grade Range'
}
},
height: '300px',
data: {
loading: !courseAnalytics
},
color: {
scale: {
'90%+ Grade': '#10b981', // green
'80-89% Grade': '#3b82f6', // blue
'70-79% Grade': '#f59e0b', // yellow
'Below 70% Grade': '#ef4444' // red
}
}
});
</script>
{#if browser && BarChartSimple}
<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">Student Progress Distribution</h3>
{#if courseAnalytics?.students && courseAnalytics.students.length > 0}
<BarChartSimple data={progressChartData} options={progressChartOptions} />
{:else}
<div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400">
No progress data available
</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">Grade Distribution</h3>
{#if courseAnalytics?.students && courseAnalytics.students.length > 0}
<BarChartSimple data={gradeChartData} options={gradeChartOptions} />
{:else}
<div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400">
No grade data available
</div>
{/if}
</div>
</div>
{:else}
<!-- Loading state -->
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
<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">Student Progress Distribution</h3>
<div class="flex h-[300px] items-center justify-center">
<div class="animate-pulse text-gray-500 dark:text-gray-400">Loading chart...</div>
</div>
</div>
<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">Grade Distribution</h3>
<div class="flex h-[300px] items-center justify-center">
<div class="animate-pulse text-gray-500 dark:text-gray-400">Loading chart...</div>
</div>
</div>
</div>
{/if}
@@ -1,13 +0,0 @@
<script>
import { course } from '$lib/components/Course/store';
import UnlockedCertificate from './UnlockedCertificate.svelte';
import LockedCertificate from './LockedCertificate.svelte';
</script>
<div>
{#if $course.is_certificate_downloadable == true}
<UnlockedCertificate />
{:else}
<LockedCertificate />
{/if}
</div>
@@ -1,66 +0,0 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import * as Dialog from '@cio/ui/base/dialog';
import { t } from '$lib/utils/functions/translations';
import { supabase } from '$lib/utils/functions/supabase';
import { course } from '../../store';
import { snackbar } from '$features/ui/snackbar/store';
interface Props {
open?: boolean;
}
let { open = $bindable(false) }: Props = $props();
let isActivating = $state(false);
const activate = async () => {
isActivating = true;
const { error } = await supabase.rpc('convert_course_to_v2', {
course_id_arg: $course.id
});
if (error) {
snackbar.error('snackbar.something');
return;
}
window.location.reload();
};
function handleClose() {
open = false;
}
</script>
<Dialog.Root
bind:open
onOpenChange={(isOpen) => {
if (!isOpen) handleClose();
}}
>
<Dialog.Content class="w-[80%] max-w-xl md:w-[65%]">
<Dialog.Header>
<Dialog.Title>{$t(`course.navItem.lessons.section_prompt.header`)}</Dialog.Title>
</Dialog.Header>
<div class="flex w-full flex-col items-center">
<div class="mb-8">
<h3 class="text-center text-2xl">
{$t('course.navItem.lessons.section_prompt.title')}
</h3>
<p class="max-w-md text-center">
{$t('course.navItem.lessons.section_prompt.description')}
</p>
</div>
<div class="flex gap-2">
<Button variant="outline" onclick={handleClose}>
{$t('course.navItem.lessons.section_prompt.cancel')}
</Button>
<Button onclick={activate} loading={isActivating}>
{$t('course.navItem.lessons.section_prompt.activate')}
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -1,254 +0,0 @@
<script lang="ts">
import * as Select from '@cio/ui/base/select';
import TrashIcon from '@lucide/svelte/icons/trash';
import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
import {
questionnaire,
deleteConfirmation,
questionnaireValidation,
handleAddOption,
handleRemoveOption,
handleRemoveQuestion,
handleCode,
handleAnswerSelect,
addDynamicValue
} from '../store/exercise';
import { preventDefault } from '$lib/utils/functions/svelte';
import { deleteExercise } from '$lib/utils/services/courses';
import { QUESTION_TYPE, QUESTION_TYPES } from '$lib/components/Question/constants';
import { Button } from '@cio/ui/base/button';
import { lesson } from '../store/lessons';
import OrderModal from './OrderModal.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 { CheckboxField } from '@cio/ui/custom/checkbox-field';
import { RadioItem } from '@cio/ui/custom/radio-item';
import { InputField } from '@cio/ui/custom/input-field';
import DeleteConfirmationModal from './DeleteConfirmation.svelte';
import { CircleCheckIcon } from '$features/ui/icons';
import QuestionContainer from '$lib/components/QuestionContainer/index.svelte';
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);
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;
}
async function handleDelete() {
isDeleting = true;
const qToDelete = $questionnaire.questions.map((q) => ({ id: `${q.id}` }));
await deleteExercise(qToDelete, exerciseId);
lesson.update((_lesson) => ({
..._lesson,
exercises: _lesson.exercises.filter((exercise) => exercise.id !== exerciseId)
}));
goBack();
}
</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>
{#snippet optionActions(question, option)}
<div data-name="option-action" class="ml-2 flex items-center gap-2">
<IconButton onclick={handleRemoveOption(question.id, option.id)}>
<TrashIcon size={18} />
</IconButton>
<IconButton
onclick={handleAnswerSelect(question.id, option.id)}
class={option.is_correct ? 'text-green-500! fill-green-500!' : ''}
>
<CircleCheckIcon size={18} filled={option.is_correct} />
</IconButton>
</div>
{/snippet}
<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.deleted_at}
<QuestionContainer
key={question.deleted_at}
onClose={onInitDeleteClicked(question.id)}
scrollToQuestion={shouldScrollToLast(question.id, $questionnaire.questions)}
bind:points={question.points}
hasError={!!errors[question.id]}
onPointsChange={() => {
question.is_dirty = true;
}}
>
<div class="flex items-center justify-between">
<div class="mr-5 w-3/5">
<InputField
placeholder={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.question')}
bind:value={question.title}
isRequired={true}
onchange={() => {
question.is_dirty = true;
}}
/>
</div>
<Select.Root
type="single"
onValueChange={(value) => {
if (!value) return;
const id = parseInt(value);
question.question_type = QUESTION_TYPES.find((q) => q.id === id) ?? QUESTION_TYPES[0];
question.question_type_id = question.question_type.id;
question.is_dirty = true;
}}
>
<Select.Trigger class="w-[180px]">
{question?.question_type?.label ? $t(question.question_type.label) : 'Select type'}
</Select.Trigger>
<Select.Content>
{#each QUESTION_TYPES as type}
<Select.Item value={type.id.toString()} label={$t(type.label)} />
{/each}
</Select.Content>
</Select.Root>
</div>
{#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">
{#each $questionnaire.questions[index]?.options || [] as option}
{#if !option.deleted_at}
{#if QUESTION_TYPE.RADIO === question.question_type.id}
<RadioItem
isEditable={true}
name={question.title || 'radio-name'}
bind:label={option.label}
onchange={addDynamicValue(question.id, option.id)}
>
{@render optionActions(question, option)}
</RadioItem>
{:else if QUESTION_TYPE.CHECKBOX === question.question_type.id}
<CheckboxField
isEditable={true}
name={question?.name || 'checkbox-name'}
bind:label={option.label}
onchange={addDynamicValue(question.id, option.id)}
>
{@render optionActions(question, option)}
</CheckboxField>
{/if}
{/if}
{/each}
{#if QUESTION_TYPE.TEXTAREA === question.question_type.id}
<TextareaField bind:value={question.value} disabled={true} />
{/if}
{#if getQuestionErrorMsg(errors, question, 'option')}
<p class="text-sm text-red-500">{getQuestionErrorMsg(errors, question, 'option')}</p>
{/if}
</div>
{#if QUESTION_TYPE.TEXTAREA !== question.question_type.id}
<div class="mt-3 flex items-center">
<Button variant="outline" onclick={handleAddOption(question.id)}>
<CirclePlusIcon size={16} />
<p class="ml-2 dark:text-white">
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.option')}
</p>
</Button>
</div>
{/if}
</QuestionContainer>
{/if}
{/each}
</div>
</div>
<style>
:global(#dnd-action-dragged-el div) {
z-index: 100;
}
</style>
@@ -1,92 +0,0 @@
<script lang="ts">
import CheckboxQuestion from '$lib/components/Question/CheckboxQuestion/index.svelte';
import RadioQuestion from '$lib/components/Question/RadioQuestion/index.svelte';
import TextareaQuestion from '$lib/components/Question/TextareaQuestion/index.svelte';
import { QUESTION_TYPE } from '$lib/components/Question/constants';
import { getPropsForQuestion } from './functions';
interface Props {
questions?: any;
questionnaireMetaData?: any;
grades?: any;
onSubmit?: any;
onPrevious?: any;
disableGrading?: boolean;
isGradeWithAI?: boolean;
isLoading?: boolean;
reasons?: any;
}
let {
questions = [],
questionnaireMetaData = {},
grades = $bindable({}),
onSubmit = () => {},
onPrevious = () => {},
disableGrading = true,
isGradeWithAI = $bindable(false),
isLoading = $bindable(false),
reasons = $bindable({})
}: Props = $props();
</script>
{#each questions as currentQuestion, currentQuestionIndex}
{#if QUESTION_TYPE.RADIO === currentQuestion.question_type.id}
<RadioQuestion
{...getPropsForQuestion(
questions,
currentQuestion,
questionnaireMetaData,
currentQuestionIndex + 1,
onSubmit,
onPrevious,
true
)}
bind:grade={grades[currentQuestion.id]}
bind:isGradeWithAI
bind:reason={reasons[currentQuestion.id]}
bind:isLoading
gradeMax={currentQuestion.points}
{disableGrading}
disabled={true}
/>
{:else if QUESTION_TYPE.CHECKBOX === currentQuestion.question_type.id}
<CheckboxQuestion
{...getPropsForQuestion(
questions,
currentQuestion,
questionnaireMetaData,
currentQuestionIndex + 1,
onSubmit,
onPrevious,
true
)}
bind:grade={grades[currentQuestion.id]}
bind:isGradeWithAI
bind:reason={reasons[currentQuestion.id]}
bind:isLoading
gradeMax={currentQuestion.points}
{disableGrading}
disabled={true}
/>
{:else if QUESTION_TYPE.TEXTAREA === currentQuestion.question_type.id}
<TextareaQuestion
{...getPropsForQuestion(
questions,
currentQuestion,
questionnaireMetaData,
currentQuestionIndex + 1,
onSubmit,
onPrevious,
true
)}
bind:grade={grades[currentQuestion.id]}
bind:isGradeWithAI
bind:reason={reasons[currentQuestion.id]}
bind:isLoading
gradeMax={currentQuestion.points}
{disableGrading}
disabled={true}
/>
{/if}
{/each}
@@ -1,119 +0,0 @@
<script lang="ts">
import { Spinner } from '@cio/ui/base/spinner';
import { submissions } from './store';
import { questionnaire } from '../../store/exercise';
import { t } from '$lib/utils/functions/translations';
import type { ExerciseSubmissions } from '$lib/utils/types';
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 isSelected = (student: ExerciseSubmissions, option: { question_id: number; value: string[] }) => {
if ($submissions) {
let submittedAnswer = student?.answers;
let filteredAnswer = submittedAnswer.filter(
(ans: { question_id: number }) => ans.question_id === option.question_id
);
if (filteredAnswer.map((ans) => ans.answers.length > 1)) {
return filteredAnswer.some((ans) => ans.answers.includes(option.value));
} else {
return filteredAnswer.map((ans: { answers: any }) => ans.answers) == option.value;
}
}
};
const isCorrect = (
student: ExerciseSubmissions,
option: { is_correct?: any; question_id?: number; value?: string[] }
) => {
if (!isSelected(student, option)) return;
if (option.is_correct) {
return 'border-green-700';
} else {
return 'border-red-700';
}
};
const getOpenAnswer = (student: ExerciseSubmissions, q: never) => {
let submittedAnswer = student?.answers;
let filteredAnswer = submittedAnswer.filter((ans: { question_id: number }) => ans.question_id === q.id);
if (filteredAnswer.some((ans: { open_answer: string }) => ans.open_answer == '')) {
return $t('course.navItem.lessons.exercises.all_exercises.analytics.individual.no');
} else {
return filteredAnswer.map((ans: { open_answer: any }) => ans.open_answer);
}
};
</script>
{#if isLoading}
<Spinner />
{:else if $submissions?.length}
<div class="mb-5 mt-2 flex w-full gap-1 overflow-auto">
{#each $submissions as student, i}
<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.submitted_by.profile.avatar_url ? student.submitted_by.profile.avatar_url : defaultImg}
alt="student"
class="m-1 max-h-10 w-10 rounded-full bg-white"
/>
</div>
<p class="line-clamp-2 w-20 whitespace-pre-wrap leading-4">
{student.submitted_by.profile.fullname}
</p>
</button>
{/each}
</div>
<p class="mb-2 font-medium">
{$submissions[studentSelected].submitted_by.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}
<div class="pb-4">
<h3 class="text-lg">{i + 1}. {q.title}</h3>
{#if q.question_type_id !== 3}
{#each q.options as option, i}
<div
class={`my-2 flex items-center gap-2 rounded-md border-2 border-gray-300 p-2 ${isCorrect(
$submissions[studentSelected],
option
)}`}
>
<input
type="radio"
name=""
id=""
class="form-radio"
checked={isSelected($submissions[studentSelected], option)}
/>
<span class="dark:text-white">{option.label}</span>
</div>
{/each}
{:else}
<div class="my-1 w-full rounded bg-slate-100 p-2 dark:bg-slate-300">
<span class="text-base font-medium text-black">
{getOpenAnswer($submissions[studentSelected], q)}
</span>
</div>
{/if}
</div>
{/each}
{/if}
{/if}
{/if}
@@ -1,191 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte';
import { browser } from '$app/environment';
import { Spinner } from '@cio/ui/base/spinner';
// import * as Chart from '@cio/ui/base/chart';
// import { scaleBand } from '@cio/ui/base/chart';
import { submissions } from './store';
import { questionnaire } from '../../store/exercise';
import { t } from '$lib/utils/functions/translations';
import type { ExerciseSubmissions } from '$lib/utils/types';
import { replaceHTMLTag } from '$lib/utils/functions/course';
interface Props {
isLoading?: boolean;
}
let { isLoading = $bindable(true) }: Props = $props();
type TransformedQuestionChartData = {
option: string;
responses: number;
isCorrect?: boolean;
};
interface TransformedQuestion {
title: string;
type: number;
chartData: TransformedQuestionChartData[];
}
let transformedQuestions: TransformedQuestion[] = $state([]);
// Create dynamic chart config based on question data
function getChartConfig(chartData: TransformedQuestionChartData[]): any {
const config: any = {};
chartData.forEach((item, index) => {
config[`option${index}`] = {
label: item.option,
color: item.isCorrect ? '#22c55e' : `hsl(${index * 60}, 70%, 50%)`
};
});
return config;
}
function getAnswerToQuestionOfStudent(
questionId: number,
isTextArea: boolean,
submission: ExerciseSubmissions
): string[] {
let questionSubmission: string[] = [];
// find the answer by this student to this question (questionId)
const studentAnswer = submission.answers.find((a) => a.question_id === questionId);
if (studentAnswer) {
const { answers, open_answer } = studentAnswer;
if (isTextArea) {
questionSubmission = [open_answer];
} else {
questionSubmission = answers;
}
}
return questionSubmission;
}
const getTransformedData = ($submissions: ExerciseSubmissions[]): void => {
const _transformedQuestions: TransformedQuestion[] = [];
$questionnaire.questions.forEach(
(question: {
id: number;
title: string;
question_type_id: number;
options: { value: string; is_correct: boolean; label: string }[];
}) => {
const transformedQuestion: {
title: string;
type: number;
chartData: TransformedQuestionChartData[];
} = {
title: question.title,
type: question.question_type_id,
chartData: []
};
// If textarea don't calculate the value just get the student's answer
if (transformedQuestion.type === 3) {
$submissions.forEach((submission) => {
const chartData: TransformedQuestionChartData = {
option: getAnswerToQuestionOfStudent(question.id, true, submission)[0],
responses: 0
};
// Update the transformed question chartData
transformedQuestion.chartData.push(chartData);
});
} else {
// radio or checkbox
question.options.forEach((option) => {
const { value, is_correct, label } = option;
const chartData: TransformedQuestionChartData = {
option: replaceHTMLTag(label),
responses: 0,
isCorrect: is_correct
};
$submissions.forEach((studentSubmission) => {
const studentAnswer = getAnswerToQuestionOfStudent(question.id, false, studentSubmission);
if (studentAnswer.includes(value)) {
chartData.responses += 1;
}
});
transformedQuestion.chartData.push(chartData);
});
}
_transformedQuestions.push(transformedQuestion);
}
);
untrack(() => {
transformedQuestions = [..._transformedQuestions];
});
console.log({ transformedQuestions });
};
$effect(() => {
if ($submissions?.length) {
getTransformedData($submissions);
}
});
</script>
{#if isLoading}
<Spinner />
{:else if browser}
<div>
<p class="mb-3 text-2xl">
{$t('course.navItem.lessons.exercises.all_exercises.analytics.summary.question_chart')}
</p>
{#each transformedQuestions as q}
<div class="mb-6">
<p class="mb-3 font-medium">{q.title}</p>
{#if q.type === 1}
<!-- Pie Chart for Radio Questions (single choice) -->
{@const chartConfig = getChartConfig(q.chartData)}
{console.log('chartConfig', chartConfig)}
<!-- <Chart.Container config={chartConfig} class="min-h-[300px] w-full">
<Chart.PieChart data={q.chartData} value="responses" name="option" />
</Chart.Container> -->
{:else if q.type === 2}
<!-- Bar Chart for Checkbox Questions (multiple choice) -->
{@const chartConfig = getChartConfig(q.chartData)}
{console.log('chartConfig', chartConfig)}
<!-- <Chart.Container config={chartConfig} class="min-h-[300px] w-full">
<Chart.BarChart
data={q.chartData}
xScale={scaleBand().padding(0.25)}
x="option"
y="responses"
axis="x"
tooltip={false}
series={q.chartData.map((item, idx) => ({
key: 'responses',
label: item.option,
color: item.isCorrect ? '#22c55e' : `hsl(${idx * 60}, 70%, 50%)`
}))}
/>
</Chart.Container> -->
{:else}
<!-- Text answers -->
<div class="max-h-[250px] overflow-auto">
<ul>
{#each q.chartData as answer (answer)}
{#if answer.option}
<div class="my-1 w-full rounded bg-slate-100 p-2 dark:bg-slate-300">
<li class="text-base font-medium text-black">{answer.option}</li>
</div>
{/if}
{/each}
</ul>
</div>
{/if}
</div>
{/each}
</div>
{/if}
@@ -1,66 +0,0 @@
import { ScaleTypes, Alignments } from '@carbon/charts-svelte';
import type { BarChartOptions, PieChartOptions } from '@carbon/charts-svelte';
export const pieOptions: PieChartOptions = {
resizable: true,
legend: {
alignment: Alignments.CENTER
},
pie: {
alignment: Alignments.CENTER
},
height: '400px'
};
export const barOptions: BarChartOptions = {
axes: {
left: {
mapsTo: 'value'
},
bottom: {
mapsTo: 'group',
scaleType: ScaleTypes['LABELS']
}
},
height: '400px'
};
export function getChartOptions(isLoading: boolean) {
const barOptions: BarChartOptions = {
axes: {
left: {
mapsTo: 'value'
},
bottom: {
mapsTo: 'group',
scaleType: ScaleTypes['LABELS']
}
},
height: '400px'
};
const pieOptions: PieChartOptions = {
resizable: true,
legend: {
alignment: Alignments.CENTER
},
pie: {
alignment: Alignments.CENTER
},
height: '400px'
};
if (isLoading) {
barOptions.data = {
loading: true
};
pieOptions.data = {
loading: true
};
}
return {
barOptions,
pieOptions
};
}
@@ -1,87 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte';
import { fetchSubmission } from '$lib/utils/services/submissions';
import Summary from './Summary.svelte';
import Individual from './Individual.svelte';
import { submissions } from './store';
import { t } from '$lib/utils/functions/translations';
interface Props {
exerciseId: string;
}
let { exerciseId = $bindable('') }: Props = $props();
const tabs = [
{
label: $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.heading'),
value: 'summary'
},
{
label: $t('course.navItem.lessons.exercises.all_exercises.analytics.individual.heading'),
value: 'individual'
}
];
let currentTab = $state(tabs[0].value);
let isLoading = $state(true);
const onChange = (tabValue: string) => () => (currentTab = tabValue);
function fetchSubmissions(id: string | undefined) {
untrack(async () => {
if (!id) return;
isLoading = true;
const { data } = await fetchSubmission({
exerciseId: id
});
if (!data) return;
submissions.set(data);
isLoading = false;
});
}
$effect(() => {
fetchSubmissions(exerciseId);
});
</script>
<div class="flex w-full flex-col">
<div class="mb-2 rounded-md border px-4">
<div class="mt-4">
<p class="text-lg">
{$submissions.length}
{$t('course.navItem.lessons.exercises.all_exercises.analytics.submissions')}
</p>
</div>
<div class="flex items-center justify-center overflow-x-auto">
{#each tabs as tab}
<button
class="relative {currentTab === tab.value
? 'ui:text-primary'
: 'dark:bg-gray-500 dark:text-white'} mr-4 w-fit px-2 py-3 text-center font-semibold focus:outline-none dark:bg-transparent"
onclick={onChange(tab.value)}
>
<div class="flex w-full items-center justify-center text-center">
{tab.label}
</div>
<span
class="bg-primary-700 absolute bottom-0 left-0 h-[2px] transition-all duration-500 ease-in-out {currentTab ===
tab.value
? 'w-full'
: 'w-0'}"
></span>
</button>
{/each}
</div>
</div>
{#if currentTab === tabs[0].value}
<Summary bind:isLoading />
{:else}
<Individual bind:isLoading />
{/if}
</div>
@@ -1,377 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte';
import { fly } from 'svelte/transition';
import { group, course } from '$lib/components/Course/store';
import { questionnaire } from '../store/exercise';
import { questionnaireMetaData } from '../store/answers';
import Preview from './Preview.svelte';
import RadioQuestion from '$lib/components/Question/RadioQuestion/index.svelte';
import CheckboxQuestion from '$lib/components/Question/CheckboxQuestion/index.svelte';
import TextareaQuestion from '$lib/components/Question/TextareaQuestion/index.svelte';
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 { removeDuplicate } from '$lib/utils/functions/removeDuplicate';
import { QUESTION_TYPE } from '$lib/components/Question/constants';
import { STATUS } from './constants';
import { getPropsForQuestion, filterOutDeleted, wasCorrectAnswerSelected } from './functions';
import { formatAnswers, getGroupMemberId } from '$lib/components/Course/function';
import { submitExercise } from '$lib/utils/services/courses';
import { fetchSubmission } from '$lib/utils/services/submissions';
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
import { NOTIFICATION_NAME, triggerSendEmail } from '$lib/utils/services/notification/notification';
import { lesson } from '../store/lessons';
import { COURSE_TYPE } from '$lib/utils/types';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { t } from '$lib/utils/functions/translations';
interface Props {
preview?: boolean;
exerciseId?: string;
isFetchingExercise?: boolean;
}
let { preview = false, exerciseId = '', isFetchingExercise = false }: Props = $props();
let submission;
let hasSubmission = false;
let isLoadingAutoSavedData = $state(false);
let alreadyCheckedAutoSavedData = $state(false);
let submissionResponse;
function handleStart() {
$questionnaireMetaData.currentQuestionIndex += 1;
}
const getStudent = (people, profileId) => {
return people.find((person) => person.profile_id === profileId);
};
const notifyEducator = () => {
const student = getStudent($group.students, $profile.id);
const teacherFullname = $group.tutors[0]?.fullname;
const teacherEmail = $group.tutors[0]?.email;
console.log({
student,
teacherFullname,
teacherEmail
});
if (!student || !teacherFullname || !teacherEmail) return;
const baseUrl = `${window.location.origin}/courses/${$course.id}`;
const exerciseLink = `${baseUrl}/lessons/${$lesson.id}/exercises/${exerciseId}`;
const submissionLink = `${baseUrl}/submissions`;
const content = `
<p>Hello ${teacherFullname},</p>
<p>A student ${student.profile.fullname} just submitted an exercise <a href=${exerciseLink}>${$questionnaire.title}</a>
<p>You can get started grading by clicking "Open Submissions"</p>
<div>
<a class="button" href=${submissionLink}>Open Submissions</a>
</div>
`;
triggerSendEmail(NOTIFICATION_NAME.EXERCISE_SUBMISSION_UPDATE, {
to: teacherEmail,
content,
orgName: $currentOrg?.name,
exerciseTitle: $questionnaire.title
});
};
async function onSubmit(id, value) {
const { answers } = $questionnaireMetaData;
const { questions } = $questionnaire;
const prevAnswer = answers[id] || [];
const formattedAnswer = typeof value === 'string' ? value : removeDuplicate([...prevAnswer, ...(value || [])]);
$questionnaireMetaData.answers = {
...answers,
[id]: formattedAnswer
};
const isCorrect = wasCorrectAnswerSelected(currentQuestion, $questionnaireMetaData.answers);
console.log({ isCorrect });
const isFinished = !questions[$questionnaireMetaData.currentQuestionIndex];
console.log(`isFinished`, isFinished);
console.log(`$questionnaireMetaData.currentQuestionIndex`, $questionnaireMetaData.currentQuestionIndex);
if (isCorrect) {
setTimeout(async () => {
$questionnaireMetaData.currentQuestionIndex += 1;
localStorage.setItem(`autosave-exercise-${exerciseId}`, JSON.stringify($questionnaireMetaData));
// If last question send to server
if (isFinished) {
localStorage.removeItem(`autosave-exercise-${exerciseId}`);
$questionnaireMetaData.status = 1;
$questionnaireMetaData.totalPossibleGrade = getTotalPossibleGrade($questionnaire.questions);
$questionnaireMetaData.grades = {};
$questionnaireMetaData.comment = '';
let response = await submitExercise(
$questionnaireMetaData.answers,
questions,
exerciseId,
$course.id,
getGroupMemberId($group.people, $profile.id)
);
if (response) {
submissionResponse = response;
}
notifyEducator();
}
}, 1000);
}
// if (moveToNextQuestion) {
// $questionnaireMetaData.currentQuestionIndex += 1;
// localStorage.setItem(
// `autosave-exercise-${exerciseId}`,
// JSON.stringify($questionnaireMetaData)
// );
// }
}
function onPrevious() {
$questionnaireMetaData.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);
}
async function checkForSubmission(people, profileId?: string, courseId?: string) {
if (!Array.isArray(people) || !profileId || !courseId || !!submission) {
return;
}
if (hasSubmission) return;
hasSubmission = true;
const args = {
exerciseId,
courseId,
submittedBy: getGroupMemberId(people, profileId)
};
const { data } = await fetchSubmission(args);
if (Array.isArray(data) && data.length) {
submission = data[0];
$questionnaireMetaData.answers = formatAnswers({
questions: $questionnaire.questions,
answers: submission.answers
});
$questionnaireMetaData.totalPossibleGrade = getTotalPossibleGrade($questionnaire.questions);
$questionnaireMetaData.currentQuestionIndex = $questionnaire.questions.length;
$questionnaireMetaData.isFinished = true;
$questionnaireMetaData.status = submission.status_id;
$questionnaireMetaData.finalTotalGrade = 0;
$questionnaireMetaData.comment = submission.feedback;
$questionnaireMetaData.grades = submission.answers.reduce((acc, answer) => {
acc[answer.question_id] = answer.point;
$questionnaireMetaData.finalTotalGrade += answer.point;
return acc;
}, {});
}
}
function getAutoSavedData() {
isLoadingAutoSavedData = true;
const stringifiedQuestionnaireMetaData = localStorage.getItem(`autosave-exercise-${exerciseId}`);
if (stringifiedQuestionnaireMetaData) {
const autoSavedData = JSON.parse(stringifiedQuestionnaireMetaData);
if (autoSavedData) {
$questionnaireMetaData = autoSavedData;
}
}
isLoadingAutoSavedData = false;
alreadyCheckedAutoSavedData = true;
}
$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 renderProps = $derived(
getPropsForQuestion(
$questionnaire.questions,
currentQuestion,
$questionnaireMetaData,
$questionnaireMetaData.currentQuestionIndex,
onSubmit,
onPrevious,
preview
)
);
$effect(() => {
untrack(() => {
$questionnaireMetaData.isFinished = isFinished;
});
});
$effect(() => {
untrack(() => {
$questionnaireMetaData.progressValue = progressValue;
});
});
$effect(() => {
!isFetchingExercise && checkForSubmission($group.people, $profile.id, $course.id);
});
</script>
{#if !preview && $questionnaire.questions.length && !$questionnaireMetaData.isFinished}
<Progress value={$questionnaireMetaData.progressValue} />
{/if}
{#if preview}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Preview questions={filterOutDeleted($questionnaire.questions)} questionnaireMetaData={$questionnaireMetaData} />
</RoleBasedSecurity>
{:else if !$questionnaire.questions.length}
<Empty
title="No question added for this exercise"
description="Click the Edit button to add."
icon={FileQuestionIcon}
variant="page"
>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<p class="ui:text-primary text-center text-sm">
Click the <span class="font-semibold">Edit</span> button to add.
</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> questions
</p>
|
<p class="mx-2 dark:text-white">
<strong>{getTotalPossibleGrade($questionnaire.questions)}</strong> points.
</p>
|
<p class="mx-2 dark:text-white">All required</p>
{#if $questionnaire.due_by}
|
<p class="mx-2 dark:text-white">
<strong>Due by:</strong>
{new Date($questionnaire.due_by).toLocaleString()}
</p>
{/if}
</div>
<article class="preview prose prose-sm sm:prose mt-3 p-2">
{@html sanitizeHtml($questionnaire.description || 'No desription')}
</article>
<Button onclick={handleStart} type="button" class="float-right my-5">Start</Button>
</div>
</RoleBasedSecurity>
{:else if $questionnaireMetaData.isFinished}
{#if !isLoadingAutoSavedData}
<div class="flex items-center justify-between">
<div class="flex w-full flex-col items-start lg:flex-row lg:items-center lg:space-x-4">
<h2 class="text-xl font-normal">{$questionnaire.title}</h2>
{#if STATUS.GRADED === $questionnaireMetaData.status}
<span
class="status-text bg-green-700 px-2 py-1 text-center text-white"
title={$t('course.navItem.lessons.exercises.all_exercises.view_mode.status_graded')}
>
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.graded')}
</span>
{:else if $course.type === COURSE_TYPE.SELF_PACED}
<span
class="status-text bg-green-700 px-2 py-1 text-center text-white"
title={$t('course.navItem.lessons.exercises.all_exercises.view_mode.status_submitted')}
>
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.submitted')}
</span>
{:else}
<span
class="status-text bg-yellow-600 px-2 py-1 text-center text-white"
title={$t('course.navItem.lessons.exercises.all_exercises.view_mode.status_pending')}
>
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.pending')}
</span>
{/if}
</div>
{#if STATUS.GRADED === $questionnaireMetaData.status && $course.type !== COURSE_TYPE.SELF_PACED}
<span
class="flex h-10 w-10 items-center justify-center rounded-full border-2 border-gray-300 bg-[#F5F8FE] p-6 text-sm font-semibold text-[#2751DA]"
title={$t('course.navItem.lessons.exercises.all_exercises.view_mode.status_graded')}
>
{$questionnaireMetaData.finalTotalGrade}/{$questionnaireMetaData.totalPossibleGrade}
</span>
{/if}
</div>
{#if $questionnaireMetaData.status === STATUS.GRADED && $questionnaireMetaData.comment && $course.type !== COURSE_TYPE.SELF_PACED}
<div class="bg-primary-700 mt-3 flex items-center justify-between rounded-sm p-4 text-white">
<span> {$questionnaireMetaData.comment}</span>
</div>
{/if}
<Preview
questions={$questionnaire.questions.sort((a, b) => a.order - b.order)}
questionnaireMetaData={$questionnaireMetaData}
grades={$questionnaireMetaData.grades}
disableGrading={true}
/>
{/if}
{:else if currentQuestion && currentQuestion?.id}
{#key currentQuestion.id}
<!-- <div transition:fade id="question"> -->
<div in:fly={{ x: 500, duration: 1000 }} id="question">
{#if QUESTION_TYPE.RADIO === currentQuestion.question_type.id}
<RadioQuestion {...renderProps} key={currentQuestion.id} hideGrading={true} />
{:else if QUESTION_TYPE.CHECKBOX === currentQuestion.question_type.id}
<CheckboxQuestion {...renderProps} key={currentQuestion.id} hideGrading={true} />
{:else if QUESTION_TYPE.TEXTAREA === currentQuestion.question_type.id}
<TextareaQuestion {...renderProps} key={currentQuestion.id} hideGrading={true} />
{/if}
</div>
{/key}
{/if}
<style>
.status-text {
width: fit-content;
}
</style>
@@ -1,104 +0,0 @@
import { QUESTION_TYPE } from '$lib/components/Question/constants';
import { toggleConfetti } from '$features/ui/confetti/store';
export const isAnswerCorrect = (options, answer) => {
return options.some((option) => option.is_correct && option.value === answer);
};
/*
GRADING RULES
Textarea = no validation
Single = only one should be correct
Multiple = all should be correct
*/
export function wasCorrectAnswerSelected(currentQuestion, answers, isFinished?: boolean) {
if (currentQuestion.question_type.id === QUESTION_TYPE.TEXTAREA) {
return true;
}
const answer = answers[currentQuestion.name];
const formattedAnswers = typeof answer === 'string' ? [answer] : answer;
let isCorrect = false;
if (currentQuestion.question_type.id === QUESTION_TYPE.CHECKBOX) {
// Every correct answer should be in the selected answer
isCorrect = currentQuestion.options
.filter((o) => o.is_correct)
.every((option) => formattedAnswers?.includes(option.value));
} else if (currentQuestion.question_type.id === QUESTION_TYPE.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,
questionnaireMetaData,
questionIndex,
onSubmit,
onPrevious,
preview
) {
const { answers, isFinished } = questionnaireMetaData;
const isLast = questionIndex === questions.length;
const isOpenQuesiton = question.question_type.id === QUESTION_TYPE.TEXTAREA;
const isCorrect = wasCorrectAnswerSelected(question, answers, isFinished);
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: `${question.name}`,
options: question.options,
code: question.code,
defaultValue: isOpenQuesiton ? answers[question.name] || '' : answers[question.name] || [],
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.deleted_at);
}
@@ -1,193 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { Button } from '@cio/ui/base/button';
import EyeIcon from '@lucide/svelte/icons/eye';
import { onDestroy, onMount, untrack } from 'svelte';
import * as Breadcrumb from '@cio/ui/base/breadcrumb';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import {
handleAddQuestion,
questionnaire,
questionnaireOrder,
reset,
validateQuestionnaire
} from '../store/exercise';
import { globalStore } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { upsertExercise } from '$lib/utils/services/courses';
import EditMode from './EditMode.svelte';
import ViewMode from './ViewMode.svelte';
import Analytics from './Submissions/index.svelte';
import { IconButton } from '@cio/ui/custom/icon-button';
import UpdateDescription from './UpdateDescription.svelte';
import { RoleBasedSecurity } from '$features/ui';
interface Props {
exerciseId?: string;
path?: string;
goBack?: any;
isFetching?: boolean;
}
let { exerciseId = $bindable(''), path = '', goBack = () => {}, isFetching = false }: Props = $props();
let preview: boolean = $state(false);
let shouldDeleteExercise = $state(false);
let isSaving = $state(false);
let selectedTab = $state('questions');
async function handleSave() {
if ($globalStore.isStudent) return;
const errors = validateQuestionnaire($questionnaire.questions);
if (Object.values(errors).length > 0) {
return;
}
isSaving = true;
reset();
try {
const updatedQuestions = await upsertExercise($questionnaire, exerciseId);
questionnaire.update((q) => ({
...q,
is_title_dirty: false,
is_description_dirty: false,
// @ts-ignore
questions: updatedQuestions
}));
snackbar.success('snackbar.exercise.success');
// Redirect to exercises page
goto(path);
} catch (error) {
console.error(error);
snackbar.error();
}
isSaving = false;
}
onDestroy(() => {
reset();
});
onMount(() => {
const tabParam = page.url.searchParams.get('tab');
if (tabParam) {
selectedTab = tabParam;
}
});
$effect(() => {
untrack(() => {
goto(page.url.pathname + '?tab=' + selectedTab);
});
});
// $effect(() => {
// const addNewQ = $questionnaire?.questions?.length < 1;
// console.log('addNewQ', addNewQ);
// if (addNewQ) {
// untrack(() => {
// handleAddQuestion();
// });
// }
// });
</script>
<div class="overflow-x-hidden px-4">
<div class="sticky top-0 z-10 mb-3 bg-gray-100 p-2 dark:bg-neutral-800">
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item>
<Breadcrumb.Link href={path}>
{$t('course.navItem.lessons.exercises.all_exercises.heading')}
</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator />
<Breadcrumb.Item>
<Breadcrumb.Page>
{$questionnaire.title}
</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<!-- <Tabs.Root bind:value={selectedTab} class="w-full">
<Tabs.List class="mb-2">
<Tabs.Trigger value="questions">
{$t('course.navItem.lessons.exercises.all_exercises.questions')} ({$questionnaire.questions.length})
</Tabs.Trigger>
<Tabs.Trigger value="submissions">
{$t('course.navItem.lessons.exercises.all_exercises.submissions')} ({$questionnaire.totalSubmissions})
</Tabs.Trigger>
</Tabs.List>
</Tabs.Root> -->
{#if selectedTab === 'questions'}
<div class="right-0 flex w-full items-center justify-end">
<div class="flex items-center">
<Button class="mr-2" onclick={handleSave} loading={isSaving}>
{$t('course.navItem.lessons.exercises.all_exercises.save')}
</Button>
<IconButton
onclick={() => (preview = !preview)}
tooltip={$t('course.navItem.lessons.exercises.all_exercises.preview')}
tooltipSide="bottom"
>
<EyeIcon size={20} class={preview ? 'filled' : ''} />
</IconButton>
<IconButton
onclick={handleAddQuestion}
tooltip={$t('course.navItem.lessons.exercises.all_exercises.add_question')}
tooltipSide="bottom"
>
<CirclePlusIcon size={20} />
</IconButton>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost" size="icon" class="h-8 w-8">
<EllipsisVerticalIcon class="h-5 w-5" />
<span class="sr-only">Open menu</span>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => ($questionnaireOrder.open = true)}>
{$t('course.navItem.lessons.exercises.all_exercises.reorder')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
class="text-red-600 focus:text-red-600 dark:text-red-400"
onclick={() => (shouldDeleteExercise = true)}
>
{$t('course.navItem.lessons.exercises.all_exercises.delete_exercise')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</div>
{/if}
</RoleBasedSecurity>
</div>
{#if selectedTab === 'questions'}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<UpdateDescription {preview} />
</RoleBasedSecurity>
{#if !$globalStore.isStudent && !preview}
<EditMode bind:shouldDeleteExercise {exerciseId} {goBack} />
{:else}
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} />
{/if}
{:else if selectedTab === 'submissions'}
<Analytics bind:exerciseId />
{/if}
</div>
@@ -1,375 +0,0 @@
<script lang="ts">
import { Badge } from '@cio/ui/base/badge';
import { Skeleton } from '@cio/ui/base/skeleton';
import * as Dialog from '@cio/ui/base/dialog';
import { InputField } from '@cio/ui/custom/input-field';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import { exerciseTemplateApi } from '$features/exercise-template/api';
import { snackbar } from '$features/ui/snackbar/store';
import { Confetti, ComingSoon } from '$features/ui';
import { CircleCheckIcon } from '$features/ui/icons';
import { TAGS } from '$features/exercise-template/utils/constants';
import type { ExerciseTemplate } from '$lib/utils/types';
interface Props {
open: boolean;
handleAddExercise: () => void;
handleCancelAddExercise: () => void;
handleTemplateCreate: (template: ExerciseTemplate) => Promise<void>;
title: string;
}
let {
open = $bindable(false),
handleAddExercise = () => {},
handleCancelAddExercise = () => {},
handleTemplateCreate,
title = $bindable('')
}: Props = $props();
const Type = {
SCRATCH: 0,
TEMPLATE: 1,
AI: 2
} as const;
const options = [
{
title: $t('course.navItem.lessons.exercises.new_exercise_modal.options.from_scratch'),
subtitle: $t('course.navItem.lessons.exercises.new_exercise_modal.options.from_scratch_subtitle'),
type: Type.SCRATCH,
isDisabled: false
},
{
title: $t('course.navItem.lessons.exercises.new_exercise_modal.options.use_template'),
subtitle: $t('course.navItem.lessons.exercises.new_exercise_modal.options.use_template_subtitle'),
type: Type.TEMPLATE,
isDisabled: false
}
// TODO: Come back to fix ai implementation
// {
// title: $t('course.navItem.lessons.exercises.new_exercise_modal.options.use_ai'),
// subtitle: $t('course.navItem.lessons.exercises.new_exercise_modal.options.use_ai_subtitle'),
// type: Type.AI,
// isDisabled: false
// }
];
const tags = Object.values(TAGS);
let step = $state(0);
let type: number = $state(Type.SCRATCH);
// let questionNumber = $state(5);
// let optionNumber = $state(5);
let isLoading = $state(false);
let isAIStarted = $state(false);
let selectedTag = $state(tags[0]);
let selectedTemplateId = $state('');
let isTemplateFinishedLoading = $state(false);
// const {
// input,
// handleSubmit
// completion
// } = useCompletion({
// api: '/api/completion/exerciseprompt',
// onFinish: async (/**prompt: string, completion: string*/) => {
// if (!$lesson.id) return;
// toggleConfetti();
// const responseData = completion. $completion.replace('```json', '').replace('```', '');
// const template: ExerciseTemplate = JSON.parse(responseData);
// await handleTemplateCreate(template);
// toggleConfetti();
// isLoading = false;
// }
// });
function handleNext() {
step = step + 1;
}
function handleBack() {
step = step - 1;
}
// function callAI() {
// $input = JSON.stringify({
// questionNumber,
// optionNumber,
// lessonNote: note
// });
// setTimeout(() => {
// isAIStarted = true;
// isLoading = true;
// handleSubmit({ preventDefault: () => {} });
// }, 500);
// }
async function handleTemplateSelection() {
isTemplateFinishedLoading = true;
const template = exerciseTemplateApi.templates?.find((t) => t.id === Number(selectedTemplateId));
if (!template) return;
let fetchedTemplate: ExerciseTemplate;
try {
await exerciseTemplateApi.fetchTemplateById(template.id);
fetchedTemplate = exerciseTemplateApi.template[0];
console.log('Fetched template', fetchedTemplate);
await handleTemplateCreate(fetchedTemplate);
} catch (error) {
console.log('Error fetching template', error);
snackbar.error($t('course.navItem.lessons.exercises.new_exercise_modal.errors.template_fetch'));
} finally {
isTemplateFinishedLoading = false;
}
}
const handleTagSelection = async (tag: string) => {
selectedTag = tag;
selectedTemplateId = '';
await exerciseTemplateApi.fetchTemplatesByTag(selectedTag);
};
// const content = $derived(
// lessonFallbackNote(
// $lesson.materials.note,
// $lessonByTranslation[$lesson.id || ''],
// $lesson.locale
// )
// );
// const note = $derived(browser ? getTextFromHTML(content) : '');
</script>
<Dialog.Root
bind:open
onOpenChange={(isOpen) => {
if (!isOpen) handleCancelAddExercise();
}}
>
<Dialog.Content class="w-4/5 max-w-2xl">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.exercises.new_exercise_modal.heading')}</Dialog.Title>
</Dialog.Header>
{#if !isLoading && isAIStarted}
<Confetti />
{/if}
{#if step === 0}
<div>
<h2 class="my-5 text-2xl font-medium">
{$t('course.navItem.lessons.exercises.new_exercise_modal.how')}?
</h2>
<div class="my-8 flex justify-between gap-2">
{#each options as option}
<button
class="h-60 w-[261px] rounded-md border-2 p-5 dark:bg-neutral-700 {option.type === type
? 'border-primary-400'
: `border-gray-200 dark:border-neutral-600 ${
!option.isDisabled && 'hover:scale-95'
}`} flex flex-col {option.isDisabled && 'cursor-not-allowed opacity-60'} transition-all ease-in-out"
type="button"
onclick={!option.isDisabled ? () => (type = option.type) : undefined}
>
<div class="flex h-[70%] w-full flex-row-reverse">
<CircleCheckIcon size={16} filled={option.type === type} />
</div>
<div>
<p class="flex items-center text-start">
<span class="mr-2 text-sm">{option.title}</span>
{#if option.isDisabled}
<ComingSoon />
{/if}
</p>
<p class="text-start text-xs font-light">{option.subtitle}</p>
</div>
</button>
{/each}
</div>
<div class="mt-8 flex flex-row-reverse items-center">
<Button onclick={handleNext}>
{$t('course.navItem.lessons.exercises.new_exercise_modal.next')}
</Button>
</div>
</div>
{:else if step === 1}
{#if type === Type.SCRATCH}
<div class="m-auto flex min-h-[300px] w-96 items-center justify-center">
<div class="w-full">
<h2 class="my-5 text-2xl font-medium">
{$t('course.navItem.lessons.exercises.new_exercise_modal.title')}
</h2>
<InputField
bind:value={title}
autoFocus={true}
placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.title_placeholder')}
className="my-4"
/>
<div class="mt-5 flex items-center justify-between">
<Button variant="outline" onclick={handleBack}>
{$t('course.navItem.lessons.exercises.new_exercise_modal.back')}
</Button>
<Button onclick={handleAddExercise}>
{$t('course.navItem.lessons.exercises.new_exercise_modal.finish')}
</Button>
</div>
</div>
</div>
{:else if type === Type.TEMPLATE}
<div>
<h2 class="m-0 mb-2 text-2xl font-medium">
{$t('course.navItem.lessons.exercises.new_exercise_modal.select_template')}
</h2>
<div>
<div class="mb-5 flex items-center gap-2">
{#each tags as tag}
<Badge class={selectedTag === tag ? 'bg-primary-400' : ''} onclick={() => handleTagSelection(tag)}
>{tag}</Badge
>
{/each}
</div>
{#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 _}
<div
class="h-[140px] w-full rounded-md border-2 border-gray-200 p-5 dark:border-neutral-600 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 > 0}
<div class="grid grid-cols-2 items-start gap-4 lg:grid-cols-3 xl:grid-cols-4">
{#each exerciseTemplateApi.templates as template}
<button
class="h-[140px] w-full rounded-md border-2 p-5 hover:scale-95 dark:bg-neutral-700 {template.id ===
selectedTemplateId
? 'border-primary-400'
: `border-gray-200 dark:border-neutral-600 `} flex flex-col transition-all ease-in-out"
type="button"
onclick={() => (selectedTemplateId = template.id)}
>
<div class="flex h-full flex-col justify-evenly">
<p class="flex items-center text-start text-sm">
{template.title}
</p>
<div class="flex flex-col items-start justify-between gap-1">
<p class="text-xs font-normal">
{template.questions}
{$t('course.navItem.lessons.exercises.new_exercise_modal.questions')}
</p>
<p class="text-start text-xs font-normal">
{template.points}
{$t('course.navItem.lessons.exercises.new_exercise_modal.points')}
</p>
</div>
</div>
</button>
{/each}
</div>
{/if}
<div class="mt-5 flex items-center justify-between">
<Button variant="outline" onclick={handleBack}>
{$t('course.navItem.lessons.exercises.new_exercise_modal.back')}
</Button>
<Button
disabled={!selectedTemplateId || exerciseTemplateApi.templates.length === 0}
loading={isTemplateFinishedLoading}
onclick={handleTemplateSelection}
>
{$t('course.navItem.lessons.exercises.new_exercise_modal.finish')}
</Button>
</div>
</div>
</div>
<!-- {:else if type === Type.AI} -->
<!-- <div>
<div class="flex max-h-[500px] flex-row justify-between">
<div class="mr-1 w-[60%] rounded-md border px-3 py-2">
{#if note.length}
<h3>{$t('course.navItem.lessons.exercises.new_exercise_modal.create_exercises')}</h3>
<p class="mb-4 text-sm">
{$t('course.navItem.lessons.exercises.new_exercise_modal.choose_questions')}
</p>
<InputField
label={$t('course.navItem.lessons.exercises.new_exercise_modal.how_many_questions')}
type="number"
bind:value={questionNumber}
placeholder="5"
className="mb-2"
isRequired
/>
<InputField
label={$t('course.navItem.lessons.exercises.new_exercise_modal.how_many_options')}
type="number"
bind:value={optionNumber}
placeholder="5"
isRequired
/>
{:else}
<h3>{$t('course.navItem.lessons.exercises.new_exercise_modal.add_note')}</h3>
{/if}
<div class="mt-5 flex flex-row-reverse items-center">
<Button
onclick={callAI}
loading={isLoading}
disabled={isLoading || !note}
variant="outline"
>
{$t('course.navItem.lessons.exercises.new_exercise_modal.generate')}
</Button>
</div>
</div>
<div
class="flex w-[40%] items-center justify-center overflow-y-auto rounded-md border px-5 py-3"
>
{#if isLoading}
<Circle3 size="60" unit="px" duration="1s" />
{:else if isAIStarted}
<p class="max-h-[200px] text-sm leading-7">
{$t('course.navItem.lessons.exercises.new_exercise_modal.completion')}
</p>
{:else}
<p class="max-h-[200px] text-sm leading-7">
{$t('course.navItem.lessons.exercises.new_exercise_modal.click_generate')}
</p>
{/if}
</div>
</div>
<div class="mt-5 flex items-center justify-between">
<Button
variant="ghost"
onclick={handleBack}
>
{$t('course.navItem.lessons.exercises.new_exercise_modal.back')}
</Button>
<Button
disabled={isLoading || !note}
onclick={handleAddExercise}
>
{$t('course.navItem.lessons.exercises.new_exercise_modal.finish')}
</Button>
</div>
</div> -->
{/if}
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -1,224 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount, untrack } from 'svelte';
import { Spinner } from '@cio/ui/base/spinner';
import { lesson } from '../store/lessons';
import { globalStore } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import * as Breadcrumb from '@cio/ui/base/breadcrumb';
import { supabase } from '$lib/utils/functions/supabase';
import type { ExerciseTemplate, Exercise as ExerciseType } from '$lib/utils/types';
import { formatDate } from '$lib/utils/functions/routes/dashboard';
import { isQuestionnaireFetching, questionnaire } from '../store/exercise';
import { createExercise, createExerciseFromTemplate } from '$lib/utils/services/courses';
import Exercise from '../Exercise/index.svelte';
import { Backdrop, RoleBasedSecurity } from '$features/ui';
import * as Empty from '@cio/ui/base/empty';
import { QUESTION_TYPES } from '$lib/components/Question/constants';
import { Button } from '@cio/ui/base/button';
import NewExerciseModal from '$lib/components/Course/components/Lesson/Exercises/NewExerciseModal.svelte';
interface Props {
path?: string;
exerciseId?: string;
lessonId?: string;
}
let { path = $bindable(''), exerciseId = '', lessonId = '' }: Props = $props();
let open = $state(false);
let isFetching = $state(false);
let newExercise = $state({
id: 1,
title: '',
description: ''
});
async function handleTemplateCreate(template: ExerciseTemplate): Promise<void> {
const newExercise = (await createExerciseFromTemplate(lessonId, template)) as ExerciseType;
console.log('newExercise', newExercise);
if (newExercise) {
lesson.update((_lesson) => ({
..._lesson,
exercises: [..._lesson.exercises, newExercise]
}));
handleCancelAddExercise();
goto(path + '/' + newExercise.id);
}
}
async function handleAddExercise() {
const { data, error } = await createExercise({
title: newExercise.title,
lesson_id: lessonId
});
if (!data) return;
console.log(`data, error `, data, error);
const insertedExercise = data?.[0] as ExerciseType;
lesson.update((_lesson) => ({
..._lesson,
exercises: [..._lesson.exercises, insertedExercise]
}));
handleCancelAddExercise();
}
function handleCancelAddExercise() {
open = false;
newExercise = {
id: new Date().getTime(),
title: '',
description: ''
};
}
function getExercises(lId: string | undefined) {
if (!lId) return;
untrack(async () => {
isFetching = true;
const exercisesData = await supabase.from('exercise').select(`id, title, created_at`).match({ lesson_id: lId });
$lesson.exercises = exercisesData.data as ExerciseType[];
isFetching = false;
});
}
async function getExercise(exerciseId: string | undefined) {
if (!exerciseId) return;
untrack(async () => {
isFetching = true;
isQuestionnaireFetching.update(() => true);
let { data, error } = await supabase
.from('exercise')
.select(
`
id, title, description, due_by,
totalSubmissions:submission(count),
questions:question(
*,
options:option(*),
question_type:question_type_id(id, label)
)
`
)
.eq('id', exerciseId)
.single();
if (error) {
return;
}
if (data && Array.isArray(data.questions)) {
// Need to set the question type inorder for the select in the questionnaire builder to match
data.questions.forEach((question) => {
question.question_type = QUESTION_TYPES.find((type) => type.id === question.question_type.id);
return question;
});
data.questions = data.questions.sort((a, b) => a.order - b.order);
} else if (data) {
data.questions = [];
}
questionnaire.set({
...(data || {}),
is_title_dirty: false,
is_description_dirty: false,
is_due_by_dirty: false,
questions: Array.isArray(data?.questions) ? data?.questions : [],
totalSubmissions: data?.totalSubmissions?.[0]?.count || 0
});
isQuestionnaireFetching.update(() => false);
isFetching = false;
});
}
function goBack() {
goto(path);
}
onMount(() => {
getExercises(lessonId);
});
$effect(() => {
getExercise(exerciseId);
});
</script>
{#if isFetching}
<Backdrop>
<Spinner class="size-14! text-blue-700!" />
</Backdrop>
{/if}
{#if exerciseId}
<Exercise {exerciseId} {goBack} {path} {isFetching} />
{:else}
<NewExerciseModal
bind:open
{handleCancelAddExercise}
{handleAddExercise}
{handleTemplateCreate}
bind:title={newExercise.title}
/>
<div>
{#snippet header()}
<slot:fragment>
<Breadcrumb.Root class="my-2">
<Breadcrumb.List>
<Breadcrumb.Item>
<Breadcrumb.Link href={path}>
{$t('course.navItem.lessons.exercises.heading')}
</Breadcrumb.Link>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Button class="my-2 mr-2" onclick={() => (open = !open)}>
{$t('course.navItem.lessons.exercises.add_button')}
</Button>
</RoleBasedSecurity>
</slot:fragment>
{/snippet}
<div class="mt-5 flex flex-wrap">
{#each $lesson.exercises as exercise}
<a class="mb-4 mr-4 w-52 rounded-lg bg-gray-100 px-4 py-7 dark:bg-neutral-800" href="{path}/{exercise.id}">
<h3 class="text-xl dark:text-white">{exercise.title}</h3>
<p class="mt-4 text-sm dark:text-white">{formatDate(exercise.created_at)}</p>
</a>
{:else}
<Empty.Root class="mt-3">
<Empty.Header>
<Empty.Media variant="icon">
<img src="/images/empty-exercise-icon.svg" alt="Exercise" class="size-6" />
</Empty.Media>
<Empty.Title>
{$t('course.navItem.lessons.exercises.no_exercises')}
</Empty.Title>
<Empty.Description>
{#if $globalStore.isStudent}
{$t('course.navItem.lessons.exercises.no_assigned_exercises')}
<strong> {$t('course.navItem.lessons.exercises.chill')} :)</strong>
{:else}
{$t('course.navItem.lessons.exercises.add_exercise')}
{/if}
</Empty.Description>
</Empty.Header>
</Empty.Root>
{/each}
</div>
</div>
{/if}
@@ -1,198 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { dndzone } from 'svelte-dnd-action';
import { Button } from '@cio/ui/base/button';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import ListChecksIcon from '@lucide/svelte/icons/list-checks';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { Chip } from '@cio/ui/custom/chip';
import { InputField } from '@cio/ui/custom/input-field';
import { RoleBasedSecurity } from '$features/ui';
import * as Empty from '@cio/ui/base/empty';
import { lessons, handleSaveLesson } from '$lib/components/Course/components/Lesson/store/lessons';
import { globalStore } from '$lib/utils/store/app';
import { course } from '$lib/components/Course/store';
import { t } from '$lib/utils/functions/translations';
import type { Course, Lesson } from '$lib/utils/types';
import { updateLesson } from '$lib/utils/services/courses';
const flipDurationMs = 300;
interface Props {
reorder?: boolean;
lessonEditing: string | undefined;
lessonToDelete: Lesson | undefined;
openDeleteModal?: boolean;
}
let {
reorder = false,
lessonEditing = $bindable(),
lessonToDelete = $bindable(),
openDeleteModal = $bindable(false)
}: Props = $props();
let errors: Record<string, string> = $state({});
async function saveLesson(lesson: Lesson, courseId: Course['id']) {
const validationRes = await handleSaveLesson(lesson, courseId);
if (validationRes && Object.keys(validationRes).length) {
// @ts-ignore
errors = validationRes;
} else {
errors = {};
lessonEditing = undefined;
}
}
function handleDndConsider(e: any) {
$lessons = e.detail.items;
}
function handleDndFinalize(e: any) {
const prevLessonsByOrder = $lessons.reduce((acc, l) => ({ ...acc, [l.id]: l.order }), {});
$lessons = e.detail.items;
// Only update the lesson order that changed
e.detail.items.forEach((item: { id: string }, index: number) => {
const order = index + 1;
if (order !== prevLessonsByOrder[item.id]) {
$lessons[index].order = order;
updateLesson({ order }, item.id).then((update) => console.log(`updated lesson order`, update));
}
});
}
function getLessonOrder(id: string): number | string {
const index = $lessons.findIndex((lesson) => lesson.id === id);
if (index < 9) {
return '0' + (index + 1);
} else {
return index + 1;
}
}
</script>
<section
class="mx-auto w-full p-3 lg:w-11/12 lg:px-4"
use:dndzone={{
items: $lessons,
flipDurationMs,
dragDisabled: !reorder,
dropTargetStyle: {
border: '2px #1d4ed8 solid',
'border-style': 'dashed'
}
}}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
>
{#each $lessons as lesson (lesson.id)}
<div
class="relative m-auto mb-4 flex max-w-xl items-center rounded-md border-2 border-gray-200 p-5 dark:border-neutral-600 dark:bg-neutral-800"
>
<!-- Number Chip -->
<div class="mr-5">
<Chip value={getLessonOrder(lesson.id)} />
</div>
<!-- Lesson Content -->
<div class="w-4/5">
<!-- Lesson Title -->
{#if lessonEditing === lesson.id}
<InputField bind:value={lesson.title} autoFocus={true} className="max-w-lg" errorMessage={errors?.title} />
{:else}
<h3 class="m-0 flex items-center text-lg dark:text-white">
<a
href={$globalStore.isStudent && !lesson.is_unlocked
? page.url.pathname
: `/courses/${$course.id}/lessons/${lesson.id}`}
class="font-medium text-black no-underline hover:underline dark:text-white {$globalStore.isStudent &&
!lesson.is_unlocked
? 'cursor-not-allowed'
: ''}"
>
{lesson.title}
</a>
</h3>
{/if}
<div class="mt-2 flex w-4/5 flex-col items-start justify-between lg:flex-row lg:items-center">
<!-- Lesson Length -->
<div class="mb-3 flex items-center lg:mb-0">
<ListChecksIcon size={16} />
<p class="ml-2 text-sm text-gray-500 dark:text-white">
{lesson?.totalExercises ? lesson?.totalExercises?.map((c) => c.count) : 0}
{$t('exercises.heading')}
</p>
</div>
</div>
</div>
<!-- 3 dot edit -->
<div class="flex flex-row">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost" size="icon" class="h-8 w-8">
<EllipsisVerticalIcon class="h-5 w-5" />
<span class="sr-only">Open menu</span>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={() => {
lesson.is_unlocked = !lesson.is_unlocked;
handleSaveLesson(lesson, $course.id);
}}
>
{lesson.is_unlocked
? $t('course.navItem.lessons.add_lesson.lock')
: $t('course.navItem.lessons.add_lesson.unlock')}
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => {
if (lessonEditing === lesson.id) {
saveLesson(lesson, $course.id);
} else {
lessonEditing = lesson.id;
}
}}
>
{lessonEditing === lesson.id
? $t('course.navItem.lessons.add_lesson.save')
: $t('course.navItem.lessons.add_lesson.edit')}
</DropdownMenu.Item>
<DropdownMenu.Item
class="text-red-600 focus:text-red-600 dark:text-red-400"
onclick={() => {
lessonToDelete = lesson;
openDeleteModal = true;
}}
>
{$t('course.navItem.lessons.add_lesson.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</RoleBasedSecurity>
</div>
</div>
{:else}
<Empty.Root>
<Empty.Header>
<Empty.Media variant="icon">
<img src="/images/empty-lesson-icon.svg" alt="Lesson" class="size-6" />
</Empty.Media>
<Empty.Title>{$t('course.navItem.lessons.body_header')}</Empty.Title>
<Empty.Description>
{$t('course.navItem.lessons.body_content')}
</Empty.Description>
</Empty.Header>
</Empty.Root>
{/each}
</section>
@@ -1,358 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { flip } from 'svelte/animate';
import { dndzone } from 'svelte-dnd-action';
import PlusIcon from '@lucide/svelte/icons/plus';
import ListChecksIcon from '@lucide/svelte/icons/list-checks';
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 { InputField } from '@cio/ui/custom/input-field';
import { Chip } from '@cio/ui/custom/chip';
import { IconButton } from '@cio/ui/custom/icon-button';
import { t } from '$lib/utils/functions/translations';
import { globalStore } from '$lib/utils/store/app';
import { course } from '$lib/components/Course/store';
import { handleAddLessonWidget } from '$lib/components/Course/components/Lesson/store';
import { snackbar } from '$features/ui/snackbar/store';
import {
lessonSections,
handleDelete,
handleSaveLesson,
handleSaveLessonSection,
handleDeleteSection
} from '$lib/components/Course/components/Lesson/store/lessons';
import { updateLesson, updateLessonSection } from '$lib/utils/services/courses';
import type { Lesson } from '$lib/utils/types';
import DeleteLessonConfirmation from '$lib/components/Course/components/Lesson/DeleteLessonConfirmation.svelte';
import { RoleBasedSecurity } from '$features/ui';
type CrudParam = {
sectionId?: string;
lessonId?: string;
sectionTitle?: string;
lessonTitle?: string;
};
interface Props {
reorder?: boolean;
lessonEditing: string | undefined;
}
let { reorder = false, lessonEditing = $bindable() }: Props = $props();
let prevTitle: string | undefined = $state();
let errors: Record<string, string> = $state({});
let openDeleteModal = $state(false);
let deletingData = {
id: '',
isSection: false
};
function onEdit(params: CrudParam) {
lessonEditing = params.sectionId || params.lessonId;
prevTitle = params.sectionTitle || params.lessonTitle;
}
async function onSave(params: CrudParam, lesson?: Lesson) {
if (params.sectionId) {
const section = $lessonSections.find((s) => s.id === params.sectionId);
if (!section) return;
const validationRes = await handleSaveLessonSection(section, section.course_id);
if (validationRes && Object.keys(validationRes).length) {
errors = {
// @ts-ignore
title: validationRes?.title
};
}
} else if (lesson) {
const validationRes = await handleSaveLesson(lesson, lesson.course_id);
if (validationRes && Object.keys(validationRes).length) {
errors = {
// @ts-ignore
title: validationRes?.title
};
}
}
if (!errors?.title) {
resetEdit();
snackbar.success('snackbar.success_update');
}
}
function onDelete(params: CrudParam) {
deletingData.id = params.sectionId || params.lessonId || '';
deletingData.isSection = params.sectionId ? true : false;
openDeleteModal = true;
}
function deleteLesson() {
if (deletingData.isSection) {
handleDeleteSection(deletingData.id);
} else {
handleDelete(deletingData.id);
}
}
function resetEdit() {
lessonEditing = undefined;
prevTitle = undefined;
errors = {};
}
function handleAddLesson(id) {
handleAddLessonWidget.update(() => ({
open: true,
isSection: false,
id
}));
}
/**
* Drag functionality
*/
const flipDurationMs = 200;
function handleDndConsiderColumns(e) {
$lessonSections = e.detail.items;
}
function handleDndFinalizeColumns(e) {
const prevSectionsOrder = $lessonSections.reduce((acc, l) => ({ ...acc, [l.id]: l.order }), {});
$lessonSections = e.detail.items;
e.detail.items.forEach((section, index) => {
const order = index + 1;
if (order !== prevSectionsOrder[section.id]) {
$lessonSections[index].order = order;
updateLessonSection({ order }, section.id).then((update) => console.log(`updated section order`, update));
}
});
}
function handleDndConsiderCards(cid, e) {
const colIdx = $lessonSections.findIndex((c) => c.id === cid);
$lessonSections[colIdx].lessons = e.detail.items;
$lessonSections = [...$lessonSections];
}
function handleDndFinalizeCards(cid, e) {
const colIdx = $lessonSections.findIndex((c) => c.id === cid);
const section = $lessonSections[colIdx];
const prevLessonOrder = section.lessons.reduce((acc, l) => ({ ...acc, [l.id]: l.order }), {});
$lessonSections[colIdx].lessons = e.detail.items;
$lessonSections = [...$lessonSections];
e.detail.items.forEach((lesson: Lesson, index: number) => {
const order = index + 1;
if (order !== prevLessonOrder[lesson.id] || lesson.section_id !== section.id) {
section.lessons[index].order = order;
const lessonUpdate = {
order,
section_id: $lessonSections[colIdx].id
};
updateLesson(lessonUpdate, lesson.id).then((update) => console.log(`updated lesson order`, update));
}
});
}
function getLessonOrder(lessons, id) {
const index = lessons.findIndex((lesson) => lesson.id === id);
if (index < 9) {
return '0' + (index + 1);
} else {
return index + 1;
}
}
</script>
<DeleteLessonConfirmation bind:openDeleteModal {deleteLesson} />
<section
class="mx-auto w-full p-3 lg:w-11/12 lg:px-4"
use:dndzone={{
items: $lessonSections,
flipDurationMs,
type: 'rows',
dropTargetStyle: {
border: '2px #1d4ed8 solid',
'border-style': 'dashed'
},
dragDisabled: !reorder
}}
onconsider={handleDndConsiderColumns}
onfinalize={handleDndFinalizeColumns}
>
{#each $lessonSections as section (section.id)}
<div class="m-auto mb-3 max-w-xl rounded-md border-2 border-gray-200 dark:border-neutral-600 dark:bg-neutral-800">
<div
class="mb-2 flex min-h-[50px] items-center justify-between rounded-tl-md rounded-tr-md border-b bg-gray-50 px-3 py-1 dark:bg-neutral-700"
>
{#if lessonEditing === section.id}
<InputField className="w-4/6" bind:value={section.title} errorMessage={errors?.title} />
{:else}
<p class="w-4/6 font-semibold">{section.title}</p>
{/if}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="flex items-center">
{#if lessonEditing === section.id}
<Button
variant="outline"
onclick={() => {
section.title = prevTitle ?? section.title;
resetEdit();
}}
>
{$t('course.navItem.lessons.add_lesson.cancel')}
</Button>
<Button onclick={() => onSave({ sectionId: section.id })}>
{$t('course.navItem.lessons.add_lesson.save')}
</Button>
{:else}
<IconButton onclick={() => handleAddLesson(section.id)} disabled={!!lessonEditing}>
<PlusIcon size={16} />
</IconButton>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost" size="icon" class="h-8 w-8">
<EllipsisVerticalIcon class="h-5 w-5" />
<span class="sr-only">Open menu</span>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
disabled={!!lessonEditing}
onclick={() => onEdit({ sectionId: section.id, sectionTitle: section.title })}
>
{$t('course.navItem.lessons.add_lesson.edit')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
class="text-red-600 focus:text-red-600 dark:text-red-400"
onclick={() => onDelete({ sectionId: section.id })}
>
{$t('course.navItem.lessons.add_lesson.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</div>
</RoleBasedSecurity>
</div>
<div
class="mx-3 py-1"
use:dndzone={{
items: section.lessons,
flipDurationMs,
dropTargetStyle: {
border: '2px #1d4ed8 solid',
'border-style': 'dashed'
},
dragDisabled: !reorder
}}
onconsider={(e) => handleDndConsiderCards(section.id, e)}
onfinalize={(e) => handleDndFinalizeCards(section.id, e)}
>
{#each section.lessons as lesson (lesson.id)}
<div
class="mb-2 flex min-h-[50px] max-w-xl items-center justify-between rounded-md border border-gray-200 px-3 py-1"
animate:flip={{ duration: flipDurationMs }}
>
{#if lessonEditing === lesson.id}
<InputField className="w-4/6" bind:value={lesson.title} errorMessage={errors?.title} />
{:else}
<div class="flex w-4/5 items-center gap-2">
<Chip value={getLessonOrder(section.lessons, lesson.id)} />
<div>
<a
href={$globalStore.isStudent && !lesson.is_unlocked
? page.url.pathname
: `/courses/${$course.id}/lessons/${lesson.id}`}
class=" text-black dark:text-white {$globalStore.isStudent && !lesson.is_unlocked
? 'cursor-not-allowed'
: ''}"
data-sveltekit-preload-data="off"
>
{lesson.title}
</a>
<div class="mb-3 mt-1 flex items-center lg:mb-0">
<ListChecksIcon size={16} />
<p class="ml-2 text-xs text-gray-500 dark:text-white">
{lesson?.totalExercises ? lesson?.totalExercises?.map((c) => c.count) : 0}
{$t('exercises.heading')}
</p>
</div>
</div>
</div>
{/if}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="flex items-center gap-1">
<!-- IS EDITING -->
{#if lessonEditing === lesson.id}
<Button
variant="outline"
onclick={() => {
lesson.title = prevTitle ?? lesson.title;
resetEdit();
}}
>
{$t('course.navItem.lessons.add_lesson.cancel')}
</Button>
<Button onclick={() => onSave({ lessonId: lesson.id }, lesson)}>
{$t('course.navItem.lessons.add_lesson.save')}
</Button>
{:else}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost" size="icon" class="h-8 w-8">
<EllipsisVerticalIcon class="h-5 w-5" />
<span class="sr-only">Open menu</span>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={() => {
lesson.is_unlocked = !lesson.is_unlocked;
handleSaveLesson(lesson, $course.id);
}}
>
{lesson.is_unlocked
? $t('course.navItem.lessons.add_lesson.lock')
: $t('course.navItem.lessons.add_lesson.unlock')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => onEdit({ lessonId: lesson.id, lessonTitle: lesson.title })}>
{$t('course.navItem.lessons.add_lesson.edit')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
class="text-red-600 focus:text-red-600 dark:text-red-400"
onclick={() => onDelete({ lessonId: lesson.id })}
>
{$t('course.navItem.lessons.add_lesson.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</div>
</RoleBasedSecurity>
</div>
{:else}
{$t('course.navItem.lessons.no_lesson')}
{/each}
</div>
</div>
{/each}
</section>
@@ -1,310 +0,0 @@
<script lang="ts">
import { lessonDocUpload, resetDocumentUploadStore } from '../../store/lessons';
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 { lesson } from '../../store/lessons';
let fileInput: HTMLInputElement | undefined = $state();
let selectedFile: File | null = $state(null);
let dragOver = $state(false);
let errorTimeout: NodeJS.Timeout | null = $state(null);
let isDisabled = $derived($lessonDocUpload.isUploading || $isFreePlan);
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 handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
selectFile(file);
}
}
function selectFile(file: File) {
const error = validateFile(file);
if (error) {
snackbar.error(error);
return;
}
selectedFile = file;
$lessonDocUpload.error = null;
}
function handleDrop(event: DragEvent) {
if (isDisabled) return;
event.preventDefault();
dragOver = false;
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
selectFile(files[0]);
}
}
function handleDragOver(event: DragEvent) {
if (isDisabled) return;
event.preventDefault();
dragOver = true;
}
function handleDragLeave() {
if (isDisabled) return;
dragOver = false;
}
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 document = {
type: getFileType(selectedFile),
name: selectedFile.name,
link: presignedUrls[fileKey],
key: fileKey,
size: selectedFile.size
};
lesson.update((state) => ({
...state,
materials: {
...(state.materials || {}),
documents: [...(state.materials.documents || []), document]
}
}));
lessonDocUpload.update((state) => ({
...state,
uploadedDocument: document,
uploadProgress: 100
}));
snackbar.success($t('course.navItem.lessons.materials.tabs.document.upload_success'));
// Reset after a short delay
setTimeout(() => {
resetDocumentUploadStore();
selectedFile = null;
if (fileInput) fileInput.value = '';
}, 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 : 'Upload failed';
snackbar.error($t('course.navItem.lessons.materials.tabs.document.upload_error'));
}
} finally {
$lessonDocUpload.isUploading = false;
}
}
function removeSelectedFile() {
selectedFile = null;
if (fileInput) fileInput.value = '';
$lessonDocUpload.error = null;
}
function cancelUpload() {
documentUploader.cancelUpload();
selectedFile = null;
if (fileInput) fileInput.value = '';
}
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>
<UpgradeBanner className="mb-3" onClick={() => ($lessonDocUpload.isModalOpen = false)}>
{$t('course.navItem.lessons.materials.tabs.document.upgrade')}
</UpgradeBanner>
<div class="p-6">
<div class="mb-6">
<h3 class="mb-2 text-lg font-semibold">
{$t('course.navItem.lessons.materials.tabs.document.upload_title')}
</h3>
<p class="mb-4 text-sm text-gray-600">
{$t('course.navItem.lessons.materials.tabs.document.upload_description')}
</p>
</div>
<!-- File Upload Area -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="rounded-lg border-2 border-dashed border-gray-300 p-8 text-center transition-colors {isDisabled &&
'opacity-50 hover:cursor-not-allowed'} {dragOver ? 'border-blue-500 bg-blue-50' : 'hover:border-gray-400'}"
ondrop={handleDrop}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
>
{#if selectedFile}
<div class="mb-4 flex items-center justify-center space-x-3">
<FileTextIcon size={16} />
<div class="text-left">
<p class="font-medium text-gray-900">{selectedFile.name}</p>
<p class="text-sm text-gray-500">{formatFileSize(selectedFile.size)}</p>
</div>
<CloseButton onClick={removeSelectedFile} />
</div>
{:else}
<FileTextIcon size={16} />
<p class="mb-2 text-gray-600">
{$t('course.navItem.lessons.materials.tabs.document.drag_drop')}
</p>
<p class="mb-4 text-sm text-gray-500">
{$t('course.navItem.lessons.materials.tabs.document.or')}
</p>
<input
bind:this={fileInput}
disabled={isDisabled}
type="file"
accept=".pdf,.docx,.doc"
onchange={handleFileSelect}
class="hidden"
/>
<div class="flex justify-center">
<Button variant="outline" disabled={isDisabled} onclick={() => fileInput?.click()}>
{$t('course.navItem.lessons.materials.tabs.document.choose_file')}
</Button>
</div>
{/if}
</div>
<!-- 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 $lessonDocUpload.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>
@@ -1,135 +0,0 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import MODES from '$lib/utils/constants/mode';
import FileTextIcon from '@lucide/svelte/icons/file-text';
import EyeIcon from '@lucide/svelte/icons/eye';
import isEmpty from 'lodash/isEmpty';
import { createEventDispatcher } from 'svelte';
import type { LessonDocument } from '$lib/utils/types';
const dispatch = createEventDispatcher();
interface Props {
mode?: any;
displayDocuments?: LessonDocument[];
downloadingDocuments: Set<string>;
formatFileSize: (bytes: number) => string;
openDocumentUploadModal: () => void;
deleteDocument: (index: number) => void;
downloadDocument: (doc: LessonDocument) => Promise<void>;
isLoading?: boolean;
}
let {
mode = MODES.view,
displayDocuments = [],
downloadingDocuments,
formatFileSize,
openDocumentUploadModal,
deleteDocument,
downloadDocument,
isLoading = false
}: Props = $props();
function handleViewPDF(document: LessonDocument) {
dispatch('viewPDF', document);
}
</script>
{#if mode === MODES.edit}
<div class="mb-4">
<Button onclick={openDocumentUploadModal} class="mb-4">
{$t('course.navItem.lessons.materials.tabs.document.add_document')}
</Button>
</div>
{#if !isEmpty(displayDocuments)}
<div class="space-y-4">
{#each displayDocuments as document, index}
<div class="rounded-lg border border-gray-200 p-4">
<div class="flex items-center space-x-3">
<FileTextIcon size={16} />
<div class="flex-1">
<h4 class="mb-2 font-medium text-gray-900 dark:text-gray-300">{document.name}</h4>
<div class="mb-3 flex items-center space-x-4 text-sm text-gray-600 dark:text-gray-400">
<span>
{$t('course.navItem.lessons.materials.tabs.document.type')}: {document.type.toUpperCase()}
</span>
{#if document.size}
<span>
{$t('course.navItem.lessons.materials.tabs.document.size')}: {formatFileSize(document.size)}
</span>
{/if}
</div>
<div class="flex gap-3">
{#if document.type === 'pdf'}
<button
onclick={() => handleViewPDF(document)}
class="text-sm text-blue-600 underline hover:text-blue-800"
>
<EyeIcon size={16} />
{$t('course.navItem.lessons.materials.tabs.document.view_pdf')}
</button>
{:else}
<a href={document.link} target="_blank" class="text-sm text-blue-600 underline hover:text-blue-800">
{$t('course.navItem.lessons.materials.tabs.document.view_document')}
</a>
{/if}
<button
onclick={() => downloadDocument(document)}
disabled={downloadingDocuments.has(document.name)}
class="cursor-pointer text-sm text-green-600 underline hover:text-green-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{downloadingDocuments.has(document.name)
? $t('course.navItem.lessons.materials.tabs.document.downloading')
: $t('course.navItem.lessons.materials.tabs.document.download')}
</button>
<button onclick={() => deleteDocument(index)} class="text-sm text-red-600 underline hover:text-red-800">
{$t('course.navItem.lessons.materials.tabs.document.delete')}
</button>
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}
{:else if !isEmpty(displayDocuments)}
<div class="space-y-4">
{#each displayDocuments as document}
<div class="rounded-md border border-gray-200 p-4 dark:border-neutral-600">
<div class="flex items-center space-x-3">
<FileTextIcon size={16} />
<div class="flex-1">
<h4 class="mb-2 font-medium text-gray-900 dark:text-gray-300">{document.name}</h4>
<div class="mb-3 flex items-center space-x-4 text-sm text-gray-600 dark:text-gray-400">
<span>{$t('course.navItem.lessons.materials.tabs.document.type')}: {document.type.toUpperCase()}</span>
{#if document.size}
<span>{$t('course.navItem.lessons.materials.tabs.document.size')}: {formatFileSize(document.size)}</span
>
{/if}
</div>
<div class="flex gap-3">
{#if document.type === 'pdf'}
<Button onclick={() => handleViewPDF(document)} loading={isLoading}>
<EyeIcon size={16} />
{$t('course.navItem.lessons.materials.tabs.document.view_pdf')}
</Button>
{:else}
<a
href={isLoading ? '#' : document.link}
target={isLoading ? null : '_blank'}
aria-disabled={isLoading}
class="text-sm text-blue-600 underline hover:text-blue-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{$t('course.navItem.lessons.materials.tabs.document.view_document')}
</a>
{/if}
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}
@@ -1,35 +0,0 @@
import { writable } from 'svelte/store';
export interface DocumentUploadState {
isUploading: boolean;
isModalOpen: boolean;
uploadProgress: number;
uploadedDocument: {
name: string;
link: string;
type: 'pdf' | 'docx' | 'doc';
size: number;
} | null;
error: string | null;
isCancelled: boolean;
}
export const lessonDocUpload = writable<DocumentUploadState>({
isUploading: false,
isModalOpen: false,
uploadProgress: 0,
uploadedDocument: null,
error: null,
isCancelled: false
});
export function resetDocumentUploadStore() {
lessonDocUpload.set({
isUploading: false,
isModalOpen: false,
uploadProgress: 0,
uploadedDocument: null,
error: null,
isCancelled: false
});
}
@@ -1,54 +0,0 @@
<script lang="ts">
import YoutubeVideo from './YoutubeVideo.svelte';
import UploadVideo from './UploadVideo.svelte';
import EmbedLink from './EmbedLink.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';
interface Props {
lessonId?: string;
}
let { lessonId = '' }: Props = $props();
const tabs = CONSTANTS.videoTabs;
let currentTab = $state(tabs[0].value);
const onChange = (tab) => () => (currentTab = tab);
</script>
<section class="flex h-full w-full flex-col items-start gap-3 md:flex-row">
<div class="flex flex-row items-center gap-2 md:flex-col">
<p class="mb-3 w-full text-start text-sm font-normal text-[#4F4B4B] dark:text-[#b0a9a9]">
{$t('course.navItem.lessons.materials.tabs.video.add_video.add_by')}
</p>
{#each tabs as item (item.value)}
<button
onclick={onChange(item.value)}
class={`my-1 w-full border px-4 py-3 ${
currentTab === item.value
? 'border border-[#0233BD] bg-[#F5F8FE] dark:text-black'
: 'border border-gray-200 dark:border-neutral-600'
} flex cursor-pointer flex-row items-center justify-start gap-2 whitespace-nowrap rounded-md`}
>
{#if $isFreePlan && item.value === 3}
<ZapIcon size={16} class="filled" />
{:else}
<item.icon color={`${currentTab === item.value ? 'dark:invert-0' : 'dark:invert'}`} />
{/if}
<p>{$t(item.title)}</p>
</button>
{/each}
</div>
<main class="h-full w-full">
{#if currentTab === 1}
<YoutubeVideo />
{:else if currentTab === 2}
<EmbedLink />
{:else}
<UploadVideo {lessonId} />
{/if}
</main>
</section>
@@ -1,54 +0,0 @@
<script lang="ts">
import YoutubeVideo from './YoutubeVideo.svelte';
import UploadVideo from './UploadVideo.svelte';
import EmbedLink from './EmbedLink.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';
interface Props {
lessonId?: string;
}
let { lessonId = '' }: Props = $props();
const tabs = CONSTANTS.videoTabs;
let currentTab = $state(tabs[0].value);
const onChange = (tab) => () => (currentTab = tab);
</script>
<section class="flex h-full w-full flex-col items-start gap-3 md:flex-row">
<div class="flex flex-row items-center gap-2 md:flex-col">
<p class="mb-3 w-full text-start text-sm font-normal text-[#4F4B4B] dark:text-[#b0a9a9]">
{$t('course.navItem.lessons.materials.tabs.video.add_video.add_by')}
</p>
{#each tabs as item (item.value)}
<button
onclick={onChange(item.value)}
class={`my-1 w-full border px-4 py-3 ${
currentTab === item.value
? 'border border-[#0233BD] bg-[#F5F8FE] dark:text-black'
: 'border border-gray-200 dark:border-neutral-600'
} flex cursor-pointer flex-row items-center justify-start gap-2 whitespace-nowrap rounded-md`}
>
{#if $isFreePlan && item.value === 3}
<ZapIcon size={16} class="filled" />
{:else}
<item.icon color={`${currentTab === item.value ? 'dark:invert-0' : 'dark:invert'}`} />
{/if}
<p>{$t(item.title)}</p>
</button>
{/each}
</div>
<main class="h-full w-full">
{#if currentTab === 1}
<YoutubeVideo />
{:else if currentTab === 2}
<EmbedLink />
{:else}
<UploadVideo {lessonId} />
{/if}
</main>
</section>
@@ -1,234 +0,0 @@
<script lang="ts">
import { Progress } from '@cio/ui/base/progress';
import { preventDefault } from '$lib/utils/functions/svelte';
import { UpgradeBanner } from '$features/ui';
import { lesson, lessonVideoUpload, cancelVideoUpload } from '$lib/components/Course/components/Lesson/store/lessons';
import { isFreePlan } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { VideoUploader } from '$lib/utils/services/courses/presign';
import { Button } from '@cio/ui/base/button';
interface Props {
lessonId?: string;
}
let { lessonId = '' }: Props = $props();
let fileSize: number | undefined = $state();
let formRes: {
url?: string;
fileKey?: string;
status?: number;
type?: string;
message?: string;
} | null = $state(null);
let isLoaded = $state(false);
let fileInput: HTMLInputElement | null = $state(null);
let submit: HTMLInputElement | null = $state(null);
let fileName = $state('');
const isDisabled = $derived($lessonVideoUpload.isUploading || $isFreePlan);
const videoUploader = new VideoUploader();
async function onUpload() {
if (!fileInput) return;
// Prevent free plan users from bypassing UI restrictions
if ($isFreePlan) {
formRes = {
type: 'AUTHORIZATION_ERROR',
status: 403,
message: $t('upgrade.required')
};
isLoaded = true;
return;
}
videoUploader.initUpload();
const videoFile = fileInput.files?.[0];
if (!videoFile) return;
fileSize = videoFile?.size / (1024 * 1024);
fileName = videoFile?.name;
try {
const { url: presignedUrl, fileKey } = await videoUploader.getPresignedUrl(videoFile);
await videoUploader.uploadFile({
url: presignedUrl,
file: videoFile
});
const { urls: presignedUrls } = await videoUploader.getDownloadPresignedUrl([fileKey]);
formRes = {
url: presignedUrls[fileKey],
fileKey: fileKey,
status: 200
};
$lesson.materials.videos = [
...$lesson.materials.videos,
{
type: 'upload',
link: formRes.url!,
key: formRes?.fileKey
}
];
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('course.navItem.lessons.materials.tabs.video.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 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('course.navItem.lessons.materials.tabs.video.add_video.upgrade')}
</UpgradeBanner>
{#if !isLoaded}
<button
type="button"
onclick={() => (fileInput && !$lessonVideoUpload.isUploading ? fileInput.click() : null)}
class="h-full w-full {isDisabled && 'opacity-50 hover:cursor-not-allowed'}"
disabled={isDisabled}
>
<form
class="border-primary-300 flex h-full w-full flex-col items-center justify-center rounded-xl border border-dashed"
onsubmit={preventDefault(onUpload)}
>
{#if $lessonVideoUpload.isUploading}
<div class="flex w-[60%] max-w-[500px] flex-col justify-center gap-5">
<p class="mt-5 text-center">
{$t('course.navItem.lessons.materials.tabs.video.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('course.navItem.lessons.materials.tabs.video.add_video.cancel_upload')}
</Button>
</div>
</div>
{:else}
<img src="/upload-video.svg" alt="upload" />
<span class="pt-3">
<h3 class="text-center text-xl font-normal dark:text-white">
{$t('course.navItem.lessons.materials.tabs.video.add_video.upload_video')}
</h3>
<p class=" text-center text-sm font-normal">
{$t('course.navItem.lessons.materials.tabs.video.add_video.select_file')}
</p>
<p>{$t('course.navItem.lessons.materials.tabs.video.add_video.size')}</p>
</span>
{/if}
<input
style="display:none;"
type="file"
accept="video/*"
name="videoFile"
onchange={() => submit?.click()}
bind:this={fileInput}
/>
<input type="text" name="lessonId" value={lessonId} style="display: none;" />
<input style="display:none;" type="submit" bind:this={submit} />
</form>
</button>
{: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="pb-2 pt-3">
<h3 class="text-center text-base font-normal dark:text-white">
{$t('course.navItem.lessons.materials.tabs.video.add_video.oops')}
</h3>
<p class="text-center text-xs font-normal text-[#ADADAD]">
{$t('course.navItem.lessons.materials.tabs.video.add_video.big_file')}<br />
{$t('course.navItem.lessons.materials.tabs.video.add_video.maximum_size')}
</p>
</span>
<Button onclick={tryAgain}>
{$t('course.navItem.lessons.materials.tabs.video.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="pb-2 pt-3">
<h3 class="text-center text-base font-normal dark:text-white">
{$t('course.navItem.lessons.materials.tabs.video.add_video.oops')}
</h3>
<p class="text-center text-xs font-normal text-[#ADADAD]">
{$t('course.navItem.lessons.materials.tabs.video.add_video.unsupported_format')}<br />
{$t('course.navItem.lessons.materials.tabs.video.add_video.format')}
</p>
</span>
<Button onclick={tryAgain}>
{$t('course.navItem.lessons.materials.tabs.video.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">
<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>
<p>{fileInput?.files?.[0]?.name || fileName}</p>
</div>
<Button onclick={() => ($lessonVideoUpload.isModalOpen = false)}>
{$t('course.navItem.lessons.materials.button_done')}
</Button>
</div>
</div>
{/if}
@@ -1,82 +0,0 @@
<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 type { LessonVideoType } from '$lib/utils/types';
import { copyToClipboard, getVideoUrls, removeVideo } from '$lib/utils/functions/formatYoutubeVideo';
import { lesson, isLessonDirty } from '$lib/components/Course/components/Lesson/store/lessons';
import { InputField } from '@cio/ui/custom/input-field';
import { Button } from '@cio/ui/base/button';
let youtubeLinks = $state('');
let error = $state('');
function addVideo() {
const links = getVideoUrls(youtubeLinks);
const validLinks = links.filter(isValidYouTubeLink);
if (validLinks.length === 0) {
error = $t('course.navItem.lessons.materials.tabs.video.add_video.invalid_youtube');
} else {
const existingLinks = $lesson?.materials?.videos || [];
$lesson.materials.videos = [
...existingLinks,
...validLinks.map((link = '') => ({
type: 'youtube' as LessonVideoType,
link,
metadata: {}
}))
];
youtubeLinks = '';
error = '';
}
}
function isValidYouTubeLink(link = '') {
const youtubeRegex = /^(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
return youtubeRegex.test(link.trim());
}
</script>
<div class="flex w-full items-{error ? 'center' : 'end'} justify-between gap-5">
<InputField
label={$t('course.navItem.lessons.materials.tabs.video.add_video.youtube_link')}
bind:value={youtubeLinks}
className="flex-1 text-left"
onchange={() => ($isLessonDirty = true)}
placeholder="https://www.youtube.com/watch?v="
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>
{$lesson?.materials?.videos.filter((v) => v.type === 'youtube' && isValidYouTubeLink(v.link)).length || 0}
</strong>
</p>
<div class="">
{#each $lesson?.materials?.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>
@@ -1,371 +0,0 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import type { PostgrestSingleResponse, RealtimePostgresChangesPayload } from '@supabase/supabase-js';
import { group } from '$lib/components/Course/store';
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 { lesson, lessonCommentsChannel } from '$lib/components/Course/components/Lesson/store/lessons';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import { calDateDiff } from '$lib/utils/functions/date';
import { snackbar } from '$features/ui/snackbar/store';
import { getSupabase } from '$lib/utils/functions/supabase';
import type { GroupPerson, LessonComment, LessonCommentInsertPayload } from '$lib/utils/types';
const supabase = getSupabase();
const PAGE_SIZE = 20;
interface Props {
lessonId?: string;
}
let { lessonId = '' }: Props = $props();
let comment = $state('');
let comments: LessonComment[] = $state([]);
let groupmember: GroupPerson | undefined = $state();
let isSaving: boolean = $state(false);
let isFetching = $state(false);
let openDeleteModal = $state(false);
let deleteCommentId: number | null = $state(null);
let editCommentId: number | null = $state(null);
interface FetchComments {
id: number;
comment: string;
created_at: string;
groupmember: {
id: string;
profile: {
avatar_url: string;
fullname: string;
};
};
}
async function handleSend() {
if (!comment || !groupmember) {
return;
}
isSaving = true;
comments = [
{
id: 0,
comment: comment,
name: $t('course.navItem.lessons.comments.you'),
avatar: $profile.avatarUrl || 'AV',
commentAt: new Date(),
groupmember_id: groupmember.id
},
...comments
];
pagination.count = comments.length;
supabase
.from('lesson_comment')
.insert({
lesson_id: $lesson.id,
groupmember_id: groupmember.id,
comment
})
.select('id')
.single()
.then(({ data, error }) => {
isSaving = false;
if (error) {
console.error('Error adding comment:', error);
snackbar.error($t('course.navItem.lessons.comments.comment_error'));
return;
}
comments = comments.map((comment) => (comment.id === 0 ? { ...comment, id: data.id } : comment));
isSaving = false;
comment = '';
});
}
async function handleUpdate(commentItem: LessonComment) {
if (!editCommentId || !commentItem.comment) {
return;
}
const { error } = await supabase
.from('lesson_comment')
.update({ comment: commentItem.comment })
.eq('id', editCommentId);
if (error) {
console.error('handleUpdate', error);
snackbar.error($t('snackbar.something'));
} else {
snackbar.success($t('snackbar.success_update'));
}
editCommentId = null;
pagination.count = comments.length;
}
async function handleDeleteComment() {
if (!deleteCommentId) {
return;
}
const { error } = await supabase.from('lesson_comment').delete().eq('id', deleteCommentId);
if (error) {
console.error('error', error);
snackbar.error($t('snackbar.something'));
} else {
snackbar.success($t('snackbar.success_delete'));
}
comments = comments.filter((comment) => comment.id !== deleteCommentId);
pagination.count = comments.length;
deleteCommentId = null;
}
async function handleInsert(payload: RealtimePostgresChangesPayload<LessonCommentInsertPayload>) {
const insertedComment = payload.new as LessonCommentInsertPayload;
if (groupmember && groupmember.id === insertedComment.groupmember_id) {
return;
}
const {
data,
error
}: PostgrestSingleResponse<{
id: string;
profile: {
fullname: string;
avatar_url: string;
};
}> = await supabase
.from('groupmember')
.select('id, profile:profile_id(fullname, avatar_url)')
.eq('id', insertedComment.groupmember_id)
.single();
if (error || !data) {
console.error('handleInsert', error);
return;
}
comments = [
{
id: insertedComment.id,
comment: insertedComment.comment,
name: data.profile.fullname,
avatar: data.profile.avatar_url,
commentAt: insertedComment.created_at,
groupmember_id: insertedComment.groupmember_id
},
...comments
];
pagination.count = comments.length;
}
let pagination = $state({
hasMore: true,
count: 0,
page: 0
});
async function fetchComments(people: GroupPerson[]) {
if (!pagination.hasMore) return;
groupmember = people.find((person) => person.profile_id === $profile.id);
if (!groupmember || !lessonId) return;
isFetching = true;
const from = pagination.page * PAGE_SIZE;
const to = from + PAGE_SIZE - 1;
const { data, error, count } = await supabase
.from('lesson_comment')
.select(
`
groupmember_id,
created_at,
id,
comment,
groupmember:groupmember_id(
id,profile:profile_id(fullname, avatar_url)
)
`,
{ count: 'exact' }
)
.match({
lesson_id: lessonId
})
.order('created_at', { ascending: false })
.range(from, to)
.returns<FetchComments[]>();
if (error || !data) {
console.error('error');
return;
}
const newComments = data.map((lessonComment) => {
return {
id: lessonComment.id,
comment: lessonComment.comment,
avatar: lessonComment.groupmember.profile.avatar_url,
commentAt: lessonComment.created_at,
groupmember_id: lessonComment.groupmember.id,
name:
lessonComment.groupmember.id === groupmember?.id
? $t('course.navItem.lessons.comments.you')
: lessonComment.groupmember.profile.fullname
};
});
comments = pagination.page === 0 ? newComments : [...comments, ...newComments];
pagination.hasMore = count ? from + data.length < count : false;
pagination.count = count ?? 0;
pagination.page++;
isFetching = false;
}
onMount(async () => {
fetchComments($group.people);
if (!$lessonCommentsChannel) {
lessonCommentsChannel.set(
supabase
.channel('any')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'lesson_comment' }, handleInsert)
.subscribe()
);
}
});
onDestroy(() => {
supabase.removeChannel($lessonCommentsChannel);
});
</script>
<DeleteModal bind:open={openDeleteModal} onDelete={handleDeleteComment} />
<div class="mx-auto w-full max-w-[65ch]">
<!-- <hr class="my-5" /> -->
<div class="mb-5">
<p class="text-xl capitalize">
{$t('course.navItem.lessons.comments.title')} ({pagination.count})
</p>
</div>
<div>
<div class="flex h-full items-start gap-3">
<Avatar.Root class="mt-2 h-8 w-8">
<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} loading={isSaving}>
{$t('course.navItem.lessons.comments.comment_btn')}
</Button>
</div>
</div>
<div class="my-10">
{#each comments as commentItem}
<div class="mt-2 flex items-start gap-3 pb-2">
<Avatar.Root class="h-8 w-8">
<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-4 pb-4 pt-2 dark:border-neutral-700">
<div class="flex items-center justify-between gap-2">
<p class="text-md 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 groupmember?.id === commentItem.groupmember_id}
<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">
<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)}>
{$t('course.navItem.lessons.comments.cancel_btn')}
</Button>
<Button onclick={() => handleUpdate(commentItem)} disabled={!commentItem.comment} loading={isSaving}>
{$t('course.navItem.lessons.comments.comment_btn')}
</Button>
</div>
{:else}
<article class="prose sm:prose-sm max-w-[300px] dark:text-white">
{commentItem.comment}
</article>
{/if}
</div>
</div>
{/each}
</div>
{#if pagination.hasMore}
<div class="mt-2 flex items-center justify-center">
<Button variant="outline" onclick={() => fetchComments($group.people)} loading={isFetching}>
{$t('course.navItem.lessons.comments.load_more_btn')}
</Button>
</div>
{/if}
</div>
@@ -1,35 +0,0 @@
<script lang="ts">
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import { lesson, lessonByTranslation } from '$lib/components/Course/components/Lesson/store/lessons';
import { t } from '$lib/utils/functions/translations';
import { lessonFallbackNote } from '$lib/utils/functions/translations';
interface Props {
lessonId: string;
}
let { lessonId }: Props = $props();
let content = $derived(lessonFallbackNote($lesson.materials.note, $lessonByTranslation[lessonId], $lesson.locale));
let hasAtLeastOneTranslation = $derived(
Object.values($lessonByTranslation[lessonId] || {}).some((content) => {
return content && !!content.length;
})
);
</script>
{#if !isHtmlValueEmpty(content)}
<HtmlRender className="m-auto">
<div>
{@html sanitizeHtml(content)}
</div>
</HtmlRender>
{:else if hasAtLeastOneTranslation}
<div class="flex flex-col items-center justify-center text-center">
<h3 class="py-2 text-lg font-normal italic dark:text-white">
{$t('course.navItem.lessons.materials.no_translation')}
</h3>
</div>
{/if}
@@ -1,34 +0,0 @@
<script lang="ts">
import { lesson } from '../../store/lessons';
let url = $derived(getUrl($lesson.materials.slide_url));
function canvaHandler(_url): string {
if (_url.includes('?embed')) return _url;
return `${_url}?embed`;
}
function getUrl(_url: string | null): string | undefined {
if (!_url) return;
console.log({ _url });
if (_url.includes('www.canva.com')) {
return canvaHandler(_url);
}
return _url;
}
</script>
{#if url}
<iframe
title="Embeded Slides"
src={url}
frameborder="0"
width="100%"
height="569"
class="iframe my-3"
allowfullscreen={true}
></iframe>
{/if}
@@ -1,84 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { formatYoutubeVideo } from '$lib/utils/functions/formatYoutubeVideo';
import { lesson } from '../../store/lessons';
let errors: Record<string, string> = {};
let videoElements: (HTMLVideoElement | null)[] = $derived.by(() => {
let elements = videoElements.slice(0, $lesson.materials.videos.length);
return [...elements, ...Array($lesson.materials.videos.length - elements.length).fill(null)];
});
function initPlyr() {
if (typeof (window as any).Plyr === 'undefined') {
console.warn('Plyr is not defined. Make sure it is properly imported.');
return;
}
const players = Array.from(document.querySelectorAll('.plyr-video-trigger')).map(
(p) => new (window as any).Plyr(p as HTMLElement)
);
if (typeof window !== 'undefined') {
(window as any).players = players;
}
}
onMount(() => {
initPlyr();
});
</script>
{#if $lesson.materials.videos.length}
<div class="w-full">
{#each $lesson.materials.videos as video, index}
<div class="mb-5 w-full overflow-hidden">
{#key video.link}
<div class="mb-5">
{#if video.type === 'youtube'}
<iframe
class="iframe"
src={formatYoutubeVideo(video.link, errors)}
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
{:else if video.type === 'generic'}
<iframe
width="100%"
height="569"
class="iframe"
src={video.link}
title="Embeded Video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
{:else if video.metadata?.svid}
<div style="position:relative;padding-bottom:51.416579%">
<iframe
src="https://muse.ai/embed/{video.metadata
?.svid}?logo=https://app.classroomio.com/logo-512.png&subtitles=auto&cover_play_position=center"
style="width:100%;height:100%;position:absolute;left:0;top:0"
frameborder="0"
allowfullscreen
title="Muse AI Video Embed"
></iframe>
</div>
{:else}
<video
bind:this={videoElements[index]}
class="plyr-video-trigger iframe h-full w-full"
playsinline
controls
style="aspect-ratio: 16/9;"
>
<source src={video.link} type="video/mp4" />
<track kind="captions" />
</video>
{/if}
</div>
{/key}
</div>
{/each}
</div>
{/if}
@@ -1,576 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte';
import isEmpty from 'lodash/isEmpty';
import { writable } from 'svelte/store';
import { fade } from 'svelte/transition';
import * as Popover from '@cio/ui/base/popover';
import TrashIcon from '@lucide/svelte/icons/trash';
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 WandSparklesIcon from '@lucide/svelte/icons/wand-sparkles';
import * as CONSTANTS from './constants';
import { orderedTabs } from './constants';
import MODES from '$lib/utils/constants/mode';
import { currentOrg } from '$lib/utils/store/org';
import type { Content } from '@cio/ui/custom/editor';
import { course } from '$lib/components/Course/store';
import { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$features/ui/snackbar/store';
import type { LessonPage } from '$lib/utils/types';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import {
deleteLessonVideo,
handleUpdateLessonMaterials,
isLessonDirty,
lesson,
lessonByTranslation,
lessonVideoUpload,
lessonDocUpload
} from '$lib/components/Course/components/Lesson/store/lessons';
import { lessonFallbackNote, t } from '$lib/utils/functions/translations';
import { formatYoutubeVideo } from '$lib/utils/functions/formatYoutubeVideo';
import { Button } from '@cio/ui/base/button';
import { Chip } from '@cio/ui/custom/chip';
import Loader from './Loader.svelte';
import Comments from './components/Comments.svelte';
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
import { Empty } from '@cio/ui/custom/empty';
import VideoIcon from '@lucide/svelte/icons/video';
import * as Dialog from '@cio/ui/base/dialog';
import { IconButton } from '@cio/ui/custom/icon-button';
import { InputField } from '@cio/ui/custom/input-field';
import ComponentNote from './components/ComponentNote.svelte';
import ComponentSlide from './components/ComponentSlide.svelte';
import ComponentVideo from './components/ComponentVideo.svelte';
import { TextEditor } from '$features/ui';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import ComponentDocument from './components/ComponentDocument.svelte';
import AddVideoToLesson from '$lib/components/Course/components/Lesson/Materials/Video/AddVideoToLesson.svelte';
import AddDocumentToLesson from '$lib/components/Course/components/Lesson/Materials/Document/AddDocumentToLesson.svelte';
import type { TLocale } from '@cio/db/types';
interface Props {
mode?: any;
prevMode?: string;
lessonId?: string;
isSaving?: boolean;
isStudent?: boolean;
toggleMode?: any;
}
let {
mode = MODES.view,
prevMode = '',
lessonId = '',
isSaving = $bindable(false),
isStudent = false,
toggleMode = () => {}
}: 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';
let localeExists: Record<string, boolean> = {};
// let prevContent = '';
let timeoutId: NodeJS.Timeout;
let errors: Record<string, string> = {};
// let editorWindowRef: Window | undefined = $state();
// let editorInstance = $state<Editor>(); // Add proper typing
('flex items-center gap-2 px-5 py-2 border border-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md w-full mb-2');
let isLoading = writable(false);
const lessonTitle = $derived($lesson.title);
const tabs = $derived.by(() => {
const ordered = orderedTabs(CONSTANTS.tabs, $course.metadata?.lessonTabsOrder);
const content = $lessonByTranslation[lessonId]?.[$lesson.locale] || '';
const { slide_url, videos, note, documents } = $lesson.materials;
return ordered.map((tab) => {
let badgeValue = 0;
if (tab.value === 1 && (!isHtmlValueEmpty(note) || !isHtmlValueEmpty(content))) {
badgeValue = 1;
} else if (tab.value === 2 && !!slide_url) {
badgeValue = 1;
} else if (tab.value === 3 && !isEmpty(videos)) {
badgeValue = videos.length;
} else if (tab.value === 4 && !isEmpty(documents)) {
badgeValue = documents.length;
}
tab.badgeValue = badgeValue;
return tab;
});
});
const componentsToRender = $derived(getComponentOrder(tabs));
let currentTab = $state<string>('');
function onEditorChange(content: Content) {
console.log('content updated:', content);
if (mode === MODES.view) return;
$lessonByTranslation[lessonId][$lesson.locale] = `${content}`;
try {
localStorage.setItem(`lesson-${lessonId}-${$lesson.locale}`, `${content}`);
} catch (error) {
console.error('Error saving lesson note to localStorage', error);
}
$isLessonDirty = true;
}
$effect(() => {
if (tabs.length > 0 && !currentTab) {
currentTab = String(tabs[0].value);
}
});
const getValue = (label: string) => {
const tabValue = tabs.find((tab) => tab.label === label)?.value;
return tabValue;
};
async function saveOrUpdateTranslation(locale, lessonId) {
const content = $lessonByTranslation[lessonId][locale];
if (typeof localeExists[locale] === 'undefined') {
const { data } = await supabase
.from('lesson_language')
.select(`id`)
.eq('lesson_id', lessonId)
.eq('locale', locale)
.maybeSingle();
localeExists[locale] = !!(data && data?.id);
}
if (localeExists[locale]) {
const { error: updateError } = await supabase
.from('lesson_language')
.update({ content })
.eq('lesson_id', lessonId)
.eq('locale', locale);
if (updateError) {
console.error('Error updating translation:', updateError.message);
snackbar.error('snackbar.materials.update_translations');
}
} else {
const { error: insertError } = await supabase.from('lesson_language').insert({
locale,
lesson_id: lessonId,
content
});
if (insertError) {
console.error('Error inserting translation:', insertError.message);
snackbar.error('snackbar.materials.creating_new');
return;
}
localeExists[locale] = true;
}
}
async function saveLesson(materials?: LessonPage['materials']) {
const _lesson = materials
? {
...$lesson,
materials
}
: $lesson;
console.log('updating lesson');
const [lessonRes] = await Promise.all([
handleUpdateLessonMaterials(_lesson, lessonId),
saveOrUpdateTranslation($lesson.locale, lessonId)
]);
return lessonRes;
}
function isMaterialsEmpty(materials: LessonPage['materials'], translation: Record<TLocale, string>) {
const { slide_url, videos, note, documents } = materials;
return (
isHtmlValueEmpty(note) &&
!slide_url &&
isEmpty(videos) &&
isEmpty(documents) &&
Object.values(translation || {}).every((t) => isHtmlValueEmpty(t))
);
}
function handleSave(prevMode: string) {
untrack(() => {
if (prevMode === MODES.edit) {
saveLesson();
}
});
}
const openAddVideoModal = () => {
$lessonVideoUpload.isModalOpen = true;
};
// const { input, handleSubmit, completion, isLoading } = useCompletion({
// api: '/api/completion'
// });
// function updateNoteByCompletion(completion: string) {
// if (!completion) return;
// if ($lessonByTranslation[lessonId]) {
// $lessonByTranslation[lessonId][$lesson.locale] = `${prevContent}${completion}`;
// }
// autoSave($lesson.materials, $lessonByTranslation[lessonId], false, lessonId);
// if (editorInstance && editorInstance.view) {
// try {
// // For TipTap editor, we can use the editor's commands to scroll
// const { view } = editorInstance;
// const { state } = view;
// const endPos = state.doc.content.size;
// // Move cursor to end and scroll into view
// editorInstance.commands.setTextSelection(endPos);
// editorInstance.commands.scrollIntoView();
// // Alternative: Focus the editor
// editorInstance.commands.focus();
// } catch (error) {
// console.warn('Error scrolling editor:', error);
// }
// }
// }
function callAI(_type = '') {
// prevContent = $lessonByTranslation[lessonId]?.[$lesson.locale] || '';
// const _lesson = $lessons.find((les) => les.id === $lesson.id);
// $input = JSON.stringify({
// _type,
// lessonTitle: _lesson?.title || '',
// courseTitle: $course.title,
// locale: $lesson.locale
// });
// setTimeout(() => {
// handleSubmit({ preventDefault: () => {} });
// }, 500);
}
function initPlyr(_player: any, _video: LessonPage['materials']['videos']) {
if (!_player) return;
const players = Array.from(document.querySelectorAll('.plyr-video-trigger')).map((p) => {
// @ts-ignore
return new Plyr(p);
});
// @ts-ignore
window.players = players;
}
function autoSave(
updatedMaterials: LessonPage['materials'],
_translation: Record<TLocale, string>,
_isLoading?: boolean,
_lessonId?: string
) {
if (mode === MODES.view) return;
if (timeoutId) clearTimeout(timeoutId);
untrack(() => {
isSaving = true;
timeoutId = setTimeout(async () => {
const { error } = await saveLesson(updatedMaterials);
if (error) {
console.error('error saving lesson', error);
snackbar.error('snackbar.materials.apology');
}
isSaving = false;
}, 1000);
});
}
const onClose = () => {
if ($lessonVideoUpload.isUploading) return;
$lessonVideoUpload.isModalOpen = false;
autoSave($lesson.materials, $lessonByTranslation[lessonId], $isLoading, lessonId);
};
const onDocumentClose = () => {
if ($lessonDocUpload.isUploading) return;
$lessonDocUpload.isModalOpen = false;
// Clear any error messages when modal closes
$lessonDocUpload.error = null;
autoSave($lesson.materials, $lessonByTranslation[lessonId], $isLoading, lessonId);
};
function getComponentOrder(tabs = CONSTANTS.tabs) {
const componentMap = {
'1': ComponentNote,
'2': ComponentSlide,
'3': ComponentVideo,
'4': ComponentDocument
};
const componentNames = tabs
.map((tab) => {
const component = componentMap[tab.value];
return component || null;
})
.filter(Boolean);
return componentNames;
}
$effect(() => {
console.log('autoSaving...');
autoSave($lesson.materials, $lessonByTranslation[lessonId], $isLoading, lessonId);
});
$effect(() => {
console.log('handleSave...');
handleSave(prevMode);
});
// $effect(() => {
// updateNoteByCompletion($completion);
// });
let player = $state<HTMLVideoElement | null>(null);
$effect(() => {
initPlyr(player, $lesson.materials.videos);
});
let _editorValue = $derived(
lessonFallbackNote($lesson.materials.note, $lessonByTranslation[lessonId], $lesson.locale)
);
</script>
<Dialog.Root
bind:open={$lessonVideoUpload.isModalOpen}
onOpenChange={(isOpen) => {
if (!isOpen) onClose();
}}
>
<Dialog.Content class="max-w-4/5 h-[80%] w-[90%] md:h-[566px]">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.materials.tabs.video.add_video.title')}</Dialog.Title>
</Dialog.Header>
<AddVideoToLesson {lessonId} />
</Dialog.Content>
</Dialog.Root>
<Dialog.Root
bind:open={$lessonDocUpload.isModalOpen}
onOpenChange={(isOpen) => {
if (!isOpen) onDocumentClose();
}}
>
<Dialog.Content class="max-w-4/5 h-[80%] w-[90%] md:h-[566px]">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.materials.tabs.document.upload_title')}</Dialog.Title>
</Dialog.Header>
<AddDocumentToLesson />
</Dialog.Content>
</Dialog.Root>
<HtmlRender className="m-auto text-center">
<h1 class="mt-0 text-2xl capitalize md:text-4xl">
{lessonTitle}
</h1>
</HtmlRender>
{#if $lesson.isFetching}
<Loader />
{:else if mode === MODES.edit}
<UnderlineTabs.Root bind:value={currentTab}>
<UnderlineTabs.List>
{#each tabs as tab}
<UnderlineTabs.Trigger value={String(tab.value)}>
{#if tab.icon}
<tab.icon size={16} />
{/if}
{$t(tab.label)}
{#if typeof tab.badgeValue === 'number' && tab.badgeValue > 0}
<Chip value={`${tab.badgeValue}`} className="ml-1" />
{/if}
</UnderlineTabs.Trigger>
{/each}
</UnderlineTabs.List>
<UnderlineTabs.Content value={String(getValue('course.navItem.lessons.materials.tabs.note.title') || '')}>
<div class="flex justify-end gap-1">
<!-- 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>
</div>
<div class="mt-5 h-[60vh]">
<TextEditor
content={_editorValue}
onChange={(content) => onEditorChange(content)}
onReady={() =>
// editor
{
// editorInstance = editor;
// editorWindowRef = editor.view.dom.ownerDocument.defaultView;
}}
placeholder={$t('course.navItem.lessons.materials.tabs.note.placeholder')}
/>
</div>
</UnderlineTabs.Content>
<UnderlineTabs.Content value={String(getValue('course.navItem.lessons.materials.tabs.slide.title') || '')}>
{#if mode === MODES.edit}
<InputField
label={$t('course.navItem.lessons.materials.tabs.slide.slide_link')}
bind:value={$lesson.materials.slide_url}
onInputChange={() => ($isLessonDirty = true)}
helperMessage={$t('course.navItem.lessons.materials.tabs.slide.helper_message')}
/>
{/if}
</UnderlineTabs.Content>
<UnderlineTabs.Content value={String(getValue('course.navItem.lessons.materials.tabs.video.title') || '')}>
<Button onclick={openAddVideoModal} class="mb-2">
{$t('course.navItem.lessons.materials.tabs.video.button')}
</Button>
{#if $lesson.materials.videos.length}
<div class="flex h-full w-full flex-col items-start">
{#each $lesson.materials.videos as video, index}
{#if mode === MODES.edit}
<div class="ml-auto">
<IconButton onclick={() => deleteLessonVideo(index)}>
<TrashIcon size={16} />
</IconButton>
</div>
{/if}
<div class="flex h-full w-full flex-col gap-2 overflow-hidden">
{#key video.link}
<div class="mb-5">
{#if video.type === 'youtube'}
<iframe
width="100%"
height="569"
class="iframe"
src={formatYoutubeVideo(video.link, errors)}
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
{:else if video.type === 'generic'}
<iframe
width="100%"
height="569"
class="iframe"
src={video.link}
title="Embeded Video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
{:else if video.metadata?.svid}
<div style="position:relative;padding-bottom:51.416579%">
<iframe
src="https://muse.ai/embed/{video.metadata
?.svid}?logo=https://app.classroomio.com/logo-512.png&subtitles=auto&cover_play_position=center"
style="width:100%;height:100%;position:absolute;left:0;top:0"
frameborder="0"
allowfullscreen
title="Muse AI Video Embed"
></iframe>
</div>
{:else}
<video
bind:this={player}
class="plyr-video-trigger"
style="max-height: 569px;"
playsinline
controls
>
<source src={video.link} type="video/mp4" />
<track kind="captions" />
</video>
{/if}
</div>
{/key}
</div>
{/each}
</div>
{/if}
</UnderlineTabs.Content>
<UnderlineTabs.Content value={String(getValue('course.navItem.lessons.materials.tabs.document.title') || '')}>
<ComponentDocument {mode} />
</UnderlineTabs.Content>
</UnderlineTabs.Root>
{:else if !isMaterialsEmpty($lesson.materials, $lessonByTranslation[lessonId])}
{#key lessonId}
<div class="mb-20 flex w-full flex-col gap-6" in:fade={{ delay: 500 }} out:fade>
{#each componentsToRender as Component}
<Component {lessonId} />
{/each}
{#if $currentOrg.customization?.apps?.comments}
<hr class="my-5" />
<Comments {lessonId} />
{/if}
</div>
{/key}
{:else}
<Empty
title={$t('course.navItem.lessons.materials.body_heading')}
description={$t('course.navItem.lessons.materials.body_content') +
' ' +
$t('course.navItem.lessons.materials.get_started') +
' ' +
$t('course.navItem.lessons.materials.button') +
'.'}
icon={VideoIcon}
variant="page"
class="text-center"
>
{#if !isStudent}
<Button onclick={toggleMode}>
{$t('course.navItem.lessons.materials.get_started')}
</Button>
{/if}
</Empty>
{/if}
@@ -1,175 +0,0 @@
<script lang="ts">
import { preventDefault } from '$lib/utils/functions/svelte';
import { Button } from '@cio/ui/base/button';
import { InputField } from '@cio/ui/custom/input-field';
import {
lessons,
lessonSections,
handleSaveLesson,
handleSaveLessonSection
} from '$lib/components/Course/components/Lesson/store/lessons';
import { course } from '$lib/components/Course/store';
import * as Dialog from '@cio/ui/base/dialog';
import { goto } from '$app/navigation';
import { handleAddLessonWidget } from './store';
import { t } from '$lib/utils/functions/translations';
import { COURSE_TYPE } from '$lib/utils/types';
import type { Lesson } from '$lib/utils/types';
let errors = $state({
title: ''
});
let lesson: Lesson = $state({
id: '',
course_id: $course.id || '',
title: '',
profile: undefined,
call_url: undefined,
lesson_at: new Date().toDateString(),
is_unlocked: true,
lesson_completion: [],
created_at: ''
});
const handleSave = async () => {
if (!lesson.title.trim()) {
errors.title = 'title cannot be empty';
return;
}
if ($handleAddLessonWidget.isSection) {
const savedSection = await handleSaveLessonSection(
{
title: lesson.title,
order: $lessonSections.length
},
$course.id
);
if (Array.isArray(savedSection) && savedSection[0]) {
const newLessonSection = savedSection[0];
lessonSections.update((sections) => {
return [
...sections,
{
id: newLessonSection.id,
title: lesson.title,
order: newLessonSection.order,
course_id: newLessonSection.course_id,
lessons: [],
created_at: ''
}
];
});
}
} else {
lesson.section_id = $handleAddLessonWidget.id || undefined;
const savedLesson = await handleSaveLesson(lesson, $course.id);
if (Array.isArray(savedLesson) && savedLesson[0]) {
const newLesson = savedLesson[0];
lesson.id = newLesson.id;
$lessons = [...$lessons, lesson];
lessonSections.update((sections) =>
sections.map((s) => {
if (s.id === newLesson.section_id) {
s.lessons = [...s.lessons, lesson];
}
return s;
})
);
goto('/courses/' + $course.id + '/lessons/' + lesson.id);
}
}
handleClose();
};
function handleClose() {
$handleAddLessonWidget.open = false;
lesson = {
id: '',
course_id: $course.id || '',
title: '',
profile: undefined,
call_url: undefined,
lesson_at: new Date().toDateString(),
is_unlocked: true,
lesson_completion: [],
created_at: ''
};
}
</script>
<Dialog.Root
bind:open={$handleAddLessonWidget.open}
onOpenChange={(isOpen) => {
if (!isOpen) handleClose();
}}
>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>
{$t(
`course.navItem.lessons.add_lesson.${$handleAddLessonWidget.isSection ? 'modal_heading_section' : 'modal_heading'}`
)}
</Dialog.Title>
</Dialog.Header>
<form
onsubmit={preventDefault(handleSave)}
class="relative mb-2 flex w-full flex-wrap items-center px-2 py-2 md:mb-4 md:px-5 md:py-3"
>
<div class="w-full">
<InputField
label={$t(
`course.navItem.lessons.add_lesson.${
$handleAddLessonWidget.isSection ? 'lesson_section_title' : 'lesson_title'
}`
)}
bind:value={lesson.title}
autoFocus={true}
className="w-full"
isRequired={true}
errorMessage={errors.title}
/>
{#if $course.type == COURSE_TYPE.LIVE_CLASS}
<!-- <div
class="flex items-start justify-evenly gap-1 flex-col lg:flex-row lg:items-center mt-2 w-4/5"
>
<div class="lg:mb-0">
<Select
bind:value={lesson.profile}
options={$group.tutors}
labelKey="fullname"
className="sm:my-1 w-[100%]"
/>
</div>
<div class="flex items-center lg:mb-0">
<input
type="date"
name="lesson-date-picker"
class="p-2 my-2 rounded-md sm:w-[179px] dark:bg-neutral-800 dark:text-white"
/>
</div>
<div class="flex items-center mb-3 lg:mb-0">
<InputField className="w-[179px]" placeholder="https://meet.google.com/mga-dsjs-fmb" />
</div>
</div> -->
{/if}
</div>
</form>
<div class="flex flex-row-reverse">
<Button onclick={handleSave}>
{$t('course.navItem.lessons.add_lesson.save')}
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -1,32 +0,0 @@
import type { LessonCompletion } from '$lib/utils/types';
export function getUniqueValue(label: string) {
return label.toLowerCase().replace(' ', '');
}
export function generateQuestion(questions) {
const generatedQuestions = [];
for (const question of questions) {
const name = getUniqueValue(question.title);
const options = question.options.map((option) => {
option.value = getUniqueValue(option.label);
return option;
});
generatedQuestions.push({
...question,
name,
options
});
}
return generatedQuestions;
}
export function getIsLessonComplete(completions: LessonCompletion[], profileId: string | undefined): boolean {
if (!Array.isArray(completions)) return false;
return completions.some((c) => {
return c.is_complete && c.profile_id === profileId;
});
}
@@ -1,7 +0,0 @@
import { writable } from 'svelte/store';
export const handleAddLessonWidget = writable({
id: '',
open: false,
isSection: false
});
@@ -1,26 +0,0 @@
import { writable } from 'svelte/store';
import { STATUS } from '../Exercise/constants';
// export const questionnaireMetaData = writable({
// answers: {},
// scores: { gates: 60 },
// currentQuestionIndex: 5,
// isFinished: true,
// progressValue: 100,
// status: STATUS.GRADED,
// });
const initAnswerState = {
answers: {},
scores: {},
grades: {},
totalPossibleGrade: 0,
finalTotalGrade: 0,
currentQuestionIndex: 0,
isFinished: false,
progressValue: 100,
status: STATUS.PENDING,
comment: ''
};
export const questionnaireMetaData = writable(initAnswerState);
@@ -1,296 +0,0 @@
import type { Course, Lesson, LessonComment, LessonCompletion, LessonPage, LessonSection } from '$lib/utils/types';
import {
createLesson,
createLessonSection,
deleteLesson,
deleteLessonSection,
updateLesson,
updateLessonSection
} from '$lib/utils/services/courses';
import type { RealtimeChannel } from '@supabase/supabase-js';
import type { TLocale } from '@cio/db/types';
import type { Writable } from 'svelte/store';
import { lessonValidation } from '$lib/utils/functions/validator';
import { snackbar } from '$features/ui/snackbar/store';
import { writable } from 'svelte/store';
export const lessonVideoUpload = writable({
error: null as string | null,
isCancelled: false,
isModalOpen: false,
isUploading: false,
uploadProgress: 0
});
export const lessonDocUpload = writable({
error: null as string | null,
isCancelled: false,
isModalOpen: false,
isUploading: false,
uploadedDocument: null,
uploadProgress: 0
});
export const lessons: Writable<Lesson[]> = writable([]);
export const lessonSections: Writable<LessonSection[]> = writable([]);
export const lessonCommentsChannel: Writable<RealtimeChannel> = writable();
export const lesson = writable<LessonPage>({
id: null,
locale: 'en',
title: '',
totalExercises: 0,
totalComments: 0,
isSaving: false,
isFetching: false,
materials: {
note: '',
slide_url: '',
videos: [],
documents: []
},
exercises: [],
lesson_completion: []
});
export const lessonByTranslation = writable<{
[key: string]: Record<TLocale, string>;
}>({});
export const lessonComments = writable<LessonComment[]>([]);
export const isLessonDirty = writable(false);
export function setLesson({
id,
lessonData,
locale,
totalExercises,
totalComments
}: {
id: string;
lessonData: Lesson & { lesson_language: { locale: TLocale; content: string }[] };
locale: TLocale;
totalExercises: number;
totalComments: number;
}) {
if (!lessonData) return;
let lesson_completion: LessonCompletion[] = [];
if (Array.isArray(lessonData.lesson_completion)) {
lesson_completion = [...lessonData.lesson_completion];
}
lesson.update((l) => ({
...l,
id: id,
title: lessonData.title,
totalExercises,
totalComments: totalComments,
materials: {
videos: lessonData.videos || [],
note: lessonData.note,
slide_url: lessonData.slide_url,
documents: lessonData.documents || []
},
lesson_completion,
exercises: [],
locale: locale
}));
if (Array.isArray(lessonData.lesson_language)) {
lessonByTranslation.update((lessLocales) => {
return {
...lessLocales,
[id]: lessonData.lesson_language.reduce(
(acc, cur) => {
acc[cur.locale] = cur.content;
return acc;
},
{} as Record<TLocale, string>
)
};
});
}
}
export function handleAddLesson() {
lessons.update((_lessons) => {
const newLesson = {
id: '',
title: 'Untitled lesson',
lesson_at: new Date().toDateString(),
is_unlocked: false,
course_id: '',
created_at: new Date().toDateString(),
lesson_completion: []
} as Lesson;
return [..._lessons, newLesson];
});
}
export async function handleDelete(lessonId: Lesson['id'] | undefined) {
// Need to implement soft delete
if (!lessonId) {
return;
}
const { error } = await deleteLesson(lessonId);
if (error) {
snackbar.error(error.message);
return console.error('Error deleting course', error);
}
lessons.update((_lessons) => _lessons.filter((lesson) => lesson.id !== lessonId));
lessonSections.update((_sections) =>
_sections.map((section) => {
section.lessons = section.lessons.filter((lesson) => lesson.id !== lessonId);
return section;
})
);
snackbar.success('snackbar.generic.success_delete');
console.log(`lessonId`, lessonId);
}
export async function handleDeleteSection(sectionId: LessonSection['id'] | undefined) {
// Need to implement soft delete
if (!sectionId) {
return;
}
const { error } = await deleteLessonSection(sectionId);
if (error) {
snackbar.error(error.message);
return console.error('Error deleting course', error);
}
lessonSections.update((_sections) => _sections.filter((section) => section.id !== sectionId));
lessons.update((_lessons) => _lessons.filter((lesson) => lesson.section_id !== sectionId));
snackbar.success('snackbar.generic.success_delete');
console.log(`sectionId`, sectionId);
}
export async function handleSaveLesson(lesson: Lesson, courseId: Course['id']) {
const result = lessonValidation(lesson);
if (Object.keys(result).length) {
return result;
}
console.log(`handleSaveLesson lesson`, lesson);
const newLesson = {
title: lesson.title,
lesson_at: lesson?.lesson_at,
call_url: lesson?.call_url,
teacher_id: lesson?.profile ? lesson?.profile.id : undefined,
course_id: courseId,
is_unlocked: lesson.is_unlocked,
section_id: lesson.section_id
};
let newLessonData: Lesson | null | undefined = null;
if (lesson.id) {
// No need to get the result of update cause we have all in local state
await updateLesson(newLesson, lesson.id);
} else {
const { data } = await createLesson(newLesson);
newLessonData = data?.[0];
}
return newLessonData;
}
export async function handleSaveLessonSection(section: Partial<LessonSection>, courseId: Course['id']) {
const result = lessonValidation(section);
if (Object.keys(result).length) {
return result;
}
console.log(`handleSaveLessonSection lesson`, section);
const newSection: Partial<LessonSection> = {
id: section.id,
title: section.title,
course_id: courseId
};
let newSectionData: any[] | null = null;
if (newSection.id) {
// No need to get the result of update cause we have all in local state
await updateLessonSection(newSection, newSection.id);
} else {
const { data } = await createLessonSection(newSection);
newSectionData = data;
}
return newSectionData;
}
export async function handleUpdateLessonMaterials(lesson: any, lessonId: Lesson['id']) {
const materials = {
...lesson.materials
};
delete materials.lesson_completion;
delete materials.profile;
return await updateLesson(materials, lessonId);
}
export const deleteLessonVideo = (index: any) => {
lesson.update((currentLesson) => ({
...currentLesson,
materials: {
...currentLesson.materials,
videos: currentLesson.materials.videos.filter((video, i) => i !== index)
}
}));
};
export const deleteLessonDocument = (index: any) => {
lesson.update((currentLesson) => ({
...currentLesson,
materials: {
...currentLesson.materials,
documents: (currentLesson.materials.documents || []).filter((document, i) => i !== index)
}
}));
};
export function resetDocumentUploadStore() {
lessonDocUpload.set({
isUploading: false,
isModalOpen: false,
uploadProgress: 0,
uploadedDocument: null,
error: null,
isCancelled: false
});
}
export function cancelDocumentUpload() {
lessonDocUpload.update((store) => ({
...store,
isCancelled: true,
isUploading: false,
error: 'Upload cancelled by user'
}));
}
export function cancelVideoUpload() {
lessonVideoUpload.update((store) => ({
...store,
isCancelled: true,
isUploading: false
}));
}
@@ -1,153 +0,0 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import type { Feed, Author } from '$lib/utils/types/feed';
import { createNewFeed } from '$lib/utils/services/newsfeed';
import { getTextFromHTML } from '$lib/utils/functions/toHtml';
import { Button } from '@cio/ui/base/button';
import { createNewsfeedValidation } from '$lib/utils/functions/validator';
import { isNewFeedModal } from '$lib/components/Course/components/NewsFeed/store';
import { NOTIFICATION_NAME, triggerSendEmail } from '$lib/utils/services/notification/notification';
import * as Dialog from '@cio/ui/base/dialog';
import { TextEditor } from '$features/ui';
interface Props {
author?: Author | any;
courseId?: string;
onSave?: any;
onEdit?: any;
edit?: boolean;
editFeed: Feed | null;
}
let {
author = {},
courseId = '',
onSave = (_feed: Feed) => {},
onEdit = (_id: string, _content: string) => {},
edit = $bindable(false),
editFeed = $bindable()
}: Props = $props();
let isLoading = $state(false);
let createdFeed;
let errors: Record<string, string> = $state({
newPost: ''
});
let newPost = $state(edit === true ? editFeed?.content : '');
const onPost = async () => {
errors = createNewsfeedValidation(getTextFromHTML(newPost ?? ''));
if (Object.keys(errors).length || !editFeed) {
return;
}
isLoading = true;
try {
if (edit) {
onEdit(editFeed.id, newPost);
snackbar.success('snackbar.newsfeed.success.edit');
edit = false;
newPost = '';
$isNewFeedModal.open = false;
} else {
const {
response: { data }
} = await createNewFeed({
content: newPost ?? '',
author_id: author.id,
course_id: courseId,
reaction: {
smile: [],
thumbsup: [],
thumbsdown: [],
clap: []
}
});
if (!data) return;
createdFeed = data[0];
if (!createdFeed) return;
onSave({
id: createdFeed.id,
content: newPost,
author: {
profile: { ...author }
},
created_at: createdFeed.created_at,
comment: [],
reaction: createdFeed.reaction,
isPinned: false
});
snackbar.success('snackbar.newsfeed.success.add');
triggerSendEmail(NOTIFICATION_NAME.NEWSFEED, {
authorId: createdFeed.author_id,
feedId: createdFeed.id
});
resetEditor();
}
} catch (error) {
snackbar.error(
'snackbar.newsfeed.error.error ' +
(edit ? 'snackbar.newsfeed.error.editing' : 'snackbar.newsfeed.error.creating')
);
} finally {
isLoading = false;
}
};
const resetEditor = () => {
newPost = '';
edit = false;
$isNewFeedModal.open = false;
};
</script>
<Dialog.Root
bind:open={$isNewFeedModal.open}
onOpenChange={(isOpen) => {
if (!isOpen) resetEditor();
}}
>
<Dialog.Content class="w-4/5 max-w-lg">
<Dialog.Header>
<Dialog.Title>
{edit === true
? $t('course.navItem.news_feed.heading_button.edit_post')
: $t('course.navItem.news_feed.heading_button.make_a_post')}
</Dialog.Title>
</Dialog.Header>
<section class="w-2/ flex h-full flex-col rounded-xl pb-3">
<TextEditor
content={newPost}
onChange={(text) => {
newPost = text;
}}
editorClass="max-h-[400px]"
placeholder={$t('course.navItem.news_feed.heading_button.placeholder')}
/>
{#if errors.newPost}
<p class="text-sm text-red-500">{errors.newPost}</p>
{/if}
<div class="flex items-center justify-end py-2">
<div class="flex gap-2">
<Button variant="outline" onclick={resetEditor}>
{$t('course.navItem.news_feed.heading_button.cancel')}
</Button>
<Button loading={isLoading} onclick={onPost}>
{$t('course.navItem.news_feed.heading_button.post')}
</Button>
</div>
</div>
</section>
</Dialog.Content>
</Dialog.Root>
@@ -1,265 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import pluralize from 'pluralize';
import UsersIcon from '@lucide/svelte/icons/users';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import SendHorizontalIcon from '@lucide/svelte/icons/send-horizontal';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { profile } from '$lib/utils/store/user';
import { isOrgAdmin } from '$lib/utils/store/org';
import { calDateDiff } from '$lib/utils/functions/date';
import type { Author, Feed } from '$lib/utils/types/feed';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { addNewsfeedCommentValidation } from '$lib/utils/functions/validator';
import { isNewFeedModal } from '$lib/components/Course/components/NewsFeed/store';
import { Chip } from '@cio/ui/custom/chip';
import DeleteFeedConfirmation from './DeleteFeedConfirmation.svelte';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import { RoleBasedSecurity } from '$features/ui';
interface Props {
feed: Feed;
editFeed: Feed | null;
author: Author;
edit?: boolean;
deleteFeed?: any;
deleteComment?: any;
addNewComment?: any;
addNewReaction?: any;
onPin?: any;
isActive?: boolean;
}
let {
feed,
editFeed = $bindable(),
author,
edit = $bindable(false),
deleteFeed = (_arg: string) => {},
deleteComment = (_arg: string) => {},
addNewComment = (_arg1: string, _arg2: string, _arg3: string) => {},
addNewReaction = (_arg1: string, _arg2: string, _arg3: string) => {},
onPin = (_feedId: Feed['id'], _isPinned: Feed['isPinned']) => {},
isActive = false
}: Props = $props();
let comment = $state('');
let areCommentsExpanded = $state(false);
let isDeleteFeedModal = $state(false);
let errors: {
newComment: string;
} = $state({
newComment: ''
});
let reactions = {
smile: '😀',
thumbsup: '👍',
thumbsdown: '👎',
clap: '👏'
};
const openEditFeed = () => {
$isNewFeedModal.open = true;
edit = true;
if (edit === true) {
editFeed = feed;
}
};
const expandComment = () => {
areCommentsExpanded = !areCommentsExpanded;
};
const handleAddNewReaction = (reactionType: string) => {
addNewReaction(reactionType, feed.id, author.id);
};
const handleAddNewComment = (event) => {
if (event.key === 'Enter' || event.type === 'click') {
errors = addNewsfeedCommentValidation(comment) as typeof errors;
if (Object.keys(errors).length) {
return;
}
event.preventDefault();
addNewComment(comment, feed.id, author.id);
comment = '';
}
};
const handleDeleteFeed = () => {
deleteFeed(feed.id);
};
const handleDeleteComment = (id: string) => {
deleteComment(id);
};
const getClassIfSelectedByAuthor = (reactionType) => {
const usersReacted = feed.reaction[reactionType] || [];
return usersReacted.includes(author.id)
? 'bg-primary-200 ui:border-primary pl-2'
: 'bg-gray-200 border-gray-600 pl-2';
};
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 gap-5 {isActive
? 'border-primary-700 border-2'
: 'border border-gray-200 dark:border-neutral-600'} mb-7 max-w-3xl rounded-md"
>
<section>
<div class="p-3 pb-0">
<div class="mb-2 flex justify-between">
<span class="flex items-center gap-3">
<div class="h-9 w-9">
<img
src={feed.author?.profile?.avatar_url}
alt="users banner"
class="h-full w-full rounded-full object-cover"
/>
</div>
<span>
<p class="text-base font-semibold capitalize">{feed.author?.profile?.fullname}</p>
<p class="text-sm font-medium text-gray-600">{calDateDiff(feed.created_at)}</p>
</span>
</span>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<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">
<DropdownMenu.Item onclick={() => onPin(feed.id, feed.isPinned)}>
{feed.isPinned ? 'Unpin' : 'Pin'}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={openEditFeed}>Edit</DropdownMenu.Item>
<DropdownMenu.Item class="text-red-600" onclick={() => (isDeleteFeedModal = true)}>
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</RoleBasedSecurity>
</div>
{#if !isHtmlValueEmpty(feed.content)}
<HtmlRender className="text-sm font-medium w-[80%] mb-4">
<div>
{@html sanitizeHtml(feed.content)}
</div>
</HtmlRender>
{/if}
</div>
<div class="flex items-center gap-4 px-3">
<div class="flex items-center gap-4 px-3">
{#each Object.keys(feed.reaction) as reactionType}
{#if reactions[reactionType]}
<button
onclick={() => handleAddNewReaction(reactionType)}
class={`flex items-center transition ${
feed.reaction[reactionType].length >= 1 &&
`${getClassIfSelectedByAuthor(reactionType)} rounded-full border dark:text-black`
}`}
>
<div class="text-[15px]">{reactions[reactionType]}</div>
{#if feed.reaction[reactionType].length >= 1}
<Chip value={feed.reaction[reactionType].length} className="bg-transparent" />
{/if}
</button>
{/if}
{/each}
</div>
</div>
</section>
<section class="border-t border-gray-200 p-3 dark:border-neutral-600">
{#if feed.comment.length > 0}
<button onclick={expandComment} class="-mx-2 flex flex-row items-center gap-1 rounded-md px-2">
<UsersIcon size={16} />
<p class="py-2 text-sm">
{pluralize('comment', feed.comment.length, true)}
</p>
</button>
{/if}
<div>
{#each feed.comment as comment, index}
{#if comment.content && (areCommentsExpanded || index === feed.comment.length - 1)}
<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={comment.author?.profile?.avatar_url}
alt="users banner"
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">{comment.author?.profile?.fullname}</p>
<p class="text-xs font-medium text-gray-600">{calDateDiff(comment.created_at)}</p>
</div>
<p>{comment.content}</p>
</span>
</span>
{#if comment.author?.profile?.id === $profile.id || $isOrgAdmin}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="hidden h-8 w-8 items-center justify-center rounded-md hover:bg-gray-100 group-hover:flex dark:hover:bg-neutral-700"
>
<EllipsisVerticalIcon class="h-5 w-5" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item class="text-red-600" onclick={() => handleDeleteComment(comment.id)}>
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</div>
{/if}
{/each}
</div>
<div class="flex items-center justify-between gap-2">
<div class="h-7 w-7">
<img src={author.avatar_url} alt="users banner" class="h-full w-full rounded-full object-cover" />
</div>
<div class="flex-1">
<input
type="text"
bind:value={comment}
onkeydown={handleAddNewComment}
placeholder="Add class comment"
class="w-full rounded-3xl border border-gray-200 bg-transparent dark:border-neutral-600"
required
/>
</div>
<button onclick={handleAddNewComment}>
<SendHorizontalIcon size={16} />
</button>
</div>
{#if errors?.newComment}
<p class="text-sm text-red-500">{errors?.newComment}</p>
{/if}
</section>
<DeleteFeedConfirmation bind:openDeleteModal={isDeleteFeedModal} deleteFeed={handleDeleteFeed} />
</div>
@@ -1,8 +0,0 @@
import type { Feed } from '$lib/utils/types/feed';
import { writable } from 'svelte/store';
export const isNewFeedModal = writable({
open: false
});
export const newsFeed = writable<Feed[]>([]);
@@ -1,290 +0,0 @@
<script lang="ts">
import QRCode from 'qrcode';
import { untrack } from 'svelte';
import { page } from '$app/state';
import copy from 'copy-to-clipboard';
import { toPng } from 'html-to-image';
import { goto } from '$app/navigation';
import XIcon from '@lucide/svelte/icons/x';
import { Badge } from '@cio/ui/base/badge';
import * as Select from '@cio/ui/base/select';
import { Spinner } from '@cio/ui/base/spinner';
import * as Popover from '@cio/ui/base/popover';
import { qrInviteNodeStore } from './store';
import { ROLE } from '@cio/utils/constants';
import { orgApi } from '$features/org/api/org.svelte';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import type { OrgTeamMember } from '$lib/utils/types/org';
import { preventDefault } from '$lib/utils/functions/svelte';
import { course, setCourse } from '$lib/components/Course/store';
import { getStudentInviteLink } from '$lib/utils/functions/course';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { addGroupMember, fetchGroup } from '$lib/utils/services/courses';
import { triggerSendEmail, NOTIFICATION_NAME } from '$lib/utils/services/notification/notification';
import ShareQrImage from './ShareQRImage.svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
interface Tutor {
id: number;
text: string;
email: string;
profileId?: string;
}
let tutors: Tutor[] = $state([]);
let selectedIds: string[] = $state([]);
const selectedTutors = $derived(tutors.filter((tutor) => selectedIds.includes(tutor.id.toString())));
let qrImage = $state('');
let isLoadingQRDownload = $state(false);
const addPeopleParm = $derived(new URLSearchParams(page.url.search).get('add'));
function onSubmit() {
if (!selectedTutors.length) {
goto(page.url.pathname);
return;
}
let membersStack: { profile_id?: string; group_id?: string; role_id: number }[] = [];
console.log(`$course`, $course);
for (const tutor of selectedTutors) {
membersStack.push({
profile_id: tutor.profileId,
group_id: $course.group?.id,
role_id: ROLE.TUTOR
});
triggerSendEmail(NOTIFICATION_NAME.WELCOME_TEACHER_TO_COURSE, {
to: tutor.email,
name: tutor.text,
orgName: $currentOrg.name,
orgSiteName: $currentOrg.siteName,
courseName: $course.title
});
}
addGroupMember(membersStack).then(async () => {
if (!$course?.group?.id) return;
const group = await fetchGroup($course?.group?.id);
setCourse(
{
...$course,
group: group.data
},
false
);
goto(page.url.pathname);
});
}
function getTutors(team: OrgTeamMember[]) {
const existingTutors = $course.group?.tutors || [];
return team
.filter((teamMember) => teamMember.verified)
.filter((teamMember) => {
// filter out teamMembers that have already been added
return !existingTutors.some((t) => t.id === teamMember.profileId);
})
.map((teamMember) => ({
id: teamMember.id,
text: teamMember.fullname,
profileId: teamMember.profileId,
email: teamMember.email
}));
}
function setTutors(orgId: string | undefined) {
if (!orgId) return;
untrack(async () => {
await orgApi.getOrgTeam(orgId);
if (orgApi.error) {
console.error('Error fetching teams', orgApi.error);
return;
}
tutors = getTutors(orgApi.teamMembers);
});
}
function copyLink() {
if (!$currentOrgDomain) {
snackbar.error('snackbar.people.error.missing_data');
console.error('snackbar.people.error.no');
return;
}
const link = getStudentInviteLink($course, $currentOrg.siteName, $currentOrgDomain);
copy(link);
}
function closeModal() {
goto(page.url.pathname);
}
function handleQRDownload() {
if (!$qrInviteNodeStore) {
console.error('Node is not defined');
return;
}
isLoadingQRDownload = true;
setTimeout(() => {
toPng($qrInviteNodeStore)
.then((dataUrl) => {
const link = document.createElement('a');
link.download = `${$course.slug}-qr-code.png`;
link.href = dataUrl;
link.click();
})
.catch((error) => {
isLoadingQRDownload = false;
console.error('Oops, something went wrong!', error);
})
.finally(() => {
isLoadingQRDownload = false;
});
}, 300);
}
async function generateQR(text) {
try {
const image = await QRCode.toDataURL(text);
untrack(() => {
qrImage = image;
});
} catch (err) {
console.error(err);
}
}
$effect(() => {
setTutors($currentOrg.id);
});
$effect(() => {
generateQR(getStudentInviteLink($course, $currentOrg.siteName, $currentOrgDomain));
});
</script>
<Dialog.Root
open={addPeopleParm === 'true'}
onOpenChange={(isOpen) => {
if (!isOpen) closeModal();
}}
>
<Dialog.Content class="w-4/5 max-w-lg md:w-2/5">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.people.invite_modal.title')}</Dialog.Title>
</Dialog.Header>
<form onsubmit={preventDefault(onSubmit)}>
<div class="mb-8">
<p class="mb-1 text-base font-semibold">{$t('course.navItem.people.invite_modal.invite')}</p>
<Select.Root type="multiple" bind:value={selectedIds} disabled={orgApi.isLoading}>
<Select.Trigger class="w-full">
<div>
{#if selectedTutors.length > 0}
<div class="flex flex-wrap gap-1">
{#each selectedTutors as tutor}
<Badge variant="secondary" class="flex items-center gap-1">
{tutor.text}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
selectedIds = selectedIds.filter((id) => id !== tutor.id.toString());
}}
class="rounded-sm hover:bg-gray-200"
>
<XIcon size={14} />
</button>
</Badge>
{/each}
</div>
{:else}
{$t('course.navItem.people.invite_modal.select')}
{/if}
</div>
</Select.Trigger>
<Select.Content>
{#each tutors as tutor}
<Select.Item value={tutor.id.toString()}>
{tutor.text}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if orgApi.isLoading}
<div class="mt-2 flex items-center gap-2">
<Spinner class="h-4 w-4" />
<span class="text-sm">Loading...</span>
</div>
{:else}
<span class="mt-2 block text-sm">
{$t('course.navItem.people.invite_modal.to_add')}
<a href={`/org/${$currentOrg.siteName}/settings/teams`} class="ui:text-primary underline">
{$t('course.navItem.people.invite_modal.go_to')}
</a>
</span>
{/if}
</div>
<div class="mb-8 flex w-full items-center justify-between">
<div class="w-3/5">
<p class="mb-1 text-base font-semibold">
{$t('course.navItem.people.invite_modal.invite_students')}
</p>
<p class=" text-sm">{$t('course.navItem.people.invite_modal.via_link')}</p>
</div>
<Popover.Root>
<Popover.Trigger>
<button type="button" onclick={copyLink} class="text-primary-800 cursor-pointer capitalize underline">
{$t('course.navItem.people.invite_modal.copy_link')}
</button>
</Popover.Trigger>
<Popover.Content align="start" class="w-auto">
<div class="p-1 text-sm">{$t('course.navItem.people.invite_modal.success')}</div>
</Popover.Content>
</Popover.Root>
</div>
<div
class="flex w-full flex-col-reverse items-center justify-between gap-5 rounded-md border p-4 md:flex-row md:items-stretch"
>
<div class="flex flex-col items-center justify-between gap-3 md:items-start">
<span class="text-sm font-medium">
{$t('course.navItem.people.invite_modal.via_qr')}
</span>
<Button variant="outline" loading={isLoadingQRDownload} onclick={handleQRDownload}>
{$t('course.navItem.people.invite_modal.download_qr')}
</Button>
</div>
<div class="w-full border-4 border-[#f7f7f7] p-1 md:w-28">
<img src={qrImage} alt="link qrcode" class="h-full w-full" />
</div>
</div>
<div class="w-160 absolute left-[-1000px]">
<ShareQrImage {qrImage} course={$course} currentOrg={$currentOrg} />
</div>
<div class="mt-5 flex flex-row-reverse items-center">
<Button variant="secondary" type="submit">
{$t('course.navItem.people.invite_modal.finish')}
</Button>
</div>
</form>
</Dialog.Content>
</Dialog.Root>
@@ -1,41 +0,0 @@
<script lang="ts">
import type { AccountOrg } from '$features/app/types';
import type { Course } from '$lib/utils/types';
import { isFreePlan } from '$lib/utils/store/org';
import { qrInviteNodeStore } from './store';
let node: any = $state();
$effect(() => {
qrInviteNodeStore.set(node);
});
interface Props {
qrImage: string;
currentOrg: AccountOrg;
course: Course;
}
let { qrImage, currentOrg, course }: Props = $props();
</script>
<div
bind:this={node}
id="qr-container"
class="h-160 w-160 flex flex-col items-center justify-center rounded-xl bg-blue-900 p-10 pb-20"
>
<div class="rounded-3xl bg-white p-6 pb-3 text-center">
<div class="my-4 rounded-xl bg-gray-100 p-2 text-xl">Scan QR</div>
<img src={qrImage} alt="qrcode" />
<div class="pb-4 pt-1">
<p class="ui:text-primary mt-2 text-2xl">{course.title}</p>
<p class="mt-1 text-lg font-semibold text-black">{currentOrg.name}</p>
</div>
</div>
{#if $isFreePlan}
<div class="relative top-16 flex items-center gap-x-2 rounded-md bg-white px-4 py-2">
<img src="/logo.svg" alt="logo" class="w-6" />
<span class="text-base">ClassroomIO.com</span>
</div>
{/if}
</div>
@@ -1,7 +0,0 @@
import { writable } from 'svelte/store';
export const deleteMemberModal = writable({
open: false
});
export const qrInviteNodeStore = writable(null);
@@ -1,21 +0,0 @@
export interface Profile {
id: string;
fullname: string;
username: string;
email: string;
avatar_url?: string;
}
export interface Person {
id: string;
email?: string;
role_id: number;
profile?: Profile;
profile_id?: string;
assigned_student_id: string;
}
export interface ProfileRole {
label: string;
value: string | number;
}
@@ -1,4 +0,0 @@
export const ROUTES = {
COURSES: 'courses',
LESSONS: 'lessons'
};
@@ -1,53 +0,0 @@
import { ROUTES } from './constants';
export function getGroupMemberId(people, profileId) {
const groupMember = people.find((person) => person.profile_id === profileId);
return groupMember ? groupMember.id : null;
}
export function getNavItemRoute(courseId, routeId?: string) {
const path = `/${ROUTES.COURSES}/${courseId}`;
if (!routeId) {
return path;
}
return `${path}/${routeId}`;
}
export function getLessonsRoute(courseId, lessonId?: string) {
const path = getNavItemRoute(courseId, ROUTES.LESSONS);
if (!lessonId) {
return path;
}
return `${path}/${lessonId}`;
}
export function getLectureNo(index, initNo = '0') {
if (index <= 9) {
return `${initNo}${index}`;
}
return index;
}
export function formatAnswers(data) {
const answers: Record<string, string> = {};
const questionByIdAndName = {};
for (const question of data.questions) {
questionByIdAndName[question.id] = question.name;
}
for (const answer of data.answers) {
const questionName = questionByIdAndName[answer.question_id];
answers[questionName] =
Array.isArray(answer.answers) && answer.answers.length ? answer.answers : answer.open_answer;
}
return answers;
}
@@ -1,237 +0,0 @@
import { COURSE_TYPE, COURSE_VERSION } from '$lib/utils/types';
import type { Course, GroupPerson, Lesson, LessonSection } from '$lib/utils/types';
import { get, writable, type Writable } from 'svelte/store';
import { lessonSections, lessons } from './components/Lesson/store/lessons';
import { ROLE } from '@cio/utils/constants';
import { fetchCourse } from '$lib/utils/services/courses';
export const defaultCourse: Course = {
id: '',
title: '',
description: '',
type: COURSE_TYPE.LIVE_CLASS,
cost: 0,
currency: '',
status: 'ACTIVE',
is_certificate_downloadable: false,
certificate_theme: 'professional',
is_published: false,
created_at: new Date().toDateString(),
updated_at: new Date().toDateString(),
attendance: [],
polls: [],
version: COURSE_VERSION.V2,
metadata: {
requirements: '',
description: '',
goals: '',
videoUrl: '',
showDiscount: false,
discount: 0,
paymentLink: '',
reward: {
show: false,
description: ''
},
instructor: {
name: '',
role: '',
coursesNo: 0,
description: '',
imgUrl: ''
},
lessonTabsOrder: [
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
{ id: 2, name: 'course.navItem.lessons.materials.tabs.slide.title' },
{ id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' },
{ id: 4, name: 'course.navItem.lessons.materials.tabs.document.title' }
],
grading: false,
lessonDownload: true,
allowNewStudent: false
}
};
export const course: Writable<Course> = writable({ ...defaultCourse });
class CourseStore {
prevId = writable('');
isLoading = writable(false);
async fetch(id: string) {
console.log('----------------FETCHING COURSE----------------');
console.log('1. fetching course', id);
if (get(this.prevId) === id) {
console.log('2. course already fetched');
return;
}
console.log('3. fetching course', id);
this.isLoading.update(() => true);
this.prevId.set(id);
course.set(defaultCourse);
lessons.set([]);
const { data } = await fetchCourse(id);
if (data) {
console.log('4. setting course');
setCourse(data);
}
console.log('5. setting isLoading to false');
this.isLoading.update(() => false);
console.log('--------------FETCHING COURSE COMPLETED------------------');
}
}
export const courseStore = new CourseStore();
export const mockGroupMember = {
id: '434534534535',
group_id: '434534534535',
role_id: 1,
profile_id: 'vcvbcvbcvbb----434534534535',
email: null,
profile: {
id: 'vcvbcvbcvbb----434534534535',
fullname: 'Rotimi Best',
username: 'robertblake',
avatar_url: '',
created_at: '2021-08-08T13:42:13+00:00',
updated_at: '2021-08-08T13:42:13+00:00'
}
};
type GroupStore = {
id?: string;
tutors: GroupPerson[];
students: GroupPerson[];
people: GroupPerson[];
members?: GroupPerson[];
memberId?: string;
};
export const group = writable<GroupStore>({
id: '',
tutors: [],
students: [],
people: []
});
export async function setCourse(data: Course, setLesson = true) {
if (!data || !(Object.values(data) && Object.values(data).length)) return;
if (data.group) {
const copiedGroup = JSON.parse(JSON.stringify(data.group));
const groupData = Object.assign(copiedGroup, {
id: copiedGroup.id,
tutors: [],
students: [],
people: []
});
if (Array.isArray(data.group.members)) {
for (const member of data.group.members) {
if (member.role_id === ROLE.STUDENT) {
groupData.students.push(member);
} else if (member.profile) {
groupData.tutors.push({
...member,
memberId: member.id
});
}
}
groupData.people = data.group.members;
}
group.set(groupData);
}
if (setLesson) {
const orderedLessons = sortLesson(data.lessons);
lessons.set(orderedLessons);
console.log('orderedLessons', orderedLessons);
if (data.lesson_section) {
const sections = data.lesson_section?.map((section) => {
const lessons = (data.lessons || []).filter((lesson) => lesson.section_id === section.id);
section.lessons = sortLesson(lessons);
return section;
});
lessonSections.set(sortLessonSection(sections));
}
}
delete data.lessons;
delete data?.lesson_section;
if (data.metadata && !Object.values(data.metadata)) {
data.metadata = {
requirements: '',
description: '',
goals: '',
videoUrl: '',
showDiscount: false,
discount: 0,
reward: {
show: false,
description: ''
},
instructor: {
name: '',
role: '',
coursesNo: 0,
description: '',
imgUrl: ''
},
allowNewStudent: false
};
}
// Ensure lessonTabsOrder includes all tabs (backward compatibility)
if (data.metadata && data.metadata.lessonTabsOrder) {
const existingTabIds = data.metadata.lessonTabsOrder.map((tab) => tab.id);
const allTabs = [
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
{ id: 2, name: 'course.navItem.lessons.materials.tabs.slide.title' },
{ id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' },
{ id: 4, name: 'course.navItem.lessons.materials.tabs.document.title' }
];
let hasChanges = false;
// Add missing tabs to existing lessonTabsOrder
allTabs.forEach((tab) => {
if (!existingTabIds.includes(tab.id) && data.metadata.lessonTabsOrder) {
data.metadata.lessonTabsOrder.push(tab);
hasChanges = true;
}
});
if (hasChanges) {
// Document tab has been added to lessonTabsOrder
}
}
if (!data.certificate_theme) {
data.certificate_theme = 'professional';
}
console.log('lessons', get(lessons));
course.set(data);
}
export function sortLesson(lessons: Lesson[] = []) {
return lessons
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
export function sortLessonSection(sections: LessonSection[] = []) {
return sections
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
@@ -1,28 +0,0 @@
export type Question = {
id: number | string;
value: string;
name: string;
title: string;
type: number;
points: number;
order: number;
question_type: {
id: number;
label: string;
};
question_type_id: number;
code?: string;
answers?: Array<any>;
deleted_at?: string;
created_at?: string;
updated_at?: string;
is_dirty?: boolean;
options: {
id: number | string;
label: string | null;
value: string | null;
is_correct: boolean;
deleted_at?: string;
is_dirty?: boolean;
}[];
};
@@ -1,100 +0,0 @@
<script lang="ts">
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { Spinner } from '@cio/ui/base/spinner';
import { Backdrop, Confetti } from '$features/ui';
import { course, group, courseStore } from '../Course/store';
import { profile } from '$lib/utils/store/user';
import * as Dialog from '@cio/ui/base/dialog';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import { isOrgAdmin } from '$lib/utils/store/org';
import type { GroupPerson } from '$lib/utils/types';
interface Props {
courseId: string;
path?: string;
isExercisePage?: boolean;
isFetching?: boolean;
containerClass?: string;
onFetchingChange?: (v: boolean) => void;
children?: import('svelte').Snippet;
renderOnlyChildren?: boolean;
}
let {
courseId,
isExercisePage = false,
isFetching = $bindable(false),
onFetchingChange = (_v: boolean) => {},
children,
renderOnlyChildren = false
}: Props = $props();
const user: GroupPerson | undefined = $derived($group.people.find((person) => person.profile_id === $profile.id));
const canCheck = $derived($profile.id && $group.id);
const isPermitted = $derived.by(() => {
if (!canCheck) return true;
if ($isOrgAdmin === null) return true;
return $isOrgAdmin || user;
});
$effect(() => {
const isLoading = get(courseStore.isLoading);
onFetchingChange(isLoading);
});
$effect(() => {
courseStore.fetch(courseId);
});
</script>
<svelte:head>
<title>{$course.title || 'ClassroomIO Course'}</title>
</svelte:head>
{#if isFetching}
<Backdrop>
<Spinner class="size-14! text-blue-700!" />
</Backdrop>
{/if}
<Dialog.Root open={!isPermitted}>
<Dialog.Content class="w-96">
<Dialog.Header>
<Dialog.Title>{$t('course.not_permitted.header')}</Dialog.Title>
</Dialog.Header>
<div>
<p class="text-md text-center dark:text-white">
{$t('course.not_permitted.body')}
</p>
<div class="mt-5 flex justify-center">
<Button
onclick={() => {
goto('/org/*');
}}
>
{$t('course.not_permitted.button')}
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
{#if renderOnlyChildren}
{@render children?.()}
{:else}
{#if isExercisePage}
<Confetti />
{/if}
<!-- Show only if permitted -->
{#if isPermitted}
{@render children?.()}
{/if}
{/if}
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { TCustomLinks } from './types';
import { Button } from '@cio/ui/base/button';
@@ -13,6 +14,10 @@
function handleLinkClick() {
if (onMobileClick) onMobileClick();
}
$effect(() => {
console.log('customLinks', customLinks);
});
</script>
{#if customLinks}
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { t } from '$lib/utils/functions/translations';
interface Props {
@@ -11,7 +12,7 @@
<div class="logo">
<a
href="/"
href={resolve('/')}
title={`${$t('navigation.goto')} ${orgName || 'ClassroomIO'} ${$t('navigation.home')}`}
class="flex items-center gap-2"
id="logo"
@@ -30,11 +30,11 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
{#if mobileMenuOpen}
<div class="mobile-menu-overlay fixed inset-0 z-50 bg-black bg-opacity-50 lg:hidden" onclick={closeMobileMenu}>
<div class="mobile-menu-overlay bg-opacity-50 fixed inset-0 z-50 bg-black lg:hidden" onclick={closeMobileMenu}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mobile-menu-sidebar absolute right-0 top-0 h-full w-64 transform bg-white shadow-lg transition-transform duration-300 ease-in-out"
class="mobile-menu-sidebar absolute top-0 right-0 h-full w-64 transform bg-white shadow-lg transition-transform duration-300 ease-in-out"
onclick={stopPropagation()}
>
<div class="flex items-center justify-between border-b border-gray-200 p-4">
@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import BellIcon from '@lucide/svelte/icons/bell';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
@@ -26,13 +27,13 @@
<div class="flex items-center text-white">
{#if isCoursePage}
<li class="hidden md:block">
<IconButton onclick={() => goto(coursesPath)}>
<IconButton onclick={() => goto(resolve(coursesPath))}>
<ArrowLeftIcon size={16} class="custom" />
</IconButton>
</li>
{/if}
<a
href={coursesPath}
href={resolve(coursesPath)}
title="{$t('navigation.goto')} {isCoursePage ? $t('navigation.courses') : $t('navigation.classroomio_home')}"
id="logo"
class="line-clamp-1 text-lg"
@@ -1,10 +1,13 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { ROLE } from '@cio/utils/constants';
import { user } from '$lib/utils/store/user';
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import { isCoursePage } from '$lib/utils/functions/app';
import { t } from '$lib/utils/functions/translations';
import type { TCustomLinks } from './types';
import { Button } from '@cio/ui/base/button';
import Logo from './Logo.svelte';
import CustomLinks from './CustomLinks.svelte';
import AuthButtons from './AuthButtons.svelte';
@@ -30,17 +33,24 @@
let navClass = '';
let mobileMenuOpen = $state(false);
let is404Page = $derived(page.url.pathname?.includes('/404'));
let redirect = $derived(isCoursePage(page.url.pathname) ? `?redirect=${page.url.pathname}` : '');
let showLinks = $derived(customLinks && customLinks.show && customLinks.links && customLinks.links.length > 0);
let isAdminOrTeacher = $derived($currentOrg.roleId === ROLE.ADMIN || $currentOrg.roleId === ROLE.TUTOR);
let gotoHref = $derived(
isAdminOrTeacher && $currentOrgPath !== '#' ? resolve($currentOrgPath, {}) : resolve('/lms', {})
);
let gotoLabel = $derived(isAdminOrTeacher ? 'navigation.goto_dashboard' : 'navigation.goto_lms');
function toggleMobileMenu() {
mobileMenuOpen = !mobileMenuOpen;
}
</script>
<nav
class="{navClass} {backgroundColor} sticky top-0 z-50 flex w-full border-b border-l-0 border-r-0 border-t-0 border-gray-300 px-2 py-1"
class="{navClass} {backgroundColor} sticky top-0 z-50 flex w-full border-t-0 border-r-0 border-b border-l-0 border-gray-300 px-2 py-1"
>
<ul class="flex w-full items-center">
<Logo {logo} {orgName} />
@@ -62,25 +72,24 @@
{#if customLinks && showLinks}
<CustomLinks {customLinks} />
<MobileMenu bind:mobileMenuOpen {customLinks} {disableSignup} {redirect} />
{/if}
{#if $user.isLoggedIn}
{#if isOrgSite}
<li><a class="block" href="/lms"> {$t('navigation.goto_lms')} </a></li>
<li>
<Button variant="secondary" size="sm" href={gotoHref}>{$t(gotoLabel)}</Button>
</li>
{/if}
{:else if isOrgSite && !page.url.pathname?.includes('/404')}
{:else if isOrgSite && !is404Page}
<!-- Hide login/signup buttons on mobile when custom links exist -->
<div class="hidden lg:block">
<AuthButtons {disableSignup} {redirect} />
</div>
{:else if !isOrgSite && !page.url.pathname?.includes('/404')}
{:else if !isOrgSite && !is404Page}
<AuthButtons {disableSignup} {redirect} />
{/if}
</ul>
{#if showLinks}
<MobileMenu bind:mobileMenuOpen {customLinks} {disableSignup} {redirect} />
{/if}
</nav>
<style>
@@ -95,17 +104,6 @@
clear: both;
}
a {
text-decoration: none;
color: var(--main-primary-color);
padding: 0 1.5em;
font-weight: 700;
display: flex;
gap: 0.5rem;
align-items: center;
flex-direction: column;
}
/* Mobile menu button styles */
.mobile-menu-btn {
display: none;
@@ -119,10 +117,6 @@
align-items: center;
}
a {
padding: 0 0.5em;
}
.mobile-menu-btn {
display: block;
}
@@ -1,160 +0,0 @@
<script lang="ts">
import { preventDefault } from '$lib/utils/functions/svelte';
import { CodeSnippet } from '$features/ui';
import { CheckboxField } from '@cio/ui/custom/checkbox-field';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import { Button } from '@cio/ui/base/button';
import Grade from '$lib/components/Question/Grade.svelte';
import { t } from '$lib/utils/functions/translations';
import QuestionTitle from '../QuestionTitle.svelte';
import ReasonBox from '../ReasonBox.svelte';
interface Props {
title?: string;
index?: number | string;
code: any;
name?: string;
options?: { value: string; label: string }[];
onSubmit?: any;
onPrevious?: any;
defaultValue?: string[];
disablePreviousButton?: boolean;
isLast?: boolean;
disabled?: boolean;
isPreview?: boolean;
nextButtonProps?: any;
grade?: number;
gradeMax?: number;
disableGrading?: boolean;
isGradeWithAI?: boolean;
reason?: string;
isLoading?: boolean;
hideGrading?: boolean;
}
let {
title = '',
index = 1,
code,
name = '',
options = [],
onSubmit = (_a: string, _b: string[]) => {},
onPrevious = () => {},
defaultValue = [],
disablePreviousButton = false,
isLast = false,
disabled = false,
isPreview = false,
nextButtonProps = {
isDisabled: false,
isActive: false
},
grade = $bindable(0),
gradeMax = 0,
disableGrading = false,
isGradeWithAI = $bindable(false),
reason = $bindable(''),
isLoading = $bindable(false),
hideGrading = false
}: Props = $props();
let gradeWithAI = $derived(isGradeWithAI);
function getVal(form, name) {
let values: string[] = [];
const checkboxEl = form.elements[name];
for (let i = 0, len = checkboxEl.length; i < len; i++) {
if (checkboxEl[i].checked) {
values.push(checkboxEl[i].value);
}
}
return values;
}
function handleFormSubmit(event) {
if (isPreview) return;
const values = getVal(event.target, name);
onSubmit(name, values);
event.target.reset();
}
function handlePrevious(event) {
event.preventDefault();
onPrevious();
}
function getValidationClassName(option) {
if (defaultValue.includes(option.value)) {
if (option.is_correct) {
return 'border-green-700';
} else {
return 'border-red-700';
}
}
return '';
}
function acceptGrade() {
gradeWithAI = false;
}
function rejectGrade() {
gradeWithAI = false;
grade = 0;
}
</script>
<form onsubmit={preventDefault(handleFormSubmit)}>
<div class="flex items-center justify-between">
<HtmlRender className="mt-4 {typeof grade === 'number' && 'w-4/5'}" disableMaxWidth>
<QuestionTitle {index} {title} />
</HtmlRender>
{#if !hideGrading}
<Grade {gradeMax} bind:grade {disableGrading} />
{/if}
</div>
{#if code}
<CodeSnippet {code} />
{/if}
<div class="ml-4">
{#each options as option}
<button
class="my-2 w-full cursor-pointer rounded-md border-2 border-gray-300 text-left hover:bg-gray-200 dark:hover:bg-neutral-800 {getValidationClassName(
option
)}"
type="button"
>
<CheckboxField
{name}
className="p-2"
value={option.value}
checked={defaultValue.includes(option.value)}
label={option.label || option.value}
{disabled}
/>
</button>
{/each}
</div>
{#if gradeWithAI}
<ReasonBox {reason} {isLoading} {acceptGrade} {rejectGrade} />
{/if}
{#if !isPreview}
<div class="mt-3 flex w-full items-center justify-between">
<Button variant="outline" onclick={handlePrevious} disabled={disablePreviousButton}>
{$t('course.navItem.lessons.exercises.all_exercises.previous')}
</Button>
<Button variant={nextButtonProps.isActive ? 'default' : 'outline'} type="submit">
{isLast
? $t('course.navItem.lessons.exercises.all_exercises.finish')
: $t('course.navItem.lessons.exercises.all_exercises.next')}
disabled={nextButtonProps.isDisabled}
</Button>
</div>
{/if}
</form>
@@ -1,162 +0,0 @@
<script lang="ts">
import { preventDefault } from '$lib/utils/functions/svelte';
import { CodeSnippet } from '$features/ui';
import { Button } from '@cio/ui/base/button';
import { RadioItem } from '@cio/ui/custom/radio-item';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import Grade from '$lib/components/Question/Grade.svelte';
import { t } from '$lib/utils/functions/translations';
import ReasonBox from '../ReasonBox.svelte';
import QuestionTitle from '../QuestionTitle.svelte';
interface Props {
title?: string;
index?: number | string;
code?: string;
name?: string;
options?: { value: string; label: string; is_correct: boolean }[];
onSubmit?: any;
onPrevious?: any;
defaultValue?: string;
disablePreviousButton?: boolean;
disabled?: boolean;
isPreview?: boolean;
nextButtonProps?: any;
isLast?: boolean;
grade?: number;
gradeMax?: number;
disableGrading?: boolean;
disableOptContainerMargin?: boolean;
isGradeWithAI?: boolean;
reason?: string;
isLoading?: boolean;
hideGrading?: boolean;
}
let {
title = '',
index = 1,
code = '',
name = '',
options = [],
onSubmit = (_a: string, _b: string[]) => {},
onPrevious = () => {},
defaultValue = '',
disablePreviousButton = false,
disabled = false,
isPreview = false,
nextButtonProps = {
isDisabled: false,
isActive: false
},
isLast = false,
grade = $bindable(0),
gradeMax = 0,
disableGrading = false,
disableOptContainerMargin = false,
isGradeWithAI = $bindable(false),
reason = $bindable(''),
isLoading = $bindable(false),
hideGrading = false
}: Props = $props();
let gradeWithAI = $derived(isGradeWithAI);
function getRadioVal(form, name): string {
let val;
const radios = form.elements[name];
for (let i = 0, len = radios.length; i < len; i++) {
if (radios[i].checked) {
val = radios[i].value;
break;
}
}
return val;
}
function handleFormSubmit(event) {
if (isPreview) return;
const value = getRadioVal(event.target, name);
onSubmit(name, [value]);
event.target.reset();
}
function handlePrevious(event) {
event.preventDefault();
onPrevious();
}
function getValidationClassName(option) {
if (defaultValue.includes(option.value)) {
if (option.is_correct) {
return 'border-green-700';
} else {
return 'border-red-700';
}
}
return '';
}
function acceptGrade() {
gradeWithAI = false;
}
function rejectGrade() {
gradeWithAI = false;
grade = 0;
}
</script>
<form onsubmit={preventDefault(handleFormSubmit)}>
<div class="flex items-center justify-between">
<HtmlRender className="mt-4 {typeof grade === 'number' && 'w-4/5'}" disableMaxWidth>
<QuestionTitle {index} {title} />
</HtmlRender>
{#if !hideGrading}
<Grade {gradeMax} bind:grade {disableGrading} />
{/if}
</div>
{#if code}
<CodeSnippet {code} />
{/if}
<div class={disableOptContainerMargin ? '' : 'ml-4'}>
{#each options as option}
<button
class="my-2 w-full cursor-pointer rounded-md border-2 border-gray-300 text-left hover:bg-gray-200 dark:hover:bg-neutral-800 {getValidationClassName(
option
)}"
type="button"
>
<RadioItem
className="p-2"
{name}
value={option.value}
checked={defaultValue.includes(option.value) && option.is_correct}
label={option.label || option.value}
{disabled}
/>
</button>
{/each}
</div>
{#if gradeWithAI}
<ReasonBox {reason} {isLoading} {acceptGrade} {rejectGrade} />
{/if}
{#if !isPreview}
<div class="mt-3 flex w-full items-center justify-between">
<Button variant="outline" onclick={handlePrevious} disabled={disablePreviousButton}>
{$t('course.navItem.lessons.exercises.all_exercises.previous')}
</Button>
<Button variant={nextButtonProps.isActive ? 'default' : 'outline'} type="submit">
{isLast
? $t('course.navItem.lessons.exercises.all_exercises.finish')
: $t('course.navItem.lessons.exercises.all_exercises.next')}
disabled={nextButtonProps.isDisabled}
</Button>
</div>
{/if}
</form>
@@ -1,121 +0,0 @@
<script lang="ts">
import { preventDefault } from '$lib/utils/functions/svelte';
import { CodeSnippet } from '$features/ui';
import { Button } from '@cio/ui/base/button';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import Grade from '$lib/components/Question/Grade.svelte';
import { t } from '$lib/utils/functions/translations';
import ReasonBox from '../ReasonBox.svelte';
import QuestionTitle from '../QuestionTitle.svelte';
interface Props {
title?: string;
index?: number | string;
code?: string;
name?: string;
onSubmit?: any;
onPrevious?: any;
defaultValue?: string;
disablePreviousButton?: boolean;
isLast?: boolean;
isPreview?: boolean;
disabled?: boolean;
grade?: number;
gradeMax?: number;
disableGrading?: boolean;
isGradeWithAI?: boolean;
reason?: string;
isLoading?: boolean;
hideGrading?: boolean;
}
let {
title = '',
index = 1,
code = '',
name = '',
onSubmit = (_a: string, _b: string) => {},
onPrevious = () => {},
defaultValue = $bindable(''),
disablePreviousButton = false,
isLast = false,
isPreview = false,
disabled = false,
grade = $bindable(0),
gradeMax = 0,
disableGrading = false,
isGradeWithAI = $bindable(false),
reason = $bindable(''),
isLoading = $bindable(false),
hideGrading = false
}: Props = $props();
let gradeWithAI = $derived(isGradeWithAI);
function handleFormSubmit() {
if (isPreview) return;
onSubmit(name, defaultValue);
// event.target.reset();
}
function handlePrevious(event) {
event.preventDefault();
onPrevious();
}
function acceptGrade() {
gradeWithAI = false;
}
function rejectGrade() {
gradeWithAI = false;
grade = 0;
}
</script>
<form onsubmit={preventDefault(handleFormSubmit)}>
<div class="mb-2 flex items-center justify-between">
<HtmlRender className="mt-4 {typeof grade === 'number' && 'w-4/5'}" disableMaxWidth>
<QuestionTitle {index} {title} />
</HtmlRender>
{#if !hideGrading}
<Grade {gradeMax} bind:grade {disableGrading} />
{/if}
</div>
{#if code}
<CodeSnippet {code} />
{/if}
<div class="ml-4">
{#if disabled}
<div class="mb-3 rounded-md bg-gray-200 px-5 py-3 dark:bg-gray-500">
{defaultValue === '' ? $t('course.navItem.lessons.exercises.all_exercises.no_answer') : defaultValue}
</div>
{#if gradeWithAI}
<ReasonBox {reason} {isLoading} {acceptGrade} {rejectGrade} />
{/if}
{:else}
<TextareaField
bind:value={defaultValue}
rows={5}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_answer_here')}
/>
{/if}
</div>
{#if !isPreview}
<div class="mt-3 flex w-full items-center justify-between">
<Button variant="outline" onclick={handlePrevious} disabled={disablePreviousButton}>
{$t('course.navItem.lessons.exercises.all_exercises.previous')}
</Button>
<Button variant="outline" type="submit">
{isLast
? $t('course.navItem.lessons.exercises.all_exercises.finish')
: $t('course.navItem.lessons.exercises.all_exercises.next')}
</Button>
</div>
{/if}
</form>
@@ -1,33 +0,0 @@
export const QUESTION_TYPE = {
RADIO: 1,
CHECKBOX: 2,
TEXTAREA: 3
};
export const QUESTION_TYPES = [
{
id: QUESTION_TYPE.RADIO,
label: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.single'
},
{
id: QUESTION_TYPE.CHECKBOX,
label: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.multiple'
},
{
id: QUESTION_TYPE.TEXTAREA,
label: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.paragraph'
}
];
export const QUESTION_TEMPLATE = {
id: 1,
title: '',
type: QUESTION_TYPE.RADIO,
answers: [],
options: [
{
id: 1,
value: null
}
]
};
-10
View File
@@ -1,10 +0,0 @@
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public';
import type { ConfigType } from '$lib/utils/types/config';
export const config: ConfigType = {
supabaseConfig: {
url: PUBLIC_SUPABASE_URL || 'http://localhost:54321',
anonKey: PUBLIC_SUPABASE_ANON_KEY || 'anon-key'
}
};
@@ -3,16 +3,19 @@ import { currentOrg, isOrgStudent, orgs } from '$lib/utils/store/org';
import { defaultProfileState, defaultUserState, profile, user } from '$lib/utils/store/user';
import type { AccountResponse } from './types';
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
import type { TUser } from '@cio/db/types';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { handleLocaleChange } from '$lib/utils/functions/translations';
import { identifyPosthogUser } from '$lib/utils/services/posthog';
import { licenseApi } from '$features/license/api/license.svelte';
import { logout } from '$lib/utils/functions/logout';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { setSentryUser } from '$lib/utils/services/sentry';
import { setTheme } from '$lib/utils/functions/theme';
import { setupAnalyticsBasedOnLicense } from '$lib/utils/functions/appSetup';
import shouldRedirectOnAuth from '$lib/utils/functions/routes/shouldRedirectOnAuth';
type AppSetupParams = {
@@ -44,6 +47,8 @@ class AppInitApi extends BaseApi {
logContext: 'fetching account',
onSuccess: (accountData) => {
this.data = accountData;
licenseApi.setFeatures(accountData.licenseFeatures);
setupAnalyticsBasedOnLicense();
this.setupStores();
this.setUserAnalytics();
this.routeUserToNextPage(params);
@@ -95,9 +100,7 @@ class AppInitApi extends BaseApi {
const theme = get(currentOrg)?.theme;
if (theme) {
setTheme(theme);
}
setTheme(theme || 'blue');
}
routeUserToNextPage({ isOrgSite }: AppSetupParams) {
@@ -106,16 +109,6 @@ class AppInitApi extends BaseApi {
return;
}
if (isOrgSite && window.location.pathname === '/') {
return;
}
const userHasOrganizations = this.data.organizations.length > 0;
const shouldRedirectToOnboarding = !isOrgSite && !userHasOrganizations;
if (shouldRedirectToOnboarding) {
return goto(resolve(`/onboarding`, {}));
}
const redirect = page.url.searchParams.get('redirect');
if (redirect) {
// goto redirect won't accept dynamic url so we need to use window.location.href
@@ -123,11 +116,40 @@ class AppInitApi extends BaseApi {
return;
}
if (!shouldRedirectOnAuth(page.url.pathname)) return;
if (isOrgSite && window.location.pathname === '/') {
return;
}
const isStudent = get(isOrgStudent);
return isOrgSite || isStudent ? this.goToLMS() : this.goToOrg();
const userHasOrganizations = this.data.organizations.length > 0;
const isCloud = PUBLIC_IS_SELFHOSTED !== 'true';
// CLOUD: when user has no orgs and isOrgSite is false, route to /onboarding
// isOrgSite - means the user is on a multi tenant organization site, we don't want to redirect to /onboarding in this case
if (isCloud) {
const shouldRedirectToOnboarding = !userHasOrganizations && !isOrgSite;
if (shouldRedirectToOnboarding) {
return goto(resolve(`/onboarding`, {}));
}
if (!shouldRedirectOnAuth(page.url.pathname)) return;
return isOrgSite || isStudent ? this.goToLMS() : this.goToOrg();
} else {
// Self-hosted: when user has no orgs, route to /onboarding
// This should only happen once cause Self hosted instance is single tenant
//
if (!userHasOrganizations) {
return goto(resolve(`/onboarding`, {}));
}
if (!shouldRedirectOnAuth(page.url.pathname)) return;
return isStudent ? this.goToLMS() : this.goToOrg();
}
}
goToLMS() {
@@ -161,6 +183,7 @@ class AppInitApi extends BaseApi {
reset() {
super.reset();
this.data = null;
licenseApi.reset();
user.set(defaultUserState);
profile.set(defaultProfileState);
@@ -1,6 +1,7 @@
import type { AccountOrg } from '$features/app/types';
import type { Cookies } from '@sveltejs/kit';
import { OrgApiServer } from '$features/org/api/org.server';
import { getFirstOrg, getOrgBySiteName, getOrgsByCustomDomain } from '$features/org/api/org.server';
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
import { blockedSubdomain } from '$lib/utils/constants/app';
import { env } from '$env/dynamic/private';
@@ -20,26 +21,17 @@ export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise<OrgSit
org: null
};
// Selfhosted usecase
// Self-hosted: single org, single domain
if (PUBLIC_IS_SELFHOSTED === 'true') {
const subdomain = getSubdomain(url);
// Student dashboard
if (subdomain) {
const org = await OrgApiServer.getOrgBySiteName(subdomain);
// Organization by subdomain not found
if (!org) {
return response;
}
response.org = org as AccountOrg;
const apiKeyHeaders = getApiKeyHeaders();
const firstOrg = await getFirstOrg(apiKeyHeaders);
if (firstOrg) {
response.org = firstOrg as AccountOrg;
response.isOrgSite = true;
response.orgSiteName = subdomain;
response.subdomain = subdomain;
response.orgSiteName = firstOrg.siteName || '';
response.subdomain = '';
}
// Never go beyond this for selfhosted instances
return response;
}
@@ -59,7 +51,8 @@ export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise<OrgSit
// Custom domain
if (isURLCustomDomain(url)) {
const orgs = await OrgApiServer.getOrgsByCustomDomain(url.host, true);
const apiKeyHeaders = getApiKeyHeaders();
const orgs = await getOrgsByCustomDomain(url.host, true, apiKeyHeaders);
if (!orgs || orgs.length === 0) {
return response;
@@ -86,8 +79,9 @@ export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise<OrgSit
response.orgSiteName = debugMode ? _orgSiteName : subdomain;
if (response.orgSiteName) {
const org = await OrgApiServer.getOrgBySiteName(response.orgSiteName);
response.org = (org as AccountOrg) || null;
const apiKeyHeaders = getApiKeyHeaders();
const org = await getOrgBySiteName(response.orgSiteName, apiKeyHeaders);
response.org = org ?? null;
}
const shouldDeleteCookie = !response.org && _orgSiteName;
@@ -1,6 +1,6 @@
<script lang="ts">
import * as Table from '@cio/ui/base/table';
import { page as pageStore } from '$app/state';
import { page } from '$app/state';
import { Skeleton } from '@cio/ui/base/skeleton';
import { Search } from '@cio/ui/custom/search';
import * as Pagination from '@cio/ui/base/pagination';
@@ -9,13 +9,23 @@
import { t } from '$lib/utils/functions/translations';
import { Empty } from '@cio/ui/custom/empty';
import { UpgradeBanner } from '$features/ui';
import { currentOrg, currentOrgMaxAudience } from '$lib/utils/store/org';
import { currentOrgMaxAudience } from '$lib/utils/store/org';
import * as Avatar from '@cio/ui/base/avatar';
import * as Page from '@cio/ui/base/page';
import { shortenName } from '$lib/utils/functions/string';
import type { OrganizationAudience } from '$features/org/utils/types';
interface Props {
audience?: OrganizationAudience;
}
let { audience }: Props = $props();
// Initialize audience from prop if provided
$effect(() => {
orgApi.getOrgAudience($currentOrg.id);
if (audience) {
orgApi.audience = audience;
}
});
const headers = [
@@ -60,7 +70,7 @@
<!-- Table -->
{#if orgApi.isLoading}
<div class="space-y-2">
{#each Array(pageSize) as _}
{#each Array(pageSize) as _, i (i)}
<Skeleton class="h-12 w-full" />
{/each}
</div>
@@ -69,17 +79,17 @@
<Table.Root>
<Table.Header>
<Table.Row>
{#each headers as header}
{#each headers as header (header)}
<Table.Head>{header.value}</Table.Head>
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#each paginatedRows as row}
{#each paginatedRows as row (row.id)}
<Table.Row>
<Table.Cell>
<a
href={`${pageStore.url.href}/${row.id}/${$currentOrg.id}`}
href={`${page.url.href}/${row.id}`}
class="ui:text-primary flex items-center gap-2 hover:underline"
>
<Avatar.Root class="h-5 w-5">
@@ -1,5 +1,4 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { Badge } from '@cio/ui/base/badge';
import UnfoldVerticalIcon from '@lucide/svelte/icons/unfold-vertical';
@@ -12,45 +11,12 @@
import { t } from '$lib/utils/functions/translations';
import { currentOrgPath } from '$lib/utils/store/org';
import { snackbar } from '$features/ui/snackbar/store';
import { getAccessToken } from '$lib/utils/functions/supabase';
import type { UserAnalytics } from '$lib/utils/types/analytics';
let { data } = $props();
let userAnalytics: UserAnalytics | undefined = $state();
let courseFilter = $state('all');
function toggleCourseFilter(filter: 'completed' | 'incomplete') {
if (courseFilter === filter) {
courseFilter = 'all';
} else {
courseFilter = filter;
}
}
async function fetchUserAnalytics() {
const accessToken = await getAccessToken();
const response = await fetch('/api/analytics/user', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
},
body: JSON.stringify(data)
});
if (!response.ok) {
console.error(response);
snackbar.error('Failed to fetch analytics data');
}
userAnalytics = (await response.json()) as UserAnalytics;
}
onMount(() => {
fetchUserAnalytics();
});
// Use analytics data from server load function if available
const userAnalytics: UserAnalytics | undefined = $derived(data.analytics);
let userMetrics = $derived([
{
@@ -81,6 +47,8 @@
userAnalytics?.courses?.filter((course) => course.lessons_count !== course.lessons_completed)?.length
);
let courseFilter = $state('all');
let filteredCourses = $derived(
userAnalytics?.courses?.filter((course) => {
if (courseFilter === 'all') {
@@ -110,7 +78,7 @@
</div>
</div>
<div class="mt-5 rounded-md border p-3 md:p-5 dark:border-neutral-600">
<div class="mt-5 rounded-md border p-3 md:p-5">
<h3 class="text-2xl">
{$t('analytics.courses')}
</h3>
@@ -132,7 +100,7 @@
<Badge
type={courseFilter === 'incomplete' ? 'secondary' : 'outline'}
class="text-yellow-700 dark:text-yellow-500"
onclick={() => toggleCourseFilter('incomplete')}
onclick={() => (courseFilter = 'incomplete')}
>
{incompleteCourses}
{$t('analytics.incomplete')}
@@ -140,7 +108,7 @@
<Badge
type={courseFilter === 'completed' ? 'secondary' : 'outline'}
class="text-green-700 dark:text-green-500"
onclick={() => toggleCourseFilter('completed')}
onclick={() => (courseFilter = 'completed')}
>
{completedCourses}
{$t('analytics.complete')}
@@ -2,66 +2,68 @@ import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type {
CommunityQuestionSuccess,
CommunityQuestionsSuccess,
CreateCommentResponse,
CreateCommunityQuestionResponse,
UpdateQuestionResponse,
UpvotePostResponse
CreateCommentData,
CreateCommentRequest,
CreateCommunityQuestionRequest,
UpdateQuestionRequest,
UpvotePostRequest
} from '../utils/types';
import { ZCommunityComment, ZCommunityQuestionUpdate, ZCreateCommunityQuestion } from '@cio/utils/validation/community';
import { currentOrgPath, currentOrg as currentOrgStore } from '$lib/utils/store/org';
import type { Course } from '$lib/utils/types';
import type { TCreateCommunityQuestion } from '@cio/utils/validation/community';
import { courses as coursesStore } from '$features/course/utils/store';
import { coursesApi } from '$features/course/api/courses.svelte';
import { currentCommunityQuestion } from '../utils/store';
import { fetchCourses } from '$lib/utils/services/courses';
import generateSlug from '$lib/utils/functions/generateSlug';
import { get } from 'svelte/store';
import { globalStore } from '$lib/utils/store/app';
import { goto } from '$app/navigation';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { profile as profileStore } from '$lib/utils/store/user';
import { resolve } from '$app/paths';
import { snackbar } from '$features/ui/snackbar/store';
type EditContent = {
title: string;
body: string;
courseId: string;
};
type DeleteCommentState = {
shouldDelete: boolean;
commentId: string;
};
/**
* API class for Organization Community
*/
class CommunityApi extends BaseApiWithErrors {
questions: CommunityQuestionsSuccess['data'] = $state([]);
question: CommunityQuestionSuccess['data'] | null = $state(null);
courses = $state<Course[]>([]);
private coursesFetched = $state(false);
courses = $state<{ title: string; id: string }[]>([]);
isEditMode = $state(false);
isEditing = $state(false);
isCommenting = $state(false);
editContent = $state<{
title: string;
body: string;
courseId: string;
}>({
editContent = $state<EditContent>({
title: '',
body: '',
courseId: ''
});
comment = $state('');
deleteCommentState = $state<{
shouldDelete: boolean;
commentId: string;
}>({
deleteCommentState = $state<DeleteCommentState>({
shouldDelete: false,
commentId: ''
});
answer = $state<{
id: number;
body: string;
votes: number;
createdAt: string;
authorId: number;
}>({
body: '',
votes: 0,
createdAt: '',
answer = $state<CreateCommentData>({
authorId: 0,
id: 0
body: '',
courseId: '',
courseTitle: '',
createdAt: '',
id: 0,
slug: '',
title: '',
votes: 0
});
/**
@@ -80,7 +82,7 @@ class CommunityApi extends BaseApiWithErrors {
onError: (result) => {
console.error('Error loading community questions:', result);
const basePath = isLMS ? '/lms' : get(currentOrgPath);
goto(basePath);
goto(resolve(basePath, {}));
}
});
}
@@ -105,7 +107,7 @@ class CommunityApi extends BaseApiWithErrors {
onError: (result) => {
console.error(`[${isLMS ? 'LMS' : 'ORG'}] Error loading community`, result);
const basePath = isLMS ? '/lms' : get(currentOrgPath);
goto(basePath);
goto(resolve(basePath, {}));
}
});
}
@@ -132,7 +134,7 @@ class CommunityApi extends BaseApiWithErrors {
return;
}
await this.execute<CreateCommunityQuestionResponse>({
await this.execute<CreateCommunityQuestionRequest>({
requestFn: () =>
classroomio.community.$post({
json: {
@@ -155,7 +157,7 @@ class CommunityApi extends BaseApiWithErrors {
// Navigate to the question based on context
const basePath = isLMS ? '/lms' : get(currentOrgPath);
goto(`${basePath}/community/${questionSlug}`);
goto(resolve(`${basePath}/community/${questionSlug}`, {}));
// Mark as successful
this.success = true;
@@ -198,11 +200,10 @@ class CommunityApi extends BaseApiWithErrors {
}
this.isCommenting = true;
await this.execute<CreateCommentResponse>({
await this.execute<CreateCommentRequest>({
requestFn: () =>
classroomio.community[':id'].comment.$post({
param: { id: String(questionId) },
// @ts-expect-error - the json type is not inferred correctly
json: {
body: result.data.body,
authorProfileId: result.data.authorProfileId,
@@ -249,11 +250,10 @@ class CommunityApi extends BaseApiWithErrors {
* @param isQuestion Boolean indicating if it's a question
*/
async upvotePost({ id, isQuestion }: { id: number | string; isQuestion: boolean }) {
await this.execute<UpvotePostResponse>({
await this.execute<UpvotePostRequest>({
requestFn: () =>
classroomio.community[':id'].upvote.$post({
param: { id: id.toString() },
// @ts-expect-error - the json type is not inferred correctly
json: { isQuestion }
}),
logContext: 'upvoting post',
@@ -300,11 +300,10 @@ class CommunityApi extends BaseApiWithErrors {
}
this.isEditing = true;
await this.execute<UpdateQuestionResponse>({
await this.execute<UpdateQuestionRequest>({
requestFn: () =>
classroomio.community[':id'].$put({
param: { id: String(questionId) },
// @ts-expect-error - the json type is not inferred correctly
json: {
title: result.data.title,
body: result.data.body,
@@ -345,11 +344,11 @@ class CommunityApi extends BaseApiWithErrors {
* Enters edit mode and initializes edit content with question data
* @param question Question data to populate edit form
*/
enterEditMode(question: { title: string; body: string; courseId?: string }) {
enterEditMode(question: { title: string | null; body: string | null; courseId?: string }) {
this.isEditMode = true;
this.editContent = {
title: question.title,
body: question.body,
title: question.title || '',
body: question.body || '',
courseId: question.courseId || ''
};
}
@@ -382,7 +381,7 @@ class CommunityApi extends BaseApiWithErrors {
// Navigate back to community page based on context
const basePath = isLMS ? '/lms' : get(currentOrgPath);
goto(`${basePath}/community`);
goto(resolve(`${basePath}/community`, {}));
// Mark as successful
this.success = true;
@@ -437,21 +436,28 @@ class CommunityApi extends BaseApiWithErrors {
* @param orgId Organization ID
*/
async fetchCoursesForOrg(userId: string | null, orgId: string) {
if (this.coursesFetched) return;
if (this.courses.length || !userId || !orgId) return;
// Check store first
const courses = get(coursesStore);
if (courses.length) {
this.courses = [...courses];
this.coursesFetched = true;
return;
}
const isLMS = get(globalStore).isOrgSite;
// Fetch from API
const coursesResults = await fetchCourses(userId, orgId);
if (coursesResults) {
this.courses = coursesResults.allCourses || [];
this.coursesFetched = true;
if (isLMS) {
const response = await coursesApi.getEnrolledCourses();
if (response?.success && response?.data) {
this.courses = response.data.map((course) => ({
id: course.id,
title: course.title
}));
}
} else {
const response = await coursesApi.getOrgCourses();
if (response?.success && response?.data) {
this.courses = response.data.map((course) => ({
id: course.id,
title: course.title
}));
}
}
}
}
@@ -6,6 +6,7 @@
import { shortenName } from '$lib/utils/functions/string';
import { Vote } from '$features/ui';
import { CircleCheckIcon } from '$features/ui/icons';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
interface CommentAuthor {
id: number | string | null;
@@ -64,7 +65,7 @@
{/if}
</header>
<article class="prose prose-sm sm:prose p-2">
{@html comment.body}
{@html sanitizeHtml(comment.body ?? '')}
</article>
</div>
</div>
@@ -2,7 +2,7 @@
import { Skeleton } from '@cio/ui/base/skeleton';
</script>
<div class="relative flex h-[70px] w-full rounded-md border p-2 transition ease-in-out dark:border-neutral-600">
<div class="relative flex h-[70px] w-full rounded-md border p-2 transition ease-in-out">
<div class="mr-2 flex w-10 flex-col justify-evenly gap-2 rounded-md px-1">
<Skeleton class="h-2/6 w-full rounded-sm" />
<Skeleton class="h-2/6 w-full rounded-sm" />
@@ -4,8 +4,8 @@
import { t } from '$lib/utils/functions/translations';
interface Props {
onDelete?: any;
onCancel?: any;
onDelete?: () => void;
onCancel?: () => void;
open?: boolean;
isQuestion?: boolean;
isDeleting?: boolean;
@@ -20,13 +20,17 @@
import { shortenName } from '$lib/utils/functions/string';
import { TextEditor } from '$features/ui';
import { CommunityDeleteModal, CommunityCommentItem } from '$features/community/components';
import type { CommunityQuestionSuccess } from '../utils/types';
import { currentCommunityQuestion } from '../utils/store';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
interface Props {
slug: string;
isLMS?: boolean;
question?: CommunityQuestionSuccess['data'] | null;
}
let { slug, isLMS = false }: Props = $props();
let { isLMS = false, question }: Props = $props();
let commentEditor: TiptapEditor | undefined = $state();
let isValidAnswer = false; // V2 allow admin mark an answer as accepted
@@ -41,13 +45,19 @@
questionId: ''
});
// Initialize question from prop if provided
$effect(() => {
if ($profile.id && $currentOrg.id) {
communityApi.fetchCoursesForOrg($profile.id, $currentOrg.id);
communityApi.fetchCommunityQuestion({ slug, isLMS });
if (question) {
communityApi.question = question;
currentCommunityQuestion.set({ title: question.title });
}
});
$effect(() => {
if (!$profile.id || !$currentOrg.id) return;
communityApi.fetchCoursesForOrg($profile.id, $currentOrg.id);
});
</script>
<svelte:head>
@@ -88,7 +98,7 @@
<Skeleton class="h-80 w-full" />
</div>
{:else}
<Page.Root class="mx-auto max-w-3xl md:mx-10 lg:mb-20">
<Page.Root class="mx-auto w-[90%] md:mx-10 md:max-w-3xl lg:mb-20">
<Page.Header>
<Page.HeaderContent>
{#if communityApi.isEditMode}
@@ -101,7 +111,7 @@
/>
<Select.Root type="single" bind:value={communityApi.editContent.courseId} disabled={communityApi.isEditing}>
<Select.Trigger class="w-30! h-full truncate" disabled={communityApi.isEditing}>
<Select.Trigger class="h-full w-30! truncate" disabled={communityApi.isEditing}>
<span class="truncate">
{communityApi.editContent.courseId
? communityApi.courses.find((course) => course.id === communityApi.editContent.courseId)?.title
@@ -120,7 +130,7 @@
{:else}
<div class="flex items-center gap-2">
<Vote
value={communityApi.question.votes}
value={communityApi.question.votes ?? 0}
upVote={() => {
if (voted.question || !communityApi.question) return;
voted.question = true;
@@ -204,7 +214,7 @@
</div>
{:else}
<section class="prose prose-sm sm:prose p-2">
{@html communityApi.question?.body}
{@html sanitizeHtml(communityApi.question?.body ?? '')}
</section>
{/if}
</div>
@@ -245,7 +255,8 @@
<TextEditor
placeholder="Give an answer"
class="h-48!"
bind:content={communityApi.comment}
content={communityApi.comment}
onChange={(content) => (communityApi.comment = content)}
onReady={(editor) => (commentEditor = editor)}
/>
@@ -1,5 +1,4 @@
<script lang="ts">
import { untrack } from 'svelte';
import * as Page from '@cio/ui/base/page';
import * as Select from '@cio/ui/base/select';
import { Empty } from '@cio/ui/custom/empty';
@@ -20,24 +19,25 @@
interface Props {
isLMS?: boolean;
questions?: CommunityQuestionData;
}
let { isLMS = false }: Props = $props();
let { isLMS = false, questions }: Props = $props();
let searchValue = $state('');
let selectedId = $state('');
function fetchCommunityQuestions(orgId?: string, profileId?: string) {
if (!orgId || !profileId) return;
untrack(async () => {
await communityApi.fetchCoursesForOrg(profileId, orgId);
await communityApi.fetchCommunityQuestions({ orgId, isLMS });
});
}
// Initialize questions from prop if provided
$effect(() => {
if (questions) {
communityApi.questions = questions;
}
});
$effect(() => {
fetchCommunityQuestions($currentOrg.id, $profile.id);
if (!$profile.id || !$currentOrg.id) return;
communityApi.fetchCoursesForOrg($profile.id, $currentOrg.id);
});
let filteredQuestions = $derived(
@@ -87,7 +87,7 @@
{question.title}
</Item.Title>
<Item.Description>
{question?.authorFullname} asked {calDateDiff(question?.createdAt)}
{question?.authorFullname} asked {calDateDiff(question.createdAt || new Date())}
</Item.Description>
<a class="m-0" href="/courses/{question.courseId}" onclick={(e) => e.stopPropagation()}>
<span class="text-muted-foreground p-0 text-xs">
@@ -1,27 +1,27 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type GetCommunityQuestionsResponse = typeof classroomio.community.$get;
export type CommunityQuestionsResponse = InferResponseType<GetCommunityQuestionsResponse> | null;
export type CommunityQuestionsSuccess = Extract<InferResponseType<GetCommunityQuestionsResponse>, { success: true }>;
export type GetCommunityQuestionsRequest = typeof classroomio.community.$get;
export type CommunityQuestionsResponse = InferResponseType<GetCommunityQuestionsRequest> | null;
export type CommunityQuestionsSuccess = Extract<InferResponseType<GetCommunityQuestionsRequest>, { success: true }>;
export type CommunityQuestionData = CommunityQuestionsSuccess['data'];
export type GetCommunityQuestionResponse = (typeof classroomio.community)[':slug']['$get'];
export type CommunityQuestionResponse = InferResponseType<GetCommunityQuestionResponse> | null;
export type CommunityQuestionSuccess = Extract<InferResponseType<GetCommunityQuestionResponse>, { success: true }>;
export type GetCommunityQuestionRequest = (typeof classroomio.community)[':slug']['$get'];
export type CommunityQuestionResponse = InferResponseType<GetCommunityQuestionRequest> | null;
export type CommunityQuestionSuccess = Extract<InferResponseType<GetCommunityQuestionRequest>, { success: true }>;
export type CreateCommentResponse = typeof classroomio.community.$post;
export type CreateCommentSuccess = Extract<InferResponseType<CreateCommentResponse>, { success: true }>;
export type CreateCommentRequest = typeof classroomio.community.$post;
export type CreateCommentSuccess = Extract<InferResponseType<CreateCommentRequest>, { success: true }>;
export type CreateCommentData = CreateCommentSuccess['data'];
export type UpvotePostResponse = (typeof classroomio.community)[':id']['upvote']['$post'];
export type UpvotePostSuccess = Extract<InferResponseType<UpvotePostResponse>, { success: true }>;
export type UpvotePostRequest = (typeof classroomio.community)[':id']['upvote']['$post'];
export type UpvotePostSuccess = Extract<InferResponseType<UpvotePostRequest>, { success: true }>;
export type UpvotePostData = UpvotePostSuccess['data'];
export type CreateCommunityQuestionResponse = typeof classroomio.community.$post;
export type CreateCommunityQuestionRequest = typeof classroomio.community.$post;
export type CreateCommunityQuestionSuccess = Extract<
InferResponseType<CreateCommunityQuestionResponse>,
InferResponseType<CreateCommunityQuestionRequest>,
{ success: true }
>;
export type UpdateQuestionResponse = (typeof classroomio.community)[':id']['$put'];
export type UpdateQuestionSuccess = Extract<InferResponseType<UpdateQuestionResponse>, { success: true }>;
export type UpdateQuestionRequest = (typeof classroomio.community)[':id']['$put'];
export type UpdateQuestionSuccess = Extract<InferResponseType<UpdateQuestionRequest>, { success: true }>;
export type UpdateQuestionData = UpdateQuestionSuccess['data'];
@@ -0,0 +1,66 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type { InferResponseType } from '@cio/api/rpc-types';
import type { TAttendanceUpsert } from '@cio/utils/validation/attendance';
import type { UpsertAttendanceRequest } from '../utils/types';
import { ZAttendanceUpsert } from '@cio/utils/validation/attendance';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { snackbar } from '$features/ui/snackbar/store';
type UpsertAttendanceResponse = Extract<InferResponseType<UpsertAttendanceRequest>, { success: true }>;
/**
* API class for attendance operations
*/
export class AttendanceApi extends BaseApiWithErrors {
/**
* Creates or updates an attendance record
*/
async upsert(fields: TAttendanceUpsert): Promise<UpsertAttendanceResponse | undefined> {
const result = ZAttendanceUpsert.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'attendance');
return undefined;
}
return await this.execute<UpsertAttendanceRequest>({
requestFn: () =>
classroomio.course[':courseId'].attendance.$post({
param: { courseId: fields.courseId },
json: result.data
}),
logContext: 'upserting attendance',
onSuccess: (response) => {
if (response.data) {
snackbar.success('Attendance recorded successfully');
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to record attendance');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
} else if ('error' in result) {
this.errors.general = result.error;
snackbar.error(result.error);
}
}
});
}
/**
* Updates an attendance record
* NOTE: Use upsert() instead - no separate update route exists
*/
async update(fields: TAttendanceUpsert) {
// Use upsert since there's no separate update route
return this.upsert(fields);
}
}
export const attendanceApi = new AttendanceApi();
@@ -0,0 +1,64 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import { snackbar } from '$features/ui/snackbar/store';
import type { DeleteCourseContentRequest, UpdateCourseContentRequest } from '../utils/types';
import { ContentType } from '@cio/utils/constants/content';
export class ContentApi extends BaseApiWithErrors {
async updateContent(
courseId: string,
items: Array<{
id: string;
type: ContentType.Lesson | ContentType.Exercise;
isUnlocked?: boolean;
order?: number;
sectionId?: string | null;
}>
) {
const result = await this.execute<UpdateCourseContentRequest>({
requestFn: () =>
classroomio.course[':courseId'].content.$put({
param: { courseId },
json: { items }
}),
logContext: 'updating course content',
onSuccess: () => {
this.success = true;
this.errors = {};
},
onError: (error) => {
if (typeof error === 'string') {
snackbar.error('Failed to update content');
}
}
});
return Boolean(result?.success);
}
async deleteContent(
courseId: string,
payload: { sectionId?: string; items?: Array<{ id: string; type: ContentType.Lesson | ContentType.Exercise }> }
) {
const result = await this.execute<DeleteCourseContentRequest>({
requestFn: () =>
classroomio.course[':courseId'].content.$delete({
param: { courseId },
json: payload
}),
logContext: 'deleting course content',
onSuccess: () => {
this.success = true;
this.errors = {};
},
onError: (error) => {
if (typeof error === 'string') {
snackbar.error('Failed to delete content');
}
}
});
return Boolean(result?.success);
}
}
export const contentApi = new ContentApi();
@@ -1,6 +1,7 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import { copyCourseModal, copyCourseModalInitialState } from '../utils/store';
import type { CloneCourseRequest } from '../utils/types';
import type { TCourseClone } from '@cio/utils/validation/course';
import { ZCourseClone } from '@cio/utils/validation/course';
import { currentOrg } from '$lib/utils/store/org';
@@ -42,11 +43,10 @@ export class CourseCloneApi extends BaseApiWithErrors {
return null;
}
await this.execute<(typeof classroomio.course)[':courseId']['clone']['$post']>({
await this.execute<CloneCourseRequest>({
requestFn: () =>
classroomio.course[':courseId'].clone.$post({
param: { courseId },
// @ts-expect-error - the json type is not inferred correctly
json: result.data
}),
logContext: 'cloning course',
@@ -0,0 +1,701 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type {
Course,
CourseAnalytics,
CreateCourseRequest,
CreatePaymentRequestRequest,
DeleteCourseRequest,
EnrollCourseRequest,
GetCourseAnalyticsRequest,
GetCourseBySlugRequest,
GetCourseProgressRequest,
GetCourseRequest,
UpdateCourseData,
UpdateCourseRequest
} from '../utils/types';
import type { TCourseType } from '@cio/db/types';
import { ZCourseCreate, ZCourseUpdate, type TCourseUpdate } from '@cio/utils/validation/course/course';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { currentOrg } from '$lib/utils/store/org';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { profile } from '$lib/utils/store/user';
import { resolve } from '$app/paths';
import { snackbar } from '$features/ui/snackbar/store';
import { ROLE } from '@cio/utils/constants';
import { ContentType } from '@cio/utils/constants/content';
import type { CourseMembers } from '../utils/types';
type GroupStore = {
id?: string;
tutors: CourseMembers;
students: CourseMembers;
people: CourseMembers;
members?: CourseMembers;
memberId?: string;
};
type CourseContentItem = NonNullable<Course['content']>['items'][number];
interface UpdateCourseOptions {
showSuccessToast?: boolean;
}
/**
* API class for course creation operations
*/
export class CourseApi extends BaseApiWithErrors {
course = $state<Course | null>(null);
courseAnalytics = $state<CourseAnalytics | null>(null);
group = $state<GroupStore>({
id: '',
tutors: [],
students: [],
people: [],
memberId: ''
});
private loadedCourseId = $state<string | null>(null);
private isCourseDirty = $state(false);
private inFlightCourseRequest: Promise<Course | null> | null = null;
private inFlightCourseId = $state<string | null>(null);
/**
* Updates a single lesson/exercise item in the local course content store.
* This avoids list staleness when navigating back from item detail pages.
*/
updateContentItem(
itemId: string,
itemType: ContentType.Lesson | ContentType.Exercise,
patch: Partial<CourseContentItem>
) {
if (!this.course?.content) return false;
const content = this.course.content;
if (content.grouped) {
let hasUpdatedItem = false;
const sections = content.sections.map((section) => {
let sectionHasUpdatedItem = false;
const items = section.items.map((item) => {
if (item.id !== itemId || item.type !== itemType) return item;
sectionHasUpdatedItem = true;
return {
...item,
...patch
};
});
if (!sectionHasUpdatedItem) {
return section;
}
hasUpdatedItem = true;
return {
...section,
items
};
});
if (!hasUpdatedItem) return false;
this.course = {
...this.course,
content: {
...content,
sections
}
};
return true;
}
let hasUpdatedItem = false;
const items = content.items.map((item) => {
if (item.id !== itemId || item.type !== itemType) return item;
hasUpdatedItem = true;
return {
...item,
...patch
};
});
if (!hasUpdatedItem) return false;
this.course = {
...this.course,
content: {
...content,
items
}
};
return true;
}
/**
* Removes a single lesson/exercise item from the local course content store.
* This keeps sidebar lists in sync after delete actions.
*/
removeContentItem(itemId: string, itemType: ContentType.Lesson | ContentType.Exercise) {
if (!this.course?.content) return false;
const content = this.course.content;
if (content.grouped) {
let hasRemovedItem = false;
const sections = content.sections.map((section) => {
const items = section.items.filter((item) => {
const shouldRemove = item.id === itemId && item.type === itemType;
if (shouldRemove) {
hasRemovedItem = true;
}
return !shouldRemove;
});
if (items.length === section.items.length) {
return section;
}
return {
...section,
items
};
});
if (!hasRemovedItem) return false;
this.course = {
...this.course,
content: {
...content,
sections
}
};
return true;
}
const items = content.items.filter((item) => !(item.id === itemId && item.type === itemType));
if (items.length === content.items.length) return false;
this.course = {
...this.course,
content: {
...content,
items
}
};
return true;
}
/**
* Gets a course by ID
* @param courseId Course ID
* @returns The course data or null on error
*/
async get(courseId: string) {
await this.execute<GetCourseRequest>({
requestFn: () =>
classroomio.course[':courseId'].$get({
param: { courseId },
query: {}
}),
logContext: 'fetching course',
onSuccess: (response) => {
console.log('response', response.data);
if (response.data) {
this.course = response.data;
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to fetch course');
}
}
});
return this.course;
}
/**
* Ensures the course store is populated for a given courseId.
* - Fetches from the API the first time per courseId
* - Reuses store data on subsequent calls
* - Can be forced to refetch by calling `invalidateCourse(courseId)`
*/
async ensureCourse(courseId: string, profileId: string) {
if (!courseId || !profileId) return null;
// Already loaded and not marked stale
if (!this.isCourseDirty && this.loadedCourseId === courseId && this.course?.id === courseId) {
return this.course;
}
// De-duplicate concurrent requests for the same courseId
if (this.inFlightCourseRequest && this.inFlightCourseId === courseId) {
return this.inFlightCourseRequest;
}
const request = (async () => {
const course = await this.get(courseId);
if (course) {
this.setCourse(course, profileId);
this.loadedCourseId = courseId;
this.isCourseDirty = false;
}
return this.course;
})();
this.inFlightCourseRequest = request;
this.inFlightCourseId = courseId;
try {
return await request;
} finally {
if (this.inFlightCourseRequest === request) {
this.inFlightCourseRequest = null;
this.inFlightCourseId = null;
}
}
}
/**
* Marks the currently loaded course as stale.
* Next call to `ensureCourse(courseId, profileId)` will refetch.
*/
invalidateCourse(courseId?: string) {
if (!courseId) {
this.isCourseDirty = true;
} else if (this.loadedCourseId === courseId) {
this.isCourseDirty = true;
}
}
/**
* Forces a refetch of the course into the store (used after mutations).
*/
async refreshCourse(courseId: string, profileId: string) {
this.invalidateCourse(courseId);
return this.ensureCourse(courseId, profileId);
}
/**
* Enrolls the current user in a course (free: direct enroll; paid: requires inviteToken).
* @param courseId Course ID
* @param body Optional invite token for paid/invited courses
* @returns Success data with redirectTo, or undefined on error
*/
async enroll(courseId: string, body: { inviteToken?: string } = {}) {
return this.execute<EnrollCourseRequest>({
requestFn: () =>
classroomio.course[':courseId'].enroll.$post({
param: { courseId },
json: body
}),
logContext: 'enrolling in course',
onSuccess: () => {
this.success = true;
this.errors = {};
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('snackbar.invite.failed_join');
} else if (typeof result === 'object' && result !== null && 'error' in result) {
snackbar.error((result as { error: string }).error);
}
}
});
}
/**
* Gets a course by slug (public route)
* @param slug Course slug
* @returns The course data or null on error
*/
async getBySlug(slug: string) {
await this.execute<GetCourseBySlugRequest>({
requestFn: () =>
classroomio.course.slug?.[':slug'].$get({
param: { slug }
}),
logContext: 'fetching course by slug',
onSuccess: (response) => {
if (response.data) {
this.course = response.data;
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to fetch course by slug');
}
}
});
return this.course;
}
/**
* Creates a new course with group, group member, and default newsfeed
* @param fields Course creation fields (title, description, type)
* @returns The created course data or null on error
*/
async create(fields: { title: string; description: string; type: TCourseType }) {
const org = get(currentOrg);
const userProfile = get(profile);
// Prepare data for validation
const data = {
title: fields.title,
description: fields.description,
type: fields.type,
organizationId: org.id
};
// Client-side validation
const result = ZCourseCreate.safeParse(data);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'course');
return null;
}
await this.execute<CreateCourseRequest>({
requestFn: () =>
classroomio.course.$post({
json: result.data
}),
logContext: 'creating course',
onSuccess: (response) => {
if (response.data?.course) {
const newCourse = response.data.course;
// Capture PostHog event
capturePosthogEvent('course_created', {
course_id: newCourse.id,
courseTitle: newCourse.title,
courseDescription: newCourse.description,
organization_id: org.id,
organization_name: org.name,
user_id: userProfile.id,
user_email: userProfile.email
});
// Navigate to the new course
goto(resolve(`/courses/${newCourse.id}`, {}));
// Show success message
snackbar.success('Course created successfully');
// Mark as successful
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to create course');
return;
}
if ('error' in result && 'field' in result && result.field) {
// Field-specific error (e.g., duplicate title)
this.errors[result.field] = result.error;
snackbar.error(result.error);
} else if ('error' in result) {
// General error
this.errors.general = result.error;
snackbar.error(result.error);
}
}
});
}
/**
* Updates a course
* @param courseId Course ID
* @param fields Course update fields
* @returns The updated course data or null on error
*/
async update(
courseId: string,
fields: TCourseUpdate,
options: UpdateCourseOptions = {}
): Promise<UpdateCourseData | null> {
const { showSuccessToast = true } = options;
const result = ZCourseUpdate.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'course');
return null;
}
const response = await this.execute<UpdateCourseRequest>({
requestFn: () =>
classroomio.course[':courseId'].$put({
param: { courseId },
json: result.data
}),
logContext: 'updating course',
onSuccess: (response) => {
if (response.data) {
// Update the stored course data, preserving fields not in the update response
// The update response may not include all fields (like group, lessons, etc.)
if (this.course) {
// Merge update response with existing course data
Object.assign(this.course, response.data);
} else {
this.course = response.data as Course;
}
if (showSuccessToast) {
snackbar.success('Course updated successfully');
}
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to update course');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
} else if ('error' in result) {
this.errors.general = result.error;
snackbar.error(result.error);
}
}
});
const updated = response?.data ?? null;
// Some update responses may omit related data (group/lessons/sections), so refresh the store.
if (updated) {
const profileId = get(profile)?.id;
if (profileId) {
await this.refreshCourse(courseId, profileId);
}
}
return updated;
}
/**
* Deletes a course (soft delete)
* @param courseId Course ID
* @returns The deleted course data or null on error
*/
async delete(courseId: string) {
await this.execute<DeleteCourseRequest>({
requestFn: () =>
classroomio.course[':courseId'].$delete({
param: { courseId }
}),
logContext: 'deleting course',
onSuccess: (response) => {
if (response.data) {
snackbar.success('Course deleted successfully');
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to delete course');
}
}
});
}
/**
* Gets course progress for a profile
* @param courseId Course ID
* @param profileId Profile ID
* @returns Course progress data or null on error
*/
async getProgress(courseId: string, profileId: string) {
return this.execute<GetCourseProgressRequest>({
requestFn: () =>
classroomio.course[':courseId']['progress'].$get({
param: { courseId },
query: { profileId }
}),
logContext: 'fetching course progress',
onSuccess: (response) => {
if (response.data) {
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to fetch course progress');
}
}
});
}
/**
* Gets course analytics including student progress, completion rates, and grades
* @param courseId Course ID
* @returns Course analytics data or null on error
*/
async getAnalytics(courseId: string) {
await this.execute<GetCourseAnalyticsRequest>({
requestFn: () =>
classroomio.course[':courseId']['analytics'].$get({
param: { courseId }
}),
logContext: 'fetching course analytics',
onSuccess: (response) => {
if (response.data) {
this.courseAnalytics = response.data;
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to fetch course analytics');
}
}
});
}
/**
* Sets course data and processes related data (group data)
* @param data Course data
* @param profileId Profile ID
*/
setCourse(data: Course, profileId: string) {
if (!data || !(Object.values(data) && Object.values(data).length)) return;
// Process group data
if (data.group) {
const copiedGroup = JSON.parse(JSON.stringify(data.group));
const groupData: GroupStore = Object.assign(copiedGroup, {
id: copiedGroup.id,
tutors: [],
students: [],
people: [],
memberId: data.group.members.find((member) => member.profileId === profileId)?.id || ''
});
if (Array.isArray(data.group.members)) {
for (const member of data.group.members) {
const roleId = Number(member.roleId || 0);
if (roleId === ROLE.STUDENT) {
groupData.students.push(member);
} else if (member.profile) {
groupData.tutors.push(member);
}
groupData.people.push(member);
}
}
this.group = groupData;
}
// Ensure metadata has default values
if (data.metadata && !Object.values(data.metadata).some((v) => v !== undefined && v !== null && v !== '')) {
data.metadata = {
requirements: '',
description: '',
goals: '',
videoUrl: '',
showDiscount: false,
discount: 0,
reward: {
show: false,
description: ''
},
instructor: {
name: '',
role: '',
coursesNo: 0,
description: '',
imgUrl: ''
},
allowNewStudent: false,
isContentGroupingEnabled: true
};
}
// Ensure lessonTabsOrder includes all tabs (backward compatibility)
const metadata = data.metadata ?? undefined;
const lessonTabsOrder = metadata?.lessonTabsOrder;
if (lessonTabsOrder) {
const existingTabIds = lessonTabsOrder.map((tab) => tab.id);
const allTabs = [
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
{ id: 2, name: 'course.navItem.lessons.materials.tabs.slide.title' },
{ id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' },
{ id: 4, name: 'course.navItem.lessons.materials.tabs.document.title' }
];
allTabs.forEach((tab) => {
if (!existingTabIds.includes(tab.id)) {
lessonTabsOrder.push(tab);
}
});
}
if (data.metadata && data.metadata.isContentGroupingEnabled === undefined) {
data.metadata.isContentGroupingEnabled = true;
}
if (!data.content) {
data.content = {
grouped: false,
sections: [],
items: []
};
}
// Ensure certificateTheme has default value
if (!data.certificateTheme) {
data.certificateTheme = 'professional';
}
// Set the course data
this.course = data;
}
/**
* Creates a payment request
* @param courseId Course ID
* @param studentEmail Student email address
* @param studentFullname Student full name
*/
async createPaymentRequest(courseId: string, studentEmail: string, studentFullname: string) {
return this.execute<CreatePaymentRequestRequest>({
requestFn: () =>
classroomio.course[':courseId']['payment-request']['$post']({
param: { courseId },
json: {
studentEmail,
studentFullname
}
}),
logContext: 'creating payment request',
onSuccess: () => {
this.success = true;
this.errors = {};
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to send payment request');
}
}
});
}
}
export const courseApi = new CourseApi();
@@ -0,0 +1,89 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type {
GetOrgCoursesRequest,
GetRecommendedCoursesRequest,
GetUserEnrolledCoursesRequest,
OrgCourses,
RecommendedCourses,
UserEnrolledCourses
} from '$features/course/types';
/**
* API class for course operations
*/
export class CoursesApi extends BaseApiWithErrors {
orgCourses = $state<OrgCourses>([]);
enrolledCourses = $state<UserEnrolledCourses>([]);
recommendedCourses = $state<RecommendedCourses>([]);
/**
* Fetches org courses for the current organization
* Org ID is automatically added from currentOrg store
*/
async getOrgCourses(tagSlugs: string[] = []) {
const normalizedTagSlugs = Array.from(new Set(tagSlugs.map((tag) => tag.trim()).filter(Boolean)));
return this.execute<GetOrgCoursesRequest>({
requestFn: () =>
classroomio.organization.courses.$get(
normalizedTagSlugs.length > 0
? {
query: {
tags: normalizedTagSlugs.join(',')
}
}
: {}
),
logContext: 'fetching org courses',
onSuccess: (response) => {
if (response.data) {
this.orgCourses = response.data;
}
}
});
}
/**
* Fetches user enrolled courses for the current organization
* Org ID is automatically added from currentOrg store
*/
async getEnrolledCourses() {
return this.execute<GetUserEnrolledCoursesRequest>({
requestFn: () => classroomio.organization.courses.enrolled.$get({}),
logContext: 'fetching enrolled courses',
onSuccess: (response) => {
if (response.data) {
this.enrolledCourses = response.data;
}
}
});
}
/**
* Fetches recommended courses (published courses user isn't enrolled in) for the current organization
* Org ID is automatically added from currentOrg store
*/
async getRecommendedCourses() {
await this.execute<GetRecommendedCoursesRequest>({
requestFn: () => classroomio.organization.courses.recommended.$get({}),
logContext: 'fetching recommended courses',
onSuccess: (response) => {
if (response.data) {
this.recommendedCourses = response.data;
}
this.errors = {};
},
onError: (result) => {
if (typeof result === 'string') {
console.error('Failed to fetch recommended courses:', result);
return;
}
if ('error' in result) {
console.error('Failed to fetch recommended courses:', result.error);
}
}
});
}
}
export const coursesApi = new CoursesApi();
@@ -0,0 +1,73 @@
import { BaseApi, classroomio } from '$lib/utils/services/api';
import type {
GetTemplateByIdData,
GetTemplateByIdRequest,
GetTemplateByTagData,
GetTemplateByTagRequest
} from '../utils/types';
/**
* API class for exercise templates
*/
class ExerciseTemplateApi extends BaseApi {
templates = $state<GetTemplateByTagData>([]);
template = $state<GetTemplateByIdData>();
templatesByTag = $state<Record<string, GetTemplateByTagData>>({});
private getTagCacheKey(courseId: string, tag: string) {
return `${courseId}:${tag}`;
}
/**
* Fetches an exercise template metadata
*/
async fetchTemplateById(courseId: string, id: string) {
await this.execute<GetTemplateByIdRequest>({
requestFn: () =>
classroomio.course[':courseId'].exercise.template[':id'].$get({
param: { courseId, id }
}),
logContext: 'fetching exercise template by id',
onSuccess: (response) => {
this.template = response.data;
}
});
}
/**
* Fetches an exercise template metadata
*/
async fetchTemplatesByTag(courseId: string, tag: string) {
const cacheKey = this.getTagCacheKey(courseId, tag);
const cachedTemplates = this.templatesByTag[cacheKey];
if (cachedTemplates) {
this.templates = cachedTemplates;
this.success = true;
return;
}
await this.execute<GetTemplateByTagRequest>({
requestFn: () =>
classroomio.course[':courseId'].exercise.template.tag[':tag'].$get({
param: { courseId, tag }
}),
logContext: 'fetching exercise template by tag',
onSuccess: (response) => {
this.templates = response.data;
this.templatesByTag = {
...this.templatesByTag,
[cacheKey]: response.data
};
}
});
}
reset() {
this.templates = [];
this.template = undefined;
this.templatesByTag = {};
}
}
export const exerciseTemplateApi = new ExerciseTemplateApi();
@@ -0,0 +1,251 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type {
CreateExerciseFromTemplateRequest,
CreateExerciseRequest,
DeleteExerciseRequest,
Exercise,
GetExerciseRequest,
SubmitExerciseRequest,
UpdateExerciseRequest
} from '../utils/types';
import type { TExerciseCreate, TExerciseSubmissionCreate, TExerciseUpdate } from '@cio/utils/validation/exercise';
import { ZExerciseCreate, ZExerciseSubmissionCreate, ZExerciseUpdate } from '@cio/utils/validation/exercise';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { snackbar } from '$features/ui/snackbar/store';
/**
* API class for exercise operations
*/
export class ExerciseApi extends BaseApiWithErrors {
exercise = $state<Exercise | null>(null);
/**
* Gets an exercise by ID
*/
async get(courseId: string, exerciseId: string) {
await this.execute<GetExerciseRequest>({
requestFn: () =>
classroomio.course[':courseId'].exercise[':exerciseId'].$get({
param: { courseId, exerciseId }
}),
logContext: 'fetching exercise',
onSuccess: (response) => {
if (response.data) {
this.exercise = response.data;
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to fetch exercise');
}
}
});
}
/**
* Creates a new exercise
*/
async create(courseId: string, fields: Omit<TExerciseCreate, 'courseId'>) {
const result = ZExerciseCreate.safeParse({ ...fields, courseId });
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'exercise');
return;
}
await this.execute<CreateExerciseRequest>({
requestFn: () =>
classroomio.course[':courseId'].exercise.$post({
param: { courseId },
json: result.data
}),
logContext: 'creating exercise',
onSuccess: (response) => {
if (response.data) {
this.exercise = response.data;
snackbar.success('Exercise created successfully');
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to create exercise');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
} else if ('error' in result) {
this.errors.general = result.error;
snackbar.error(result.error);
}
}
});
}
/**
* Updates an exercise
*/
async update(courseId: string, exerciseId: string, fields: TExerciseUpdate) {
this.success = false;
const result = ZExerciseUpdate.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'exercise');
return;
}
this.errors = {};
await this.execute<UpdateExerciseRequest>({
requestFn: () =>
classroomio.course[':courseId'].exercise[':exerciseId'].$put({
param: { courseId, exerciseId },
json: result.data
}),
logContext: 'updating exercise',
onSuccess: (response) => {
if (response.data) {
this.exercise = response.data;
this.success = true;
this.errors = {};
}
},
onError: (result) => {
this.success = false;
if (typeof result === 'string') {
snackbar.error('Failed to update exercise');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
} else if ('error' in result) {
this.errors.general = result.error;
snackbar.error(result.error);
}
}
});
}
/**
* Deletes an exercise
*/
async delete(courseId: string, exerciseId: string) {
await this.execute<DeleteExerciseRequest>({
requestFn: () =>
classroomio.course[':courseId'].exercise[':exerciseId'].$delete({
param: { courseId, exerciseId }
}),
logContext: 'deleting exercise',
onSuccess: (response) => {
if (response.data) {
snackbar.success('Exercise deleted successfully');
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to delete exercise');
}
}
});
}
/**
* Submits an exercise
*/
async submit(courseId: string, exerciseId: string, answers: TExerciseSubmissionCreate['answers']) {
const result = ZExerciseSubmissionCreate.safeParse({ exerciseId, answers });
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'exercise');
return;
}
await this.execute<SubmitExerciseRequest>({
requestFn: () =>
classroomio.course[':courseId'].exercise[':exerciseId'].submission.$post({
param: { courseId, exerciseId },
json: result.data
}),
logContext: 'submitting exercise',
onSuccess: (response) => {
if (response.data) {
snackbar.success('Exercise submitted successfully');
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to submit exercise');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
} else if ('error' in result) {
this.errors.general = result.error;
snackbar.error(result.error);
}
}
});
}
/**
* Creates an exercise from a template
* @param courseId Course ID
* @param lessonId Lesson ID
* @param templateId Template ID
* @returns The created exercise data or null on error
*/
async createFromTemplate(
courseId: string,
templateId: number | string,
options: { lessonId?: string; sectionId?: string; order?: number } = {}
) {
const templateIdValue = Number(templateId);
await this.execute<CreateExerciseFromTemplateRequest>({
requestFn: () =>
classroomio.course[':courseId']['exercise']['from-template'].$post({
param: { courseId },
json: {
lessonId: options.lessonId,
sectionId: options.sectionId,
order: options.order,
templateId: templateIdValue
}
}),
logContext: 'creating exercise from template',
onSuccess: (response) => {
if (response.data) {
this.exercise = response.data;
snackbar.success('Exercise created successfully');
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to create exercise from template');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
} else if ('error' in result) {
this.errors.general = result.error;
snackbar.error(result.error);
}
}
});
}
/**
* Checks if exercise is completed
* NOTE: This route does not exist in the API. Use the submission list endpoint instead.
*/
}
export const exerciseApi = new ExerciseApi();
@@ -1 +1,12 @@
export * from './attendance.svelte';
export * from './course-clone.svelte';
export * from './course.svelte';
export * from './courses.svelte';
export * from './content.svelte';
export * from './exercise.svelte';
export * from './lesson.svelte';
export * from './mark.svelte';
export * from './newsfeed.svelte';
export * from './people.svelte';
export * from './presign.svelte';
export * from './submission.svelte';
@@ -0,0 +1,863 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type {
CourseSectionWithLessons,
CreateCourseSectionRequest,
CreateLessonComment,
CreateLessonCommentRequest,
CreateLessonRequest,
DeleteCourseSectionRequest,
DeleteLessonCommentRequest,
DeleteLessonRequest,
GetLessonCommentsRequest,
GetLessonHistoryRequest,
GetLessonLanguageRequest,
GetLessonRequest,
Lesson,
LessonComments,
PromoteUngroupedSectionRequest,
ReorderCourseSectionsRequest,
ReorderLessonsRequest,
UpdateCourseSectionRequest,
UpdateLessonCommentRequest,
UpdateLessonCompletionRequest,
UpdateLessonRequest
} from '../utils/types';
import type {
TCourseSectionCreate,
TCourseSectionPromoteUngrouped,
TCourseSectionReorder,
TCourseSectionUpdate
} from '@cio/utils/validation/course/section';
import type { TLessonCreate, TLessonReorder, TLessonUpdate } from '@cio/utils/validation/lesson';
import {
ZCourseSectionCreate,
ZCourseSectionPromoteUngrouped,
ZCourseSectionReorder,
ZCourseSectionUpdate
} from '@cio/utils/validation/course/section';
import {
ZLessonCommentCreate,
ZLessonCommentUpdate,
ZLessonCompletionUpdate,
ZLessonCreate,
ZLessonReorder,
ZLessonUpdate
} from '@cio/utils/validation/lesson';
import type { TLocale } from '@cio/db/types';
import { get } from 'svelte/store';
import { mediaApi } from '$features/media/api';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { profile } from '$lib/utils/store/user';
import { snackbar } from '$features/ui/snackbar/store';
/**
* API class for lesson operations
*/
export class LessonApi extends BaseApiWithErrors {
lesson = $state<Lesson | null>(null);
sections = $state<CourseSectionWithLessons[]>([]);
commentsByLessonId = $state<
Record<
string,
{
items: LessonComments;
totalCount: number;
hasMore: boolean;
isLoading: boolean;
cursor: string | null;
}
>
>({});
currentLocale = $state<TLocale>('en');
isSaving = $state(false);
isDirty = $state(false);
isCommenting = $state(false);
isUpdatingComment = $state(false);
translations = $state<Record<string, Record<TLocale, string>>>({});
note = $state('');
private autoSaveTimeoutId: NodeJS.Timeout | null = null;
/**
* Gets a lesson by ID
*/
async get(courseId: string, lessonId: string) {
return this.execute<GetLessonRequest>({
requestFn: () =>
classroomio.course[':courseId'].lesson[':lessonId'].$get({
param: { courseId, lessonId }
}),
logContext: 'fetching lesson',
onSuccess: (response) => {
if (response.data) {
this.lesson = response.data;
if (this.lesson.lessonLanguages) {
this.setTranslations();
}
}
}
});
}
/**
* Creates a new lesson
*/
async create(courseId: string, fields: TLessonCreate) {
const result = ZLessonCreate.safeParse({ ...fields, courseId });
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'lesson');
return;
}
await this.execute<CreateLessonRequest>({
requestFn: () =>
classroomio.course[':courseId'].lesson.$post({
param: { courseId },
json: result.data
}),
logContext: 'creating lesson',
onSuccess: (response) => {
if (response.data) {
const createdLesson = response.data as Lesson;
this.lesson = {
...createdLesson,
lessonLanguages: createdLesson.lessonLanguages ?? []
};
snackbar.success('snackbar.lessons.lesson_created');
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('snackbar.lessons.lesson_create_failed');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
} else if ('error' in result) {
this.errors.general = result.error;
snackbar.error(result.error);
}
}
});
}
/**
* Updates a lesson
*/
async update(courseId: string, lessonId: string, fields: TLessonUpdate): Promise<boolean> {
const result = ZLessonUpdate.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'lesson');
return false;
}
this.isSaving = true;
const response = await this.execute<UpdateLessonRequest>({
requestFn: () =>
classroomio.course[':courseId'].lesson[':lessonId'].$put({
param: { courseId, lessonId },
json: result.data
}),
logContext: 'updating lesson',
onError: (result) => {
console.log('onError', result);
snackbar.error('snackbar.lessons.lesson_update_failed');
}
});
this.isSaving = false;
return Boolean(response);
}
/**
* Deletes a lesson
*/
async delete(courseId: string, lessonId: string) {
await this.execute<DeleteLessonRequest>({
requestFn: () =>
classroomio.course[':courseId'].lesson[':lessonId'].$delete({
param: { courseId, lessonId }
}),
logContext: 'deleting lesson',
onSuccess: (response) => {
if (response.data) {
snackbar.success('snackbar.lessons.lesson_deleted');
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('snackbar.lessons.lesson_delete_failed');
}
}
});
}
// Course Section methods
/**
* Creates a course section
*/
async createSection(courseId: string, fields: TCourseSectionCreate) {
const result = ZCourseSectionCreate.safeParse({ ...fields, courseId });
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'lesson');
return;
}
await this.execute<CreateCourseSectionRequest>({
requestFn: () =>
classroomio.course[':courseId'].section.$post({
param: { courseId },
json: result.data
}),
logContext: 'creating course section',
onSuccess: (response) => {
if (response.data) {
snackbar.success('snackbar.lessons.section_created');
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('snackbar.lessons.section_create_failed');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
}
}
});
}
/**
* Creates a real section from synthetic ungrouped content and moves all ungrouped items.
*/
async promoteUngroupedSection(courseId: string, fields: TCourseSectionPromoteUngrouped) {
const result = ZCourseSectionPromoteUngrouped.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'lesson');
return;
}
await this.execute<PromoteUngroupedSectionRequest>({
requestFn: () =>
classroomio.course[':courseId'].section['promote-ungrouped'].$post({
param: { courseId },
json: result.data
}),
logContext: 'promoting ungrouped section',
onSuccess: () => {
snackbar.success('snackbar.lessons.section_updated');
this.success = true;
this.errors = {};
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('snackbar.lessons.section_update_failed');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
return;
}
if ('error' in result) {
this.errors.general = result.error;
snackbar.error(result.error);
}
}
});
}
/**
* Updates a course section
*/
async updateSection(courseId: string, sectionId: string, fields: TCourseSectionUpdate) {
const result = ZCourseSectionUpdate.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'lesson');
return;
}
await this.execute<UpdateCourseSectionRequest>({
requestFn: () =>
classroomio.course[':courseId'].section[':sectionId'].$put({
param: { courseId, sectionId },
json: result.data
}),
logContext: 'updating course section',
onSuccess: (response) => {
if (response.data) {
snackbar.success('snackbar.lessons.section_updated');
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('snackbar.lessons.section_update_failed');
}
}
});
}
/**
* Deletes a course section
*/
async deleteSection(courseId: string, sectionId: string) {
await this.execute<DeleteCourseSectionRequest>({
requestFn: () =>
classroomio.course[':courseId'].section[':sectionId'].$delete({
param: { courseId, sectionId }
}),
logContext: 'deleting course section'
});
}
/**
* Reorders course sections
*/
async reorderSections(courseId: string, sections: TCourseSectionReorder['sections']) {
const result = ZCourseSectionReorder.safeParse({ sections });
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'lesson');
return;
}
await this.execute<ReorderCourseSectionsRequest>({
requestFn: () =>
classroomio.course[':courseId'].section.reorder.$post({
param: { courseId },
json: result.data
}),
logContext: 'reordering course sections',
onSuccess: (response) => {
if (response.data) {
snackbar.success('snackbar.lessons.sections_reordered');
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('snackbar.lessons.sections_reorder_failed');
}
}
});
}
/**
* Reorders lessons
*/
async reorderLessons(courseId: string, lessons: TLessonReorder['lessons']) {
const result = ZLessonReorder.safeParse({ lessons });
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'lesson');
return;
}
await this.execute<ReorderLessonsRequest>({
requestFn: () =>
classroomio.course[':courseId'].lesson.reorder.$post({
param: { courseId },
json: result.data
}),
logContext: 'reordering lessons',
onSuccess: (response) => {
if (response.data) {
snackbar.success('snackbar.lessons.lessons_reordered');
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('snackbar.lessons.lessons_reorder_failed');
}
}
});
}
// Lesson Comment methods
/**
* Initializes comments state for a lesson
*/
initComments(lessonId: string, totalCount: number) {
if (!this.commentsByLessonId[lessonId]) {
this.commentsByLessonId[lessonId] = {
items: [],
totalCount,
hasMore: totalCount > 0,
isLoading: false,
cursor: null
};
}
}
/**
* Gets comments for a lesson
*/
async getComments(courseId: string, lessonId: string, limit = 10) {
if (!this.commentsByLessonId[lessonId]) {
this.initComments(lessonId, 0);
}
this.commentsByLessonId[lessonId].isLoading = true;
await this.execute<GetLessonCommentsRequest>({
requestFn: () =>
classroomio.course[':courseId'].lesson[':lessonId'].comment.$get({
param: { courseId, lessonId },
query: { limit: String(limit) }
}),
logContext: 'fetching lesson comments',
onSuccess: (response) => {
if (response.data) {
this.commentsByLessonId[lessonId] = {
items: response.data.items,
totalCount: response.data.totalCount,
hasMore: response.data.hasMore,
isLoading: false,
cursor: response.data.nextCursor
};
this.success = true;
this.errors = {};
}
},
onError: (result) => {
this.commentsByLessonId[lessonId].isLoading = false;
if (typeof result === 'string') {
snackbar.error('snackbar.lessons.comment_fetch_failed');
}
}
});
}
/**
* Load more comments (uses cursor)
*/
async loadMoreComments(courseId: string, lessonId: string, limit = 10) {
const commentState = this.commentsByLessonId[lessonId];
if (!commentState || !commentState.hasMore || commentState.isLoading || !commentState.cursor) {
return;
}
commentState.isLoading = true;
await this.execute<GetLessonCommentsRequest>({
requestFn: () =>
classroomio.course[':courseId'].lesson[':lessonId'].comment.$get({
param: { courseId, lessonId },
query: { cursor: commentState.cursor!, limit: String(limit) }
}),
logContext: 'loading more lesson comments',
onSuccess: (response) => {
if (response.data) {
this.commentsByLessonId[lessonId] = {
items: [...commentState.items, ...response.data.items],
totalCount: response.data.totalCount,
hasMore: response.data.hasMore,
isLoading: false,
cursor: response.data.nextCursor
};
this.success = true;
this.errors = {};
}
},
onError: (result) => {
commentState.isLoading = false;
if (typeof result === 'string') {
snackbar.error('snackbar.lessons.load_more_comments_failed');
}
}
});
}
/**
* Creates a lesson comment
*/
async createComment(courseId: string, lessonId: string, comment: string) {
if (!courseId || !lessonId) {
snackbar.error('snackbar.lessons.comment_add_failed');
return;
}
const result = ZLessonCommentCreate.safeParse({ lessonId, comment });
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'lesson');
return;
}
this.isCommenting = true;
try {
await this.execute<CreateLessonCommentRequest>({
requestFn: () =>
classroomio.course[':courseId'].lesson[':lessonId'].comment.$post({
param: { courseId, lessonId },
json: result.data
}),
logContext: 'creating lesson comment',
onSuccess: (response) => {
if (response.data && this.commentsByLessonId[lessonId]) {
this.commentsByLessonId[lessonId].items = [
this.getCommentFromResponse(response.data),
...this.commentsByLessonId[lessonId].items
];
this.commentsByLessonId[lessonId].totalCount++;
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('snackbar.lessons.comment_add_failed');
}
}
});
} finally {
this.isCommenting = false;
}
}
/**
* Updates a lesson comment
*/
async updateComment(courseId: string, lessonId: string, commentId: string, comment: string) {
const result = ZLessonCommentUpdate.safeParse({ comment });
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'lesson');
return;
}
this.success = false;
this.isUpdatingComment = true;
try {
await this.execute<UpdateLessonCommentRequest>({
requestFn: () =>
classroomio.course[':courseId'].lesson.comment[':commentId'].$put({
param: { courseId, commentId },
json: result.data
}),
logContext: 'updating lesson comment',
onSuccess: (response) => {
if (response.data) {
// Update local state
if (this.commentsByLessonId[lessonId]) {
this.commentsByLessonId[lessonId].items = this.commentsByLessonId[lessonId].items.map((c) =>
c.id === Number(commentId) ? this.getCommentFromResponse(response.data) : c
);
}
snackbar.success('snackbar.lessons.comment_updated');
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('snackbar.lessons.comment_update_failed');
}
}
});
} finally {
this.isUpdatingComment = false;
}
}
/**
* Deletes a lesson comment
*/
async deleteComment(courseId: string, lessonId: string, commentId: string) {
await this.execute<DeleteLessonCommentRequest>({
requestFn: () =>
classroomio.course[':courseId'].lesson.comment[':commentId'].$delete({
param: { courseId, commentId }
}),
logContext: 'deleting lesson comment',
onSuccess: (response) => {
if (response.data) {
// Update local state
if (this.commentsByLessonId[lessonId]) {
this.commentsByLessonId[lessonId].items = this.commentsByLessonId[lessonId].items.filter(
(c) => c.id !== Number(commentId)
);
this.commentsByLessonId[lessonId].totalCount--;
}
snackbar.success('snackbar.lessons.comment_deleted');
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('snackbar.lessons.comment_delete_failed');
}
}
});
}
/**
* Updates lesson completion
*/
async updateCompletion(courseId: string, lessonId: string, isComplete: boolean) {
const result = ZLessonCompletionUpdate.safeParse({ isComplete });
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'lesson');
return;
}
await this.execute<UpdateLessonCompletionRequest>({
requestFn: () =>
classroomio.course[':courseId'].lesson[':lessonId'].completion.$put({
param: { courseId, lessonId },
json: result.data
}),
logContext: 'updating lesson completion',
onSuccess: (response) => {
if (response.data) {
// Update the current lesson if it's the one being updated
if (this.lesson?.id === lessonId) {
this.lesson = { ...this.lesson, isComplete };
}
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('snackbar.lessons.completion_update_failed');
}
}
});
}
/**
* Sets translations for a lesson
* Translations are stored separately from the lesson data
*/
setTranslations() {
if (!this.lesson) return;
const translations: Record<TLocale, string> = this.lesson?.lessonLanguages?.reduce(
(acc, cur) => {
if (cur.locale) {
acc[cur.locale] = cur.content || '';
}
return acc;
},
{} as Record<TLocale, string>
);
this.translations = {
...this.translations,
[this.lesson.id]: translations
};
}
updateTranslation(lessonId: string, locale: TLocale, content: string) {
this.translations = {
...this.translations,
[lessonId]: {
...this.translations[lessonId],
[locale]: content
}
};
}
getCommentFromResponse(response: CreateLessonComment): LessonComments[number] {
const userProfile = get(profile);
return {
id: response.id,
createdAt: response.createdAt,
updatedAt: response.updatedAt,
lessonId: response.lessonId,
groupmemberId: response.groupmemberId,
comment: response.comment,
groupmember: {
id: response.groupmemberId!
},
profile: {
fullname: userProfile.fullname,
avatarUrl: userProfile.avatarUrl
}
};
}
/**
* Deletes a video from lesson materials
*/
async deleteLessonVideo(videoIndex: number) {
if (!this.lesson) return;
const videos = Array.isArray(this.lesson.videos) ? [...this.lesson.videos] : [];
const removedVideo = videos[videoIndex] as { assetId?: string } | undefined;
if (removedVideo?.assetId && this.lesson.id) {
await mediaApi.detachAsset(removedVideo.assetId, {
targetType: 'lesson',
targetId: this.lesson.id,
slotType: 'lesson_video',
position: videoIndex
});
}
this.lesson = {
...this.lesson,
videos: videos.filter((_v, i) => i !== videoIndex)
};
this.isDirty = true;
}
/**
* Deletes a document from lesson materials
*/
async deleteLessonDocument(documentIndex: number) {
if (!this.lesson) return;
const documents = Array.isArray(this.lesson.documents) ? [...this.lesson.documents] : [];
const removedDocument = documents[documentIndex] as { assetId?: string } | undefined;
if (removedDocument?.assetId && this.lesson.id) {
await mediaApi.detachAsset(removedDocument.assetId, {
targetType: 'lesson',
targetId: this.lesson.id,
slotType: 'lesson_document',
position: documentIndex
});
}
this.lesson = {
...this.lesson,
documents: documents.filter((_d, i) => i !== documentIndex)
};
this.isDirty = true;
}
/**
* Generic method to update lesson state
* @param field - The field name to update (type-safe)
* @param value - The new value for the field (or array of items to append)
* @param options - Optional configuration for array operations
*/
updateLessonState<K extends keyof Lesson>(
field: K,
value: Lesson[K] | (Lesson[K] extends Array<infer T> ? T | T[] : never),
options?: { append?: boolean }
) {
if (!this.lesson) return;
// Handle array fields with append option
if (options?.append && Array.isArray(this.lesson[field])) {
const existingArray = this.lesson[field];
const newItems = Array.isArray(value) ? value : [value];
this.lesson = {
...this.lesson,
[field]: [...existingArray, ...newItems] as Lesson[K]
};
} else {
// Direct field replacement
this.lesson = {
...this.lesson,
[field]: value as Lesson[K]
};
}
this.isDirty = true;
}
/**
* Gets lesson version history for a lesson and locale
* @param courseId Course ID
* @param lessonId Lesson ID
* @param locale Locale
* @param endRange End range for pagination (0-indexed, inclusive)
* @returns Lesson history data or null on error
*/
async getHistory(courseId: string, lessonId: string, locale: TLocale, endRange: number) {
return this.execute<GetLessonHistoryRequest>({
requestFn: () =>
classroomio.course[':courseId']['lesson'][':lessonId']['history'].$get({
param: { courseId, lessonId },
query: { locale, endRange: endRange.toString() }
}),
logContext: 'fetching lesson history'
});
}
/**
* Creates or updates a lesson language translation (upsert)
* @param courseId Course ID
* @param lessonId Lesson ID
* @param locale Locale
* @param content Translation content
*/
async upsertLanguage(courseId: string, lessonId: string, locale: TLocale, content: string) {
return this.execute<GetLessonLanguageRequest>({
requestFn: () =>
classroomio.course[':courseId'].lesson[':lessonId'].language.$post({
param: { courseId, lessonId },
json: { locale, content }
}),
logContext: 'upserting lesson language',
onSuccess: () => {
this.success = true;
this.errors = {};
},
onError: () => {
snackbar.error('snackbar.materials.update_translations');
}
});
}
/**
* Saves the current lesson state (materials and translations)
* @param courseId Course ID
* @param lessonId Lesson ID
*/
async save(courseId: string, lessonId: string) {
if (!this.lesson) return false;
// Prevent autosave loops: we only set this back to `true` when the user edits again.
this.isDirty = false;
await Promise.all([
this.update(courseId, lessonId, {
slideUrl: this.lesson.slideUrl || undefined,
videos: this.lesson.videos || [],
documents: this.lesson.documents || []
}),
this.upsertLanguage(
courseId,
lessonId,
this.currentLocale,
this.translations[lessonId]?.[this.currentLocale] || ''
)
]);
}
/**
* Starts autosave with debouncing
* @param courseId Course ID
* @param lessonId Lesson ID
*/
startAutoSave(courseId: string, lessonId: string) {
if (!this.isDirty || this.isSaving) return;
if (this.autoSaveTimeoutId) clearTimeout(this.autoSaveTimeoutId);
this.isSaving = true;
this.autoSaveTimeoutId = setTimeout(async () => {
await this.save(courseId, lessonId);
this.isSaving = false;
this.autoSaveTimeoutId = null;
}, 1500);
}
}
export const lessonApi = new LessonApi();
@@ -0,0 +1,38 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type { GetMarksRequest, Marks } from '../utils/types';
import { snackbar } from '$features/ui/snackbar/store';
/**
* API class for mark operations
*/
export class MarkApi extends BaseApiWithErrors {
data = $state<Marks | null>(null);
/**
* Gets marks for a course
*/
async getMarks(courseId: string) {
await this.execute<GetMarksRequest>({
requestFn: () =>
classroomio.course[':courseId'].mark.$get({
param: { courseId }
}),
logContext: 'fetching marks',
onSuccess: (response) => {
if (response.data) {
this.data = response.data;
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to fetch marks');
}
}
});
}
}
export const markApi = new MarkApi();
@@ -0,0 +1,498 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type {
CreateNewsfeedCommentRequest,
CreateNewsfeedRequest,
DeleteNewsfeedCommentRequest,
DeleteNewsfeedRequest,
GetNewsfeedCommentsRequest,
GetNewsfeedRequest,
ListNewsfeed,
ListNewsfeedRequest,
NewsfeedCommentsResponse,
ReactToNewsfeedRequest,
UpdateNewsfeedCommentRequest,
UpdateNewsfeedRequest
} from '../utils/types';
import type { TNewsfeedCreate, TNewsfeedReactionUpdate, TNewsfeedUpdate } from '@cio/utils/validation/newsfeed';
import {
ZNewsfeedCommentCreate,
ZNewsfeedCommentUpdate,
ZNewsfeedCreate,
ZNewsfeedReactionUpdate,
ZNewsfeedUpdate
} from '@cio/utils/validation/newsfeed';
import { get } from 'svelte/store';
import { getTextFromHTML } from '$lib/utils/functions/toHtml';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { profile } from '$lib/utils/store/user';
import { snackbar } from '$features/ui/snackbar/store';
export type NewsfeedCommentsByFeedId = {
items: NewsfeedCommentsResponse['items'];
totalCount: number;
hasMore: boolean;
isLoading: boolean;
cursor: string | null;
};
/**
* API class for newsfeed operations
*/
export class NewsfeedApi extends BaseApiWithErrors {
feeds = $state<ListNewsfeed>([]);
commentsByFeedId = $state<Record<string, NewsfeedCommentsByFeedId>>({});
isNewFeedModalOpen = $state(false);
/**
* Opens the new feed modal
*/
openNewFeedModal() {
this.isNewFeedModalOpen = true;
}
/**
* Closes the new feed modal
*/
closeNewFeedModal() {
this.isNewFeedModalOpen = false;
}
/**
* Lists newsfeed items for a course
*/
async list(courseId: string) {
await this.execute<ListNewsfeedRequest>({
requestFn: () =>
classroomio.course[':courseId'].newsfeed.$get({
param: { courseId },
query: {}
}),
logContext: 'listing newsfeed',
onSuccess: (response) => {
if (response.data) {
this.feeds = Array.isArray(response.data) ? response.data : response.data.items || [];
for (const feed of this.feeds) {
this.initComments(feed.id, 0);
}
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to list newsfeed');
}
}
});
}
/**
* Gets a newsfeed item by ID
*/
async get(courseId: string, feedId: string) {
await this.execute<GetNewsfeedRequest>({
requestFn: () =>
classroomio.course[':courseId'].newsfeed[':feedId'].$get({
param: { courseId, feedId }
}),
logContext: 'fetching newsfeed item',
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to fetch newsfeed item');
}
}
});
}
/**
* Creates a new newsfeed item
*/
async create(courseId: string, fields: TNewsfeedCreate) {
const result = ZNewsfeedCreate.safeParse({ ...fields, content: getTextFromHTML(fields.content), courseId });
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed');
return;
}
await this.execute<CreateNewsfeedRequest>({
requestFn: () =>
classroomio.course[':courseId'].newsfeed.$post({
param: { courseId },
json: result.data
}),
logContext: 'creating newsfeed',
onSuccess: (response) => {
if (response.data) {
snackbar.success('Post created successfully');
const feedsArray = Array.isArray(this.feeds) ? this.feeds : [];
const author = get(profile);
const newFeed = {
...response.data,
authorProfileId: author.id,
authorFullname: author.fullname,
authorUsername: author.username,
authorAvatarUrl: author.avatarUrl,
commentCount: 0
};
this.feeds = [...feedsArray, newFeed];
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to create post');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
} else if ('error' in result) {
this.errors.general = result.error;
snackbar.error(result.error);
}
}
});
}
/**
* Updates a newsfeed item (content and isPinned only)
*/
async update(courseId: string, feedId: string, fields: TNewsfeedUpdate) {
const result = ZNewsfeedUpdate.safeParse({
...fields,
content: getTextFromHTML(fields.content ?? '')
});
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed');
return;
}
await this.execute<UpdateNewsfeedRequest>({
requestFn: () =>
classroomio.course[':courseId'].newsfeed[':feedId'].$put({
param: { courseId, feedId },
json: result.data
}),
logContext: 'updating newsfeed',
onSuccess: (response) => {
if (response.data) {
snackbar.success('Post updated successfully');
// Update local state, preserving author fields from existing feed
const index = this.feeds.findIndex((f) => f.id === feedId);
if (index !== -1) {
const existingFeed = this.feeds[index];
// Merge response data with existing feed to preserve author fields
this.feeds[index] = {
...existingFeed,
...response.data,
// Preserve author fields from existing feed
authorProfileId: existingFeed.authorProfileId,
authorFullname: existingFeed.authorFullname,
authorUsername: existingFeed.authorUsername,
authorAvatarUrl: existingFeed.authorAvatarUrl
};
}
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to update post');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
} else if ('error' in result) {
this.errors.general = result.error;
snackbar.error(result.error);
}
}
});
}
/**
* Updates a newsfeed item's reaction
*/
async react(courseId: string, feedId: string, reaction: TNewsfeedReactionUpdate['reaction']) {
const result = ZNewsfeedReactionUpdate.safeParse({ reaction });
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed');
return;
}
await this.execute<ReactToNewsfeedRequest>({
requestFn: () =>
classroomio.course[':courseId'].newsfeed[':feedId'].react.$put({
param: { courseId, feedId },
json: result.data
}),
logContext: 'updating newsfeed reaction',
onSuccess: (response) => {
if (response.data) {
// Update local state, preserving author fields from existing feed
const index = this.feeds.findIndex((f) => f.id === feedId);
if (index !== -1) {
const existingFeed = this.feeds[index];
// Merge response data with existing feed to preserve author fields
this.feeds[index] = {
...existingFeed,
...response.data,
// Preserve author fields from existing feed
authorProfileId: existingFeed.authorProfileId,
authorFullname: existingFeed.authorFullname,
authorUsername: existingFeed.authorUsername,
authorAvatarUrl: existingFeed.authorAvatarUrl
};
}
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to update reaction');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
} else if ('error' in result) {
this.errors.general = result.error;
snackbar.error(result.error);
}
}
});
}
/**
* Deletes a newsfeed item
*/
async delete(courseId: string, feedId: string) {
await this.execute<DeleteNewsfeedRequest>({
requestFn: () =>
classroomio.course[':courseId'].newsfeed[':feedId'].$delete({
param: { courseId, feedId }
}),
logContext: 'deleting newsfeed',
onSuccess: (response) => {
if (response.data) {
snackbar.success('Post deleted successfully');
this.feeds = this.feeds.filter((feed) => feed.id !== feedId);
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to delete post');
}
}
});
}
/**
* Initialize comment state for a feed
*/
initComments(feedId: string, totalCount: number) {
if (!this.commentsByFeedId[feedId]) {
this.commentsByFeedId[feedId] = {
items: [],
totalCount,
hasMore: totalCount > 0,
isLoading: false,
cursor: null
};
} else {
this.commentsByFeedId[feedId].totalCount = totalCount;
this.commentsByFeedId[feedId].hasMore = totalCount > this.commentsByFeedId[feedId].items.length;
}
}
/**
* Fetch paginated comments for a feed
*/
async getComments(courseId: string, feedId: string, limit = 5) {
if (!this.commentsByFeedId[feedId]) {
this.initComments(feedId, 0);
}
this.commentsByFeedId[feedId].isLoading = true;
await this.execute<GetNewsfeedCommentsRequest>({
requestFn: () =>
classroomio.course[':courseId'].newsfeed[':feedId'].comments.$get({
param: { courseId, feedId },
query: { limit: String(limit) }
}),
logContext: 'fetching newsfeed comments',
onSuccess: (response) => {
if (response.data) {
this.commentsByFeedId[feedId] = {
items: response.data.items,
totalCount: response.data.totalCount,
hasMore: response.data.hasMore,
isLoading: false,
cursor: response.data.nextCursor
};
}
},
onError: (result) => {
this.commentsByFeedId[feedId].isLoading = false;
if (typeof result === 'string') {
snackbar.error('Failed to fetch comments');
}
}
});
}
/**
* Load more comments (uses cursor)
*/
async loadMoreComments(courseId: string, feedId: string, limit = 5) {
const commentState = this.commentsByFeedId[feedId];
if (!commentState || !commentState.hasMore || commentState.isLoading || !commentState.cursor) {
return;
}
commentState.isLoading = true;
await this.execute<GetNewsfeedCommentsRequest>({
requestFn: () =>
classroomio.course[':courseId'].newsfeed[':feedId'].comments.$get({
param: { courseId, feedId },
query: { cursor: commentState.cursor!, limit: String(limit) }
}),
logContext: 'loading more newsfeed comments',
onSuccess: (response) => {
if (response.data) {
this.commentsByFeedId[feedId] = {
items: [...commentState.items, ...response.data.items],
totalCount: response.data.totalCount,
hasMore: response.data.hasMore,
isLoading: false,
cursor: response.data.nextCursor
};
}
},
onError: (result) => {
this.commentsByFeedId[feedId].isLoading = false;
if (typeof result === 'string') {
snackbar.error('Failed to load more comments');
}
}
});
}
/**
* Creates a newsfeed comment
*/
async createComment(
courseId: string,
feedId: string,
content: string,
author: { id: string; username: string; fullname: string; avatarUrl: string }
) {
const result = ZNewsfeedCommentCreate.safeParse({ courseNewsfeedId: feedId, content });
console.log('result', result);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed');
return;
}
await this.execute<CreateNewsfeedCommentRequest>({
requestFn: () =>
classroomio.course[':courseId'].newsfeed[':feedId'].comment.$post({
param: { courseId, feedId },
json: result.data
}),
logContext: 'creating newsfeed comment',
onSuccess: (response) => {
if (response.data) {
snackbar.success('Comment added successfully');
// Add comment to local state
const commentState = this.commentsByFeedId[feedId];
if (commentState) {
const newComment = {
...response.data,
// Map author data to the expected flat structure
authorProfileId: author.id,
authorFullname: author.fullname,
authorUsername: author.username,
authorAvatarUrl: author.avatarUrl
};
this.commentsByFeedId[feedId] = {
...commentState,
items: [newComment, ...commentState.items],
totalCount: commentState.totalCount + 1
};
}
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to add comment');
}
}
});
}
/**
* Updates a newsfeed comment
*/
async updateComment(courseId: string, commentId: string, content: string) {
const result = ZNewsfeedCommentUpdate.safeParse({ content });
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed');
return;
}
await this.execute<UpdateNewsfeedCommentRequest>({
requestFn: () =>
classroomio.course[':courseId'].newsfeed.comment[':commentId'].$put({
param: { courseId, commentId },
json: result.data
}),
logContext: 'updating newsfeed comment',
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to update comment');
}
}
});
}
/**
* Deletes a newsfeed comment
*/
async deleteComment(courseId: string, feedId: string, commentId: string) {
await this.execute<DeleteNewsfeedCommentRequest>({
requestFn: () =>
classroomio.course[':courseId'].newsfeed.comment[':commentId'].$delete({
param: { courseId, commentId }
}),
logContext: 'deleting newsfeed comment',
onSuccess: (response) => {
if (response.data) {
snackbar.success('Comment deleted successfully');
// Remove comment from local state
const commentState = this.commentsByFeedId[feedId];
if (commentState) {
this.commentsByFeedId[feedId] = {
...commentState,
items: commentState.items.filter((item) => String(item.id) !== commentId),
totalCount: Math.max(0, commentState.totalCount - 1)
};
}
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to delete comment');
}
}
});
}
}
export const newsfeedApi = new NewsfeedApi();
@@ -0,0 +1,295 @@
import type {
AddPeopleRequest,
CreateStudentInviteRequest,
DeletePeopleRequest,
GetStudentInviteAuditRequest,
GetUserCourseAnalyticsRequest,
ListStudentInvitesRequest,
ListPeopleRequest,
RevokeStudentInviteRequest,
UpdatePeopleRequest
} from '../utils/types';
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type { TAddCourseMembers } from '@cio/utils/validation/course/people';
import { snackbar } from '$features/ui/snackbar/store';
/**
* API class for course members operations
*/
export class PeopleApi extends BaseApiWithErrors {
/**
* Lists all course members for a course
* @param courseId Course ID
* @returns Array of course members with profile data
*/
async list(courseId: string) {
return this.execute<ListPeopleRequest>({
requestFn: () =>
classroomio.course[':courseId']['members'].$get({
param: { courseId }
}),
logContext: 'listing course members',
onSuccess: (response) => {
if (response.data) {
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to list course members');
}
}
});
}
/**
* Adds course members to a course
* @param courseId Course ID
* @param members Array of members to add
* @returns Array of created members or null on error
*/
async add(courseId: string, members: TAddCourseMembers) {
await this.execute<AddPeopleRequest>({
requestFn: () =>
classroomio.course[':courseId']['members'].$post({
param: { courseId },
json: members
}),
logContext: 'adding course members',
onSuccess: (response) => {
if (response.data) {
const count = Array.isArray(response.data) ? response.data.length : 1;
snackbar.success(`${count} member${count > 1 ? 's' : ''} added successfully`);
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to add course members');
}
}
});
}
/**
* Creates a secure student invite link for a course
* @param courseId Course ID
* @param payload Invite settings
* @returns Invite data including inviteLink
*/
async createStudentInvite(
courseId: string,
payload: {
preset?: 'ONE_TIME_24H' | 'MULTI_USE_7D' | 'MULTI_USE_30D' | 'CUSTOM';
expiresAt?: string;
maxUses?: number;
allowedEmails?: string[];
allowedDomains?: string[];
recipientEmails?: string[];
recipientCsv?: string;
sendEmail?: boolean;
metadata?: Record<string, unknown>;
}
) {
const result = await this.execute<CreateStudentInviteRequest>({
requestFn: () =>
classroomio.course[':courseId']['invites'].$post({
param: { courseId },
json: payload
}),
logContext: 'creating student invite',
onSuccess: () => {
this.success = true;
this.errors = {};
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to create student invite');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
} else if ('error' in result) {
this.errors.general = result.error;
}
}
});
return result?.data;
}
/**
* Lists secure student invites for a course
* @param courseId Course ID
*/
async listStudentInvites(courseId: string) {
const result = await this.execute<ListStudentInvitesRequest>({
requestFn: () =>
classroomio.course[':courseId']['invites'].$get({
param: { courseId }
}),
logContext: 'listing student invites',
onSuccess: () => {
this.success = true;
this.errors = {};
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to list invites');
}
}
});
return result?.data;
}
/**
* Revokes a secure student invite
* @param courseId Course ID
* @param inviteId Invite ID
*/
async revokeStudentInvite(courseId: string, inviteId: string) {
const result = await this.execute<RevokeStudentInviteRequest>({
requestFn: () =>
classroomio.course[':courseId']['invites'][':inviteId']['revoke'].$post({
param: { courseId, inviteId }
}),
logContext: 'revoking student invite',
onSuccess: () => {
this.success = true;
this.errors = {};
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to revoke invite');
}
}
});
return result?.data;
}
/**
* Loads invite audit events for one invite
* @param courseId Course ID
* @param inviteId Invite ID
*/
async getStudentInviteAudit(courseId: string, inviteId: string) {
const result = await this.execute<GetStudentInviteAuditRequest>({
requestFn: () =>
classroomio.course[':courseId']['invites'][':inviteId']['audit'].$get({
param: { courseId, inviteId }
}),
logContext: 'loading invite audit',
onSuccess: () => {
this.success = true;
this.errors = {};
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to load invite audit');
}
}
});
return result?.data;
}
/**
* Updates a course member
* @param courseId Course ID
* @param memberId Member ID
* @param data Partial member data to update
* @returns The updated member data or null on error
*/
async update(courseId: string, memberId: string, data: Partial<{ roleId: number; email: string }>) {
await this.execute<UpdatePeopleRequest>({
requestFn: () =>
classroomio.course[':courseId']['members'][':memberId'].$put({
param: { courseId, memberId },
json: data
}),
logContext: 'updating course member',
onSuccess: (response) => {
if (response.data) {
snackbar.success('Member updated successfully');
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to update course member');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
} else if ('error' in result) {
this.errors.general = result.error;
snackbar.error(result.error);
}
}
});
}
/**
* Deletes a course member
* @param courseId Course ID
* @param memberId Member ID
* @returns The deleted member data or null on error
*/
async delete(courseId: string, memberId: string) {
await this.execute<DeletePeopleRequest>({
requestFn: () =>
classroomio.course[':courseId']['members'][':memberId'].$delete({
param: { courseId, memberId }
}),
logContext: 'deleting course member',
onSuccess: (response) => {
if (response.data) {
snackbar.success('Member removed successfully');
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to delete course member');
}
}
});
}
/**
* Gets user course analytics for a specific course
* @param courseId Course ID
* @param userId User ID (profile ID)
* @returns User course analytics data or null on error
*/
async getUserCourseAnalytics(courseId: string, userId: string) {
return this.execute<GetUserCourseAnalyticsRequest>({
requestFn: () =>
classroomio.course[':courseId']['members'][':userId']['analytics'].$get({
param: { courseId, userId }
}),
logContext: 'fetching user course analytics',
onSuccess: (response) => {
if (response.data) {
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to fetch user course analytics');
}
}
});
}
}
export const peopleApi = new PeopleApi();
@@ -0,0 +1,79 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type {
DocumentDownloadPresignRequest,
DocumentUploadPresignRequest,
VideoDownloadPresignRequest,
VideoUploadPresignRequest
} from '../utils/types';
/**
* API class for presigned URL requests (upload and download).
* All presign calls should go through this class instead of calling classroomio.course.presign directly.
*/
export class PresignApi extends BaseApiWithErrors {
/**
* Gets a presigned upload URL for a document.
* @returns { url, fileKey } on success
*/
async getDocumentUploadUrl(fileName: string, fileType: string) {
const result = await this.execute<DocumentUploadPresignRequest>({
requestFn: () =>
classroomio.course.presign.document.upload.$post({
json: { fileName, fileType }
}),
logContext: 'getting document upload URL'
});
return result?.url != null && result?.fileKey != null ? { url: result.url, fileKey: result.fileKey } : null;
}
/**
* Gets presigned download URLs for documents.
* @param keys Array of document storage keys
* @returns Record mapping keys to presigned URLs
*/
async getDocumentDownloadUrls(keys: string[]) {
if (keys.length === 0) return {};
const result = await this.execute<DocumentDownloadPresignRequest>({
requestFn: () =>
classroomio.course.presign.document.download.$post({
json: { keys }
}),
logContext: 'getting document download URLs'
});
return result?.urls ?? {};
}
/**
* Gets a presigned upload URL for a video.
* @returns { url, fileKey } on success
*/
async getVideoUploadUrl(fileName: string, fileType: string) {
const result = await this.execute<VideoUploadPresignRequest>({
requestFn: () =>
classroomio.course.presign.video.upload.$post({
json: { fileName, fileType }
}),
logContext: 'getting video upload URL'
});
return result?.url != null && result?.fileKey != null ? { url: result.url, fileKey: result.fileKey } : null;
}
/**
* Gets presigned download URLs for videos.
* @param keys Array of video storage keys
* @returns Record mapping keys to presigned URLs
*/
async getVideoDownloadUrls(keys: string[]) {
if (keys.length === 0) return {};
const result = await this.execute<VideoDownloadPresignRequest>({
requestFn: () =>
classroomio.course.presign.video.download.$post({
json: { keys }
}),
logContext: 'getting video download URLs'
});
return result?.urls ?? {};
}
}
export const presignApi = new PresignApi();
@@ -0,0 +1,162 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type {
DeleteSubmissionRequest,
UpdateSubmissionAnswerRequest,
UpdateSubmissionGradesRequest,
UpdateSubmissionRequest
} from '../utils/types';
import type {
TSubmissionAnswerUpdate,
TSubmissionGradesUpdate,
TSubmissionUpdate
} from '@cio/utils/validation/submission';
import { ZSubmissionAnswerUpdate, ZSubmissionGradesUpdate, ZSubmissionUpdate } from '@cio/utils/validation/submission';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { snackbar } from '$features/ui/snackbar/store';
/**
* API class for submission operations
*/
export class SubmissionApi extends BaseApiWithErrors {
/**
* Updates a submission
*/
async update(courseId: string, submissionId: string, fields: TSubmissionUpdate) {
const result = ZSubmissionUpdate.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'submission');
return;
}
await this.execute<UpdateSubmissionRequest>({
requestFn: () =>
classroomio.course[':courseId'].submission[':submissionId'].$put({
param: { courseId, submissionId },
json: result.data
}),
logContext: 'updating submission',
onSuccess: (response) => {
if (response.data) {
snackbar.success('Submission updated successfully');
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to update submission');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
} else if ('error' in result) {
this.errors.general = result.error;
snackbar.error(result.error);
}
}
});
}
/**
* Deletes a submission
*/
async delete(courseId: string, submissionId: string) {
await this.execute<DeleteSubmissionRequest>({
requestFn: () =>
classroomio.course[':courseId'].submission[':submissionId'].$delete({
param: { courseId, submissionId }
}),
logContext: 'deleting submission',
onSuccess: (response) => {
if (response.data) {
snackbar.success('Submission deleted successfully');
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to delete submission');
}
}
});
}
/**
* Updates all grades (answer points + total + feedback) in a single request
*/
async updateGrades(courseId: string, submissionId: string, data: TSubmissionGradesUpdate) {
const result = ZSubmissionGradesUpdate.safeParse(data);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'submission');
return;
}
await this.execute<UpdateSubmissionGradesRequest>({
requestFn: () =>
classroomio.course[':courseId'].submission[':submissionId'].grades.$put({
param: { courseId, submissionId },
json: result.data
}),
logContext: 'updating submission grades',
onSuccess: (response) => {
if (response.data) {
snackbar.success('snackbar.submissions.success.grading');
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to update grades');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
}
}
});
}
/**
* Updates a question answer in a submission.
* Does not show a snackbar (caller shows one when saving multiple answers).
*/
async updateAnswer(courseId: string, submissionId: string, data: TSubmissionAnswerUpdate) {
const result = ZSubmissionAnswerUpdate.safeParse(data);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'submission');
return;
}
await this.execute<UpdateSubmissionAnswerRequest>({
requestFn: () =>
classroomio.course[':courseId'].submission[':submissionId'].answer.$put({
param: { courseId, submissionId },
json: result.data
}),
logContext: 'updating submission answer',
onSuccess: (response) => {
if (response.data) {
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to update answer');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
}
}
});
}
}
export const submissionApi = new SubmissionApi();
@@ -1,9 +1,11 @@
<script lang="ts">
import type { Component } from 'svelte';
interface Props {
title: string;
value: string | number;
subtitle?: string;
icon: any;
icon: Component;
iconColor?: string;
className?: string;
}
@@ -33,7 +35,7 @@
{value}
</p>
{#if subtitle}
<p class="text-xs capitalize text-gray-500 dark:text-gray-500">
<p class="text-xs text-gray-500 capitalize dark:text-gray-500">
{subtitle}
</p>
{/if}
@@ -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>
@@ -7,7 +7,7 @@
<!-- 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 _}
{#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]" />
@@ -41,7 +41,7 @@
</div>
<div class="p-6">
<div class="space-y-4">
{#each Array(5) as _}
{#each Array(5) as _, i (i)}
<div class="flex items-center space-x-4">
<Skeleton class="h-[32px] w-[32px]" />
<div class="flex-1">
@@ -1,10 +1,11 @@
<script lang="ts">
import type { Component } from 'svelte';
import ChartBarIcon from '@lucide/svelte/icons/chart-bar';
interface Props {
title?: string;
description?: string;
icon?: any;
icon?: Component;
className?: string;
}
@@ -4,16 +4,16 @@
import UserIcon from '@lucide/svelte/icons/user';
import * as Pagination from '@cio/ui/base/pagination';
import { course } from '../../store';
import { courseApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
import EmptyState from './EmptyState.svelte';
import EmptyState from './empty-state.svelte';
import * as Avatar from '@cio/ui/base/avatar';
import type { StudentOverview } from '$lib/utils/types/analytics';
import type { CourseAnalytics } from '$features/course/utils/types';
import { shortenName } from '$lib/utils/functions/string';
interface Props {
students?: StudentOverview[];
students?: CourseAnalytics['students'];
}
let { students = [] }: Props = $props();
@@ -30,10 +30,10 @@
let startItem = $derived(startIndex + 1);
let endItem = $derived(Math.min(endIndex, students.length));
const GotoFullProfile = (student: StudentOverview) => {
const GotoFullProfile = (student: CourseAnalytics['students'][number]) => {
if (!student) return;
goto(`/courses/${$course.id}/people/${student.id}?back=/courses/${$course.id}/analytics`);
goto(`/courses/${courseApi.course?.id}/people/${student.id}?back=/courses/${courseApi.course?.id}/analytics`);
};
// Reset to first page when students array changes
@@ -57,34 +57,34 @@
<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 dark:border-neutral-600">
<tr class="border-b border-gray-200">
<th
class="sticky left-0 top-0 z-20 min-w-[200px] whitespace-nowrap bg-white px-4 py-3 text-left text-sm font-medium text-gray-500 dark:bg-neutral-800 dark:text-gray-300"
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] whitespace-nowrap px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300"
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] whitespace-nowrap px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300"
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] whitespace-nowrap px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300"
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] whitespace-nowrap px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300"
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] whitespace-nowrap px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300"
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>
@@ -95,13 +95,13 @@
<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] whitespace-nowrap bg-white px-4 py-3 transition-colors group-hover:bg-gray-50 dark:bg-neutral-800 dark:group-hover:bg-gray-700"
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 : 'Student'}
alt={student.profile.fullname ? student.profile.fullname : $t('analytics.student')}
/>
<Avatar.Fallback>{shortenName(student.profile.fullname) || 'S'}</Avatar.Fallback>
</Avatar.Root>
@@ -116,7 +116,7 @@
</div>
</td>
<!-- Scrollable Content -->
<td class="min-w-[150px] whitespace-nowrap px-4 py-3">
<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}
@@ -128,12 +128,12 @@
</div>
</div>
</td>
<td class="min-w-[140px] whitespace-nowrap px-4 py-3">
<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] whitespace-nowrap px-4 py-3">
<td class="min-w-[120px] px-4 py-3 whitespace-nowrap">
<Badge
class={student.averageGrade >= 80
? 'bg-green-600'
@@ -144,14 +144,14 @@
{student.averageGrade}%
</Badge>
</td>
<td class="min-w-[120px] whitespace-nowrap px-4 py-3">
<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] whitespace-nowrap px-4 py-3">
<td class="min-w-[120px] px-4 py-3 whitespace-nowrap">
<button
class="whitespace-nowrap rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700"
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')}
@@ -166,7 +166,7 @@
<!-- Pagination Controls -->
{#if students.length > pageSize}
<div class="mt-4 flex items-center justify-between border-t border-gray-200 px-4 py-3 dark:border-neutral-600">
<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,
@@ -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}
@@ -1,26 +1,25 @@
<script>
<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 { course } from '$lib/components/Course/store';
import { courseApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { updateCourse } from '$lib/utils/services/courses';
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 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/BlueBadgePattern.svelte';
import PurpleBadgePattern from './templates/PurpleBadgePattern.svelte';
import BlueProfessionalBadge from './templates/BlueProfessionalBadge.svelte';
import PurpleProfessionalBadge from './templates/PurpleProfessionalBadge.svelte';
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 = [
@@ -32,29 +31,30 @@
'blueBadgePattern'
];
let isSaving = $state(false);
let errors = $state({
description: ''
});
const helperText = $derived(
`${$course.description?.length || 0}/200 ${$t('course.navItem.certificates.characters')}`
`${courseApi.course?.description?.length || 0}/200 ${$t('course.navItem.certificates.characters')}`
);
let certificateTheme = $derived<string | undefined>(courseApi.course?.certificateTheme ?? undefined);
const saveCertificate = async () => {
isSaving = true;
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 {
// Prevent free plan users from bypassing UI restrictions
if ($isFreePlan) {
errors.description = 'Certificate customization is only available on paid plans';
throw new Error(errors.description);
}
const result = saveCertificateValidation({
description: $course.description || '',
is_certificate_downloadable: $course.is_certificate_downloadable || false,
certificate_theme: $course.certificate_theme || ''
description: courseApi.course?.description || '',
isCertificateDownloadable: courseApi.course?.isCertificateDownloadable || false,
certificateTheme: certificateTheme || ''
});
if (result && Object.keys(result).length > 0) {
@@ -64,11 +64,12 @@
errors.description = '';
await updateCourse($course.id, undefined, {
description: $course.description || '',
is_certificate_downloadable: $course.is_certificate_downloadable || false,
certificate_theme: $course.certificate_theme || ''
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) {
@@ -76,8 +77,6 @@
} else {
errors.description = $t('course.navItem.certificates.unexpected_error');
}
} finally {
isSaving = false;
}
};
</script>
@@ -93,7 +92,7 @@
<UpgradeBanner>{$t('upgrade.certificate')}</UpgradeBanner>
<main class="mt-2 px-2 md:-ml-3 md:-mr-3">
<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"
@@ -103,7 +102,7 @@
{$t('course.navItem.certificates.theme')}
</p>
<RadioGroup.Root bind:value={$course.certificate_theme} disabled={$isFreePlan} class="mb-10">
<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">
@@ -132,13 +131,14 @@
</Button>
</div>
<span class="my-4">
<p class="mb-2 mt-4 text-xs font-normal dark:text-gray-100">
<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')}
bind:value={$course.description}
value={courseApi.course?.description}
onchange={(e) => (courseApi.course!.description = e.currentTarget.value)}
errorMessage={errors.description}
disabled={$isFreePlan}
helperMessage={helperText}
@@ -147,7 +147,8 @@
<div class="my-4 flex items-center space-x-2">
<Switch
id="certificate-downloadable"
bind:checked={$course.is_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">
@@ -158,24 +159,24 @@
</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 $course.certificate_theme === 'professional'}
{#if certificateTheme === 'professional'}
<Professional studentName={studentNamePlaceholder} />
{:else if $course.certificate_theme === 'plain'}
{:else if certificateTheme === 'plain'}
<Plain studentName={studentNamePlaceholder} />
{:else if $course.certificate_theme === 'purpleProfessionalBadge'}
{:else if certificateTheme === 'purpleProfessionalBadge'}
<PurpleProfessionalBadge studentName={studentNamePlaceholder} />
{:else if $course.certificate_theme === 'blueProfessionalBadge'}
{:else if certificateTheme === 'blueProfessionalBadge'}
<BlueProfessionalBadge studentName={studentNamePlaceholder} />
{:else if $course.certificate_theme === 'purpleBadgePattern'}
{:else if certificateTheme === 'purpleBadgePattern'}
<PurpleBadgePattern studentName={studentNamePlaceholder} />
{:else if $course.certificate_theme === 'blueBadgePattern'}
{:else if certificateTheme === 'blueBadgePattern'}
<BlueBadgePattern studentName={studentNamePlaceholder} />
{/if}
</div>
</section>
</div>
<div class="h-1/5">
<Button variant="secondary" onclick={saveCertificate} loading={isSaving} disabled={$isFreePlan}>
<Button variant="secondary" onclick={saveCertificate} loading={courseApi.isLoading} disabled={$isFreePlan}>
{#if $isFreePlan}
<ZapIcon size={16} class="filled" />
{/if}
@@ -41,11 +41,9 @@
}
</script>
<section class="-ml-4 -mr-4 mb-10 flex flex-wrap items-start gap-2">
<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 dark:border-neutral-600"
>
<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>
@@ -56,14 +54,14 @@
<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 left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<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 dark:border-neutral-600">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
@@ -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>

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