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:
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
@@ -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,42 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle?: string;
|
||||
icon: any;
|
||||
iconColor?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { title, value, subtitle = '', icon, iconColor = 'blue', className = '' }: Props = $props();
|
||||
|
||||
let colorClasses = $derived({
|
||||
blue: 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400',
|
||||
green: 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400',
|
||||
purple: 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
|
||||
orange: 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400',
|
||||
red: 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400',
|
||||
yellow: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400'
|
||||
});
|
||||
|
||||
const SvelteComponent = $derived(icon);
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800 {className}">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg {colorClasses[iconColor]}">
|
||||
<SvelteComponent className={colorClasses[iconColor]} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">{title}</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{value}
|
||||
</p>
|
||||
{#if subtitle}
|
||||
<p class="text-xs capitalize text-gray-500 dark:text-gray-500">
|
||||
{subtitle}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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}
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen">
|
||||
<div class="px-4 py-8">
|
||||
<!-- Course Overview Cards Skeleton -->
|
||||
<div class="mb-12">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{#each Array(4) as _}
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
|
||||
<div class="flex items-center space-x-4">
|
||||
<Skeleton class="h-[48px] w-[48px]" />
|
||||
<div class="flex-1">
|
||||
<Skeleton class="h-[48px] w-[48px]" />
|
||||
<Skeleton class="mb-2 h-[16px] w-[80px]" />
|
||||
<Skeleton class="mb-1 h-[24px] w-[40px]" />
|
||||
<Skeleton class="h-[12px] w-[60px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Section Skeleton -->
|
||||
<div class="mb-12">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
|
||||
<Skeleton class="mb-6 h-[24px] w-[200px]" />
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Skeleton class="h-[300px] w-full" />
|
||||
<Skeleton class="h-[300px] w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Students Table Skeleton -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white dark:border-neutral-700 dark:bg-neutral-800">
|
||||
<div class="border-b border-gray-200 px-6 py-4 dark:border-neutral-700">
|
||||
<Skeleton class="mb-6 h-[24px] w-[200px]" />
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
{#each Array(5) as _}
|
||||
<div class="flex items-center space-x-4">
|
||||
<Skeleton class="h-[32px] w-[32px]" />
|
||||
<div class="flex-1">
|
||||
<Skeleton class="mb-1 h-[16px] w-[150px]" />
|
||||
<Skeleton class="h-[12px] w-[200px]" />
|
||||
</div>
|
||||
<Skeleton class="h-[16px] w-[80px]" />
|
||||
<Skeleton class="h-[16px] w-[60px]" />
|
||||
<Skeleton class="h-[20px] w-[40px]" />
|
||||
<Skeleton class="h-[12px] w-[80px]" />
|
||||
<Skeleton class="h-[32px] w-[100px]" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,31 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ChartBarIcon from '@lucide/svelte/icons/chart-bar';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: any;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
title = 'No data available',
|
||||
description = 'There is no data to display at this time.',
|
||||
icon = ChartBarIcon,
|
||||
className = ''
|
||||
}: Props = $props();
|
||||
|
||||
const SvelteComponent = $derived(icon);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center justify-center px-4 py-12 {className}">
|
||||
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800">
|
||||
<SvelteComponent size="24" class="text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-medium text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
<p class="max-w-sm text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1,210 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import * as Pagination from '@cio/ui/base/pagination';
|
||||
|
||||
import { course } from '../../store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
import EmptyState from './EmptyState.svelte';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import type { StudentOverview } from '$lib/utils/types/analytics';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
|
||||
interface Props {
|
||||
students?: StudentOverview[];
|
||||
}
|
||||
|
||||
let { students = [] }: Props = $props();
|
||||
|
||||
// Pagination state
|
||||
let currentPage = $state(1);
|
||||
const pageSize = 15;
|
||||
|
||||
// Computed values
|
||||
let totalPages = $derived(Math.ceil(students.length / pageSize));
|
||||
let startIndex = $derived((currentPage - 1) * pageSize);
|
||||
let endIndex = $derived(startIndex + pageSize);
|
||||
let paginatedStudents = $derived(students.slice(startIndex, endIndex));
|
||||
let startItem = $derived(startIndex + 1);
|
||||
let endItem = $derived(Math.min(endIndex, students.length));
|
||||
|
||||
const GotoFullProfile = (student: StudentOverview) => {
|
||||
if (!student) return;
|
||||
|
||||
goto(`/courses/${$course.id}/people/${student.id}?back=/courses/${$course.id}/analytics`);
|
||||
};
|
||||
|
||||
// Reset to first page when students array changes
|
||||
$effect(() => {
|
||||
if (students.length > 0 && currentPage > totalPages) {
|
||||
currentPage = 1;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if students.length === 0}
|
||||
<EmptyState
|
||||
title={$t('analytics.no_students_found')}
|
||||
description={$t('analytics.no_students_description')}
|
||||
icon={UserIcon}
|
||||
/>
|
||||
{:else}
|
||||
<!-- Responsive Table -->
|
||||
<div class="relative h-[300px] overflow-hidden">
|
||||
<div class="h-full overflow-auto">
|
||||
<table class="w-full min-w-[800px]">
|
||||
<!-- Fixed Header -->
|
||||
<thead class="sticky top-0 z-10 bg-white dark:bg-neutral-800">
|
||||
<tr class="border-b border-gray-200 dark:border-neutral-600">
|
||||
<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"
|
||||
>
|
||||
{$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"
|
||||
>
|
||||
{$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"
|
||||
>
|
||||
{$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"
|
||||
>
|
||||
{$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"
|
||||
>
|
||||
{$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"
|
||||
>
|
||||
{$t('analytics.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-600">
|
||||
{#each paginatedStudents as student}
|
||||
<tr class="group h-[100px] border-b hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<!-- Fixed Name Column -->
|
||||
<td
|
||||
class="sticky left-0 z-10 min-w-[200px] 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"
|
||||
>
|
||||
<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'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(student.profile.fullname) || 'S'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium text-gray-900 dark:text-white">
|
||||
{student.profile.fullname}
|
||||
</p>
|
||||
<p class="truncate text-sm text-gray-500 dark:text-gray-400">
|
||||
{student.profile.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Scrollable Content -->
|
||||
<td class="min-w-[150px] whitespace-nowrap px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{student.lessonsCompleted}/{student.totalLessons}
|
||||
</span>
|
||||
<div class="w-20 flex-shrink-0">
|
||||
<div class="h-2 rounded-full bg-gray-200 dark:bg-neutral-700">
|
||||
<div class="h-2 rounded-full bg-blue-500" style="width: {student.progressPercentage}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="min-w-[140px] whitespace-nowrap px-4 py-3">
|
||||
<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">
|
||||
<Badge
|
||||
class={student.averageGrade >= 80
|
||||
? 'bg-green-600'
|
||||
: student.averageGrade >= 60
|
||||
? 'bg-blue-600'
|
||||
: 'bg-red-600'}
|
||||
>
|
||||
{student.averageGrade}%
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="min-w-[120px] whitespace-nowrap px-4 py-3">
|
||||
<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">
|
||||
<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"
|
||||
onclick={() => GotoFullProfile(student)}
|
||||
>
|
||||
{$t('analytics.view_details')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
{#if students.length > pageSize}
|
||||
<div class="mt-4 flex items-center justify-between border-t border-gray-200 px-4 py-3 dark:border-neutral-600">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{$t('analytics.showing_students', {
|
||||
start: startItem,
|
||||
end: endItem,
|
||||
total: students.length
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Pagination.Root
|
||||
count={students.length}
|
||||
perPage={pageSize}
|
||||
page={currentPage}
|
||||
onPageChange={(page) => (currentPage = page)}
|
||||
>
|
||||
{#snippet children({ pages, currentPage: activePage })}
|
||||
<Pagination.Content>
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton />
|
||||
</Pagination.Item>
|
||||
{#each pages as page (page.key)}
|
||||
{#if page.type === 'ellipsis'}
|
||||
<Pagination.Item>
|
||||
<Pagination.Ellipsis />
|
||||
</Pagination.Item>
|
||||
{:else}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link {page} isActive={activePage === page.value}>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
class="h-6 w-6 {className}"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
class="h-6 w-6 {className}"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 5a2 2 0 012-2h8a2 2 0 012 2v6a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 1a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3zm-3 4a1 1 0 100 2h.01a1 1 0 100-2H7zm3 0a1 1 0 100 2h3a1 1 0 100-2h-3z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
class="h-6 w-6 {className}"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
class="h-6 w-6 {className}"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3z" />
|
||||
</svg>
|
||||
@@ -1,195 +0,0 @@
|
||||
<script>
|
||||
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 { 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 { 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';
|
||||
|
||||
const studentNamePlaceholder = 'Name of student';
|
||||
const themes = [
|
||||
'professional',
|
||||
'plain',
|
||||
'purpleProfessionalBadge',
|
||||
'blueProfessionalBadge',
|
||||
'purpleBadgePattern',
|
||||
'blueBadgePattern'
|
||||
];
|
||||
|
||||
let isSaving = $state(false);
|
||||
let errors = $state({
|
||||
description: ''
|
||||
});
|
||||
|
||||
const helperText = $derived(
|
||||
`${$course.description?.length || 0}/200 ${$t('course.navItem.certificates.characters')}`
|
||||
);
|
||||
|
||||
const saveCertificate = async () => {
|
||||
isSaving = true;
|
||||
|
||||
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 || ''
|
||||
});
|
||||
|
||||
if (result && Object.keys(result).length > 0) {
|
||||
errors.description = $t(result.description) || $t('course.navItem.certificates.description_error');
|
||||
throw new Error(errors.description);
|
||||
}
|
||||
|
||||
errors.description = '';
|
||||
|
||||
await updateCourse($course.id, undefined, {
|
||||
description: $course.description || '',
|
||||
is_certificate_downloadable: $course.is_certificate_downloadable || false,
|
||||
certificate_theme: $course.certificate_theme || ''
|
||||
});
|
||||
snackbar.success('snackbar.course_settings.success.saved');
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
errors.description = error.message;
|
||||
} else {
|
||||
errors.description = $t('course.navItem.certificates.unexpected_error');
|
||||
}
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Qwitcher+Grypen&family=Roboto:wght@300;400;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<UpgradeBanner>{$t('upgrade.certificate')}</UpgradeBanner>
|
||||
|
||||
<main class="mt-2 px-2 md:-ml-3 md:-mr-3">
|
||||
<div class="mb-3 flex h-4/5 w-full flex-1 flex-col justify-between gap-3 lg:flex-row">
|
||||
<section class="h-full w-full lg:w-2/5">
|
||||
<strong class="my-2 text-base font-semibold text-black dark:text-gray-100"
|
||||
>{$t('course.navItem.certificates.certificate_settings')}</strong
|
||||
>
|
||||
<p class="my-4 text-xs font-normal dark:text-gray-100">
|
||||
{$t('course.navItem.certificates.theme')}
|
||||
</p>
|
||||
|
||||
<RadioGroup.Root bind:value={$course.certificate_theme} disabled={$isFreePlan} class="mb-10">
|
||||
<div class="flex flex-wrap justify-between gap-y-5">
|
||||
{#each themes as theme}
|
||||
<div class="mr-3 flex items-start space-x-2">
|
||||
<RadioGroup.Item value={theme} id={theme} />
|
||||
<Label for={theme} class="cursor-pointer">
|
||||
<img src={`/images/certificate_theme_${theme}.png`} alt="themes" class="h-[82px] w-[110px]" />
|
||||
</Label>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
|
||||
<div>
|
||||
<p class="my-2 text-xs font-normal text-black dark:text-gray-100">
|
||||
{$t('course.navItem.certificates.logo')}
|
||||
</p>
|
||||
<div>
|
||||
<p class="mt-1 text-base dark:text-gray-100">
|
||||
{$t('course.navItem.certificates.to_update')}
|
||||
<strong class="font-semibold">{$t('course.navItem.certificates.settings')}</strong>
|
||||
{$t('course.navItem.certificates.and_upload')}
|
||||
</p>
|
||||
|
||||
<Button variant="outline" class="mt-3" onclick={() => goto(`/org/${$currentOrg.siteName}/settings`)}>
|
||||
{$t('course.navItem.certificates.goto_settings')}
|
||||
</Button>
|
||||
</div>
|
||||
<span class="my-4">
|
||||
<p class="mb-2 mt-4 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}
|
||||
errorMessage={errors.description}
|
||||
disabled={$isFreePlan}
|
||||
helperMessage={helperText}
|
||||
/>
|
||||
</span>
|
||||
<div class="my-4 flex items-center space-x-2">
|
||||
<Switch
|
||||
id="certificate-downloadable"
|
||||
bind:checked={$course.is_certificate_downloadable}
|
||||
disabled={$isFreePlan}
|
||||
/>
|
||||
<Label for="certificate-downloadable" class="text-sm font-medium dark:text-gray-100">
|
||||
{$t('course.navItem.certificates.allow')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="flex w-full items-center justify-center rounded-md bg-gray-100 lg:w-3/5 dark:bg-neutral-800">
|
||||
<div class="certificate-container flex items-center justify-center">
|
||||
{#if $course.certificate_theme === 'professional'}
|
||||
<Professional studentName={studentNamePlaceholder} />
|
||||
{:else if $course.certificate_theme === 'plain'}
|
||||
<Plain studentName={studentNamePlaceholder} />
|
||||
{:else if $course.certificate_theme === 'purpleProfessionalBadge'}
|
||||
<PurpleProfessionalBadge studentName={studentNamePlaceholder} />
|
||||
{:else if $course.certificate_theme === 'blueProfessionalBadge'}
|
||||
<BlueProfessionalBadge studentName={studentNamePlaceholder} />
|
||||
{:else if $course.certificate_theme === 'purpleBadgePattern'}
|
||||
<PurpleBadgePattern studentName={studentNamePlaceholder} />
|
||||
{:else if $course.certificate_theme === 'blueBadgePattern'}
|
||||
<BlueBadgePattern studentName={studentNamePlaceholder} />
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="h-1/5">
|
||||
<Button variant="secondary" onclick={saveCertificate} loading={isSaving} disabled={$isFreePlan}>
|
||||
{#if $isFreePlan}
|
||||
<ZapIcon size={16} class="filled" />
|
||||
{/if}
|
||||
{$t('course.navItem.certificates.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
p {
|
||||
color: #262626;
|
||||
}
|
||||
.certificate-container {
|
||||
width: 95%;
|
||||
max-width: 95%;
|
||||
}
|
||||
</style>
|
||||
-74
@@ -1,74 +0,0 @@
|
||||
<script>
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
|
||||
import { issueCertificateModal, resetForm } from './store';
|
||||
import { preventDefault } from '$lib/utils/functions/svelte';
|
||||
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
let isAutomatic = $state(false);
|
||||
|
||||
const issueCertificate = () => {
|
||||
resetForm();
|
||||
};
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={$issueCertificateModal.open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) resetForm();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-3/5">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Send Certificate</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<main>
|
||||
<div>
|
||||
<div class="mb-4 flex items-center space-x-2">
|
||||
<Switch id="auto-certificate" bind:checked={isAutomatic} />
|
||||
<Label for="auto-certificate" class="text-sm font-medium">Automatic</Label>
|
||||
</div>
|
||||
<p class="my-4 text-sm font-medium">
|
||||
If you set this as automatic, certificates will be issued after the learner completes the course.
|
||||
</p>
|
||||
</div>
|
||||
<p class="my-4 text-xs font-normal text-gray-500">or send a personalised/ custom certificate below:</p>
|
||||
<form onsubmit={preventDefault(issueCertificate)}>
|
||||
<div class="flex w-full flex-col gap-2 md:flex-row">
|
||||
<InputField
|
||||
label="Email address of the student"
|
||||
className="w-full my-4"
|
||||
labelClassName="text-xs font-normal"
|
||||
placeholder="email,comma seperated"
|
||||
bind:value={$issueCertificateModal.email}
|
||||
/>
|
||||
<InputField
|
||||
label="Schedule date"
|
||||
className="w-full my-4"
|
||||
labelClassName="text-xs font-normal"
|
||||
placeholder="12/06/2023"
|
||||
bind:value={$issueCertificateModal.date}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextareaField
|
||||
label="Add a personalized message"
|
||||
labelClassName="text-xs font-normal"
|
||||
bind:value={$issueCertificateModal.message}
|
||||
rows={2}
|
||||
placeholder="your message here"
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex w-full items-end justify-end">
|
||||
<Button type="submit">Issue certificate</Button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -1,132 +0,0 @@
|
||||
<script>
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
import * as Pagination from '@cio/ui/base/pagination';
|
||||
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
|
||||
import { CertificateReportData, students } from './mockData';
|
||||
|
||||
let searchValue = $state('');
|
||||
let selectAll = $state(false);
|
||||
let selectedStudents = $state(new Set());
|
||||
|
||||
let currentPage = $state(1);
|
||||
const pageSize = 10;
|
||||
|
||||
// let totalPages = $derived(Math.ceil(students.length / pageSize));
|
||||
let startIndex = $derived((currentPage - 1) * pageSize);
|
||||
let endIndex = $derived(startIndex + pageSize);
|
||||
let paginatedStudents = $derived(students.slice(startIndex, endIndex));
|
||||
|
||||
function toggleSelectAll() {
|
||||
selectAll = !selectAll;
|
||||
if (selectAll) {
|
||||
selectedStudents = new Set(paginatedStudents.map((s) => s.id));
|
||||
} else {
|
||||
selectedStudents = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleStudent(id) {
|
||||
if (selectedStudents.has(id)) {
|
||||
selectedStudents.delete(id);
|
||||
} else {
|
||||
selectedStudents.add(id);
|
||||
}
|
||||
selectedStudents = selectedStudents;
|
||||
selectAll = selectedStudents.size === paginatedStudents.length;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="-ml-4 -mr-4 mb-10 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"
|
||||
>
|
||||
<p class="text-xs font-normal">{data.title}</p>
|
||||
<bold class="text-2xl font-medium">{data.value}</bold>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="flex flex-row items-center justify-between gap-4">
|
||||
<p class="w-full text-base font-semibold">Certificate issued</p>
|
||||
<div class="relative w-full max-w-sm">
|
||||
<SearchIcon class="absolute left-3 top-1/2 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">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[50px]">
|
||||
<Checkbox checked={selectAll} onCheckedChange={toggleSelectAll} />
|
||||
</Table.Head>
|
||||
<Table.Head>Name of student</Table.Head>
|
||||
<Table.Head>Date issued</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each paginatedStudents as student (student.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<Checkbox checked={selectedStudents.has(student.id)} onCheckedChange={() => toggleStudent(student.id)} />
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-medium">{student.name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm">{student.issued}</span>
|
||||
{#if student.peronalized}
|
||||
<Badge variant="secondary" class="bg-primary-100 ui:text-primary">personalized</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
{#if students.length > pageSize}
|
||||
<div class="mt-4 flex items-center justify-end">
|
||||
<Pagination.Root
|
||||
count={students.length}
|
||||
perPage={pageSize}
|
||||
page={currentPage}
|
||||
onPageChange={(page) => (currentPage = page)}
|
||||
>
|
||||
{#snippet children({ pages, currentPage: activePage })}
|
||||
<Pagination.Content>
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton />
|
||||
</Pagination.Item>
|
||||
{#each pages as page (page.key)}
|
||||
{#if page.type === 'ellipsis'}
|
||||
<Pagination.Item>
|
||||
<Pagination.Ellipsis />
|
||||
</Pagination.Item>
|
||||
{:else}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link {page} isActive={activePage === page.value}>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
-13
@@ -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>
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
<script>
|
||||
import Empty from '@cio/ui/custom/empty/empty.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import LockIcon from '@lucide/svelte/icons/lock';
|
||||
</script>
|
||||
|
||||
<div class="w-full flex-1">
|
||||
<Empty
|
||||
title={$t('course.navItem.certificates.locked_certificate')}
|
||||
description={`${$t('course.navItem.certificates.instructor_unlock')} ${$t('course.navItem.certificates.contact_instructor')}`}
|
||||
icon={LockIcon}
|
||||
variant="page"
|
||||
/>
|
||||
</div>
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import DownloadIcon from '@lucide/svelte/icons/download';
|
||||
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import Empty from '@cio/ui/custom/empty/empty.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { fetchProfileCourseProgress } from '$lib/utils/services/courses';
|
||||
import type { ProfileCourseProgress } from '$lib/utils/types';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
|
||||
let isLoading = $state(false);
|
||||
let isCourseComplete = $state(false);
|
||||
let progress: ProfileCourseProgress | undefined;
|
||||
|
||||
const downLoadCertificate = async () => {
|
||||
if (!isCourseComplete || !$course.id) return;
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
const response = await classroomio.course[':courseId']['download']['certificate']['$post']({
|
||||
param: { courseId: $course.id },
|
||||
json: {
|
||||
theme: `${$course.certificate_theme}`,
|
||||
studentName: `${$profile.fullname}`,
|
||||
courseName: `${$course.title}`,
|
||||
courseDescription: `${$course.description}`,
|
||||
orgLogoUrl: $currentOrg.avatarUrl || `${$currentOrgDomain}/logo-512.png`,
|
||||
orgName: `${$currentOrg.name}`
|
||||
}
|
||||
});
|
||||
|
||||
const blobResponse = await response.blob();
|
||||
|
||||
const file = new Blob([blobResponse], { type: 'application/pdf' });
|
||||
const fileURL = URL.createObjectURL(file);
|
||||
|
||||
let a = document.createElement('a');
|
||||
document.body.append(a);
|
||||
a.download = 'Certificate of Completion - ' + $currentOrg.name;
|
||||
a.href = fileURL;
|
||||
a.click();
|
||||
a.remove();
|
||||
} catch (error) {
|
||||
console.error('Error downloading', error);
|
||||
snackbar.error($t('course.navItem.certificates.unexpected_error'));
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
};
|
||||
|
||||
const hasUserCompletedCourse = async () => {
|
||||
isLoading = true;
|
||||
|
||||
const { data } = await fetchProfileCourseProgress($course.id, $profile.id);
|
||||
progress = data?.[0] || undefined;
|
||||
|
||||
if (progress) {
|
||||
isCourseComplete =
|
||||
progress.lessons_count === progress.lessons_completed &&
|
||||
progress.exercises_count === progress.exercises_completed;
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
hasUserCompletedCourse();
|
||||
});
|
||||
|
||||
let title = $derived(
|
||||
isCourseComplete
|
||||
? 'course.navItem.certificates.unlocked_certificate'
|
||||
: 'course.navItem.certificates.complete_to_download_title'
|
||||
);
|
||||
let subtitle = $derived(
|
||||
isCourseComplete
|
||||
? 'course.navItem.certificates.unlocked_certificate_subtitle'
|
||||
: 'course.navItem.certificates.complete_to_download_subtitle'
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex-1">
|
||||
<Empty title={$t(title)} description={$t(subtitle)} icon={DownloadIcon} variant="page">
|
||||
<Button onclick={downLoadCertificate} disabled={!isCourseComplete} loading={isLoading}>
|
||||
<DownloadIcon size={16} />
|
||||
{$t('course.navItem.certificates.download_certificate')}
|
||||
</Button>
|
||||
</Empty>
|
||||
</div>
|
||||
@@ -1,85 +0,0 @@
|
||||
export const CertificateReportData = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Total students taking course',
|
||||
value: 100
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Number of certificates issued ',
|
||||
value: 45
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Certificates left to be issued',
|
||||
value: 55
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Personalised cetificates',
|
||||
value: 15
|
||||
}
|
||||
];
|
||||
|
||||
export const students = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: false
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: false
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: true
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: false
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: false
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: false
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: false
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: false
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: false
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: true
|
||||
}
|
||||
];
|
||||
@@ -1,18 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const issueCertificateModal = writable({
|
||||
open: false,
|
||||
automatic: false,
|
||||
email: '',
|
||||
date: '',
|
||||
message: ''
|
||||
});
|
||||
|
||||
export const resetForm = () =>
|
||||
issueCertificateModal.update(() => ({
|
||||
open: false,
|
||||
automatic: false,
|
||||
email: '',
|
||||
date: '',
|
||||
message: ''
|
||||
}));
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const logo = '/logo-512.png';
|
||||
|
||||
const dateIssued = new Date().toLocaleDateString();
|
||||
</script>
|
||||
|
||||
<div class="relative w-full dark:text-black">
|
||||
<img src="/images/blue-badge-pattern-background.svg" alt="" class="absolute z-0 h-full w-full object-cover" />
|
||||
<div class="relative z-20 m-2 flex h-full justify-between bg-white md:h-[17rem]">
|
||||
<div class="w-[70%] pl-3">
|
||||
<header class="absolute flex w-full items-center justify-center gap-1 text-sm">
|
||||
<img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class="w-5" />
|
||||
<h1 class="text-sm">{$currentOrg.name}</h1>
|
||||
</header>
|
||||
|
||||
<div class="mt-16">
|
||||
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
|
||||
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#2D499F]">{studentName}</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 w-[90%]">
|
||||
<h1 class="text-[7px]">has successfully completed training on</h1>
|
||||
<h2 class="m-0 text-lg">
|
||||
{$course.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="my-8 flex items-center justify-between text-center">
|
||||
<div>
|
||||
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
|
||||
<p class="border-b border-black text-[7px]">{dateIssued}</p>
|
||||
</div>
|
||||
<div class="w-[20%] text-[7px]">
|
||||
<div></div>
|
||||
<p class="border-t border-black text-[7px]">Signature</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="desc w-[90%] text-[8px]">
|
||||
{$course.description}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center overflow-hidden">
|
||||
<img src="/images/blue-badge-pattern-badge.svg" alt="A badge" class="w-[8rem] max-w-none md:w-[10rem]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const logo = '/logo-512.png';
|
||||
|
||||
const dateIssued = new Date().toLocaleDateString();
|
||||
</script>
|
||||
|
||||
<div class="w-full bg-[#2D499F] p-3 dark:text-black">
|
||||
<div class="flex w-full bg-white pb-5">
|
||||
<div class="w-[80%] pl-3">
|
||||
<header class="flex items-center justify-start gap-1 text-sm">
|
||||
<img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class="w-5" />
|
||||
<h1 class="text-sm">{$currentOrg.name}</h1>
|
||||
</header>
|
||||
|
||||
<div class="mt-7">
|
||||
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
|
||||
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#2D499F]">{studentName}</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 w-[90%]">
|
||||
<h1 class="text-[7px]">has succesfully completed training on</h1>
|
||||
<h2 class="m-0 text-lg">
|
||||
{$course.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="mb-5 mt-8 flex items-center justify-between text-center">
|
||||
<div class="">
|
||||
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
|
||||
<p class="border-b border-black text-[7px]">{dateIssued}</p>
|
||||
</div>
|
||||
<div class="w-[20%] text-[7px]">
|
||||
<div class=""></div>
|
||||
<p class="border-t border-black text-[7px]">Signature</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="desc w-[90%] text-[8px]">
|
||||
{$course.description}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="w-[20%]">
|
||||
<img src="/images/blue-professional-badge.svg" alt="A badge" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const borderwidth = 'border-4';
|
||||
const bordercolor = 'border-pink-700';
|
||||
const logo = '/logo-512.png';
|
||||
</script>
|
||||
|
||||
<div class="certificate-bg flex w-full flex-col items-center justify-center {borderwidth} {bordercolor} py-3">
|
||||
<div class="my-2 flex items-center gap-1">
|
||||
<img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class=" h-10 w-10 rounded-md" />
|
||||
<p class="font-semibold capitalize dark:text-black">{$currentOrg.name}</p>
|
||||
</div>
|
||||
<div class="w-full px-5 py-3">
|
||||
<p class="my-2 w-full text-center text-sm font-normal uppercase tracking-widest text-gray-500">
|
||||
Certificate of completion
|
||||
</p>
|
||||
<div class="mb-4 border-b border-gray-500">
|
||||
<p class="text-xs font-medium text-black">This is to certify that</p>
|
||||
<p class="student-name text-center text-5xl dark:text-black">{studentName}</p>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<p class="text-xs font-medium text-black">has successfully completed the course</p>
|
||||
<div>
|
||||
<p class="clamp bg-transparent text-center text-base font-semibold uppercase text-pink-700">
|
||||
{$course.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="desc text-center dark:text-black">{$course.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.student-name {
|
||||
font-family: 'Qwitcher Grypen', cursive;
|
||||
}
|
||||
.desc {
|
||||
font-size: 10px;
|
||||
}
|
||||
.clamp {
|
||||
font-size: clamp(14px, 3vw, 16px);
|
||||
}
|
||||
.certificate-bg {
|
||||
background-color: #f5f8fe;
|
||||
}
|
||||
</style>
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const borderwidth = 'border-l-8';
|
||||
const bordercolor = 'border-primary-800';
|
||||
const logo = '/logo-512.png';
|
||||
</script>
|
||||
|
||||
<div class="certificate-bg border-primary-400 flex w-full items-center justify-center border border-dashed">
|
||||
<div class="{borderwidth} {bordercolor} w-full">
|
||||
<div class="w-full px-5 py-3">
|
||||
<p class="my-2 text-sm font-normal uppercase text-gray-500">
|
||||
Certificate<br /> of completion
|
||||
</p>
|
||||
<div class="mb-4 border-b border-gray-500">
|
||||
<p class="text-xs font-medium text-black">This is to certify that</p>
|
||||
<p class="student-name text-center text-5xl dark:text-black">{studentName}</p>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<p class="text-xs font-medium text-black">has successfully completed introductory courses in</p>
|
||||
<div>
|
||||
<p class="clamp text-primary-800 my-2 bg-transparent text-center text-base font-semibold uppercase">
|
||||
{$course.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="desc mb-4 text-justify dark:text-black">{$course.description}</p>
|
||||
<div class="my-2 flex items-center gap-1">
|
||||
<img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class="h-10 w-10 rounded-md" />
|
||||
<p class="font-semibold capitalize dark:text-black">{$currentOrg.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.student-name {
|
||||
font-family: 'Qwitcher Grypen', cursive;
|
||||
}
|
||||
.desc {
|
||||
font-size: 10px;
|
||||
}
|
||||
.clamp {
|
||||
font-size: clamp(14px, 3vw, 16px);
|
||||
}
|
||||
.certificate-bg {
|
||||
background-color: #f5f8fe;
|
||||
}
|
||||
</style>
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const logo = '/logo-512.png';
|
||||
|
||||
const dateIssued = new Date().toLocaleDateString();
|
||||
</script>
|
||||
|
||||
<div class="relative w-full dark:text-black">
|
||||
<img src="/images/purple-badge-pattern-background.svg" alt="" class="absolute z-0 h-full w-full object-cover" />
|
||||
<div class="relative z-20 m-2 flex h-full justify-between bg-white md:h-[17rem]">
|
||||
<div class="w-[70%] pl-3">
|
||||
<header class="absolute flex w-full items-center justify-center gap-1 text-sm">
|
||||
<img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class="w-5" />
|
||||
<h1 class="text-sm">{$currentOrg.name}</h1>
|
||||
</header>
|
||||
|
||||
<div class="mt-16">
|
||||
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
|
||||
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#914397]">{studentName}</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 w-[90%]">
|
||||
<h1 class="text-[7px]">has successfully completed training on</h1>
|
||||
<h2 class="m-0 text-lg">
|
||||
{$course.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="my-8 flex items-center justify-between text-center">
|
||||
<div>
|
||||
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
|
||||
<p class="border-b border-black text-[7px]">{dateIssued}</p>
|
||||
</div>
|
||||
<div class="w-[20%] text-[7px]">
|
||||
<div></div>
|
||||
<p class="border-t border-black text-[7px]">Signature</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="desc w-[90%] text-[8px]">
|
||||
{$course.description}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center overflow-hidden">
|
||||
<img src="/images/purple-badge-pattern-badge.svg" alt="A badge" class="w-[8rem] max-w-none md:w-[10rem]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const logo = '/logo-512.png';
|
||||
|
||||
const dateIssued = new Date().toLocaleDateString();
|
||||
</script>
|
||||
|
||||
<div class="w-full bg-[#95449A] p-3 dark:text-black">
|
||||
<div class="flex w-full bg-white pb-5">
|
||||
<div class="w-[80%] pl-3">
|
||||
<header class="flex items-center justify-start gap-1 text-sm">
|
||||
<img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class="w-5" />
|
||||
<h1 class="text-sm">{$currentOrg.name}</h1>
|
||||
</header>
|
||||
|
||||
<div class="mt-7">
|
||||
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
|
||||
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#95449A]">{studentName}</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 w-[90%]">
|
||||
<h1 class="text-[7px]">has succesfully completed training on</h1>
|
||||
<h2 class="m-0 text-lg">
|
||||
{$course.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="mb-5 mt-8 flex items-center justify-between text-center">
|
||||
<div class="">
|
||||
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
|
||||
<p class="border-b border-black text-[7px]">{dateIssued}</p>
|
||||
</div>
|
||||
<div class="w-[20%] text-[7px]">
|
||||
<div class=""></div>
|
||||
<p class="border-t border-black text-[7px]">Signature</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="desc w-[90%] text-[8px]">
|
||||
{$course.description}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="w-[20%]">
|
||||
<img src="/images/purple-professional-badge.svg" alt="A badge" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
-66
@@ -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>
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
openDeleteModal?: boolean;
|
||||
deleteLesson?: any;
|
||||
}
|
||||
|
||||
let { openDeleteModal = $bindable(false), deleteLesson = () => {} }: Props = $props();
|
||||
|
||||
async function handleDelete() {
|
||||
deleteLesson();
|
||||
openDeleteModal = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={openDeleteModal}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) openDeleteModal = false;
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-96 px-6 pb-6 pt-2">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('delete_modal.label')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
<h1 class="text-lg dark:text-white">
|
||||
{$t('delete_modal.content')}?
|
||||
</h1>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={() => (openDeleteModal = false)}>
|
||||
{$t('delete_modal.no')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={handleDelete}>
|
||||
{$t('delete_modal.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { deleteConfirmation } from '../store/exercise';
|
||||
|
||||
let { onDelete = () => {}, onCancel = () => {} } = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open={$deleteConfirmation.open}>
|
||||
<Dialog.Content class="w-96">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
<p class="mt-0 text-base dark:text-white">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.sure')}
|
||||
</p>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
$deleteConfirmation.open = false;
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.no')}
|
||||
</Button>
|
||||
<Button
|
||||
onclick={() => {
|
||||
onDelete();
|
||||
$deleteConfirmation.open = false;
|
||||
}}
|
||||
>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -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>
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
<script>
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<Skeleton class="h-4 w-full" />
|
||||
<div class="space-y-3">
|
||||
<Skeleton class="h-4 w-full" />
|
||||
<Skeleton class="h-4 w-full" />
|
||||
<Skeleton class="h-4 w-full" />
|
||||
<Skeleton class="h-4 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
-417
@@ -1,417 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
|
||||
import { STATUS } from './constants';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import type { SubmissionIdData } from '$lib/utils/types/submission';
|
||||
|
||||
import Preview from './Preview.svelte';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
// import { useCompletion } from 'ai/svelte';
|
||||
// import { QUESTION_TYPE } from '$lib/components/Question/constants';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
onClose?: any;
|
||||
handleSave?: any;
|
||||
isGradeWithAI?: boolean;
|
||||
data: SubmissionIdData;
|
||||
deleteSubmission?: any;
|
||||
updateStatus?: any;
|
||||
isSaving?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onClose = () => {},
|
||||
handleSave = (_d: SubmissionIdData) => {},
|
||||
isGradeWithAI = $bindable(false),
|
||||
data = $bindable(),
|
||||
deleteSubmission = async (_id: string, _statusId: number) => {},
|
||||
updateStatus = (_arg: { submissionId: string; prevStatusId: number; nextStatusId: number; total: number }) => {},
|
||||
isSaving = false
|
||||
}: Props = $props();
|
||||
|
||||
const SELECTABLE_STATUS = [
|
||||
{
|
||||
id: STATUS.SUBMITTED,
|
||||
text: $t('course.navItem.submissions.submission_status.submitted')
|
||||
},
|
||||
{
|
||||
id: STATUS.IN_PROGRESS,
|
||||
text: $t('course.navItem.submissions.submission_status.in_progress')
|
||||
},
|
||||
{
|
||||
id: STATUS.GRADED,
|
||||
text: $t('course.navItem.submissions.submission_status.graded')
|
||||
}
|
||||
];
|
||||
|
||||
let status = $state(SELECTABLE_STATUS[0]);
|
||||
let reasons = $state({});
|
||||
let isLoading = $state(false);
|
||||
let openDeletePrompt = $state(false);
|
||||
let isDeleting = $state(false);
|
||||
|
||||
const total = calculateTotal(data.questionAnswerByPoint);
|
||||
const maxPoints = $derived(getMaxPoints(data.questions));
|
||||
const sortedQuestions = $derived(
|
||||
Array.isArray(data.questions) ? [...data.questions].sort((a, b) => a.order - b.order) : []
|
||||
);
|
||||
|
||||
function getMaxPoints(questions) {
|
||||
return (questions || []).reduce((acc, question) => acc + question.points, 0);
|
||||
}
|
||||
|
||||
function calculateTotal(grades: Record<string, string>): number {
|
||||
if (!grades) return 0;
|
||||
return Object.values(grades).reduce((acc, grade) => acc + parseInt(grade || '0'), 0);
|
||||
}
|
||||
|
||||
function handleStatusChange(value: string) {
|
||||
const newSelectedId = parseInt(value);
|
||||
const prevStatusId = data.statusId;
|
||||
|
||||
// Find the new status
|
||||
const newStatus = SELECTABLE_STATUS.find((s) => s.id === newSelectedId);
|
||||
|
||||
if (newStatus) {
|
||||
// Update state
|
||||
status = newStatus;
|
||||
data.statusId = newSelectedId;
|
||||
|
||||
updateStatus({
|
||||
submissionId: data.id,
|
||||
prevStatusId: prevStatusId,
|
||||
nextStatusId: newSelectedId,
|
||||
total
|
||||
});
|
||||
|
||||
snackbar.success(`${$t('snackbar.exercise.submission_updated')} '${newStatus.text}'`);
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(data: SubmissionIdData) {
|
||||
untrack(() => {
|
||||
const statusBySelectedId = SELECTABLE_STATUS.find((s) => s.id === data.statusId);
|
||||
|
||||
if (statusBySelectedId) {
|
||||
status = statusBySelectedId;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// const getMultipleAnswersGrade = (options, answer, points) => {
|
||||
// const correctOptions = options
|
||||
// .filter((option) => option.is_correct)
|
||||
// .map((option) => option.value);
|
||||
|
||||
// const correctSelections = answer.answers.filter((answer) =>
|
||||
// correctOptions.includes(answer)
|
||||
// ).length;
|
||||
|
||||
// const incorrectSelections = answer.answers.length - correctSelections;
|
||||
|
||||
// const pointsEarned = (correctSelections / correctOptions.length) * points;
|
||||
// const deduction = (incorrectSelections * points) / correctOptions.length;
|
||||
// const finalPoints = Math.max(pointsEarned - deduction, 0);
|
||||
|
||||
// return {
|
||||
// finalPoints,
|
||||
// correctOptions,
|
||||
// correctSelections,
|
||||
// incorrectSelections
|
||||
// };
|
||||
// };
|
||||
|
||||
async function handleDeleteSubmission() {
|
||||
isDeleting = true;
|
||||
await deleteSubmission(data.id, status.id);
|
||||
isDeleting = false;
|
||||
}
|
||||
|
||||
// const { input, handleSubmit, completion } = useCompletion({
|
||||
// api: '/api/completion/gradingprompt',
|
||||
// onFinish: async () => {
|
||||
// try {
|
||||
// const responseData = $completion.replace('```json', '').replace('```', '');
|
||||
|
||||
// let aiResponses: {
|
||||
// id: number;
|
||||
// score: number;
|
||||
// explanation: string;
|
||||
// }[] = [];
|
||||
// try {
|
||||
// // Parse the modified response data as JSON
|
||||
// aiResponses = JSON.parse(responseData);
|
||||
// } catch (error) {
|
||||
// console.error('Error parsing AI response', error);
|
||||
// }
|
||||
|
||||
// data?.questions.forEach((question) => {
|
||||
// const { id, points, question_type_id, options } = question;
|
||||
// if (question_type_id == QUESTION_TYPE.RADIO) {
|
||||
// const answer = data.questionAnswers.find((q) => q.question_id === id);
|
||||
|
||||
// const answerPoints = answer?.answers?.length ?? 0;
|
||||
|
||||
// reasons = {
|
||||
// ...reasons,
|
||||
// [id]: `${$t('course.navItem.submissions.grading_modal.allocated')} ${$t(
|
||||
// 'course.navItem.submissions.grading_modal.total_try'
|
||||
// )} ${answerPoints} `
|
||||
// };
|
||||
// data.questionAnswerByPoint[id] = `${Math.ceil(points / answerPoints)}`;
|
||||
// } else if (question_type_id == QUESTION_TYPE.CHECKBOX) {
|
||||
// const answer = data.questionAnswers.find((q) => q.question_id === id);
|
||||
|
||||
// const { finalPoints, correctOptions, correctSelections, incorrectSelections } =
|
||||
// getMultipleAnswersGrade(options, answer, points);
|
||||
|
||||
// reasons = {
|
||||
// ...reasons,
|
||||
// [id]: `${$t(
|
||||
// 'course.navItem.submissions.grading_modal.allocated'
|
||||
// )} ${correctSelections} ${$t('course.navItem.submissions.grading_modal.out_of')} ${
|
||||
// correctOptions.length
|
||||
// } ${$t('course.navItem.submissions.grading_modal.options_correct')} ${$t(
|
||||
// 'course.navItem.submissions.grading_modal.options_wrong'
|
||||
// )} ${incorrectSelections} `
|
||||
// };
|
||||
|
||||
// data.questionAnswerByPoint[id] = `${finalPoints}`;
|
||||
// } else if (aiResponses.length) {
|
||||
// const graded = aiResponses.find((res) => res.id === id);
|
||||
|
||||
// reasons = {
|
||||
// ...reasons,
|
||||
// [id]: `${graded?.explanation}`
|
||||
// };
|
||||
|
||||
// data.questionAnswerByPoint[id] = `${graded?.score}`;
|
||||
// }
|
||||
// });
|
||||
// } catch (error) {
|
||||
// console.error('Error', error);
|
||||
// } finally {
|
||||
// isLoading = false;
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
|
||||
function gradeWithAI() {
|
||||
// isGradeWithAI = true;
|
||||
// isLoading = true;
|
||||
// const paragraphAiInput = data.questions
|
||||
// .filter((q) => q.question_type_id === QUESTION_TYPE.TEXTAREA)
|
||||
// .map((q) => {
|
||||
// const answer = data.questionAnswers.find((qA) => qA.question_id === q.id); // { open_answer: '' }
|
||||
// return {
|
||||
// id: q.id,
|
||||
// question: q.title,
|
||||
// answer: answer?.open_answer,
|
||||
// point: q.points
|
||||
// };
|
||||
// });
|
||||
// $input = JSON.stringify(paragraphAiInput);
|
||||
// handleSubmit({ preventDefault: () => {} });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
setStatus(data);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) onClose();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="flex h-[85%] h-[90%] !max-h-full w-4/5 items-start px-4 py-0">
|
||||
<Dialog.Header class="py-2">
|
||||
<Dialog.Title class="text-base font-semibold">{data.title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div class="mt-2 h-full w-full">
|
||||
{#if openDeletePrompt}
|
||||
<div class="mx-auto w-96 rounded-md border border-gray-300 p-3">
|
||||
<h1 class="text-lg dark:text-white">
|
||||
{$t('delete_modal.content')}
|
||||
</h1>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={() => (openDeletePrompt = false)} disabled={isDeleting}>
|
||||
{$t('delete_modal.no')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={handleDeleteSubmission} loading={isDeleting}>
|
||||
{$t('delete_modal.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Preview
|
||||
questions={sortedQuestions}
|
||||
questionnaireMetaData={{
|
||||
answers: data.answers || {},
|
||||
isFinished: true
|
||||
}}
|
||||
bind:grades={data.questionAnswerByPoint}
|
||||
bind:reasons
|
||||
bind:isGradeWithAI
|
||||
bind:isLoading
|
||||
disableGrading={false}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="sticky top-0 ml-4 mt-2 w-2/5">
|
||||
<div class="rounded-md border border-gray-300">
|
||||
<div
|
||||
class="flex items-center justify-between gap-1 border-b border-l-0 border-r-0 border-t-0 border-gray-300 p-3"
|
||||
>
|
||||
<p class="text-base dark:text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.details')}
|
||||
{#if data.isEarly}
|
||||
<span class="badge ml-2 rounded-sm bg-green-500 px-2 text-sm text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.early')}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="badge ml-2 rounded-sm bg-red-500 px-2 text-sm text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.late')}
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md hover:bg-gray-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<EllipsisVerticalIcon class="h-5 w-5" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item
|
||||
class="text-red-600"
|
||||
onclick={() => {
|
||||
openDeletePrompt = true;
|
||||
}}
|
||||
>
|
||||
{$t('delete_modal.label')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4 px-3 py-2 text-sm">
|
||||
<p class="text-sm font-semibold text-gray-500 dark:text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.total_grade')}:
|
||||
</p>
|
||||
|
||||
<Badge class="w-fit rounded-md bg-gray-100 font-semibold text-black dark:bg-neutral-700 dark:text-white">
|
||||
{total}/{maxPoints}
|
||||
</Badge>
|
||||
</div>
|
||||
<!-- <div class="flex items-center text-sm p-3">
|
||||
<p class="dark:text-white w-1/2">Status</p>
|
||||
<div class="flex items-center">
|
||||
<p class="dark:text-white rounded-full w-5 h-5 bg-yellow-300 mr-2" />
|
||||
<p class="dark:text-white">Grading</p>
|
||||
</div>
|
||||
</div> -->
|
||||
<div class="flex items-center space-x-4 px-3 py-2 text-sm">
|
||||
<p class="text-sm font-semibold text-gray-500 dark:text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.student')}:
|
||||
</p>
|
||||
{#if data.student}
|
||||
<div class="flex flex-row items-center justify-center rounded-md bg-gray-100 p-[6px] dark:bg-neutral-700">
|
||||
<img alt="Student avatar" class="flex h-5 w-5 rounded-full" src={data.student.avatar_url} />
|
||||
<p class="ml-2 line-clamp-1 text-sm font-semibold dark:text-white">
|
||||
{data.student.fullname}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- <div class="flex items-center space-x-4 text-sm px-3 py-2">
|
||||
<p class="dark:text-white text-sm text-gray-500 font-semibold">Assesment Type:</p>
|
||||
<Tag
|
||||
class="dark:text-white font-semibold bg-gray-100 dark:bg-neutral-700 rounded-md text-black w-fit"
|
||||
>Paragraph</Tag
|
||||
>
|
||||
</div> -->
|
||||
|
||||
<div class="flex flex-col items-start px-3 py-2 text-sm">
|
||||
<p class="font-semibold text-gray-500 dark:text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.status')}:
|
||||
</p>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={status.id.toString()}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
handleStatusChange(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
{status.text}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each SELECTABLE_STATUS as statusOption}
|
||||
<Select.Item value={statusOption.id.toString()}>
|
||||
{statusOption.text}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start px-3 py-2 text-sm">
|
||||
<p class="font-semibold text-gray-500 dark:text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.add_comment')}:
|
||||
</p>
|
||||
<TextareaField
|
||||
bgColor="bg-gray-100 dark:bg-neutral-700"
|
||||
className="font-semibold"
|
||||
placeholder={$t('course.navItem.submissions.grading_modal.add_comment_placeholder')}
|
||||
bind:value={data.feedback}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col space-y-3 px-3 py-2">
|
||||
<Button variant="outline" onclick={gradeWithAI} class="w-full">
|
||||
<img src="/ai.svg" alt="ai" />
|
||||
<p class="text-sm font-semibold">
|
||||
{$t('course.navItem.submissions.grading_modal.grade_with_ai')}
|
||||
</p>
|
||||
</Button>
|
||||
<Button
|
||||
onclick={() => {
|
||||
handleSave(data);
|
||||
// onClose();
|
||||
}}
|
||||
loading={isSaving}
|
||||
class="w-full"
|
||||
>
|
||||
{$t('course.navItem.submissions.grading_modal.submit_grades')}
|
||||
</Button>
|
||||
</div>
|
||||
<!-- <div class="flex items-center text-sm p-3">
|
||||
<p class="dark:text-white w-1/2">Teacher</p>
|
||||
<p class="dark:text-white">rotimi-best</p>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
width: fit-content;
|
||||
}
|
||||
</style>
|
||||
-113
@@ -1,113 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
import SquareCheckIcon from '@lucide/svelte/icons/square-check';
|
||||
import CircleDotIcon from '@lucide/svelte/icons/circle-dot';
|
||||
import TablePropertiesIcon from '@lucide/svelte/icons/table-properties';
|
||||
|
||||
import { flip } from 'svelte/animate';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
|
||||
import { questionnaireOrder, questionnaire } from '../store/exercise';
|
||||
import { filterOutDeleted } from './functions';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
const flipDurationMs = 300;
|
||||
|
||||
interface Question {
|
||||
id: number;
|
||||
name: string;
|
||||
type: number;
|
||||
}
|
||||
|
||||
let items: Array<Question> = $state([]);
|
||||
|
||||
function setItemsFromQuestions(questions: typeof $questionnaire.questions) {
|
||||
untrack(() => {
|
||||
items = filterOutDeleted(questions).map((q) => ({
|
||||
id: q.id,
|
||||
name: q.title,
|
||||
type: q.question_type.id
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function handleDndConsider(e) {
|
||||
items = e.detail.items;
|
||||
}
|
||||
|
||||
function handleDndFinalize(e) {
|
||||
items = e.detail.items;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
items.forEach((item, index) => {
|
||||
const order = index + 1;
|
||||
$questionnaire.questions.some((q) => {
|
||||
if (q.id === item.id) {
|
||||
q.is_dirty = true;
|
||||
q.order = order;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
$questionnaire.questions = $questionnaire.questions.sort((a, b) => a.order - b.order);
|
||||
$questionnaireOrder.open = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
setItemsFromQuestions($questionnaire.questions);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={$questionnaireOrder.open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) handleClose();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-96">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.order_questions')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<section
|
||||
use:dndzone={{
|
||||
items,
|
||||
flipDurationMs,
|
||||
dropTargetStyle: {
|
||||
border: '2px #1d4ed8 solid',
|
||||
'border-style': 'dashed'
|
||||
}
|
||||
}}
|
||||
onconsider={handleDndConsider}
|
||||
onfinalize={handleDndFinalize}
|
||||
class="w-full"
|
||||
>
|
||||
{#each items as item (item.id)}
|
||||
<div
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
class="ui:border-primary flex items-center rounded-md border p-4"
|
||||
>
|
||||
{#if item.type === 1}
|
||||
<SquareCheckIcon size={16} />
|
||||
{:else if item.type === 2}
|
||||
<CircleDotIcon size={16} />
|
||||
{:else}
|
||||
<TablePropertiesIcon size={16} />
|
||||
{/if}
|
||||
{` ${item.name}`}
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<style>
|
||||
section {
|
||||
padding: 0.3em;
|
||||
overflow: auto;
|
||||
}
|
||||
div {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
</style>
|
||||
@@ -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}
|
||||
-119
@@ -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}
|
||||
-191
@@ -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}
|
||||
-66
@@ -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
|
||||
};
|
||||
}
|
||||
-87
@@ -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>
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { ExerciseSubmissions } from '$lib/utils/types';
|
||||
|
||||
export const submissions = writable<ExerciseSubmissions[]>([]);
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { questionnaire } from '../store/exercise';
|
||||
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { TextEditor } from '$features/ui';
|
||||
import QuestionContainer from '$lib/components/QuestionContainer/index.svelte';
|
||||
|
||||
interface Props {
|
||||
preview: boolean;
|
||||
}
|
||||
|
||||
let { preview }: Props = $props();
|
||||
|
||||
function getTotalPossibleGrade(questions: { points: number | string }[]) {
|
||||
return questions.reduce((acc, question) => {
|
||||
acc += typeof question.points === 'string' ? parseFloat(question.points) : question.points;
|
||||
return acc;
|
||||
}, 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-5 {!preview ? 'px-6' : 'px-2'}">
|
||||
<QuestionContainer isTitle={true}>
|
||||
{#if !preview}
|
||||
<InputField
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.description.title')}
|
||||
bind:value={$questionnaire.title}
|
||||
className="mb-2"
|
||||
onchange={() => ($questionnaire.is_title_dirty = true)}
|
||||
/>
|
||||
<InputField
|
||||
label={$t('course.navItem.lessons.exercises.all_exercises.view_mode.due')}
|
||||
className="w-50"
|
||||
type="datetime-local"
|
||||
bind:value={$questionnaire.due_by}
|
||||
onchange={() => {
|
||||
$questionnaire.is_due_by_dirty = true;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="mt-3">
|
||||
<p class="mb-1">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.description.heading')}
|
||||
</p>
|
||||
|
||||
<TextEditor
|
||||
content={$questionnaire.description}
|
||||
onChange={(html) => {
|
||||
$questionnaire.is_description_dirty = true;
|
||||
$questionnaire.description = html;
|
||||
}}
|
||||
editorClass="max-h-[300px]"
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.description.describe')}
|
||||
/>
|
||||
</div>
|
||||
{:else if preview}
|
||||
<h2 class="my-1">{$questionnaire.title}</h2>
|
||||
<div class="flex items-center">
|
||||
<p class="mx-2 dark:text-white">
|
||||
<strong>{$questionnaire.questions.length}</strong>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.questions')}
|
||||
</p>
|
||||
|
|
||||
<p class="mx-2 dark:text-white">
|
||||
<strong>{getTotalPossibleGrade($questionnaire.questions)}</strong>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')}.
|
||||
</p>
|
||||
|
|
||||
<p class="mx-2 dark:text-white">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.all')}
|
||||
</p>
|
||||
{#if $questionnaire.due_by}
|
||||
|
|
||||
<p class="mx-2 dark:text-white">
|
||||
<strong>{$t('course.navItem.lessons.exercises.all_exercises.view_mode.due')}:</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 || $t('course.navItem.lessons.exercises.all_exercises.description.no')
|
||||
)}
|
||||
</article>
|
||||
{/if}
|
||||
</QuestionContainer>
|
||||
</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,5 +0,0 @@
|
||||
export const STATUS = {
|
||||
SUBMITTED: 1,
|
||||
IN_PROGRESS: 2,
|
||||
GRADED: 3
|
||||
};
|
||||
@@ -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>
|
||||
-375
@@ -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>
|
||||
-268
@@ -1,268 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
|
||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import PlayIcon from '@lucide/svelte/icons/play';
|
||||
import { fetchLesssonLanguageHistory } from '$lib/utils/services/courses';
|
||||
import { diffLines } from 'diff';
|
||||
import { lesson, lessons } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
|
||||
interface LessonHistory {
|
||||
new_content: string;
|
||||
old_content: string;
|
||||
timestamp: Date;
|
||||
locale: TLocale;
|
||||
lesson_id: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
let { open = false }: Props = $props();
|
||||
let lessonHistory: LessonHistory[] = $state([]);
|
||||
let content = '';
|
||||
let selectedVersion: LessonHistory = {
|
||||
new_content: '',
|
||||
old_content: '',
|
||||
timestamp: new Date(),
|
||||
locale: 'en',
|
||||
lesson_id: ''
|
||||
};
|
||||
let selectedVersionIndex = $state(0);
|
||||
let contentRestoreLoading = $state(false);
|
||||
let versionsToFetch = $state(9);
|
||||
let isMoreHistoryLoading = $state(false);
|
||||
|
||||
let mounted = false;
|
||||
|
||||
const lessonId = $derived($lesson.id || '');
|
||||
const lessonTitle = $derived($lessons.find((les) => les.id === lessonId)?.title || '');
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function scrollLock(open) {
|
||||
if (mounted) {
|
||||
const body = document.querySelector('body');
|
||||
if (!body) return;
|
||||
|
||||
body.style.overflow = open ? 'hidden' : 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp) {
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: true
|
||||
};
|
||||
const date = new Date(timestamp + 'Z');
|
||||
return new Intl.DateTimeFormat('en-US', options).format(date);
|
||||
}
|
||||
|
||||
function handleDrawerClose() {
|
||||
dispatch('close');
|
||||
}
|
||||
|
||||
function getMinutes(time: Date) {
|
||||
return new Date(time).getMinutes();
|
||||
}
|
||||
|
||||
function removeDuplicate(history: LessonHistory[]) {
|
||||
return history.filter(
|
||||
(obj1, i, arr) => arr.findIndex((obj2) => getMinutes(obj2.timestamp) === getMinutes(obj1.timestamp)) === i
|
||||
);
|
||||
}
|
||||
|
||||
function fetchLessonHistory(lessonId: string, locale: string, endRange: number) {
|
||||
untrack(async () => {
|
||||
try {
|
||||
isMoreHistoryLoading = true;
|
||||
const { data, error } = await fetchLesssonLanguageHistory(lessonId, locale, endRange);
|
||||
|
||||
if (!data) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Filter out duplicates based on timestamp
|
||||
const existingTimestamps = new Set(lessonHistory.map((item) => new Date(item.timestamp).getMinutes()));
|
||||
const newEntries = data.filter((item) => !existingTimestamps.has(new Date(item.timestamp).getMinutes()));
|
||||
lessonHistory = removeDuplicate([...lessonHistory, ...newEntries]);
|
||||
|
||||
updateContentVersion(lessonHistory[0], 0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
snackbar.error('Failed to fetch history');
|
||||
} finally {
|
||||
isMoreHistoryLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
scrollLock(open);
|
||||
});
|
||||
|
||||
function updateContentVersion(content: LessonHistory, index: number) {
|
||||
selectedVersionIndex = index;
|
||||
selectedVersion = content;
|
||||
const display = document.getElementById('display');
|
||||
display!.innerHTML = '';
|
||||
|
||||
const diff = diffLines(content.old_content, content.new_content);
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
diff.forEach((part) => {
|
||||
const span = document.createElement('span');
|
||||
if (part.added) {
|
||||
span.classList.add('text-green-500');
|
||||
} else if (part.removed) {
|
||||
span.classList.add('text-red-500', 'line-through');
|
||||
} else {
|
||||
span.classList.add('text-black', 'dark:text-white');
|
||||
}
|
||||
span.innerHTML = part.value;
|
||||
fragment.appendChild(span);
|
||||
});
|
||||
|
||||
display!.appendChild(fragment);
|
||||
}
|
||||
|
||||
async function restoreSelectedVersion() {
|
||||
try {
|
||||
contentRestoreLoading = true;
|
||||
await supabase
|
||||
.from('lesson_language')
|
||||
.update({ content: selectedVersion.new_content })
|
||||
.eq('lesson_id', selectedVersion.lesson_id)
|
||||
.eq('locale', selectedVersion.locale);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
snackbar.error('Failed to restore');
|
||||
} finally {
|
||||
contentRestoreLoading = false;
|
||||
dispatch('restore');
|
||||
}
|
||||
}
|
||||
|
||||
function loadMoreHistory() {
|
||||
versionsToFetch += 10;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
scrollLock(open);
|
||||
});
|
||||
$effect(() => {
|
||||
fetchLessonHistory(lessonId, $lesson.locale, versionsToFetch);
|
||||
});
|
||||
</script>
|
||||
|
||||
<aside class="drawer bg-gray-100 dark:bg-neutral-800" class:open>
|
||||
<div class="panel bg-white dark:bg-black">
|
||||
<div class="w-full p-10 pr-80">
|
||||
<div class="flex items-start gap-x-10">
|
||||
<Button variant="outline" onclick={handleDrawerClose}>
|
||||
<ArrowLeftIcon size={16} />
|
||||
</Button>
|
||||
|
||||
{#if selectedVersionIndex != 0}
|
||||
<div class="">
|
||||
<Button loading={contentRestoreLoading} onclick={restoreSelectedVersion}>
|
||||
{$t('course.navItem.lessons.version_history.restore_version')}
|
||||
</Button>
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex h-full w-full flex-col items-start">
|
||||
<HtmlRender className="m-auto text-center mt-6 flex items-center justify-center">
|
||||
<h1 class="mt-0 text-2xl capitalize md:text-4xl">
|
||||
{lessonTitle}
|
||||
</h1>
|
||||
</HtmlRender>
|
||||
|
||||
{#key lessonId}
|
||||
<HtmlRender id="display" className="m-auto">
|
||||
<div class="amen">
|
||||
{@html sanitizeHtml(content)}
|
||||
</div>
|
||||
</HtmlRender>
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="scroll-container"
|
||||
class="fixed right-0 top-0 z-10 h-full min-h-screen w-80 space-y-6 overflow-x-auto overflow-y-scroll bg-gray-100 py-10 dark:bg-neutral-800"
|
||||
>
|
||||
<p class="flex items-start justify-start px-10 text-left text-xl font-medium">
|
||||
{$t('course.navItem.lessons.version_history.title')}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
{#each lessonHistory as version, index}
|
||||
<button
|
||||
onclick={() => updateContentVersion(version, index)}
|
||||
class="flex w-full cursor-pointer items-start p-4 px-10 hover:bg-gray-200 dark:hover:bg-neutral-700 {index ==
|
||||
selectedVersionIndex
|
||||
? 'bg-gray-200 dark:bg-neutral-700'
|
||||
: ''}"
|
||||
>
|
||||
<PlayIcon size={16} class="mt-1" />
|
||||
<div>
|
||||
<span class="inline-block text-base font-medium">{formatTimestamp(version.timestamp)}</span>
|
||||
{#if index == 0}
|
||||
<span class="block text-start text-xs italic"
|
||||
>{$t('course.navItem.lessons.version_history.current_version')}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
<div class="mt-2 flex h-10 items-center justify-start px-10">
|
||||
<Button loading={isMoreHistoryLoading} onclick={loadMoreHistory}>
|
||||
{$t('course.navItem.lessons.version_history.fetch_more_versions')}
|
||||
</Button>
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.drawer.open {
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 3;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.drawer.open .panel {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
</style>
|
||||
-310
@@ -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>
|
||||
-135
@@ -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}
|
||||
-35
@@ -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,12 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
</script>
|
||||
|
||||
<div class="w-full">
|
||||
<Skeleton class="h-40 w-full" />
|
||||
<div class="my-3">
|
||||
<Skeleton class="h-4 w-full" />
|
||||
<Skeleton class="h-4 w-full" />
|
||||
<Skeleton class="h-4 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
-54
@@ -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>
|
||||
-93
@@ -1,93 +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 type { LessonVideoType } from '$lib/utils/types';
|
||||
import { isLessonDirty, lesson } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { copyToClipboard, getVideoUrls, removeVideo } from '$lib/utils/functions/formatYoutubeVideo';
|
||||
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
let genericLinks = $state('');
|
||||
let error = $state('');
|
||||
|
||||
function addVideo() {
|
||||
const links = getVideoUrls(genericLinks);
|
||||
const validLinks = links.filter(isValidLink);
|
||||
|
||||
if (validLinks.length === 0) {
|
||||
error = $t('course.navItem.lessons.materials.tabs.video.add_video.invalid_link');
|
||||
} else {
|
||||
const existingLinks = $lesson?.materials?.videos || [];
|
||||
|
||||
$lesson.materials.videos = [
|
||||
...existingLinks,
|
||||
...validLinks.map((link = '') => ({
|
||||
type: 'generic' as LessonVideoType,
|
||||
link,
|
||||
metadata: {}
|
||||
}))
|
||||
];
|
||||
genericLinks = '';
|
||||
error = '';
|
||||
}
|
||||
}
|
||||
|
||||
function isValidLink(link = '') {
|
||||
// Basic URL validation
|
||||
if (!link) return false;
|
||||
|
||||
try {
|
||||
const url = new URL(link.trim());
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch {
|
||||
// If URL construction fails, try prepending https://
|
||||
try {
|
||||
return !!new URL(`https://${link.trim()}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex w-full items-{error ? 'center' : 'end'} justify-between gap-5">
|
||||
<InputField
|
||||
label={$t('course.navItem.lessons.materials.tabs.video.embed_link')}
|
||||
bind:value={genericLinks}
|
||||
className="flex-1"
|
||||
onchange={() => ($isLessonDirty = true)}
|
||||
placeholder="https://www.videoplayer.com/"
|
||||
errorMessage={error}
|
||||
/>
|
||||
<Button onclick={addVideo}>
|
||||
{$t('course.navItem.lessons.materials.tabs.video.add_video.add_video')}
|
||||
</Button>
|
||||
</div>
|
||||
<p class="mt-4 pl-2 text-sm">
|
||||
{$t('course.navItem.lessons.materials.tabs.video.add_video.videos_added')}:
|
||||
<strong>
|
||||
{$lesson?.materials?.videos.filter((v) => v.type === 'generic').length || 0}
|
||||
</strong>
|
||||
</p>
|
||||
<div class="">
|
||||
{#each $lesson?.materials?.videos as video, index}
|
||||
{#if video.type === 'generic'}
|
||||
<div class="flex items-center gap-1">
|
||||
<Badge class="max-w-md truncate" variant="secondary">
|
||||
{video.link}
|
||||
</Badge>
|
||||
<IconButton onclick={() => copyToClipboard(video.link)}>
|
||||
<CopyIcon size={16} />
|
||||
</IconButton>
|
||||
<IconButton onclick={() => removeVideo(index)}>
|
||||
<TrashIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
-54
@@ -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>
|
||||
-234
@@ -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}
|
||||
-82
@@ -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>
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
import FileSymlinkIcon from '@lucide/svelte/icons/file-symlink';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import YoutubeIcon from '@lucide/svelte/icons/youtube';
|
||||
|
||||
export const videoTabs = [
|
||||
{
|
||||
value: 1,
|
||||
title: 'course.navItem.lessons.materials.tabs.video.add_video.youtube_link',
|
||||
icon: YoutubeIcon
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
title: 'course.navItem.lessons.materials.tabs.video.embed_link',
|
||||
icon: FileSymlinkIcon
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
title: 'course.navItem.lessons.materials.tabs.video.upload',
|
||||
icon: VideoIcon
|
||||
}
|
||||
];
|
||||
-371
@@ -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>
|
||||
-434
@@ -1,434 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { lesson, deleteLessonDocument } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { CloseButton } from '$features/ui';
|
||||
import { lessonDocUpload } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import MODES from '$lib/utils/constants/mode';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import ZoomInIcon from '@lucide/svelte/icons/zoom-in';
|
||||
import ZoomOutIcon from '@lucide/svelte/icons/zoom-out';
|
||||
import { onMount } from 'svelte';
|
||||
import DocumentList from '../Document/DocumentList.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { LessonDocument } from '$lib/utils/types';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
|
||||
let { mode = MODES.view } = $props();
|
||||
|
||||
let downloadingDocuments = $state(new Set<string>());
|
||||
let viewingPDF: any = $state(null);
|
||||
let pdfViewerOpen = $state(false);
|
||||
let pdfCanvas: HTMLCanvasElement | undefined = $state();
|
||||
let pdfDoc: any = null;
|
||||
let pageNum = $state(1);
|
||||
let pageCount = $state(0);
|
||||
let scale = $state(1.0);
|
||||
let isLoading = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
let pdfjsLib: any = null;
|
||||
let renderTimeout: any = null;
|
||||
let currentRenderTask: any = null;
|
||||
|
||||
onMount(() => {
|
||||
// Load PDF.js dynamically
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
|
||||
script.onload = () => {
|
||||
pdfjsLib = (window as any).pdfjsLib;
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
if (script.parentNode) {
|
||||
script.parentNode.removeChild(script);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
function openDocumentUploadModal() {
|
||||
$lessonDocUpload.isModalOpen = true;
|
||||
}
|
||||
|
||||
function deleteDocument(index: number) {
|
||||
deleteLessonDocument(index);
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
async function downloadDocument(doc: LessonDocument) {
|
||||
downloadingDocuments.add(doc.name);
|
||||
downloadingDocuments = downloadingDocuments;
|
||||
|
||||
if (!doc.key) {
|
||||
snackbar.error('Document not loaded correctly');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(doc.link);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch document');
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = doc.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Error downloading document:', error);
|
||||
window.open(doc.link, '_blank');
|
||||
} finally {
|
||||
downloadingDocuments.delete(doc.name);
|
||||
downloadingDocuments = downloadingDocuments;
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedRender() {
|
||||
if (renderTimeout) {
|
||||
clearTimeout(renderTimeout);
|
||||
}
|
||||
renderTimeout = setTimeout(() => {
|
||||
renderPage();
|
||||
renderTimeout = null;
|
||||
}, 150); // 150ms debounce
|
||||
}
|
||||
// Wait for PDF.js to load
|
||||
const checkPDFJS = () => {
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
if (pdfjsLib) {
|
||||
resolve(true);
|
||||
} else {
|
||||
setTimeout(check, 100);
|
||||
}
|
||||
};
|
||||
check();
|
||||
});
|
||||
};
|
||||
|
||||
async function viewPDF(document: LessonDocument) {
|
||||
// Wait for PDF.js to be fully loaded
|
||||
if (!pdfjsLib) {
|
||||
// Show loading state while waiting for PDF.js
|
||||
viewingPDF = document;
|
||||
pdfViewerOpen = true;
|
||||
isLoading = true;
|
||||
error = 'Loading PDF viewer...';
|
||||
|
||||
await checkPDFJS();
|
||||
}
|
||||
|
||||
viewingPDF = document;
|
||||
pdfViewerOpen = true;
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
if (!document.key) {
|
||||
snackbar.error('Document not loaded correctly');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(document.link);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch PDF');
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
pdfDoc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
|
||||
pageCount = pdfDoc.numPages;
|
||||
pageNum = 1;
|
||||
scale = 1.0;
|
||||
isLoading = false;
|
||||
|
||||
// Wait for the canvas to be available in the DOM
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await renderPage();
|
||||
} catch (err) {
|
||||
console.error('Error loading PDF:', err);
|
||||
error = 'course.navItem.lessons.materials.tabs.document.failed_to_load_pdf';
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPage() {
|
||||
if (!pdfDoc) return;
|
||||
|
||||
// Wait for canvas to be available
|
||||
let attempts = 0;
|
||||
while (!pdfCanvas && attempts < 50) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (!pdfCanvas) {
|
||||
console.error('Canvas not available after waiting');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Cancel any ongoing render task
|
||||
if (currentRenderTask) {
|
||||
currentRenderTask.cancel();
|
||||
}
|
||||
|
||||
const page = await pdfDoc.getPage(pageNum);
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
pdfCanvas.height = viewport.height;
|
||||
pdfCanvas.width = viewport.width;
|
||||
|
||||
const ctx = pdfCanvas.getContext('2d');
|
||||
const renderContext = {
|
||||
canvasContext: ctx,
|
||||
viewport: viewport
|
||||
};
|
||||
|
||||
currentRenderTask = page.render(renderContext);
|
||||
await currentRenderTask.promise;
|
||||
currentRenderTask = null;
|
||||
} catch (err) {
|
||||
console.error('Error rendering page:', err);
|
||||
error = 'course.navItem.lessons.materials.tabs.document.failed_to_render_pdf';
|
||||
currentRenderTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (pageNum < pageCount) {
|
||||
pageNum++;
|
||||
debouncedRender();
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (pageNum > 1) {
|
||||
pageNum--;
|
||||
debouncedRender();
|
||||
}
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
scale = Math.min(scale + 0.25, 3.0);
|
||||
debouncedRender();
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
scale = Math.max(scale - 0.25, 0.5);
|
||||
debouncedRender();
|
||||
}
|
||||
|
||||
function handleViewPDF(event: CustomEvent<LessonDocument>) {
|
||||
viewPDF(event.detail);
|
||||
}
|
||||
|
||||
function closePDFViewer() {
|
||||
// Cancel any ongoing render task
|
||||
if (currentRenderTask) {
|
||||
currentRenderTask.cancel();
|
||||
}
|
||||
|
||||
// Clear any pending render timeout
|
||||
if (renderTimeout) {
|
||||
clearTimeout(renderTimeout);
|
||||
}
|
||||
|
||||
pdfViewerOpen = false;
|
||||
viewingPDF = null;
|
||||
pdfDoc = null;
|
||||
pageNum = 1;
|
||||
pageCount = 0;
|
||||
scale = 1.0;
|
||||
error = null;
|
||||
currentRenderTask = null;
|
||||
renderTimeout = null;
|
||||
}
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!pdfViewerOpen) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
prevPage();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
nextPage();
|
||||
break;
|
||||
case '+':
|
||||
case '=':
|
||||
zoomIn();
|
||||
break;
|
||||
case '-':
|
||||
zoomOut();
|
||||
break;
|
||||
case 'Escape':
|
||||
closePDFViewer();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
});
|
||||
|
||||
function handleContextMenu(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleDragStart(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
let displayDocuments = $derived($lesson?.materials?.documents || []);
|
||||
</script>
|
||||
|
||||
<DocumentList
|
||||
{isLoading}
|
||||
{mode}
|
||||
{displayDocuments}
|
||||
{downloadingDocuments}
|
||||
{formatFileSize}
|
||||
{openDocumentUploadModal}
|
||||
{deleteDocument}
|
||||
{downloadDocument}
|
||||
on:viewPDF={handleViewPDF}
|
||||
/>
|
||||
|
||||
<!-- PDF Viewer Modal -->
|
||||
{#if pdfViewerOpen}
|
||||
<div class="fixed inset-0 z-50 flex flex-col bg-white dark:bg-neutral-800">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-gray-200 bg-white px-4 py-3 dark:bg-neutral-800">
|
||||
<div class="flex items-center space-x-4">
|
||||
<h2 class="max-w-md truncate text-lg font-semibold text-gray-900 dark:text-gray-300">
|
||||
{viewingPDF?.name}
|
||||
</h2>
|
||||
{#if !isLoading && !error}
|
||||
<span class="text-sm text-gray-500">
|
||||
{$t('course.navItem.lessons.materials.tabs.document.page_of', {
|
||||
page: pageNum,
|
||||
total: pageCount
|
||||
})}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- Navigation Controls -->
|
||||
{#if !isLoading && !error}
|
||||
<div class="flex items-center space-x-1">
|
||||
<IconButton onclick={prevPage} disabled={pageNum <= 1} tooltip="Previous page (←)">
|
||||
<ChevronLeftIcon size={16} />
|
||||
</IconButton>
|
||||
|
||||
<IconButton onclick={nextPage} disabled={pageNum >= pageCount} tooltip="Next page (→)">
|
||||
<ChevronRightIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<!-- Zoom Controls -->
|
||||
<div class="flex items-center space-x-1">
|
||||
<IconButton onclick={zoomOut} disabled={scale <= 0.5} tooltip="Zoom out (-)">
|
||||
<ZoomOutIcon size={16} />
|
||||
</IconButton>
|
||||
|
||||
<span class="min-w-[3rem] text-center text-sm text-gray-600">
|
||||
{Math.round(scale * 100)}%
|
||||
</span>
|
||||
|
||||
<IconButton onclick={zoomIn} disabled={scale >= 3.0} tooltip="Zoom in (+)">
|
||||
<ZoomInIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<CloseButton onClick={closePDFViewer} toolTipProps={{ title: 'Close (Esc)', hotkeys: ['Esc'] }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PDF Content -->
|
||||
<div class="flex-1 overflow-auto bg-gray-100 p-4">
|
||||
{#if isLoading}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent"
|
||||
></div>
|
||||
<p class="text-gray-600">
|
||||
{$t('course.navItem.lessons.materials.tabs.document.loading_pdf')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="mb-4 text-red-500">
|
||||
<svg class="mx-auto h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="mb-2 text-red-600">{$t(error)}</p>
|
||||
<button onclick={() => viewPDF(viewingPDF)} class="text-blue-600 underline hover:text-blue-800">
|
||||
{$t('course.navItem.lessons.materials.tabs.document.try_again')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex justify-center">
|
||||
<canvas
|
||||
bind:this={pdfCanvas}
|
||||
oncontextmenu={handleContextMenu}
|
||||
ondragstart={handleDragStart}
|
||||
class="shadow-lg"
|
||||
></canvas>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer with instructions -->
|
||||
{#if !isLoading && !error}
|
||||
<div class="border-t border-gray-200 bg-gray-50 px-4 py-2">
|
||||
<p class="text-center text-xs text-gray-500">
|
||||
{$t('course.navItem.lessons.materials.tabs.document.use_arrow_keys')}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Disable text selection on the canvas */
|
||||
canvas {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
/* Disable drag and drop */
|
||||
canvas {
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
-35
@@ -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}
|
||||
-34
@@ -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}
|
||||
-84
@@ -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,59 +0,0 @@
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import type { Component } from 'svelte';
|
||||
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
||||
import PresentationIcon from '@lucide/svelte/icons/presentation';
|
||||
import type { Tabs } from '$lib/utils/types';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
|
||||
interface MaterialTab {
|
||||
label: string;
|
||||
icon: Component;
|
||||
value: number;
|
||||
badgeValue?: number;
|
||||
}
|
||||
|
||||
export const tabs: MaterialTab[] = [
|
||||
{
|
||||
label: 'course.navItem.lessons.materials.tabs.note.title',
|
||||
icon: BookOpenIcon,
|
||||
value: 1,
|
||||
badgeValue: 0
|
||||
},
|
||||
{
|
||||
label: 'course.navItem.lessons.materials.tabs.slide.title',
|
||||
icon: PresentationIcon,
|
||||
value: 2,
|
||||
badgeValue: 0
|
||||
},
|
||||
{
|
||||
label: 'course.navItem.lessons.materials.tabs.video.title',
|
||||
icon: VideoIcon,
|
||||
value: 3,
|
||||
badgeValue: 0
|
||||
},
|
||||
{
|
||||
label: 'course.navItem.lessons.materials.tabs.document.title',
|
||||
icon: FileTextIcon,
|
||||
value: 4,
|
||||
badgeValue: 0
|
||||
}
|
||||
];
|
||||
|
||||
export function orderedTabs(tabs: MaterialTab[], settingTabs?: Tabs[]) {
|
||||
if (!Array.isArray(settingTabs)) return tabs;
|
||||
|
||||
const reorderedTabs: MaterialTab[] = [];
|
||||
|
||||
const tabMap = new Map(tabs.map((tab) => [tab.value, tab]));
|
||||
|
||||
settingTabs.forEach((settingTab, index) => {
|
||||
const tab = tabMap.get(settingTab.id);
|
||||
if (tab) {
|
||||
reorderedTabs[index] = tab;
|
||||
}
|
||||
});
|
||||
|
||||
const filteredReorderedTabs = reorderedTabs.filter((tab) => tab !== undefined);
|
||||
|
||||
return filteredReorderedTabs;
|
||||
}
|
||||
@@ -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,241 +0,0 @@
|
||||
import { QUESTION_TEMPLATE, QUESTION_TYPE, QUESTION_TYPES } from '$lib/components/Question/constants';
|
||||
|
||||
import type { Question } from '$lib/components/Course/types';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { isUUID } from '$lib/utils/functions/isUUID';
|
||||
import { questionnaireMetaData } from './answers';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const isQuestionnaireFetching = writable(false);
|
||||
export const deleteConfirmation = writable({ open: false });
|
||||
export const questionnaireOrder = writable({ open: false });
|
||||
|
||||
// {'question-id': { option: '', title: '', ... }}
|
||||
export const questionnaireValidation = writable({});
|
||||
|
||||
export const questionnaire: Writable<{
|
||||
title?: string;
|
||||
due_by?: string;
|
||||
is_due_by_dirty?: boolean;
|
||||
is_title_dirty?: boolean;
|
||||
description?: string;
|
||||
is_description_dirty?: boolean;
|
||||
questions: Question[];
|
||||
totalSubmissions: number;
|
||||
}> = writable({
|
||||
title: '',
|
||||
due_by: '',
|
||||
is_due_by_dirty: false,
|
||||
is_title_dirty: false,
|
||||
description: '',
|
||||
is_description_dirty: false,
|
||||
questions: [],
|
||||
totalSubmissions: 0
|
||||
});
|
||||
|
||||
export function reset() {
|
||||
questionnaireMetaData.update((metaData) => {
|
||||
metaData.answers = {};
|
||||
metaData.currentQuestionIndex = 0;
|
||||
metaData.isFinished = false;
|
||||
return metaData;
|
||||
});
|
||||
}
|
||||
|
||||
export function validateQuestionnaire(questions) {
|
||||
const errors = {};
|
||||
|
||||
for (const question of questions) {
|
||||
if (question.question_type.id === QUESTION_TYPE.TEXTAREA || question.deleted_at) {
|
||||
continue;
|
||||
}
|
||||
const qErrors = errors[question.id] || {};
|
||||
|
||||
if (question.question_type.id !== QUESTION_TYPE.TEXTAREA) {
|
||||
const hasEmptyOptionLabel = question.options
|
||||
.filter((option) => !option.deleted_at)
|
||||
.some((option) => option.label.trim() === '');
|
||||
|
||||
if (hasEmptyOptionLabel) {
|
||||
qErrors.option = 'Please enter a label for all options';
|
||||
errors[question.id] = { ...qErrors };
|
||||
}
|
||||
}
|
||||
|
||||
const hasAnswer = question.options.filter((o) => !o.deleted_at).some((option) => option.is_correct);
|
||||
|
||||
if (!hasAnswer) {
|
||||
qErrors.option = 'Please mark an option as the correct answer';
|
||||
errors[question.id] = { ...qErrors };
|
||||
}
|
||||
}
|
||||
|
||||
questionnaireValidation.set(errors);
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function handleAddQuestion() {
|
||||
questionnaire.update((q) => {
|
||||
const { questions } = q;
|
||||
return {
|
||||
...q,
|
||||
questions: [
|
||||
...questions,
|
||||
{
|
||||
...QUESTION_TEMPLATE,
|
||||
id: `${questions.length + 1}-form`,
|
||||
name: `${questions.length + 1}-form`,
|
||||
value: '',
|
||||
points: 0,
|
||||
order: questions.length,
|
||||
question_type: QUESTION_TYPES[0],
|
||||
question_type_id: QUESTION_TYPES[0].id,
|
||||
options: [
|
||||
{
|
||||
id: '1-form',
|
||||
label: '',
|
||||
value: null,
|
||||
is_correct: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function handleAddOption(questionId) {
|
||||
return () => {
|
||||
questionnaire.update((_questionnaire) => {
|
||||
const { questions } = _questionnaire;
|
||||
return {
|
||||
..._questionnaire,
|
||||
questions: questions.map((question) => {
|
||||
if (question.id === questionId) {
|
||||
return {
|
||||
...question,
|
||||
options: [
|
||||
...question.options,
|
||||
{
|
||||
id: `${new Date().getTime()}-form`,
|
||||
label: '',
|
||||
value: '',
|
||||
is_correct: false
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
return question;
|
||||
})
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function handleRemoveOption(questionId, optionId) {
|
||||
return () => {
|
||||
questionnaire.update((q) => {
|
||||
const questionIndex = q.questions.findIndex((qItem) => qItem.id === questionId);
|
||||
if (questionIndex === -1) return q;
|
||||
|
||||
const optionsIndex = q.questions[questionIndex].options.findIndex((oItem) => oItem.id === optionId);
|
||||
if (optionsIndex === -1) return q;
|
||||
|
||||
q.questions[questionIndex].options[optionsIndex].deleted_at = new Date().toString();
|
||||
q.questions[questionIndex].options[optionsIndex].is_dirty = true;
|
||||
q.questions[questionIndex].is_dirty = true; // Mark as dirty if needed
|
||||
|
||||
return q;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function handleRemoveQuestion(questionId) {
|
||||
return () => {
|
||||
questionnaire.update((q) => {
|
||||
const questionIndex = q.questions.findIndex((qItem) => qItem.id === questionId);
|
||||
if (questionIndex === -1) return q;
|
||||
|
||||
q.questions[questionIndex].deleted_at = new Date().toString();
|
||||
|
||||
return q;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function handleCode(questionId, shouldAdd = true) {
|
||||
questionnaire.update((q) => {
|
||||
const questionIndex = q.questions.findIndex((qItem) => qItem.id === questionId);
|
||||
if (questionIndex === -1) return q;
|
||||
|
||||
const question = q.questions[questionIndex];
|
||||
q.questions[questionIndex].code = shouldAdd ? question.code || '' : undefined;
|
||||
|
||||
return q;
|
||||
});
|
||||
}
|
||||
|
||||
export function handleAnswerSelect(questionId, optionId) {
|
||||
return () => {
|
||||
questionnaire.update((q) => {
|
||||
const { questions } = q;
|
||||
|
||||
return {
|
||||
...q,
|
||||
questions: questions.map((question) => {
|
||||
if (question.id === questionId) {
|
||||
return {
|
||||
...question,
|
||||
options: question.options.map((option) => {
|
||||
if (option.id === optionId) {
|
||||
option.is_correct = !option.is_correct;
|
||||
option.is_dirty = true;
|
||||
}
|
||||
return option;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
return question;
|
||||
})
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function addDynamicValue(questionId, optionId) {
|
||||
return (e) => {
|
||||
questionnaire.update((q) => {
|
||||
const { questions } = q;
|
||||
|
||||
return {
|
||||
...q,
|
||||
questions: questions.map((question) => {
|
||||
if (question.id === questionId) {
|
||||
return {
|
||||
...question,
|
||||
options: question.options.map((option) => {
|
||||
if (option.id === optionId) {
|
||||
const label = e.target.value;
|
||||
|
||||
if (!isUUID(option.value || '')) {
|
||||
option.value = label.split(' ').join('-');
|
||||
}
|
||||
|
||||
option.label = label;
|
||||
option.is_dirty = true;
|
||||
}
|
||||
|
||||
return option;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
return question;
|
||||
})
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
interface Props {
|
||||
openDeleteModal?: boolean;
|
||||
deleteFeed?: any;
|
||||
}
|
||||
|
||||
let { openDeleteModal = $bindable(false), deleteFeed = () => {} }: Props = $props();
|
||||
|
||||
async function handleDelete() {
|
||||
deleteFeed();
|
||||
openDeleteModal = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open={openDeleteModal}>
|
||||
<Dialog.Content class="w-96">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Delete Feed</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
<h1 class="text-lg dark:text-white">Are you sure you want to delete this feed?</h1>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={() => (openDeleteModal = false)}>No</Button>
|
||||
<Button variant="outline" onclick={handleDelete}>Yes</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -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,12 +0,0 @@
|
||||
<script>
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
</script>
|
||||
|
||||
<div class="mb-7 flex w-full max-w-3xl flex-col gap-3 rounded-md border border-gray-300 p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Skeleton class="h-8 w-8 overflow-hidden rounded-full" />
|
||||
<Skeleton class="h-4 w-[30%]" />
|
||||
</div>
|
||||
<Skeleton class="h-4 w-full" />
|
||||
<Skeleton class="h-4 w-full" />
|
||||
</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,35 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { deleteMemberModal } from './store';
|
||||
|
||||
let { email, deletePerson = () => {} } = $props();
|
||||
|
||||
async function handleDelete() {
|
||||
deletePerson();
|
||||
$deleteMemberModal.open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open={$deleteMemberModal.open}>
|
||||
<Dialog.Content class="w-96 pt-3">
|
||||
<Dialog.Header class="px-5 py-2">
|
||||
<Dialog.Title>{$t('course.navItem.people.delete_confirmation.title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
<p class="mt-0 text-base dark:text-white">
|
||||
{$t('course.navItem.people.delete_confirmation.sure')} <strong>{email}</strong>?
|
||||
</p>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={() => ($deleteMemberModal.open = false)}>
|
||||
{$t('course.navItem.people.delete_confirmation.no')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={handleDelete}>
|
||||
{$t('course.navItem.people.delete_confirmation.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -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 +0,0 @@
|
||||
export { default as CourseContainer } from './index.svelte';
|
||||
@@ -1,11 +0,0 @@
|
||||
<script lang="ts">
|
||||
let { children, className = '', id = '', disableMaxWidth = false } = $props();
|
||||
</script>
|
||||
|
||||
<article
|
||||
style:max-width={disableMaxWidth ? 'unset' : undefined}
|
||||
{id}
|
||||
class="preview prose sm:prose-sm {className} dark:text-white"
|
||||
>
|
||||
{@render children()}
|
||||
</article>
|
||||
@@ -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,36 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
|
||||
interface Props {
|
||||
gradeMax?: number;
|
||||
disableGrading?: boolean;
|
||||
grade?: number | undefined;
|
||||
}
|
||||
|
||||
let { gradeMax = 0, disableGrading = false, grade = $bindable(0) }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (grade && grade > gradeMax) {
|
||||
snackbar.error('grade cant be more than max value');
|
||||
grade = gradeMax;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex items-center font-semibold">
|
||||
<InputField
|
||||
placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.points')}
|
||||
bind:value={grade}
|
||||
max={gradeMax}
|
||||
type="number"
|
||||
className="w-12"
|
||||
inputClassName="h-10"
|
||||
isDisabled={disableGrading}
|
||||
/>
|
||||
|
||||
<p class="ml-2 flex items-center text-base font-semibold dark:text-white">
|
||||
<span class="mr-1">/</span> <span>{gradeMax}</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -1,13 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
index: number | string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
let { index, title }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span class="flex gap-2">
|
||||
<p class="text-md font-medium">{index}</p>
|
||||
<p class="text-md font-medium">{title}</p>
|
||||
</span>
|
||||
@@ -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,50 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
reason?: string;
|
||||
isLoading?: boolean;
|
||||
rejectGrade?: any;
|
||||
acceptGrade?: any;
|
||||
}
|
||||
|
||||
let { reason = '', isLoading = false, rejectGrade = () => {}, acceptGrade = () => {} }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border">
|
||||
{#if isLoading}
|
||||
<div class="flex w-full px-2 py-4">
|
||||
<div class="flex w-full flex-row items-start space-x-4">
|
||||
<img src="/ai.svg" alt="alt" class="animate-pulse" />
|
||||
<div class="w-full">
|
||||
<Skeleton class="h-4 w-full" />
|
||||
<Skeleton class="h-4 w-full" />
|
||||
<Skeleton class="h-4 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<Skeleton class="h-4 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-start justify-between px-2 py-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<img src="/ai.svg" alt="alt" />
|
||||
<p class="text-sm font-normal">
|
||||
{reason}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<Button onclick={acceptGrade}>
|
||||
{$t('course.navItem.submissions.grading_modal.accept_grade')}
|
||||
</Button>
|
||||
<Button variant="destructive" onclick={rejectGrade}>
|
||||
{$t('course.navItem.submissions.grading_modal.reject_grade')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -1,92 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
isTitle?: boolean;
|
||||
onClose?: any;
|
||||
scrollToQuestion?: boolean;
|
||||
points?: any;
|
||||
hasError?: boolean;
|
||||
onPointsChange?: any;
|
||||
key?: string;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
isTitle = false,
|
||||
onClose = () => {},
|
||||
scrollToQuestion = false,
|
||||
points = $bindable(undefined),
|
||||
hasError = false,
|
||||
onPointsChange = () => {},
|
||||
key,
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
let ref: HTMLDivElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
if (ref && scrollToQuestion) {
|
||||
ref.scrollIntoView({
|
||||
block: 'start',
|
||||
behavior: 'smooth',
|
||||
inline: 'nearest'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
console.log('container key', key);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
class="border-2 bg-white dark:bg-black {hasError
|
||||
? 'border-red-700'
|
||||
: 'border-gray hover:border-primary-700'} root relative mb-6 rounded-md"
|
||||
>
|
||||
{#if isTitle}
|
||||
<div class="title bg-primary-700 absolute"></div>
|
||||
{/if}
|
||||
<div class="px-4 {isTitle ? 'pt-4' : 'pt-2'} pb-3">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
{#if typeof points !== 'undefined'}
|
||||
<div class="border-gray flex items-center justify-between border-b-0 border-l-0 border-r-0 border-t-2 p-2">
|
||||
<div class="flex w-40 items-center">
|
||||
<p class="mr-2 text-sm dark:text-white">
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.points')}:
|
||||
</p>
|
||||
<InputField
|
||||
placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.points')}
|
||||
bind:value={points}
|
||||
type="number"
|
||||
onchange={onPointsChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if onClose && !isTitle}
|
||||
<IconButton onclick={onClose}>
|
||||
<TrashIcon size={16} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.title {
|
||||
color: rgba(255, 255, 255, 1);
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
height: 10px;
|
||||
left: -1px;
|
||||
top: -1px;
|
||||
width: calc(100% + 2px);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user