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
@@ -3,16 +3,19 @@ import { currentOrg, isOrgStudent, orgs } from '$lib/utils/store/org';
|
||||
import { defaultProfileState, defaultUserState, profile, user } from '$lib/utils/store/user';
|
||||
|
||||
import type { AccountResponse } from './types';
|
||||
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
|
||||
import type { TUser } from '@cio/db/types';
|
||||
import { get } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { handleLocaleChange } from '$lib/utils/functions/translations';
|
||||
import { identifyPosthogUser } from '$lib/utils/services/posthog';
|
||||
import { licenseApi } from '$features/license/api/license.svelte';
|
||||
import { logout } from '$lib/utils/functions/logout';
|
||||
import { page } from '$app/state';
|
||||
import { resolve } from '$app/paths';
|
||||
import { setSentryUser } from '$lib/utils/services/sentry';
|
||||
import { setTheme } from '$lib/utils/functions/theme';
|
||||
import { setupAnalyticsBasedOnLicense } from '$lib/utils/functions/appSetup';
|
||||
import shouldRedirectOnAuth from '$lib/utils/functions/routes/shouldRedirectOnAuth';
|
||||
|
||||
type AppSetupParams = {
|
||||
@@ -44,6 +47,8 @@ class AppInitApi extends BaseApi {
|
||||
logContext: 'fetching account',
|
||||
onSuccess: (accountData) => {
|
||||
this.data = accountData;
|
||||
licenseApi.setFeatures(accountData.licenseFeatures);
|
||||
setupAnalyticsBasedOnLicense();
|
||||
this.setupStores();
|
||||
this.setUserAnalytics();
|
||||
this.routeUserToNextPage(params);
|
||||
@@ -95,9 +100,7 @@ class AppInitApi extends BaseApi {
|
||||
|
||||
const theme = get(currentOrg)?.theme;
|
||||
|
||||
if (theme) {
|
||||
setTheme(theme);
|
||||
}
|
||||
setTheme(theme || 'blue');
|
||||
}
|
||||
|
||||
routeUserToNextPage({ isOrgSite }: AppSetupParams) {
|
||||
@@ -106,16 +109,6 @@ class AppInitApi extends BaseApi {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOrgSite && window.location.pathname === '/') {
|
||||
return;
|
||||
}
|
||||
|
||||
const userHasOrganizations = this.data.organizations.length > 0;
|
||||
const shouldRedirectToOnboarding = !isOrgSite && !userHasOrganizations;
|
||||
if (shouldRedirectToOnboarding) {
|
||||
return goto(resolve(`/onboarding`, {}));
|
||||
}
|
||||
|
||||
const redirect = page.url.searchParams.get('redirect');
|
||||
if (redirect) {
|
||||
// goto redirect won't accept dynamic url so we need to use window.location.href
|
||||
@@ -123,11 +116,40 @@ class AppInitApi extends BaseApi {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldRedirectOnAuth(page.url.pathname)) return;
|
||||
if (isOrgSite && window.location.pathname === '/') {
|
||||
return;
|
||||
}
|
||||
|
||||
const isStudent = get(isOrgStudent);
|
||||
|
||||
return isOrgSite || isStudent ? this.goToLMS() : this.goToOrg();
|
||||
const userHasOrganizations = this.data.organizations.length > 0;
|
||||
|
||||
const isCloud = PUBLIC_IS_SELFHOSTED !== 'true';
|
||||
|
||||
// CLOUD: when user has no orgs and isOrgSite is false, route to /onboarding
|
||||
// isOrgSite - means the user is on a multi tenant organization site, we don't want to redirect to /onboarding in this case
|
||||
if (isCloud) {
|
||||
const shouldRedirectToOnboarding = !userHasOrganizations && !isOrgSite;
|
||||
|
||||
if (shouldRedirectToOnboarding) {
|
||||
return goto(resolve(`/onboarding`, {}));
|
||||
}
|
||||
|
||||
if (!shouldRedirectOnAuth(page.url.pathname)) return;
|
||||
|
||||
return isOrgSite || isStudent ? this.goToLMS() : this.goToOrg();
|
||||
} else {
|
||||
// Self-hosted: when user has no orgs, route to /onboarding
|
||||
// This should only happen once cause Self hosted instance is single tenant
|
||||
//
|
||||
if (!userHasOrganizations) {
|
||||
return goto(resolve(`/onboarding`, {}));
|
||||
}
|
||||
|
||||
if (!shouldRedirectOnAuth(page.url.pathname)) return;
|
||||
|
||||
return isStudent ? this.goToLMS() : this.goToOrg();
|
||||
}
|
||||
}
|
||||
|
||||
goToLMS() {
|
||||
@@ -161,6 +183,7 @@ class AppInitApi extends BaseApi {
|
||||
reset() {
|
||||
super.reset();
|
||||
this.data = null;
|
||||
licenseApi.reset();
|
||||
|
||||
user.set(defaultUserState);
|
||||
profile.set(defaultProfileState);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import { OrgApiServer } from '$features/org/api/org.server';
|
||||
import { getFirstOrg, getOrgBySiteName, getOrgsByCustomDomain } from '$features/org/api/org.server';
|
||||
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
|
||||
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
|
||||
import { blockedSubdomain } from '$lib/utils/constants/app';
|
||||
import { env } from '$env/dynamic/private';
|
||||
@@ -20,26 +21,17 @@ export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise<OrgSit
|
||||
org: null
|
||||
};
|
||||
|
||||
// Selfhosted usecase
|
||||
// Self-hosted: single org, single domain
|
||||
if (PUBLIC_IS_SELFHOSTED === 'true') {
|
||||
const subdomain = getSubdomain(url);
|
||||
|
||||
// Student dashboard
|
||||
if (subdomain) {
|
||||
const org = await OrgApiServer.getOrgBySiteName(subdomain);
|
||||
|
||||
// Organization by subdomain not found
|
||||
if (!org) {
|
||||
return response;
|
||||
}
|
||||
|
||||
response.org = org as AccountOrg;
|
||||
const apiKeyHeaders = getApiKeyHeaders();
|
||||
const firstOrg = await getFirstOrg(apiKeyHeaders);
|
||||
if (firstOrg) {
|
||||
response.org = firstOrg as AccountOrg;
|
||||
response.isOrgSite = true;
|
||||
response.orgSiteName = subdomain;
|
||||
response.subdomain = subdomain;
|
||||
response.orgSiteName = firstOrg.siteName || '';
|
||||
response.subdomain = '';
|
||||
}
|
||||
|
||||
// Never go beyond this for selfhosted instances
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -59,7 +51,8 @@ export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise<OrgSit
|
||||
|
||||
// Custom domain
|
||||
if (isURLCustomDomain(url)) {
|
||||
const orgs = await OrgApiServer.getOrgsByCustomDomain(url.host, true);
|
||||
const apiKeyHeaders = getApiKeyHeaders();
|
||||
const orgs = await getOrgsByCustomDomain(url.host, true, apiKeyHeaders);
|
||||
|
||||
if (!orgs || orgs.length === 0) {
|
||||
return response;
|
||||
@@ -86,8 +79,9 @@ export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise<OrgSit
|
||||
response.orgSiteName = debugMode ? _orgSiteName : subdomain;
|
||||
|
||||
if (response.orgSiteName) {
|
||||
const org = await OrgApiServer.getOrgBySiteName(response.orgSiteName);
|
||||
response.org = (org as AccountOrg) || null;
|
||||
const apiKeyHeaders = getApiKeyHeaders();
|
||||
const org = await getOrgBySiteName(response.orgSiteName, apiKeyHeaders);
|
||||
response.org = org ?? null;
|
||||
}
|
||||
|
||||
const shouldDeleteCookie = !response.org && _orgSiteName;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import { page as pageStore } from '$app/state';
|
||||
import { page } from '$app/state';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import * as Pagination from '@cio/ui/base/pagination';
|
||||
@@ -9,13 +9,23 @@
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { UpgradeBanner } from '$features/ui';
|
||||
import { currentOrg, currentOrgMaxAudience } from '$lib/utils/store/org';
|
||||
import { currentOrgMaxAudience } from '$lib/utils/store/org';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import type { OrganizationAudience } from '$features/org/utils/types';
|
||||
|
||||
interface Props {
|
||||
audience?: OrganizationAudience;
|
||||
}
|
||||
|
||||
let { audience }: Props = $props();
|
||||
|
||||
// Initialize audience from prop if provided
|
||||
$effect(() => {
|
||||
orgApi.getOrgAudience($currentOrg.id);
|
||||
if (audience) {
|
||||
orgApi.audience = audience;
|
||||
}
|
||||
});
|
||||
|
||||
const headers = [
|
||||
@@ -60,7 +70,7 @@
|
||||
<!-- Table -->
|
||||
{#if orgApi.isLoading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(pageSize) as _}
|
||||
{#each Array(pageSize) as _, i (i)}
|
||||
<Skeleton class="h-12 w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
@@ -69,17 +79,17 @@
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{#each headers as header}
|
||||
{#each headers as header (header)}
|
||||
<Table.Head>{header.value}</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each paginatedRows as row}
|
||||
{#each paginatedRows as row (row.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<a
|
||||
href={`${pageStore.url.href}/${row.id}/${$currentOrg.id}`}
|
||||
href={`${page.url.href}/${row.id}`}
|
||||
class="ui:text-primary flex items-center gap-2 hover:underline"
|
||||
>
|
||||
<Avatar.Root class="h-5 w-5">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import UnfoldVerticalIcon from '@lucide/svelte/icons/unfold-vertical';
|
||||
@@ -12,45 +11,12 @@
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { getAccessToken } from '$lib/utils/functions/supabase';
|
||||
import type { UserAnalytics } from '$lib/utils/types/analytics';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let userAnalytics: UserAnalytics | undefined = $state();
|
||||
|
||||
let courseFilter = $state('all');
|
||||
function toggleCourseFilter(filter: 'completed' | 'incomplete') {
|
||||
if (courseFilter === filter) {
|
||||
courseFilter = 'all';
|
||||
} else {
|
||||
courseFilter = filter;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserAnalytics() {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await fetch('/api/analytics/user', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: accessToken
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(response);
|
||||
snackbar.error('Failed to fetch analytics data');
|
||||
}
|
||||
|
||||
userAnalytics = (await response.json()) as UserAnalytics;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchUserAnalytics();
|
||||
});
|
||||
// Use analytics data from server load function if available
|
||||
const userAnalytics: UserAnalytics | undefined = $derived(data.analytics);
|
||||
|
||||
let userMetrics = $derived([
|
||||
{
|
||||
@@ -81,6 +47,8 @@
|
||||
userAnalytics?.courses?.filter((course) => course.lessons_count !== course.lessons_completed)?.length
|
||||
);
|
||||
|
||||
let courseFilter = $state('all');
|
||||
|
||||
let filteredCourses = $derived(
|
||||
userAnalytics?.courses?.filter((course) => {
|
||||
if (courseFilter === 'all') {
|
||||
@@ -110,7 +78,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 rounded-md border p-3 md:p-5 dark:border-neutral-600">
|
||||
<div class="mt-5 rounded-md border p-3 md:p-5">
|
||||
<h3 class="text-2xl">
|
||||
{$t('analytics.courses')}
|
||||
</h3>
|
||||
@@ -132,7 +100,7 @@
|
||||
<Badge
|
||||
type={courseFilter === 'incomplete' ? 'secondary' : 'outline'}
|
||||
class="text-yellow-700 dark:text-yellow-500"
|
||||
onclick={() => toggleCourseFilter('incomplete')}
|
||||
onclick={() => (courseFilter = 'incomplete')}
|
||||
>
|
||||
{incompleteCourses}
|
||||
{$t('analytics.incomplete')}
|
||||
@@ -140,7 +108,7 @@
|
||||
<Badge
|
||||
type={courseFilter === 'completed' ? 'secondary' : 'outline'}
|
||||
class="text-green-700 dark:text-green-500"
|
||||
onclick={() => toggleCourseFilter('completed')}
|
||||
onclick={() => (courseFilter = 'completed')}
|
||||
>
|
||||
{completedCourses}
|
||||
{$t('analytics.complete')}
|
||||
|
||||
@@ -2,66 +2,68 @@ import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
CommunityQuestionSuccess,
|
||||
CommunityQuestionsSuccess,
|
||||
CreateCommentResponse,
|
||||
CreateCommunityQuestionResponse,
|
||||
UpdateQuestionResponse,
|
||||
UpvotePostResponse
|
||||
CreateCommentData,
|
||||
CreateCommentRequest,
|
||||
CreateCommunityQuestionRequest,
|
||||
UpdateQuestionRequest,
|
||||
UpvotePostRequest
|
||||
} from '../utils/types';
|
||||
import { ZCommunityComment, ZCommunityQuestionUpdate, ZCreateCommunityQuestion } from '@cio/utils/validation/community';
|
||||
import { currentOrgPath, currentOrg as currentOrgStore } from '$lib/utils/store/org';
|
||||
|
||||
import type { Course } from '$lib/utils/types';
|
||||
import type { TCreateCommunityQuestion } from '@cio/utils/validation/community';
|
||||
import { courses as coursesStore } from '$features/course/utils/store';
|
||||
import { coursesApi } from '$features/course/api/courses.svelte';
|
||||
import { currentCommunityQuestion } from '../utils/store';
|
||||
import { fetchCourses } from '$lib/utils/services/courses';
|
||||
import generateSlug from '$lib/utils/functions/generateSlug';
|
||||
import { get } from 'svelte/store';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { goto } from '$app/navigation';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
import { profile as profileStore } from '$lib/utils/store/user';
|
||||
import { resolve } from '$app/paths';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
|
||||
type EditContent = {
|
||||
title: string;
|
||||
body: string;
|
||||
courseId: string;
|
||||
};
|
||||
type DeleteCommentState = {
|
||||
shouldDelete: boolean;
|
||||
commentId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* API class for Organization Community
|
||||
*/
|
||||
class CommunityApi extends BaseApiWithErrors {
|
||||
questions: CommunityQuestionsSuccess['data'] = $state([]);
|
||||
question: CommunityQuestionSuccess['data'] | null = $state(null);
|
||||
courses = $state<Course[]>([]);
|
||||
private coursesFetched = $state(false);
|
||||
courses = $state<{ title: string; id: string }[]>([]);
|
||||
isEditMode = $state(false);
|
||||
isEditing = $state(false);
|
||||
isCommenting = $state(false);
|
||||
editContent = $state<{
|
||||
title: string;
|
||||
body: string;
|
||||
courseId: string;
|
||||
}>({
|
||||
editContent = $state<EditContent>({
|
||||
title: '',
|
||||
body: '',
|
||||
courseId: ''
|
||||
});
|
||||
|
||||
comment = $state('');
|
||||
deleteCommentState = $state<{
|
||||
shouldDelete: boolean;
|
||||
commentId: string;
|
||||
}>({
|
||||
deleteCommentState = $state<DeleteCommentState>({
|
||||
shouldDelete: false,
|
||||
commentId: ''
|
||||
});
|
||||
answer = $state<{
|
||||
id: number;
|
||||
body: string;
|
||||
votes: number;
|
||||
createdAt: string;
|
||||
authorId: number;
|
||||
}>({
|
||||
body: '',
|
||||
votes: 0,
|
||||
createdAt: '',
|
||||
answer = $state<CreateCommentData>({
|
||||
authorId: 0,
|
||||
id: 0
|
||||
body: '',
|
||||
courseId: '',
|
||||
courseTitle: '',
|
||||
createdAt: '',
|
||||
id: 0,
|
||||
slug: '',
|
||||
title: '',
|
||||
votes: 0
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -80,7 +82,7 @@ class CommunityApi extends BaseApiWithErrors {
|
||||
onError: (result) => {
|
||||
console.error('Error loading community questions:', result);
|
||||
const basePath = isLMS ? '/lms' : get(currentOrgPath);
|
||||
goto(basePath);
|
||||
goto(resolve(basePath, {}));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -105,7 +107,7 @@ class CommunityApi extends BaseApiWithErrors {
|
||||
onError: (result) => {
|
||||
console.error(`[${isLMS ? 'LMS' : 'ORG'}] Error loading community`, result);
|
||||
const basePath = isLMS ? '/lms' : get(currentOrgPath);
|
||||
goto(basePath);
|
||||
goto(resolve(basePath, {}));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -132,7 +134,7 @@ class CommunityApi extends BaseApiWithErrors {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<CreateCommunityQuestionResponse>({
|
||||
await this.execute<CreateCommunityQuestionRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.community.$post({
|
||||
json: {
|
||||
@@ -155,7 +157,7 @@ class CommunityApi extends BaseApiWithErrors {
|
||||
|
||||
// Navigate to the question based on context
|
||||
const basePath = isLMS ? '/lms' : get(currentOrgPath);
|
||||
goto(`${basePath}/community/${questionSlug}`);
|
||||
goto(resolve(`${basePath}/community/${questionSlug}`, {}));
|
||||
|
||||
// Mark as successful
|
||||
this.success = true;
|
||||
@@ -198,11 +200,10 @@ class CommunityApi extends BaseApiWithErrors {
|
||||
}
|
||||
|
||||
this.isCommenting = true;
|
||||
await this.execute<CreateCommentResponse>({
|
||||
await this.execute<CreateCommentRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.community[':id'].comment.$post({
|
||||
param: { id: String(questionId) },
|
||||
// @ts-expect-error - the json type is not inferred correctly
|
||||
json: {
|
||||
body: result.data.body,
|
||||
authorProfileId: result.data.authorProfileId,
|
||||
@@ -249,11 +250,10 @@ class CommunityApi extends BaseApiWithErrors {
|
||||
* @param isQuestion Boolean indicating if it's a question
|
||||
*/
|
||||
async upvotePost({ id, isQuestion }: { id: number | string; isQuestion: boolean }) {
|
||||
await this.execute<UpvotePostResponse>({
|
||||
await this.execute<UpvotePostRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.community[':id'].upvote.$post({
|
||||
param: { id: id.toString() },
|
||||
// @ts-expect-error - the json type is not inferred correctly
|
||||
json: { isQuestion }
|
||||
}),
|
||||
logContext: 'upvoting post',
|
||||
@@ -300,11 +300,10 @@ class CommunityApi extends BaseApiWithErrors {
|
||||
}
|
||||
|
||||
this.isEditing = true;
|
||||
await this.execute<UpdateQuestionResponse>({
|
||||
await this.execute<UpdateQuestionRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.community[':id'].$put({
|
||||
param: { id: String(questionId) },
|
||||
// @ts-expect-error - the json type is not inferred correctly
|
||||
json: {
|
||||
title: result.data.title,
|
||||
body: result.data.body,
|
||||
@@ -345,11 +344,11 @@ class CommunityApi extends BaseApiWithErrors {
|
||||
* Enters edit mode and initializes edit content with question data
|
||||
* @param question Question data to populate edit form
|
||||
*/
|
||||
enterEditMode(question: { title: string; body: string; courseId?: string }) {
|
||||
enterEditMode(question: { title: string | null; body: string | null; courseId?: string }) {
|
||||
this.isEditMode = true;
|
||||
this.editContent = {
|
||||
title: question.title,
|
||||
body: question.body,
|
||||
title: question.title || '',
|
||||
body: question.body || '',
|
||||
courseId: question.courseId || ''
|
||||
};
|
||||
}
|
||||
@@ -382,7 +381,7 @@ class CommunityApi extends BaseApiWithErrors {
|
||||
|
||||
// Navigate back to community page based on context
|
||||
const basePath = isLMS ? '/lms' : get(currentOrgPath);
|
||||
goto(`${basePath}/community`);
|
||||
goto(resolve(`${basePath}/community`, {}));
|
||||
|
||||
// Mark as successful
|
||||
this.success = true;
|
||||
@@ -437,21 +436,28 @@ class CommunityApi extends BaseApiWithErrors {
|
||||
* @param orgId Organization ID
|
||||
*/
|
||||
async fetchCoursesForOrg(userId: string | null, orgId: string) {
|
||||
if (this.coursesFetched) return;
|
||||
if (this.courses.length || !userId || !orgId) return;
|
||||
|
||||
// Check store first
|
||||
const courses = get(coursesStore);
|
||||
if (courses.length) {
|
||||
this.courses = [...courses];
|
||||
this.coursesFetched = true;
|
||||
return;
|
||||
}
|
||||
const isLMS = get(globalStore).isOrgSite;
|
||||
|
||||
// Fetch from API
|
||||
const coursesResults = await fetchCourses(userId, orgId);
|
||||
if (coursesResults) {
|
||||
this.courses = coursesResults.allCourses || [];
|
||||
this.coursesFetched = true;
|
||||
if (isLMS) {
|
||||
const response = await coursesApi.getEnrolledCourses();
|
||||
|
||||
if (response?.success && response?.data) {
|
||||
this.courses = response.data.map((course) => ({
|
||||
id: course.id,
|
||||
title: course.title
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
const response = await coursesApi.getOrgCourses();
|
||||
|
||||
if (response?.success && response?.data) {
|
||||
this.courses = response.data.map((course) => ({
|
||||
id: course.id,
|
||||
title: course.title
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import { Vote } from '$features/ui';
|
||||
import { CircleCheckIcon } from '$features/ui/icons';
|
||||
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
|
||||
|
||||
interface CommentAuthor {
|
||||
id: number | string | null;
|
||||
@@ -64,7 +65,7 @@
|
||||
{/if}
|
||||
</header>
|
||||
<article class="prose prose-sm sm:prose p-2">
|
||||
{@html comment.body}
|
||||
{@html sanitizeHtml(comment.body ?? '')}
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
</script>
|
||||
|
||||
<div class="relative flex h-[70px] w-full rounded-md border p-2 transition ease-in-out dark:border-neutral-600">
|
||||
<div class="relative flex h-[70px] w-full rounded-md border p-2 transition ease-in-out">
|
||||
<div class="mr-2 flex w-10 flex-col justify-evenly gap-2 rounded-md px-1">
|
||||
<Skeleton class="h-2/6 w-full rounded-sm" />
|
||||
<Skeleton class="h-2/6 w-full rounded-sm" />
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
onDelete?: any;
|
||||
onCancel?: any;
|
||||
onDelete?: () => void;
|
||||
onCancel?: () => void;
|
||||
open?: boolean;
|
||||
isQuestion?: boolean;
|
||||
isDeleting?: boolean;
|
||||
|
||||
@@ -20,13 +20,17 @@
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import { TextEditor } from '$features/ui';
|
||||
import { CommunityDeleteModal, CommunityCommentItem } from '$features/community/components';
|
||||
import type { CommunityQuestionSuccess } from '../utils/types';
|
||||
import { currentCommunityQuestion } from '../utils/store';
|
||||
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
|
||||
|
||||
interface Props {
|
||||
slug: string;
|
||||
isLMS?: boolean;
|
||||
question?: CommunityQuestionSuccess['data'] | null;
|
||||
}
|
||||
|
||||
let { slug, isLMS = false }: Props = $props();
|
||||
let { isLMS = false, question }: Props = $props();
|
||||
|
||||
let commentEditor: TiptapEditor | undefined = $state();
|
||||
let isValidAnswer = false; // V2 allow admin mark an answer as accepted
|
||||
@@ -41,13 +45,19 @@
|
||||
questionId: ''
|
||||
});
|
||||
|
||||
// Initialize question from prop if provided
|
||||
$effect(() => {
|
||||
if ($profile.id && $currentOrg.id) {
|
||||
communityApi.fetchCoursesForOrg($profile.id, $currentOrg.id);
|
||||
|
||||
communityApi.fetchCommunityQuestion({ slug, isLMS });
|
||||
if (question) {
|
||||
communityApi.question = question;
|
||||
currentCommunityQuestion.set({ title: question.title });
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!$profile.id || !$currentOrg.id) return;
|
||||
|
||||
communityApi.fetchCoursesForOrg($profile.id, $currentOrg.id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -88,7 +98,7 @@
|
||||
<Skeleton class="h-80 w-full" />
|
||||
</div>
|
||||
{:else}
|
||||
<Page.Root class="mx-auto max-w-3xl md:mx-10 lg:mb-20">
|
||||
<Page.Root class="mx-auto w-[90%] md:mx-10 md:max-w-3xl lg:mb-20">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
{#if communityApi.isEditMode}
|
||||
@@ -101,7 +111,7 @@
|
||||
/>
|
||||
|
||||
<Select.Root type="single" bind:value={communityApi.editContent.courseId} disabled={communityApi.isEditing}>
|
||||
<Select.Trigger class="w-30! h-full truncate" disabled={communityApi.isEditing}>
|
||||
<Select.Trigger class="h-full w-30! truncate" disabled={communityApi.isEditing}>
|
||||
<span class="truncate">
|
||||
{communityApi.editContent.courseId
|
||||
? communityApi.courses.find((course) => course.id === communityApi.editContent.courseId)?.title
|
||||
@@ -120,7 +130,7 @@
|
||||
{:else}
|
||||
<div class="flex items-center gap-2">
|
||||
<Vote
|
||||
value={communityApi.question.votes}
|
||||
value={communityApi.question.votes ?? 0}
|
||||
upVote={() => {
|
||||
if (voted.question || !communityApi.question) return;
|
||||
voted.question = true;
|
||||
@@ -204,7 +214,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<section class="prose prose-sm sm:prose p-2">
|
||||
{@html communityApi.question?.body}
|
||||
{@html sanitizeHtml(communityApi.question?.body ?? '')}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -245,7 +255,8 @@
|
||||
<TextEditor
|
||||
placeholder="Give an answer"
|
||||
class="h-48!"
|
||||
bind:content={communityApi.comment}
|
||||
content={communityApi.comment}
|
||||
onChange={(content) => (communityApi.comment = content)}
|
||||
onReady={(editor) => (commentEditor = editor)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
@@ -20,24 +19,25 @@
|
||||
|
||||
interface Props {
|
||||
isLMS?: boolean;
|
||||
questions?: CommunityQuestionData;
|
||||
}
|
||||
|
||||
let { isLMS = false }: Props = $props();
|
||||
let { isLMS = false, questions }: Props = $props();
|
||||
|
||||
let searchValue = $state('');
|
||||
let selectedId = $state('');
|
||||
|
||||
function fetchCommunityQuestions(orgId?: string, profileId?: string) {
|
||||
if (!orgId || !profileId) return;
|
||||
|
||||
untrack(async () => {
|
||||
await communityApi.fetchCoursesForOrg(profileId, orgId);
|
||||
await communityApi.fetchCommunityQuestions({ orgId, isLMS });
|
||||
});
|
||||
}
|
||||
// Initialize questions from prop if provided
|
||||
$effect(() => {
|
||||
if (questions) {
|
||||
communityApi.questions = questions;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
fetchCommunityQuestions($currentOrg.id, $profile.id);
|
||||
if (!$profile.id || !$currentOrg.id) return;
|
||||
|
||||
communityApi.fetchCoursesForOrg($profile.id, $currentOrg.id);
|
||||
});
|
||||
|
||||
let filteredQuestions = $derived(
|
||||
@@ -87,7 +87,7 @@
|
||||
{question.title}
|
||||
</Item.Title>
|
||||
<Item.Description>
|
||||
{question?.authorFullname} asked {calDateDiff(question?.createdAt)}
|
||||
{question?.authorFullname} asked {calDateDiff(question.createdAt || new Date())}
|
||||
</Item.Description>
|
||||
<a class="m-0" href="/courses/{question.courseId}" onclick={(e) => e.stopPropagation()}>
|
||||
<span class="text-muted-foreground p-0 text-xs">
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
|
||||
export type GetCommunityQuestionsResponse = typeof classroomio.community.$get;
|
||||
export type CommunityQuestionsResponse = InferResponseType<GetCommunityQuestionsResponse> | null;
|
||||
export type CommunityQuestionsSuccess = Extract<InferResponseType<GetCommunityQuestionsResponse>, { success: true }>;
|
||||
export type GetCommunityQuestionsRequest = typeof classroomio.community.$get;
|
||||
export type CommunityQuestionsResponse = InferResponseType<GetCommunityQuestionsRequest> | null;
|
||||
export type CommunityQuestionsSuccess = Extract<InferResponseType<GetCommunityQuestionsRequest>, { success: true }>;
|
||||
export type CommunityQuestionData = CommunityQuestionsSuccess['data'];
|
||||
|
||||
export type GetCommunityQuestionResponse = (typeof classroomio.community)[':slug']['$get'];
|
||||
export type CommunityQuestionResponse = InferResponseType<GetCommunityQuestionResponse> | null;
|
||||
export type CommunityQuestionSuccess = Extract<InferResponseType<GetCommunityQuestionResponse>, { success: true }>;
|
||||
export type GetCommunityQuestionRequest = (typeof classroomio.community)[':slug']['$get'];
|
||||
export type CommunityQuestionResponse = InferResponseType<GetCommunityQuestionRequest> | null;
|
||||
export type CommunityQuestionSuccess = Extract<InferResponseType<GetCommunityQuestionRequest>, { success: true }>;
|
||||
|
||||
export type CreateCommentResponse = typeof classroomio.community.$post;
|
||||
export type CreateCommentSuccess = Extract<InferResponseType<CreateCommentResponse>, { success: true }>;
|
||||
export type CreateCommentRequest = typeof classroomio.community.$post;
|
||||
export type CreateCommentSuccess = Extract<InferResponseType<CreateCommentRequest>, { success: true }>;
|
||||
export type CreateCommentData = CreateCommentSuccess['data'];
|
||||
|
||||
export type UpvotePostResponse = (typeof classroomio.community)[':id']['upvote']['$post'];
|
||||
export type UpvotePostSuccess = Extract<InferResponseType<UpvotePostResponse>, { success: true }>;
|
||||
export type UpvotePostRequest = (typeof classroomio.community)[':id']['upvote']['$post'];
|
||||
export type UpvotePostSuccess = Extract<InferResponseType<UpvotePostRequest>, { success: true }>;
|
||||
export type UpvotePostData = UpvotePostSuccess['data'];
|
||||
|
||||
export type CreateCommunityQuestionResponse = typeof classroomio.community.$post;
|
||||
export type CreateCommunityQuestionRequest = typeof classroomio.community.$post;
|
||||
export type CreateCommunityQuestionSuccess = Extract<
|
||||
InferResponseType<CreateCommunityQuestionResponse>,
|
||||
InferResponseType<CreateCommunityQuestionRequest>,
|
||||
{ success: true }
|
||||
>;
|
||||
export type UpdateQuestionResponse = (typeof classroomio.community)[':id']['$put'];
|
||||
export type UpdateQuestionSuccess = Extract<InferResponseType<UpdateQuestionResponse>, { success: true }>;
|
||||
export type UpdateQuestionRequest = (typeof classroomio.community)[':id']['$put'];
|
||||
export type UpdateQuestionSuccess = Extract<InferResponseType<UpdateQuestionRequest>, { success: true }>;
|
||||
export type UpdateQuestionData = UpdateQuestionSuccess['data'];
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
|
||||
import type { InferResponseType } from '@cio/api/rpc-types';
|
||||
import type { TAttendanceUpsert } from '@cio/utils/validation/attendance';
|
||||
import type { UpsertAttendanceRequest } from '../utils/types';
|
||||
import { ZAttendanceUpsert } from '@cio/utils/validation/attendance';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
|
||||
type UpsertAttendanceResponse = Extract<InferResponseType<UpsertAttendanceRequest>, { success: true }>;
|
||||
|
||||
/**
|
||||
* API class for attendance operations
|
||||
*/
|
||||
export class AttendanceApi extends BaseApiWithErrors {
|
||||
/**
|
||||
* Creates or updates an attendance record
|
||||
*/
|
||||
async upsert(fields: TAttendanceUpsert): Promise<UpsertAttendanceResponse | undefined> {
|
||||
const result = ZAttendanceUpsert.safeParse(fields);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'attendance');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return await this.execute<UpsertAttendanceRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].attendance.$post({
|
||||
param: { courseId: fields.courseId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'upserting attendance',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
snackbar.success('Attendance recorded successfully');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to record attendance');
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
snackbar.error(result.error);
|
||||
} else if ('error' in result) {
|
||||
this.errors.general = result.error;
|
||||
snackbar.error(result.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an attendance record
|
||||
* NOTE: Use upsert() instead - no separate update route exists
|
||||
*/
|
||||
async update(fields: TAttendanceUpsert) {
|
||||
// Use upsert since there's no separate update route
|
||||
return this.upsert(fields);
|
||||
}
|
||||
}
|
||||
|
||||
export const attendanceApi = new AttendanceApi();
|
||||
@@ -0,0 +1,64 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import type { DeleteCourseContentRequest, UpdateCourseContentRequest } from '../utils/types';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
|
||||
export class ContentApi extends BaseApiWithErrors {
|
||||
async updateContent(
|
||||
courseId: string,
|
||||
items: Array<{
|
||||
id: string;
|
||||
type: ContentType.Lesson | ContentType.Exercise;
|
||||
isUnlocked?: boolean;
|
||||
order?: number;
|
||||
sectionId?: string | null;
|
||||
}>
|
||||
) {
|
||||
const result = await this.execute<UpdateCourseContentRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].content.$put({
|
||||
param: { courseId },
|
||||
json: { items }
|
||||
}),
|
||||
logContext: 'updating course content',
|
||||
onSuccess: () => {
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (error) => {
|
||||
if (typeof error === 'string') {
|
||||
snackbar.error('Failed to update content');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Boolean(result?.success);
|
||||
}
|
||||
|
||||
async deleteContent(
|
||||
courseId: string,
|
||||
payload: { sectionId?: string; items?: Array<{ id: string; type: ContentType.Lesson | ContentType.Exercise }> }
|
||||
) {
|
||||
const result = await this.execute<DeleteCourseContentRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].content.$delete({
|
||||
param: { courseId },
|
||||
json: payload
|
||||
}),
|
||||
logContext: 'deleting course content',
|
||||
onSuccess: () => {
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (error) => {
|
||||
if (typeof error === 'string') {
|
||||
snackbar.error('Failed to delete content');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Boolean(result?.success);
|
||||
}
|
||||
}
|
||||
|
||||
export const contentApi = new ContentApi();
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import { copyCourseModal, copyCourseModalInitialState } from '../utils/store';
|
||||
|
||||
import type { CloneCourseRequest } from '../utils/types';
|
||||
import type { TCourseClone } from '@cio/utils/validation/course';
|
||||
import { ZCourseClone } from '@cio/utils/validation/course';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
@@ -42,11 +43,10 @@ export class CourseCloneApi extends BaseApiWithErrors {
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.execute<(typeof classroomio.course)[':courseId']['clone']['$post']>({
|
||||
await this.execute<CloneCourseRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].clone.$post({
|
||||
param: { courseId },
|
||||
// @ts-expect-error - the json type is not inferred correctly
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'cloning course',
|
||||
|
||||
@@ -0,0 +1,701 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
|
||||
import type {
|
||||
Course,
|
||||
CourseAnalytics,
|
||||
CreateCourseRequest,
|
||||
CreatePaymentRequestRequest,
|
||||
DeleteCourseRequest,
|
||||
EnrollCourseRequest,
|
||||
GetCourseAnalyticsRequest,
|
||||
GetCourseBySlugRequest,
|
||||
GetCourseProgressRequest,
|
||||
GetCourseRequest,
|
||||
UpdateCourseData,
|
||||
UpdateCourseRequest
|
||||
} from '../utils/types';
|
||||
import type { TCourseType } from '@cio/db/types';
|
||||
import { ZCourseCreate, ZCourseUpdate, type TCourseUpdate } from '@cio/utils/validation/course/course';
|
||||
import { capturePosthogEvent } from '$lib/utils/services/posthog';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { get } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { resolve } from '$app/paths';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import type { CourseMembers } from '../utils/types';
|
||||
|
||||
type GroupStore = {
|
||||
id?: string;
|
||||
tutors: CourseMembers;
|
||||
students: CourseMembers;
|
||||
people: CourseMembers;
|
||||
members?: CourseMembers;
|
||||
memberId?: string;
|
||||
};
|
||||
|
||||
type CourseContentItem = NonNullable<Course['content']>['items'][number];
|
||||
interface UpdateCourseOptions {
|
||||
showSuccessToast?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* API class for course creation operations
|
||||
*/
|
||||
export class CourseApi extends BaseApiWithErrors {
|
||||
course = $state<Course | null>(null);
|
||||
courseAnalytics = $state<CourseAnalytics | null>(null);
|
||||
group = $state<GroupStore>({
|
||||
id: '',
|
||||
tutors: [],
|
||||
students: [],
|
||||
people: [],
|
||||
memberId: ''
|
||||
});
|
||||
|
||||
private loadedCourseId = $state<string | null>(null);
|
||||
private isCourseDirty = $state(false);
|
||||
private inFlightCourseRequest: Promise<Course | null> | null = null;
|
||||
private inFlightCourseId = $state<string | null>(null);
|
||||
|
||||
/**
|
||||
* Updates a single lesson/exercise item in the local course content store.
|
||||
* This avoids list staleness when navigating back from item detail pages.
|
||||
*/
|
||||
updateContentItem(
|
||||
itemId: string,
|
||||
itemType: ContentType.Lesson | ContentType.Exercise,
|
||||
patch: Partial<CourseContentItem>
|
||||
) {
|
||||
if (!this.course?.content) return false;
|
||||
|
||||
const content = this.course.content;
|
||||
|
||||
if (content.grouped) {
|
||||
let hasUpdatedItem = false;
|
||||
|
||||
const sections = content.sections.map((section) => {
|
||||
let sectionHasUpdatedItem = false;
|
||||
const items = section.items.map((item) => {
|
||||
if (item.id !== itemId || item.type !== itemType) return item;
|
||||
sectionHasUpdatedItem = true;
|
||||
return {
|
||||
...item,
|
||||
...patch
|
||||
};
|
||||
});
|
||||
|
||||
if (!sectionHasUpdatedItem) {
|
||||
return section;
|
||||
}
|
||||
|
||||
hasUpdatedItem = true;
|
||||
return {
|
||||
...section,
|
||||
items
|
||||
};
|
||||
});
|
||||
|
||||
if (!hasUpdatedItem) return false;
|
||||
|
||||
this.course = {
|
||||
...this.course,
|
||||
content: {
|
||||
...content,
|
||||
sections
|
||||
}
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
let hasUpdatedItem = false;
|
||||
const items = content.items.map((item) => {
|
||||
if (item.id !== itemId || item.type !== itemType) return item;
|
||||
hasUpdatedItem = true;
|
||||
return {
|
||||
...item,
|
||||
...patch
|
||||
};
|
||||
});
|
||||
|
||||
if (!hasUpdatedItem) return false;
|
||||
|
||||
this.course = {
|
||||
...this.course,
|
||||
content: {
|
||||
...content,
|
||||
items
|
||||
}
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a single lesson/exercise item from the local course content store.
|
||||
* This keeps sidebar lists in sync after delete actions.
|
||||
*/
|
||||
removeContentItem(itemId: string, itemType: ContentType.Lesson | ContentType.Exercise) {
|
||||
if (!this.course?.content) return false;
|
||||
|
||||
const content = this.course.content;
|
||||
|
||||
if (content.grouped) {
|
||||
let hasRemovedItem = false;
|
||||
|
||||
const sections = content.sections.map((section) => {
|
||||
const items = section.items.filter((item) => {
|
||||
const shouldRemove = item.id === itemId && item.type === itemType;
|
||||
if (shouldRemove) {
|
||||
hasRemovedItem = true;
|
||||
}
|
||||
return !shouldRemove;
|
||||
});
|
||||
|
||||
if (items.length === section.items.length) {
|
||||
return section;
|
||||
}
|
||||
|
||||
return {
|
||||
...section,
|
||||
items
|
||||
};
|
||||
});
|
||||
|
||||
if (!hasRemovedItem) return false;
|
||||
|
||||
this.course = {
|
||||
...this.course,
|
||||
content: {
|
||||
...content,
|
||||
sections
|
||||
}
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const items = content.items.filter((item) => !(item.id === itemId && item.type === itemType));
|
||||
|
||||
if (items.length === content.items.length) return false;
|
||||
|
||||
this.course = {
|
||||
...this.course,
|
||||
content: {
|
||||
...content,
|
||||
items
|
||||
}
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a course by ID
|
||||
* @param courseId Course ID
|
||||
* @returns The course data or null on error
|
||||
*/
|
||||
async get(courseId: string) {
|
||||
await this.execute<GetCourseRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].$get({
|
||||
param: { courseId },
|
||||
query: {}
|
||||
}),
|
||||
logContext: 'fetching course',
|
||||
onSuccess: (response) => {
|
||||
console.log('response', response.data);
|
||||
if (response.data) {
|
||||
this.course = response.data;
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to fetch course');
|
||||
}
|
||||
}
|
||||
});
|
||||
return this.course;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the course store is populated for a given courseId.
|
||||
* - Fetches from the API the first time per courseId
|
||||
* - Reuses store data on subsequent calls
|
||||
* - Can be forced to refetch by calling `invalidateCourse(courseId)`
|
||||
*/
|
||||
async ensureCourse(courseId: string, profileId: string) {
|
||||
if (!courseId || !profileId) return null;
|
||||
|
||||
// Already loaded and not marked stale
|
||||
if (!this.isCourseDirty && this.loadedCourseId === courseId && this.course?.id === courseId) {
|
||||
return this.course;
|
||||
}
|
||||
|
||||
// De-duplicate concurrent requests for the same courseId
|
||||
if (this.inFlightCourseRequest && this.inFlightCourseId === courseId) {
|
||||
return this.inFlightCourseRequest;
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
const course = await this.get(courseId);
|
||||
if (course) {
|
||||
this.setCourse(course, profileId);
|
||||
this.loadedCourseId = courseId;
|
||||
this.isCourseDirty = false;
|
||||
}
|
||||
return this.course;
|
||||
})();
|
||||
|
||||
this.inFlightCourseRequest = request;
|
||||
this.inFlightCourseId = courseId;
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
if (this.inFlightCourseRequest === request) {
|
||||
this.inFlightCourseRequest = null;
|
||||
this.inFlightCourseId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the currently loaded course as stale.
|
||||
* Next call to `ensureCourse(courseId, profileId)` will refetch.
|
||||
*/
|
||||
invalidateCourse(courseId?: string) {
|
||||
if (!courseId) {
|
||||
this.isCourseDirty = true;
|
||||
} else if (this.loadedCourseId === courseId) {
|
||||
this.isCourseDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces a refetch of the course into the store (used after mutations).
|
||||
*/
|
||||
async refreshCourse(courseId: string, profileId: string) {
|
||||
this.invalidateCourse(courseId);
|
||||
return this.ensureCourse(courseId, profileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrolls the current user in a course (free: direct enroll; paid: requires inviteToken).
|
||||
* @param courseId Course ID
|
||||
* @param body Optional invite token for paid/invited courses
|
||||
* @returns Success data with redirectTo, or undefined on error
|
||||
*/
|
||||
async enroll(courseId: string, body: { inviteToken?: string } = {}) {
|
||||
return this.execute<EnrollCourseRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].enroll.$post({
|
||||
param: { courseId },
|
||||
json: body
|
||||
}),
|
||||
logContext: 'enrolling in course',
|
||||
onSuccess: () => {
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.invite.failed_join');
|
||||
} else if (typeof result === 'object' && result !== null && 'error' in result) {
|
||||
snackbar.error((result as { error: string }).error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a course by slug (public route)
|
||||
* @param slug Course slug
|
||||
* @returns The course data or null on error
|
||||
*/
|
||||
async getBySlug(slug: string) {
|
||||
await this.execute<GetCourseBySlugRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course.slug?.[':slug'].$get({
|
||||
param: { slug }
|
||||
}),
|
||||
logContext: 'fetching course by slug',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.course = response.data;
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to fetch course by slug');
|
||||
}
|
||||
}
|
||||
});
|
||||
return this.course;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new course with group, group member, and default newsfeed
|
||||
* @param fields Course creation fields (title, description, type)
|
||||
* @returns The created course data or null on error
|
||||
*/
|
||||
async create(fields: { title: string; description: string; type: TCourseType }) {
|
||||
const org = get(currentOrg);
|
||||
const userProfile = get(profile);
|
||||
|
||||
// Prepare data for validation
|
||||
const data = {
|
||||
title: fields.title,
|
||||
description: fields.description,
|
||||
type: fields.type,
|
||||
organizationId: org.id
|
||||
};
|
||||
|
||||
// Client-side validation
|
||||
const result = ZCourseCreate.safeParse(data);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'course');
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.execute<CreateCourseRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course.$post({
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'creating course',
|
||||
onSuccess: (response) => {
|
||||
if (response.data?.course) {
|
||||
const newCourse = response.data.course;
|
||||
|
||||
// Capture PostHog event
|
||||
capturePosthogEvent('course_created', {
|
||||
course_id: newCourse.id,
|
||||
courseTitle: newCourse.title,
|
||||
courseDescription: newCourse.description,
|
||||
organization_id: org.id,
|
||||
organization_name: org.name,
|
||||
user_id: userProfile.id,
|
||||
user_email: userProfile.email
|
||||
});
|
||||
|
||||
// Navigate to the new course
|
||||
goto(resolve(`/courses/${newCourse.id}`, {}));
|
||||
|
||||
// Show success message
|
||||
snackbar.success('Course created successfully');
|
||||
|
||||
// Mark as successful
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to create course');
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
// Field-specific error (e.g., duplicate title)
|
||||
this.errors[result.field] = result.error;
|
||||
snackbar.error(result.error);
|
||||
} else if ('error' in result) {
|
||||
// General error
|
||||
this.errors.general = result.error;
|
||||
snackbar.error(result.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a course
|
||||
* @param courseId Course ID
|
||||
* @param fields Course update fields
|
||||
* @returns The updated course data or null on error
|
||||
*/
|
||||
async update(
|
||||
courseId: string,
|
||||
fields: TCourseUpdate,
|
||||
options: UpdateCourseOptions = {}
|
||||
): Promise<UpdateCourseData | null> {
|
||||
const { showSuccessToast = true } = options;
|
||||
|
||||
const result = ZCourseUpdate.safeParse(fields);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'course');
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await this.execute<UpdateCourseRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].$put({
|
||||
param: { courseId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'updating course',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
// Update the stored course data, preserving fields not in the update response
|
||||
// The update response may not include all fields (like group, lessons, etc.)
|
||||
if (this.course) {
|
||||
// Merge update response with existing course data
|
||||
Object.assign(this.course, response.data);
|
||||
} else {
|
||||
this.course = response.data as Course;
|
||||
}
|
||||
if (showSuccessToast) {
|
||||
snackbar.success('Course updated successfully');
|
||||
}
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to update course');
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
snackbar.error(result.error);
|
||||
} else if ('error' in result) {
|
||||
this.errors.general = result.error;
|
||||
snackbar.error(result.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const updated = response?.data ?? null;
|
||||
|
||||
// Some update responses may omit related data (group/lessons/sections), so refresh the store.
|
||||
if (updated) {
|
||||
const profileId = get(profile)?.id;
|
||||
if (profileId) {
|
||||
await this.refreshCourse(courseId, profileId);
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a course (soft delete)
|
||||
* @param courseId Course ID
|
||||
* @returns The deleted course data or null on error
|
||||
*/
|
||||
async delete(courseId: string) {
|
||||
await this.execute<DeleteCourseRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].$delete({
|
||||
param: { courseId }
|
||||
}),
|
||||
logContext: 'deleting course',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
snackbar.success('Course deleted successfully');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to delete course');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets course progress for a profile
|
||||
* @param courseId Course ID
|
||||
* @param profileId Profile ID
|
||||
* @returns Course progress data or null on error
|
||||
*/
|
||||
async getProgress(courseId: string, profileId: string) {
|
||||
return this.execute<GetCourseProgressRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId']['progress'].$get({
|
||||
param: { courseId },
|
||||
query: { profileId }
|
||||
}),
|
||||
logContext: 'fetching course progress',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to fetch course progress');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets course analytics including student progress, completion rates, and grades
|
||||
* @param courseId Course ID
|
||||
* @returns Course analytics data or null on error
|
||||
*/
|
||||
async getAnalytics(courseId: string) {
|
||||
await this.execute<GetCourseAnalyticsRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId']['analytics'].$get({
|
||||
param: { courseId }
|
||||
}),
|
||||
logContext: 'fetching course analytics',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.courseAnalytics = response.data;
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to fetch course analytics');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets course data and processes related data (group data)
|
||||
* @param data Course data
|
||||
* @param profileId Profile ID
|
||||
*/
|
||||
setCourse(data: Course, profileId: string) {
|
||||
if (!data || !(Object.values(data) && Object.values(data).length)) return;
|
||||
|
||||
// Process group data
|
||||
if (data.group) {
|
||||
const copiedGroup = JSON.parse(JSON.stringify(data.group));
|
||||
const groupData: GroupStore = Object.assign(copiedGroup, {
|
||||
id: copiedGroup.id,
|
||||
tutors: [],
|
||||
students: [],
|
||||
people: [],
|
||||
memberId: data.group.members.find((member) => member.profileId === profileId)?.id || ''
|
||||
});
|
||||
|
||||
if (Array.isArray(data.group.members)) {
|
||||
for (const member of data.group.members) {
|
||||
const roleId = Number(member.roleId || 0);
|
||||
if (roleId === ROLE.STUDENT) {
|
||||
groupData.students.push(member);
|
||||
} else if (member.profile) {
|
||||
groupData.tutors.push(member);
|
||||
}
|
||||
|
||||
groupData.people.push(member);
|
||||
}
|
||||
}
|
||||
|
||||
this.group = groupData;
|
||||
}
|
||||
|
||||
// Ensure metadata has default values
|
||||
if (data.metadata && !Object.values(data.metadata).some((v) => v !== undefined && v !== null && v !== '')) {
|
||||
data.metadata = {
|
||||
requirements: '',
|
||||
description: '',
|
||||
goals: '',
|
||||
videoUrl: '',
|
||||
showDiscount: false,
|
||||
discount: 0,
|
||||
reward: {
|
||||
show: false,
|
||||
description: ''
|
||||
},
|
||||
instructor: {
|
||||
name: '',
|
||||
role: '',
|
||||
coursesNo: 0,
|
||||
description: '',
|
||||
imgUrl: ''
|
||||
},
|
||||
allowNewStudent: false,
|
||||
isContentGroupingEnabled: true
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure lessonTabsOrder includes all tabs (backward compatibility)
|
||||
const metadata = data.metadata ?? undefined;
|
||||
const lessonTabsOrder = metadata?.lessonTabsOrder;
|
||||
if (lessonTabsOrder) {
|
||||
const existingTabIds = lessonTabsOrder.map((tab) => tab.id);
|
||||
const allTabs = [
|
||||
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
|
||||
{ id: 2, name: 'course.navItem.lessons.materials.tabs.slide.title' },
|
||||
{ id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' },
|
||||
{ id: 4, name: 'course.navItem.lessons.materials.tabs.document.title' }
|
||||
];
|
||||
|
||||
allTabs.forEach((tab) => {
|
||||
if (!existingTabIds.includes(tab.id)) {
|
||||
lessonTabsOrder.push(tab);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (data.metadata && data.metadata.isContentGroupingEnabled === undefined) {
|
||||
data.metadata.isContentGroupingEnabled = true;
|
||||
}
|
||||
|
||||
if (!data.content) {
|
||||
data.content = {
|
||||
grouped: false,
|
||||
sections: [],
|
||||
items: []
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure certificateTheme has default value
|
||||
if (!data.certificateTheme) {
|
||||
data.certificateTheme = 'professional';
|
||||
}
|
||||
|
||||
// Set the course data
|
||||
this.course = data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a payment request
|
||||
* @param courseId Course ID
|
||||
* @param studentEmail Student email address
|
||||
* @param studentFullname Student full name
|
||||
*/
|
||||
async createPaymentRequest(courseId: string, studentEmail: string, studentFullname: string) {
|
||||
return this.execute<CreatePaymentRequestRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId']['payment-request']['$post']({
|
||||
param: { courseId },
|
||||
json: {
|
||||
studentEmail,
|
||||
studentFullname
|
||||
}
|
||||
}),
|
||||
logContext: 'creating payment request',
|
||||
onSuccess: () => {
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to send payment request');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const courseApi = new CourseApi();
|
||||
@@ -0,0 +1,89 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
GetOrgCoursesRequest,
|
||||
GetRecommendedCoursesRequest,
|
||||
GetUserEnrolledCoursesRequest,
|
||||
OrgCourses,
|
||||
RecommendedCourses,
|
||||
UserEnrolledCourses
|
||||
} from '$features/course/types';
|
||||
|
||||
/**
|
||||
* API class for course operations
|
||||
*/
|
||||
export class CoursesApi extends BaseApiWithErrors {
|
||||
orgCourses = $state<OrgCourses>([]);
|
||||
enrolledCourses = $state<UserEnrolledCourses>([]);
|
||||
recommendedCourses = $state<RecommendedCourses>([]);
|
||||
|
||||
/**
|
||||
* Fetches org courses for the current organization
|
||||
* Org ID is automatically added from currentOrg store
|
||||
*/
|
||||
async getOrgCourses(tagSlugs: string[] = []) {
|
||||
const normalizedTagSlugs = Array.from(new Set(tagSlugs.map((tag) => tag.trim()).filter(Boolean)));
|
||||
|
||||
return this.execute<GetOrgCoursesRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.courses.$get(
|
||||
normalizedTagSlugs.length > 0
|
||||
? {
|
||||
query: {
|
||||
tags: normalizedTagSlugs.join(',')
|
||||
}
|
||||
}
|
||||
: {}
|
||||
),
|
||||
logContext: 'fetching org courses',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.orgCourses = response.data;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches user enrolled courses for the current organization
|
||||
* Org ID is automatically added from currentOrg store
|
||||
*/
|
||||
async getEnrolledCourses() {
|
||||
return this.execute<GetUserEnrolledCoursesRequest>({
|
||||
requestFn: () => classroomio.organization.courses.enrolled.$get({}),
|
||||
logContext: 'fetching enrolled courses',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.enrolledCourses = response.data;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches recommended courses (published courses user isn't enrolled in) for the current organization
|
||||
* Org ID is automatically added from currentOrg store
|
||||
*/
|
||||
async getRecommendedCourses() {
|
||||
await this.execute<GetRecommendedCoursesRequest>({
|
||||
requestFn: () => classroomio.organization.courses.recommended.$get({}),
|
||||
logContext: 'fetching recommended courses',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.recommendedCourses = response.data;
|
||||
}
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
console.error('Failed to fetch recommended courses:', result);
|
||||
return;
|
||||
}
|
||||
if ('error' in result) {
|
||||
console.error('Failed to fetch recommended courses:', result.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const coursesApi = new CoursesApi();
|
||||
@@ -0,0 +1,73 @@
|
||||
import { BaseApi, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
GetTemplateByIdData,
|
||||
GetTemplateByIdRequest,
|
||||
GetTemplateByTagData,
|
||||
GetTemplateByTagRequest
|
||||
} from '../utils/types';
|
||||
|
||||
/**
|
||||
* API class for exercise templates
|
||||
*/
|
||||
class ExerciseTemplateApi extends BaseApi {
|
||||
templates = $state<GetTemplateByTagData>([]);
|
||||
template = $state<GetTemplateByIdData>();
|
||||
templatesByTag = $state<Record<string, GetTemplateByTagData>>({});
|
||||
|
||||
private getTagCacheKey(courseId: string, tag: string) {
|
||||
return `${courseId}:${tag}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches an exercise template metadata
|
||||
*/
|
||||
async fetchTemplateById(courseId: string, id: string) {
|
||||
await this.execute<GetTemplateByIdRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].exercise.template[':id'].$get({
|
||||
param: { courseId, id }
|
||||
}),
|
||||
logContext: 'fetching exercise template by id',
|
||||
onSuccess: (response) => {
|
||||
this.template = response.data;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches an exercise template metadata
|
||||
*/
|
||||
async fetchTemplatesByTag(courseId: string, tag: string) {
|
||||
const cacheKey = this.getTagCacheKey(courseId, tag);
|
||||
const cachedTemplates = this.templatesByTag[cacheKey];
|
||||
|
||||
if (cachedTemplates) {
|
||||
this.templates = cachedTemplates;
|
||||
this.success = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<GetTemplateByTagRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].exercise.template.tag[':tag'].$get({
|
||||
param: { courseId, tag }
|
||||
}),
|
||||
logContext: 'fetching exercise template by tag',
|
||||
onSuccess: (response) => {
|
||||
this.templates = response.data;
|
||||
this.templatesByTag = {
|
||||
...this.templatesByTag,
|
||||
[cacheKey]: response.data
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.templates = [];
|
||||
this.template = undefined;
|
||||
this.templatesByTag = {};
|
||||
}
|
||||
}
|
||||
|
||||
export const exerciseTemplateApi = new ExerciseTemplateApi();
|
||||
@@ -0,0 +1,251 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
CreateExerciseFromTemplateRequest,
|
||||
CreateExerciseRequest,
|
||||
DeleteExerciseRequest,
|
||||
Exercise,
|
||||
GetExerciseRequest,
|
||||
SubmitExerciseRequest,
|
||||
UpdateExerciseRequest
|
||||
} from '../utils/types';
|
||||
import type { TExerciseCreate, TExerciseSubmissionCreate, TExerciseUpdate } from '@cio/utils/validation/exercise';
|
||||
import { ZExerciseCreate, ZExerciseSubmissionCreate, ZExerciseUpdate } from '@cio/utils/validation/exercise';
|
||||
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
|
||||
/**
|
||||
* API class for exercise operations
|
||||
*/
|
||||
export class ExerciseApi extends BaseApiWithErrors {
|
||||
exercise = $state<Exercise | null>(null);
|
||||
|
||||
/**
|
||||
* Gets an exercise by ID
|
||||
*/
|
||||
async get(courseId: string, exerciseId: string) {
|
||||
await this.execute<GetExerciseRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].exercise[':exerciseId'].$get({
|
||||
param: { courseId, exerciseId }
|
||||
}),
|
||||
logContext: 'fetching exercise',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.exercise = response.data;
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to fetch exercise');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new exercise
|
||||
*/
|
||||
async create(courseId: string, fields: Omit<TExerciseCreate, 'courseId'>) {
|
||||
const result = ZExerciseCreate.safeParse({ ...fields, courseId });
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'exercise');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<CreateExerciseRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].exercise.$post({
|
||||
param: { courseId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'creating exercise',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.exercise = response.data;
|
||||
snackbar.success('Exercise created successfully');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to create exercise');
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
snackbar.error(result.error);
|
||||
} else if ('error' in result) {
|
||||
this.errors.general = result.error;
|
||||
snackbar.error(result.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an exercise
|
||||
*/
|
||||
async update(courseId: string, exerciseId: string, fields: TExerciseUpdate) {
|
||||
this.success = false;
|
||||
const result = ZExerciseUpdate.safeParse(fields);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'exercise');
|
||||
return;
|
||||
}
|
||||
this.errors = {};
|
||||
await this.execute<UpdateExerciseRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].exercise[':exerciseId'].$put({
|
||||
param: { courseId, exerciseId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'updating exercise',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.exercise = response.data;
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
this.success = false;
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to update exercise');
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
snackbar.error(result.error);
|
||||
} else if ('error' in result) {
|
||||
this.errors.general = result.error;
|
||||
snackbar.error(result.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an exercise
|
||||
*/
|
||||
async delete(courseId: string, exerciseId: string) {
|
||||
await this.execute<DeleteExerciseRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].exercise[':exerciseId'].$delete({
|
||||
param: { courseId, exerciseId }
|
||||
}),
|
||||
logContext: 'deleting exercise',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
snackbar.success('Exercise deleted successfully');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to delete exercise');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits an exercise
|
||||
*/
|
||||
async submit(courseId: string, exerciseId: string, answers: TExerciseSubmissionCreate['answers']) {
|
||||
const result = ZExerciseSubmissionCreate.safeParse({ exerciseId, answers });
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'exercise');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<SubmitExerciseRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].exercise[':exerciseId'].submission.$post({
|
||||
param: { courseId, exerciseId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'submitting exercise',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
snackbar.success('Exercise submitted successfully');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to submit exercise');
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
snackbar.error(result.error);
|
||||
} else if ('error' in result) {
|
||||
this.errors.general = result.error;
|
||||
snackbar.error(result.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an exercise from a template
|
||||
* @param courseId Course ID
|
||||
* @param lessonId Lesson ID
|
||||
* @param templateId Template ID
|
||||
* @returns The created exercise data or null on error
|
||||
*/
|
||||
async createFromTemplate(
|
||||
courseId: string,
|
||||
templateId: number | string,
|
||||
options: { lessonId?: string; sectionId?: string; order?: number } = {}
|
||||
) {
|
||||
const templateIdValue = Number(templateId);
|
||||
await this.execute<CreateExerciseFromTemplateRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId']['exercise']['from-template'].$post({
|
||||
param: { courseId },
|
||||
json: {
|
||||
lessonId: options.lessonId,
|
||||
sectionId: options.sectionId,
|
||||
order: options.order,
|
||||
templateId: templateIdValue
|
||||
}
|
||||
}),
|
||||
logContext: 'creating exercise from template',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.exercise = response.data;
|
||||
snackbar.success('Exercise created successfully');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to create exercise from template');
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
snackbar.error(result.error);
|
||||
} else if ('error' in result) {
|
||||
this.errors.general = result.error;
|
||||
snackbar.error(result.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if exercise is completed
|
||||
* NOTE: This route does not exist in the API. Use the submission list endpoint instead.
|
||||
*/
|
||||
}
|
||||
|
||||
export const exerciseApi = new ExerciseApi();
|
||||
@@ -1 +1,12 @@
|
||||
export * from './attendance.svelte';
|
||||
export * from './course-clone.svelte';
|
||||
export * from './course.svelte';
|
||||
export * from './courses.svelte';
|
||||
export * from './content.svelte';
|
||||
export * from './exercise.svelte';
|
||||
export * from './lesson.svelte';
|
||||
export * from './mark.svelte';
|
||||
export * from './newsfeed.svelte';
|
||||
export * from './people.svelte';
|
||||
export * from './presign.svelte';
|
||||
export * from './submission.svelte';
|
||||
|
||||
@@ -0,0 +1,863 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
CourseSectionWithLessons,
|
||||
CreateCourseSectionRequest,
|
||||
CreateLessonComment,
|
||||
CreateLessonCommentRequest,
|
||||
CreateLessonRequest,
|
||||
DeleteCourseSectionRequest,
|
||||
DeleteLessonCommentRequest,
|
||||
DeleteLessonRequest,
|
||||
GetLessonCommentsRequest,
|
||||
GetLessonHistoryRequest,
|
||||
GetLessonLanguageRequest,
|
||||
GetLessonRequest,
|
||||
Lesson,
|
||||
LessonComments,
|
||||
PromoteUngroupedSectionRequest,
|
||||
ReorderCourseSectionsRequest,
|
||||
ReorderLessonsRequest,
|
||||
UpdateCourseSectionRequest,
|
||||
UpdateLessonCommentRequest,
|
||||
UpdateLessonCompletionRequest,
|
||||
UpdateLessonRequest
|
||||
} from '../utils/types';
|
||||
import type {
|
||||
TCourseSectionCreate,
|
||||
TCourseSectionPromoteUngrouped,
|
||||
TCourseSectionReorder,
|
||||
TCourseSectionUpdate
|
||||
} from '@cio/utils/validation/course/section';
|
||||
import type { TLessonCreate, TLessonReorder, TLessonUpdate } from '@cio/utils/validation/lesson';
|
||||
import {
|
||||
ZCourseSectionCreate,
|
||||
ZCourseSectionPromoteUngrouped,
|
||||
ZCourseSectionReorder,
|
||||
ZCourseSectionUpdate
|
||||
} from '@cio/utils/validation/course/section';
|
||||
import {
|
||||
ZLessonCommentCreate,
|
||||
ZLessonCommentUpdate,
|
||||
ZLessonCompletionUpdate,
|
||||
ZLessonCreate,
|
||||
ZLessonReorder,
|
||||
ZLessonUpdate
|
||||
} from '@cio/utils/validation/lesson';
|
||||
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
import { get } from 'svelte/store';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
|
||||
/**
|
||||
* API class for lesson operations
|
||||
*/
|
||||
export class LessonApi extends BaseApiWithErrors {
|
||||
lesson = $state<Lesson | null>(null);
|
||||
sections = $state<CourseSectionWithLessons[]>([]);
|
||||
commentsByLessonId = $state<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
items: LessonComments;
|
||||
totalCount: number;
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
cursor: string | null;
|
||||
}
|
||||
>
|
||||
>({});
|
||||
currentLocale = $state<TLocale>('en');
|
||||
isSaving = $state(false);
|
||||
isDirty = $state(false);
|
||||
isCommenting = $state(false);
|
||||
isUpdatingComment = $state(false);
|
||||
|
||||
translations = $state<Record<string, Record<TLocale, string>>>({});
|
||||
note = $state('');
|
||||
private autoSaveTimeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Gets a lesson by ID
|
||||
*/
|
||||
async get(courseId: string, lessonId: string) {
|
||||
return this.execute<GetLessonRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].lesson[':lessonId'].$get({
|
||||
param: { courseId, lessonId }
|
||||
}),
|
||||
logContext: 'fetching lesson',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.lesson = response.data;
|
||||
|
||||
if (this.lesson.lessonLanguages) {
|
||||
this.setTranslations();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new lesson
|
||||
*/
|
||||
async create(courseId: string, fields: TLessonCreate) {
|
||||
const result = ZLessonCreate.safeParse({ ...fields, courseId });
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'lesson');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<CreateLessonRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].lesson.$post({
|
||||
param: { courseId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'creating lesson',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
const createdLesson = response.data as Lesson;
|
||||
this.lesson = {
|
||||
...createdLesson,
|
||||
lessonLanguages: createdLesson.lessonLanguages ?? []
|
||||
};
|
||||
snackbar.success('snackbar.lessons.lesson_created');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.lessons.lesson_create_failed');
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
snackbar.error(result.error);
|
||||
} else if ('error' in result) {
|
||||
this.errors.general = result.error;
|
||||
snackbar.error(result.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a lesson
|
||||
*/
|
||||
async update(courseId: string, lessonId: string, fields: TLessonUpdate): Promise<boolean> {
|
||||
const result = ZLessonUpdate.safeParse(fields);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'lesson');
|
||||
return false;
|
||||
}
|
||||
|
||||
this.isSaving = true;
|
||||
|
||||
const response = await this.execute<UpdateLessonRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].lesson[':lessonId'].$put({
|
||||
param: { courseId, lessonId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'updating lesson',
|
||||
onError: (result) => {
|
||||
console.log('onError', result);
|
||||
snackbar.error('snackbar.lessons.lesson_update_failed');
|
||||
}
|
||||
});
|
||||
|
||||
this.isSaving = false;
|
||||
return Boolean(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a lesson
|
||||
*/
|
||||
async delete(courseId: string, lessonId: string) {
|
||||
await this.execute<DeleteLessonRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].lesson[':lessonId'].$delete({
|
||||
param: { courseId, lessonId }
|
||||
}),
|
||||
logContext: 'deleting lesson',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
snackbar.success('snackbar.lessons.lesson_deleted');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.lessons.lesson_delete_failed');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Course Section methods
|
||||
|
||||
/**
|
||||
* Creates a course section
|
||||
*/
|
||||
async createSection(courseId: string, fields: TCourseSectionCreate) {
|
||||
const result = ZCourseSectionCreate.safeParse({ ...fields, courseId });
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'lesson');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<CreateCourseSectionRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].section.$post({
|
||||
param: { courseId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'creating course section',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
snackbar.success('snackbar.lessons.section_created');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.lessons.section_create_failed');
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
snackbar.error(result.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a real section from synthetic ungrouped content and moves all ungrouped items.
|
||||
*/
|
||||
async promoteUngroupedSection(courseId: string, fields: TCourseSectionPromoteUngrouped) {
|
||||
const result = ZCourseSectionPromoteUngrouped.safeParse(fields);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'lesson');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<PromoteUngroupedSectionRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].section['promote-ungrouped'].$post({
|
||||
param: { courseId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'promoting ungrouped section',
|
||||
onSuccess: () => {
|
||||
snackbar.success('snackbar.lessons.section_updated');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.lessons.section_update_failed');
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
snackbar.error(result.error);
|
||||
return;
|
||||
}
|
||||
if ('error' in result) {
|
||||
this.errors.general = result.error;
|
||||
snackbar.error(result.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a course section
|
||||
*/
|
||||
async updateSection(courseId: string, sectionId: string, fields: TCourseSectionUpdate) {
|
||||
const result = ZCourseSectionUpdate.safeParse(fields);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'lesson');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<UpdateCourseSectionRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].section[':sectionId'].$put({
|
||||
param: { courseId, sectionId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'updating course section',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
snackbar.success('snackbar.lessons.section_updated');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.lessons.section_update_failed');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a course section
|
||||
*/
|
||||
async deleteSection(courseId: string, sectionId: string) {
|
||||
await this.execute<DeleteCourseSectionRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].section[':sectionId'].$delete({
|
||||
param: { courseId, sectionId }
|
||||
}),
|
||||
logContext: 'deleting course section'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorders course sections
|
||||
*/
|
||||
async reorderSections(courseId: string, sections: TCourseSectionReorder['sections']) {
|
||||
const result = ZCourseSectionReorder.safeParse({ sections });
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'lesson');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<ReorderCourseSectionsRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].section.reorder.$post({
|
||||
param: { courseId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'reordering course sections',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
snackbar.success('snackbar.lessons.sections_reordered');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.lessons.sections_reorder_failed');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorders lessons
|
||||
*/
|
||||
async reorderLessons(courseId: string, lessons: TLessonReorder['lessons']) {
|
||||
const result = ZLessonReorder.safeParse({ lessons });
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'lesson');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<ReorderLessonsRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].lesson.reorder.$post({
|
||||
param: { courseId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'reordering lessons',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
snackbar.success('snackbar.lessons.lessons_reordered');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.lessons.lessons_reorder_failed');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Lesson Comment methods
|
||||
|
||||
/**
|
||||
* Initializes comments state for a lesson
|
||||
*/
|
||||
initComments(lessonId: string, totalCount: number) {
|
||||
if (!this.commentsByLessonId[lessonId]) {
|
||||
this.commentsByLessonId[lessonId] = {
|
||||
items: [],
|
||||
totalCount,
|
||||
hasMore: totalCount > 0,
|
||||
isLoading: false,
|
||||
cursor: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets comments for a lesson
|
||||
*/
|
||||
async getComments(courseId: string, lessonId: string, limit = 10) {
|
||||
if (!this.commentsByLessonId[lessonId]) {
|
||||
this.initComments(lessonId, 0);
|
||||
}
|
||||
this.commentsByLessonId[lessonId].isLoading = true;
|
||||
|
||||
await this.execute<GetLessonCommentsRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].lesson[':lessonId'].comment.$get({
|
||||
param: { courseId, lessonId },
|
||||
query: { limit: String(limit) }
|
||||
}),
|
||||
logContext: 'fetching lesson comments',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.commentsByLessonId[lessonId] = {
|
||||
items: response.data.items,
|
||||
totalCount: response.data.totalCount,
|
||||
hasMore: response.data.hasMore,
|
||||
isLoading: false,
|
||||
cursor: response.data.nextCursor
|
||||
};
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
this.commentsByLessonId[lessonId].isLoading = false;
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.lessons.comment_fetch_failed');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more comments (uses cursor)
|
||||
*/
|
||||
async loadMoreComments(courseId: string, lessonId: string, limit = 10) {
|
||||
const commentState = this.commentsByLessonId[lessonId];
|
||||
if (!commentState || !commentState.hasMore || commentState.isLoading || !commentState.cursor) {
|
||||
return;
|
||||
}
|
||||
|
||||
commentState.isLoading = true;
|
||||
|
||||
await this.execute<GetLessonCommentsRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].lesson[':lessonId'].comment.$get({
|
||||
param: { courseId, lessonId },
|
||||
query: { cursor: commentState.cursor!, limit: String(limit) }
|
||||
}),
|
||||
logContext: 'loading more lesson comments',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.commentsByLessonId[lessonId] = {
|
||||
items: [...commentState.items, ...response.data.items],
|
||||
totalCount: response.data.totalCount,
|
||||
hasMore: response.data.hasMore,
|
||||
isLoading: false,
|
||||
cursor: response.data.nextCursor
|
||||
};
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
commentState.isLoading = false;
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.lessons.load_more_comments_failed');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a lesson comment
|
||||
*/
|
||||
async createComment(courseId: string, lessonId: string, comment: string) {
|
||||
if (!courseId || !lessonId) {
|
||||
snackbar.error('snackbar.lessons.comment_add_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = ZLessonCommentCreate.safeParse({ lessonId, comment });
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'lesson');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isCommenting = true;
|
||||
try {
|
||||
await this.execute<CreateLessonCommentRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].lesson[':lessonId'].comment.$post({
|
||||
param: { courseId, lessonId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'creating lesson comment',
|
||||
onSuccess: (response) => {
|
||||
if (response.data && this.commentsByLessonId[lessonId]) {
|
||||
this.commentsByLessonId[lessonId].items = [
|
||||
this.getCommentFromResponse(response.data),
|
||||
...this.commentsByLessonId[lessonId].items
|
||||
];
|
||||
this.commentsByLessonId[lessonId].totalCount++;
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.lessons.comment_add_failed');
|
||||
}
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
this.isCommenting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a lesson comment
|
||||
*/
|
||||
async updateComment(courseId: string, lessonId: string, commentId: string, comment: string) {
|
||||
const result = ZLessonCommentUpdate.safeParse({ comment });
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'lesson');
|
||||
return;
|
||||
}
|
||||
|
||||
this.success = false;
|
||||
this.isUpdatingComment = true;
|
||||
|
||||
try {
|
||||
await this.execute<UpdateLessonCommentRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].lesson.comment[':commentId'].$put({
|
||||
param: { courseId, commentId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'updating lesson comment',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
// Update local state
|
||||
if (this.commentsByLessonId[lessonId]) {
|
||||
this.commentsByLessonId[lessonId].items = this.commentsByLessonId[lessonId].items.map((c) =>
|
||||
c.id === Number(commentId) ? this.getCommentFromResponse(response.data) : c
|
||||
);
|
||||
}
|
||||
snackbar.success('snackbar.lessons.comment_updated');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.lessons.comment_update_failed');
|
||||
}
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
this.isUpdatingComment = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a lesson comment
|
||||
*/
|
||||
async deleteComment(courseId: string, lessonId: string, commentId: string) {
|
||||
await this.execute<DeleteLessonCommentRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].lesson.comment[':commentId'].$delete({
|
||||
param: { courseId, commentId }
|
||||
}),
|
||||
logContext: 'deleting lesson comment',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
// Update local state
|
||||
if (this.commentsByLessonId[lessonId]) {
|
||||
this.commentsByLessonId[lessonId].items = this.commentsByLessonId[lessonId].items.filter(
|
||||
(c) => c.id !== Number(commentId)
|
||||
);
|
||||
this.commentsByLessonId[lessonId].totalCount--;
|
||||
}
|
||||
snackbar.success('snackbar.lessons.comment_deleted');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.lessons.comment_delete_failed');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates lesson completion
|
||||
*/
|
||||
async updateCompletion(courseId: string, lessonId: string, isComplete: boolean) {
|
||||
const result = ZLessonCompletionUpdate.safeParse({ isComplete });
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'lesson');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<UpdateLessonCompletionRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].lesson[':lessonId'].completion.$put({
|
||||
param: { courseId, lessonId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'updating lesson completion',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
// Update the current lesson if it's the one being updated
|
||||
if (this.lesson?.id === lessonId) {
|
||||
this.lesson = { ...this.lesson, isComplete };
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.lessons.completion_update_failed');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets translations for a lesson
|
||||
* Translations are stored separately from the lesson data
|
||||
*/
|
||||
setTranslations() {
|
||||
if (!this.lesson) return;
|
||||
|
||||
const translations: Record<TLocale, string> = this.lesson?.lessonLanguages?.reduce(
|
||||
(acc, cur) => {
|
||||
if (cur.locale) {
|
||||
acc[cur.locale] = cur.content || '';
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<TLocale, string>
|
||||
);
|
||||
|
||||
this.translations = {
|
||||
...this.translations,
|
||||
[this.lesson.id]: translations
|
||||
};
|
||||
}
|
||||
|
||||
updateTranslation(lessonId: string, locale: TLocale, content: string) {
|
||||
this.translations = {
|
||||
...this.translations,
|
||||
[lessonId]: {
|
||||
...this.translations[lessonId],
|
||||
[locale]: content
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getCommentFromResponse(response: CreateLessonComment): LessonComments[number] {
|
||||
const userProfile = get(profile);
|
||||
|
||||
return {
|
||||
id: response.id,
|
||||
createdAt: response.createdAt,
|
||||
updatedAt: response.updatedAt,
|
||||
lessonId: response.lessonId,
|
||||
groupmemberId: response.groupmemberId,
|
||||
comment: response.comment,
|
||||
groupmember: {
|
||||
id: response.groupmemberId!
|
||||
},
|
||||
profile: {
|
||||
fullname: userProfile.fullname,
|
||||
avatarUrl: userProfile.avatarUrl
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a video from lesson materials
|
||||
*/
|
||||
async deleteLessonVideo(videoIndex: number) {
|
||||
if (!this.lesson) return;
|
||||
|
||||
const videos = Array.isArray(this.lesson.videos) ? [...this.lesson.videos] : [];
|
||||
const removedVideo = videos[videoIndex] as { assetId?: string } | undefined;
|
||||
|
||||
if (removedVideo?.assetId && this.lesson.id) {
|
||||
await mediaApi.detachAsset(removedVideo.assetId, {
|
||||
targetType: 'lesson',
|
||||
targetId: this.lesson.id,
|
||||
slotType: 'lesson_video',
|
||||
position: videoIndex
|
||||
});
|
||||
}
|
||||
|
||||
this.lesson = {
|
||||
...this.lesson,
|
||||
videos: videos.filter((_v, i) => i !== videoIndex)
|
||||
};
|
||||
this.isDirty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a document from lesson materials
|
||||
*/
|
||||
async deleteLessonDocument(documentIndex: number) {
|
||||
if (!this.lesson) return;
|
||||
|
||||
const documents = Array.isArray(this.lesson.documents) ? [...this.lesson.documents] : [];
|
||||
const removedDocument = documents[documentIndex] as { assetId?: string } | undefined;
|
||||
|
||||
if (removedDocument?.assetId && this.lesson.id) {
|
||||
await mediaApi.detachAsset(removedDocument.assetId, {
|
||||
targetType: 'lesson',
|
||||
targetId: this.lesson.id,
|
||||
slotType: 'lesson_document',
|
||||
position: documentIndex
|
||||
});
|
||||
}
|
||||
|
||||
this.lesson = {
|
||||
...this.lesson,
|
||||
documents: documents.filter((_d, i) => i !== documentIndex)
|
||||
};
|
||||
this.isDirty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic method to update lesson state
|
||||
* @param field - The field name to update (type-safe)
|
||||
* @param value - The new value for the field (or array of items to append)
|
||||
* @param options - Optional configuration for array operations
|
||||
*/
|
||||
updateLessonState<K extends keyof Lesson>(
|
||||
field: K,
|
||||
value: Lesson[K] | (Lesson[K] extends Array<infer T> ? T | T[] : never),
|
||||
options?: { append?: boolean }
|
||||
) {
|
||||
if (!this.lesson) return;
|
||||
|
||||
// Handle array fields with append option
|
||||
if (options?.append && Array.isArray(this.lesson[field])) {
|
||||
const existingArray = this.lesson[field];
|
||||
const newItems = Array.isArray(value) ? value : [value];
|
||||
|
||||
this.lesson = {
|
||||
...this.lesson,
|
||||
[field]: [...existingArray, ...newItems] as Lesson[K]
|
||||
};
|
||||
} else {
|
||||
// Direct field replacement
|
||||
this.lesson = {
|
||||
...this.lesson,
|
||||
[field]: value as Lesson[K]
|
||||
};
|
||||
}
|
||||
this.isDirty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets lesson version history for a lesson and locale
|
||||
* @param courseId Course ID
|
||||
* @param lessonId Lesson ID
|
||||
* @param locale Locale
|
||||
* @param endRange End range for pagination (0-indexed, inclusive)
|
||||
* @returns Lesson history data or null on error
|
||||
*/
|
||||
async getHistory(courseId: string, lessonId: string, locale: TLocale, endRange: number) {
|
||||
return this.execute<GetLessonHistoryRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId']['lesson'][':lessonId']['history'].$get({
|
||||
param: { courseId, lessonId },
|
||||
query: { locale, endRange: endRange.toString() }
|
||||
}),
|
||||
logContext: 'fetching lesson history'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or updates a lesson language translation (upsert)
|
||||
* @param courseId Course ID
|
||||
* @param lessonId Lesson ID
|
||||
* @param locale Locale
|
||||
* @param content Translation content
|
||||
*/
|
||||
async upsertLanguage(courseId: string, lessonId: string, locale: TLocale, content: string) {
|
||||
return this.execute<GetLessonLanguageRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].lesson[':lessonId'].language.$post({
|
||||
param: { courseId, lessonId },
|
||||
json: { locale, content }
|
||||
}),
|
||||
logContext: 'upserting lesson language',
|
||||
onSuccess: () => {
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: () => {
|
||||
snackbar.error('snackbar.materials.update_translations');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the current lesson state (materials and translations)
|
||||
* @param courseId Course ID
|
||||
* @param lessonId Lesson ID
|
||||
*/
|
||||
async save(courseId: string, lessonId: string) {
|
||||
if (!this.lesson) return false;
|
||||
|
||||
// Prevent autosave loops: we only set this back to `true` when the user edits again.
|
||||
this.isDirty = false;
|
||||
|
||||
await Promise.all([
|
||||
this.update(courseId, lessonId, {
|
||||
slideUrl: this.lesson.slideUrl || undefined,
|
||||
videos: this.lesson.videos || [],
|
||||
documents: this.lesson.documents || []
|
||||
}),
|
||||
this.upsertLanguage(
|
||||
courseId,
|
||||
lessonId,
|
||||
this.currentLocale,
|
||||
this.translations[lessonId]?.[this.currentLocale] || ''
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts autosave with debouncing
|
||||
* @param courseId Course ID
|
||||
* @param lessonId Lesson ID
|
||||
*/
|
||||
startAutoSave(courseId: string, lessonId: string) {
|
||||
if (!this.isDirty || this.isSaving) return;
|
||||
if (this.autoSaveTimeoutId) clearTimeout(this.autoSaveTimeoutId);
|
||||
|
||||
this.isSaving = true;
|
||||
this.autoSaveTimeoutId = setTimeout(async () => {
|
||||
await this.save(courseId, lessonId);
|
||||
this.isSaving = false;
|
||||
this.autoSaveTimeoutId = null;
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
export const lessonApi = new LessonApi();
|
||||
@@ -0,0 +1,38 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type { GetMarksRequest, Marks } from '../utils/types';
|
||||
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
|
||||
/**
|
||||
* API class for mark operations
|
||||
*/
|
||||
export class MarkApi extends BaseApiWithErrors {
|
||||
data = $state<Marks | null>(null);
|
||||
|
||||
/**
|
||||
* Gets marks for a course
|
||||
*/
|
||||
async getMarks(courseId: string) {
|
||||
await this.execute<GetMarksRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].mark.$get({
|
||||
param: { courseId }
|
||||
}),
|
||||
logContext: 'fetching marks',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.data = response.data;
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to fetch marks');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const markApi = new MarkApi();
|
||||
@@ -0,0 +1,498 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
CreateNewsfeedCommentRequest,
|
||||
CreateNewsfeedRequest,
|
||||
DeleteNewsfeedCommentRequest,
|
||||
DeleteNewsfeedRequest,
|
||||
GetNewsfeedCommentsRequest,
|
||||
GetNewsfeedRequest,
|
||||
ListNewsfeed,
|
||||
ListNewsfeedRequest,
|
||||
NewsfeedCommentsResponse,
|
||||
ReactToNewsfeedRequest,
|
||||
UpdateNewsfeedCommentRequest,
|
||||
UpdateNewsfeedRequest
|
||||
} from '../utils/types';
|
||||
import type { TNewsfeedCreate, TNewsfeedReactionUpdate, TNewsfeedUpdate } from '@cio/utils/validation/newsfeed';
|
||||
import {
|
||||
ZNewsfeedCommentCreate,
|
||||
ZNewsfeedCommentUpdate,
|
||||
ZNewsfeedCreate,
|
||||
ZNewsfeedReactionUpdate,
|
||||
ZNewsfeedUpdate
|
||||
} from '@cio/utils/validation/newsfeed';
|
||||
|
||||
import { get } from 'svelte/store';
|
||||
import { getTextFromHTML } from '$lib/utils/functions/toHtml';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
|
||||
export type NewsfeedCommentsByFeedId = {
|
||||
items: NewsfeedCommentsResponse['items'];
|
||||
totalCount: number;
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
cursor: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* API class for newsfeed operations
|
||||
*/
|
||||
export class NewsfeedApi extends BaseApiWithErrors {
|
||||
feeds = $state<ListNewsfeed>([]);
|
||||
|
||||
commentsByFeedId = $state<Record<string, NewsfeedCommentsByFeedId>>({});
|
||||
|
||||
isNewFeedModalOpen = $state(false);
|
||||
|
||||
/**
|
||||
* Opens the new feed modal
|
||||
*/
|
||||
openNewFeedModal() {
|
||||
this.isNewFeedModalOpen = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the new feed modal
|
||||
*/
|
||||
closeNewFeedModal() {
|
||||
this.isNewFeedModalOpen = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists newsfeed items for a course
|
||||
*/
|
||||
async list(courseId: string) {
|
||||
await this.execute<ListNewsfeedRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].newsfeed.$get({
|
||||
param: { courseId },
|
||||
query: {}
|
||||
}),
|
||||
logContext: 'listing newsfeed',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.feeds = Array.isArray(response.data) ? response.data : response.data.items || [];
|
||||
for (const feed of this.feeds) {
|
||||
this.initComments(feed.id, 0);
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to list newsfeed');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a newsfeed item by ID
|
||||
*/
|
||||
async get(courseId: string, feedId: string) {
|
||||
await this.execute<GetNewsfeedRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].newsfeed[':feedId'].$get({
|
||||
param: { courseId, feedId }
|
||||
}),
|
||||
logContext: 'fetching newsfeed item',
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to fetch newsfeed item');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new newsfeed item
|
||||
*/
|
||||
async create(courseId: string, fields: TNewsfeedCreate) {
|
||||
const result = ZNewsfeedCreate.safeParse({ ...fields, content: getTextFromHTML(fields.content), courseId });
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<CreateNewsfeedRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].newsfeed.$post({
|
||||
param: { courseId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'creating newsfeed',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
snackbar.success('Post created successfully');
|
||||
const feedsArray = Array.isArray(this.feeds) ? this.feeds : [];
|
||||
const author = get(profile);
|
||||
|
||||
const newFeed = {
|
||||
...response.data,
|
||||
authorProfileId: author.id,
|
||||
authorFullname: author.fullname,
|
||||
authorUsername: author.username,
|
||||
authorAvatarUrl: author.avatarUrl,
|
||||
commentCount: 0
|
||||
};
|
||||
|
||||
this.feeds = [...feedsArray, newFeed];
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to create post');
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
snackbar.error(result.error);
|
||||
} else if ('error' in result) {
|
||||
this.errors.general = result.error;
|
||||
snackbar.error(result.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a newsfeed item (content and isPinned only)
|
||||
*/
|
||||
async update(courseId: string, feedId: string, fields: TNewsfeedUpdate) {
|
||||
const result = ZNewsfeedUpdate.safeParse({
|
||||
...fields,
|
||||
content: getTextFromHTML(fields.content ?? '')
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<UpdateNewsfeedRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].newsfeed[':feedId'].$put({
|
||||
param: { courseId, feedId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'updating newsfeed',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
snackbar.success('Post updated successfully');
|
||||
// Update local state, preserving author fields from existing feed
|
||||
const index = this.feeds.findIndex((f) => f.id === feedId);
|
||||
if (index !== -1) {
|
||||
const existingFeed = this.feeds[index];
|
||||
// Merge response data with existing feed to preserve author fields
|
||||
this.feeds[index] = {
|
||||
...existingFeed,
|
||||
...response.data,
|
||||
// Preserve author fields from existing feed
|
||||
authorProfileId: existingFeed.authorProfileId,
|
||||
authorFullname: existingFeed.authorFullname,
|
||||
authorUsername: existingFeed.authorUsername,
|
||||
authorAvatarUrl: existingFeed.authorAvatarUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to update post');
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
snackbar.error(result.error);
|
||||
} else if ('error' in result) {
|
||||
this.errors.general = result.error;
|
||||
snackbar.error(result.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a newsfeed item's reaction
|
||||
*/
|
||||
async react(courseId: string, feedId: string, reaction: TNewsfeedReactionUpdate['reaction']) {
|
||||
const result = ZNewsfeedReactionUpdate.safeParse({ reaction });
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<ReactToNewsfeedRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].newsfeed[':feedId'].react.$put({
|
||||
param: { courseId, feedId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'updating newsfeed reaction',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
// Update local state, preserving author fields from existing feed
|
||||
const index = this.feeds.findIndex((f) => f.id === feedId);
|
||||
if (index !== -1) {
|
||||
const existingFeed = this.feeds[index];
|
||||
// Merge response data with existing feed to preserve author fields
|
||||
this.feeds[index] = {
|
||||
...existingFeed,
|
||||
...response.data,
|
||||
// Preserve author fields from existing feed
|
||||
authorProfileId: existingFeed.authorProfileId,
|
||||
authorFullname: existingFeed.authorFullname,
|
||||
authorUsername: existingFeed.authorUsername,
|
||||
authorAvatarUrl: existingFeed.authorAvatarUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to update reaction');
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
snackbar.error(result.error);
|
||||
} else if ('error' in result) {
|
||||
this.errors.general = result.error;
|
||||
snackbar.error(result.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a newsfeed item
|
||||
*/
|
||||
async delete(courseId: string, feedId: string) {
|
||||
await this.execute<DeleteNewsfeedRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].newsfeed[':feedId'].$delete({
|
||||
param: { courseId, feedId }
|
||||
}),
|
||||
logContext: 'deleting newsfeed',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
snackbar.success('Post deleted successfully');
|
||||
this.feeds = this.feeds.filter((feed) => feed.id !== feedId);
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to delete post');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize comment state for a feed
|
||||
*/
|
||||
initComments(feedId: string, totalCount: number) {
|
||||
if (!this.commentsByFeedId[feedId]) {
|
||||
this.commentsByFeedId[feedId] = {
|
||||
items: [],
|
||||
totalCount,
|
||||
hasMore: totalCount > 0,
|
||||
isLoading: false,
|
||||
cursor: null
|
||||
};
|
||||
} else {
|
||||
this.commentsByFeedId[feedId].totalCount = totalCount;
|
||||
this.commentsByFeedId[feedId].hasMore = totalCount > this.commentsByFeedId[feedId].items.length;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch paginated comments for a feed
|
||||
*/
|
||||
async getComments(courseId: string, feedId: string, limit = 5) {
|
||||
if (!this.commentsByFeedId[feedId]) {
|
||||
this.initComments(feedId, 0);
|
||||
}
|
||||
|
||||
this.commentsByFeedId[feedId].isLoading = true;
|
||||
|
||||
await this.execute<GetNewsfeedCommentsRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].newsfeed[':feedId'].comments.$get({
|
||||
param: { courseId, feedId },
|
||||
query: { limit: String(limit) }
|
||||
}),
|
||||
logContext: 'fetching newsfeed comments',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.commentsByFeedId[feedId] = {
|
||||
items: response.data.items,
|
||||
totalCount: response.data.totalCount,
|
||||
hasMore: response.data.hasMore,
|
||||
isLoading: false,
|
||||
cursor: response.data.nextCursor
|
||||
};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
this.commentsByFeedId[feedId].isLoading = false;
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to fetch comments');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more comments (uses cursor)
|
||||
*/
|
||||
async loadMoreComments(courseId: string, feedId: string, limit = 5) {
|
||||
const commentState = this.commentsByFeedId[feedId];
|
||||
if (!commentState || !commentState.hasMore || commentState.isLoading || !commentState.cursor) {
|
||||
return;
|
||||
}
|
||||
|
||||
commentState.isLoading = true;
|
||||
|
||||
await this.execute<GetNewsfeedCommentsRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].newsfeed[':feedId'].comments.$get({
|
||||
param: { courseId, feedId },
|
||||
query: { cursor: commentState.cursor!, limit: String(limit) }
|
||||
}),
|
||||
logContext: 'loading more newsfeed comments',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.commentsByFeedId[feedId] = {
|
||||
items: [...commentState.items, ...response.data.items],
|
||||
totalCount: response.data.totalCount,
|
||||
hasMore: response.data.hasMore,
|
||||
isLoading: false,
|
||||
cursor: response.data.nextCursor
|
||||
};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
this.commentsByFeedId[feedId].isLoading = false;
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to load more comments');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a newsfeed comment
|
||||
*/
|
||||
async createComment(
|
||||
courseId: string,
|
||||
feedId: string,
|
||||
content: string,
|
||||
author: { id: string; username: string; fullname: string; avatarUrl: string }
|
||||
) {
|
||||
const result = ZNewsfeedCommentCreate.safeParse({ courseNewsfeedId: feedId, content });
|
||||
console.log('result', result);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<CreateNewsfeedCommentRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].newsfeed[':feedId'].comment.$post({
|
||||
param: { courseId, feedId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'creating newsfeed comment',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
snackbar.success('Comment added successfully');
|
||||
|
||||
// Add comment to local state
|
||||
const commentState = this.commentsByFeedId[feedId];
|
||||
if (commentState) {
|
||||
const newComment = {
|
||||
...response.data,
|
||||
// Map author data to the expected flat structure
|
||||
authorProfileId: author.id,
|
||||
authorFullname: author.fullname,
|
||||
authorUsername: author.username,
|
||||
authorAvatarUrl: author.avatarUrl
|
||||
};
|
||||
this.commentsByFeedId[feedId] = {
|
||||
...commentState,
|
||||
items: [newComment, ...commentState.items],
|
||||
totalCount: commentState.totalCount + 1
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to add comment');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a newsfeed comment
|
||||
*/
|
||||
async updateComment(courseId: string, commentId: string, content: string) {
|
||||
const result = ZNewsfeedCommentUpdate.safeParse({ content });
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<UpdateNewsfeedCommentRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].newsfeed.comment[':commentId'].$put({
|
||||
param: { courseId, commentId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'updating newsfeed comment',
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to update comment');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a newsfeed comment
|
||||
*/
|
||||
async deleteComment(courseId: string, feedId: string, commentId: string) {
|
||||
await this.execute<DeleteNewsfeedCommentRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].newsfeed.comment[':commentId'].$delete({
|
||||
param: { courseId, commentId }
|
||||
}),
|
||||
logContext: 'deleting newsfeed comment',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
snackbar.success('Comment deleted successfully');
|
||||
|
||||
// Remove comment from local state
|
||||
const commentState = this.commentsByFeedId[feedId];
|
||||
if (commentState) {
|
||||
this.commentsByFeedId[feedId] = {
|
||||
...commentState,
|
||||
items: commentState.items.filter((item) => String(item.id) !== commentId),
|
||||
totalCount: Math.max(0, commentState.totalCount - 1)
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to delete comment');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const newsfeedApi = new NewsfeedApi();
|
||||
@@ -0,0 +1,295 @@
|
||||
import type {
|
||||
AddPeopleRequest,
|
||||
CreateStudentInviteRequest,
|
||||
DeletePeopleRequest,
|
||||
GetStudentInviteAuditRequest,
|
||||
GetUserCourseAnalyticsRequest,
|
||||
ListStudentInvitesRequest,
|
||||
ListPeopleRequest,
|
||||
RevokeStudentInviteRequest,
|
||||
UpdatePeopleRequest
|
||||
} from '../utils/types';
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
|
||||
import type { TAddCourseMembers } from '@cio/utils/validation/course/people';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
|
||||
/**
|
||||
* API class for course members operations
|
||||
*/
|
||||
export class PeopleApi extends BaseApiWithErrors {
|
||||
/**
|
||||
* Lists all course members for a course
|
||||
* @param courseId Course ID
|
||||
* @returns Array of course members with profile data
|
||||
*/
|
||||
async list(courseId: string) {
|
||||
return this.execute<ListPeopleRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId']['members'].$get({
|
||||
param: { courseId }
|
||||
}),
|
||||
logContext: 'listing course members',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to list course members');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds course members to a course
|
||||
* @param courseId Course ID
|
||||
* @param members Array of members to add
|
||||
* @returns Array of created members or null on error
|
||||
*/
|
||||
async add(courseId: string, members: TAddCourseMembers) {
|
||||
await this.execute<AddPeopleRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId']['members'].$post({
|
||||
param: { courseId },
|
||||
json: members
|
||||
}),
|
||||
logContext: 'adding course members',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
const count = Array.isArray(response.data) ? response.data.length : 1;
|
||||
snackbar.success(`${count} member${count > 1 ? 's' : ''} added successfully`);
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to add course members');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a secure student invite link for a course
|
||||
* @param courseId Course ID
|
||||
* @param payload Invite settings
|
||||
* @returns Invite data including inviteLink
|
||||
*/
|
||||
async createStudentInvite(
|
||||
courseId: string,
|
||||
payload: {
|
||||
preset?: 'ONE_TIME_24H' | 'MULTI_USE_7D' | 'MULTI_USE_30D' | 'CUSTOM';
|
||||
expiresAt?: string;
|
||||
maxUses?: number;
|
||||
allowedEmails?: string[];
|
||||
allowedDomains?: string[];
|
||||
recipientEmails?: string[];
|
||||
recipientCsv?: string;
|
||||
sendEmail?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
) {
|
||||
const result = await this.execute<CreateStudentInviteRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId']['invites'].$post({
|
||||
param: { courseId },
|
||||
json: payload
|
||||
}),
|
||||
logContext: 'creating student invite',
|
||||
onSuccess: () => {
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to create student invite');
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
} else if ('error' in result) {
|
||||
this.errors.general = result.error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result?.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists secure student invites for a course
|
||||
* @param courseId Course ID
|
||||
*/
|
||||
async listStudentInvites(courseId: string) {
|
||||
const result = await this.execute<ListStudentInvitesRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId']['invites'].$get({
|
||||
param: { courseId }
|
||||
}),
|
||||
logContext: 'listing student invites',
|
||||
onSuccess: () => {
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to list invites');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result?.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes a secure student invite
|
||||
* @param courseId Course ID
|
||||
* @param inviteId Invite ID
|
||||
*/
|
||||
async revokeStudentInvite(courseId: string, inviteId: string) {
|
||||
const result = await this.execute<RevokeStudentInviteRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId']['invites'][':inviteId']['revoke'].$post({
|
||||
param: { courseId, inviteId }
|
||||
}),
|
||||
logContext: 'revoking student invite',
|
||||
onSuccess: () => {
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to revoke invite');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result?.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads invite audit events for one invite
|
||||
* @param courseId Course ID
|
||||
* @param inviteId Invite ID
|
||||
*/
|
||||
async getStudentInviteAudit(courseId: string, inviteId: string) {
|
||||
const result = await this.execute<GetStudentInviteAuditRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId']['invites'][':inviteId']['audit'].$get({
|
||||
param: { courseId, inviteId }
|
||||
}),
|
||||
logContext: 'loading invite audit',
|
||||
onSuccess: () => {
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to load invite audit');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result?.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a course member
|
||||
* @param courseId Course ID
|
||||
* @param memberId Member ID
|
||||
* @param data Partial member data to update
|
||||
* @returns The updated member data or null on error
|
||||
*/
|
||||
async update(courseId: string, memberId: string, data: Partial<{ roleId: number; email: string }>) {
|
||||
await this.execute<UpdatePeopleRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId']['members'][':memberId'].$put({
|
||||
param: { courseId, memberId },
|
||||
json: data
|
||||
}),
|
||||
logContext: 'updating course member',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
snackbar.success('Member updated successfully');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to update course member');
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
snackbar.error(result.error);
|
||||
} else if ('error' in result) {
|
||||
this.errors.general = result.error;
|
||||
snackbar.error(result.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a course member
|
||||
* @param courseId Course ID
|
||||
* @param memberId Member ID
|
||||
* @returns The deleted member data or null on error
|
||||
*/
|
||||
async delete(courseId: string, memberId: string) {
|
||||
await this.execute<DeletePeopleRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId']['members'][':memberId'].$delete({
|
||||
param: { courseId, memberId }
|
||||
}),
|
||||
logContext: 'deleting course member',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
snackbar.success('Member removed successfully');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to delete course member');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets user course analytics for a specific course
|
||||
* @param courseId Course ID
|
||||
* @param userId User ID (profile ID)
|
||||
* @returns User course analytics data or null on error
|
||||
*/
|
||||
async getUserCourseAnalytics(courseId: string, userId: string) {
|
||||
return this.execute<GetUserCourseAnalyticsRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId']['members'][':userId']['analytics'].$get({
|
||||
param: { courseId, userId }
|
||||
}),
|
||||
logContext: 'fetching user course analytics',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to fetch user course analytics');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const peopleApi = new PeopleApi();
|
||||
@@ -0,0 +1,79 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
DocumentDownloadPresignRequest,
|
||||
DocumentUploadPresignRequest,
|
||||
VideoDownloadPresignRequest,
|
||||
VideoUploadPresignRequest
|
||||
} from '../utils/types';
|
||||
|
||||
/**
|
||||
* API class for presigned URL requests (upload and download).
|
||||
* All presign calls should go through this class instead of calling classroomio.course.presign directly.
|
||||
*/
|
||||
export class PresignApi extends BaseApiWithErrors {
|
||||
/**
|
||||
* Gets a presigned upload URL for a document.
|
||||
* @returns { url, fileKey } on success
|
||||
*/
|
||||
async getDocumentUploadUrl(fileName: string, fileType: string) {
|
||||
const result = await this.execute<DocumentUploadPresignRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course.presign.document.upload.$post({
|
||||
json: { fileName, fileType }
|
||||
}),
|
||||
logContext: 'getting document upload URL'
|
||||
});
|
||||
return result?.url != null && result?.fileKey != null ? { url: result.url, fileKey: result.fileKey } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets presigned download URLs for documents.
|
||||
* @param keys Array of document storage keys
|
||||
* @returns Record mapping keys to presigned URLs
|
||||
*/
|
||||
async getDocumentDownloadUrls(keys: string[]) {
|
||||
if (keys.length === 0) return {};
|
||||
const result = await this.execute<DocumentDownloadPresignRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course.presign.document.download.$post({
|
||||
json: { keys }
|
||||
}),
|
||||
logContext: 'getting document download URLs'
|
||||
});
|
||||
return result?.urls ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a presigned upload URL for a video.
|
||||
* @returns { url, fileKey } on success
|
||||
*/
|
||||
async getVideoUploadUrl(fileName: string, fileType: string) {
|
||||
const result = await this.execute<VideoUploadPresignRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course.presign.video.upload.$post({
|
||||
json: { fileName, fileType }
|
||||
}),
|
||||
logContext: 'getting video upload URL'
|
||||
});
|
||||
return result?.url != null && result?.fileKey != null ? { url: result.url, fileKey: result.fileKey } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets presigned download URLs for videos.
|
||||
* @param keys Array of video storage keys
|
||||
* @returns Record mapping keys to presigned URLs
|
||||
*/
|
||||
async getVideoDownloadUrls(keys: string[]) {
|
||||
if (keys.length === 0) return {};
|
||||
const result = await this.execute<VideoDownloadPresignRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course.presign.video.download.$post({
|
||||
json: { keys }
|
||||
}),
|
||||
logContext: 'getting video download URLs'
|
||||
});
|
||||
return result?.urls ?? {};
|
||||
}
|
||||
}
|
||||
|
||||
export const presignApi = new PresignApi();
|
||||
@@ -0,0 +1,162 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
DeleteSubmissionRequest,
|
||||
UpdateSubmissionAnswerRequest,
|
||||
UpdateSubmissionGradesRequest,
|
||||
UpdateSubmissionRequest
|
||||
} from '../utils/types';
|
||||
import type {
|
||||
TSubmissionAnswerUpdate,
|
||||
TSubmissionGradesUpdate,
|
||||
TSubmissionUpdate
|
||||
} from '@cio/utils/validation/submission';
|
||||
import { ZSubmissionAnswerUpdate, ZSubmissionGradesUpdate, ZSubmissionUpdate } from '@cio/utils/validation/submission';
|
||||
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
|
||||
/**
|
||||
* API class for submission operations
|
||||
*/
|
||||
export class SubmissionApi extends BaseApiWithErrors {
|
||||
/**
|
||||
* Updates a submission
|
||||
*/
|
||||
async update(courseId: string, submissionId: string, fields: TSubmissionUpdate) {
|
||||
const result = ZSubmissionUpdate.safeParse(fields);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'submission');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<UpdateSubmissionRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].submission[':submissionId'].$put({
|
||||
param: { courseId, submissionId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'updating submission',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
snackbar.success('Submission updated successfully');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to update submission');
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
snackbar.error(result.error);
|
||||
} else if ('error' in result) {
|
||||
this.errors.general = result.error;
|
||||
snackbar.error(result.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a submission
|
||||
*/
|
||||
async delete(courseId: string, submissionId: string) {
|
||||
await this.execute<DeleteSubmissionRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].submission[':submissionId'].$delete({
|
||||
param: { courseId, submissionId }
|
||||
}),
|
||||
logContext: 'deleting submission',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
snackbar.success('Submission deleted successfully');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to delete submission');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates all grades (answer points + total + feedback) in a single request
|
||||
*/
|
||||
async updateGrades(courseId: string, submissionId: string, data: TSubmissionGradesUpdate) {
|
||||
const result = ZSubmissionGradesUpdate.safeParse(data);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'submission');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<UpdateSubmissionGradesRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].submission[':submissionId'].grades.$put({
|
||||
param: { courseId, submissionId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'updating submission grades',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
snackbar.success('snackbar.submissions.success.grading');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to update grades');
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
snackbar.error(result.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a question answer in a submission.
|
||||
* Does not show a snackbar (caller shows one when saving multiple answers).
|
||||
*/
|
||||
async updateAnswer(courseId: string, submissionId: string, data: TSubmissionAnswerUpdate) {
|
||||
const result = ZSubmissionAnswerUpdate.safeParse(data);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'submission');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<UpdateSubmissionAnswerRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].submission[':submissionId'].answer.$put({
|
||||
param: { courseId, submissionId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'updating submission answer',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to update answer');
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
snackbar.error(result.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const submissionApi = new SubmissionApi();
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle?: string;
|
||||
icon: Component;
|
||||
iconColor?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { title, value, subtitle = '', icon, iconColor = 'blue', className = '' }: Props = $props();
|
||||
|
||||
let colorClasses = $derived({
|
||||
blue: 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400',
|
||||
green: 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400',
|
||||
purple: 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
|
||||
orange: 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400',
|
||||
red: 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400',
|
||||
yellow: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400'
|
||||
});
|
||||
|
||||
const SvelteComponent = $derived(icon);
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800 {className}">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg {colorClasses[iconColor]}">
|
||||
<SvelteComponent className={colorClasses[iconColor]} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">{title}</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{value}
|
||||
</p>
|
||||
{#if subtitle}
|
||||
<p class="text-xs text-gray-500 capitalize dark:text-gray-500">
|
||||
{subtitle}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import * as Chart from '@cio/ui/base/chart';
|
||||
import type { CourseAnalytics } from '$features/course/utils/types';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
courseAnalytics?: CourseAnalytics | null;
|
||||
}
|
||||
|
||||
type DistributionChartData = {
|
||||
group: string;
|
||||
value: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
let { courseAnalytics = null }: Props = $props();
|
||||
|
||||
const chartConfig: Chart.ChartConfig = {};
|
||||
|
||||
let hasStudentData = $derived(Boolean(courseAnalytics?.students?.length));
|
||||
|
||||
// Transform data for progress distribution chart
|
||||
let progressChartData = $derived<DistributionChartData[]>(
|
||||
courseAnalytics?.students
|
||||
? [
|
||||
{
|
||||
group: $t('analytics.progress_80_plus'),
|
||||
value: courseAnalytics.students.filter((s) => s.progressPercentage >= 80).length,
|
||||
color: '#10b981'
|
||||
},
|
||||
{
|
||||
group: $t('analytics.progress_60_79'),
|
||||
value: courseAnalytics.students.filter((s) => s.progressPercentage >= 60 && s.progressPercentage < 80)
|
||||
.length,
|
||||
color: '#f59e0b'
|
||||
},
|
||||
{
|
||||
group: $t('analytics.progress_below_60'),
|
||||
value: courseAnalytics.students.filter((s) => s.progressPercentage < 60).length,
|
||||
color: '#ef4444'
|
||||
}
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
// Transform data for grade distribution chart
|
||||
let gradeChartData = $derived<DistributionChartData[]>(
|
||||
courseAnalytics?.students
|
||||
? [
|
||||
{
|
||||
group: $t('analytics.grade_90_plus'),
|
||||
value: courseAnalytics.students.filter((s) => s.averageGrade >= 90).length,
|
||||
color: '#10b981'
|
||||
},
|
||||
{
|
||||
group: $t('analytics.grade_80_89'),
|
||||
value: courseAnalytics.students.filter((s) => s.averageGrade >= 80 && s.averageGrade < 90).length,
|
||||
color: '#3b82f6'
|
||||
},
|
||||
{
|
||||
group: $t('analytics.grade_70_79'),
|
||||
value: courseAnalytics.students.filter((s) => s.averageGrade >= 70 && s.averageGrade < 80).length,
|
||||
color: '#f59e0b'
|
||||
},
|
||||
{
|
||||
group: $t('analytics.grade_below_70'),
|
||||
value: courseAnalytics.students.filter((s) => s.averageGrade < 70).length,
|
||||
color: '#ef4444'
|
||||
}
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
let progressChartProps = $derived({
|
||||
xAxis: {
|
||||
label: $t('analytics.progress_range')
|
||||
},
|
||||
yAxis: {
|
||||
label: $t('analytics.number_of_students')
|
||||
}
|
||||
});
|
||||
|
||||
let gradeChartProps = $derived({
|
||||
xAxis: {
|
||||
label: $t('analytics.grade_range')
|
||||
},
|
||||
yAxis: {
|
||||
label: $t('analytics.number_of_students')
|
||||
}
|
||||
});
|
||||
|
||||
let progressChartColors = $derived({
|
||||
domain: progressChartData.map((item) => item.group),
|
||||
range: progressChartData.map((item) => item.color)
|
||||
});
|
||||
|
||||
let gradeChartColors = $derived({
|
||||
domain: gradeChartData.map((item) => item.group),
|
||||
range: gradeChartData.map((item) => item.color)
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||
<!-- Progress Distribution Chart -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
|
||||
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{$t('analytics.student_progress_distribution')}
|
||||
</h3>
|
||||
{#if !courseAnalytics}
|
||||
<div class="flex h-[300px] items-center justify-center">
|
||||
<div class="animate-pulse text-gray-500 dark:text-gray-400">{$t('analytics.loading_chart')}</div>
|
||||
</div>
|
||||
{:else if hasStudentData}
|
||||
<Chart.ChartContainer class="h-[300px] w-full" config={chartConfig}>
|
||||
<Chart.BarChart
|
||||
data={progressChartData}
|
||||
x="group"
|
||||
y="value"
|
||||
c="group"
|
||||
cDomain={progressChartColors.domain}
|
||||
cRange={progressChartColors.range}
|
||||
props={progressChartProps}
|
||||
/>
|
||||
</Chart.ChartContainer>
|
||||
{:else}
|
||||
<div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
{$t('analytics.no_progress_data')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Grade Distribution Chart -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
|
||||
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">{$t('analytics.grade_distribution')}</h3>
|
||||
{#if !courseAnalytics}
|
||||
<div class="flex h-[300px] items-center justify-center">
|
||||
<div class="animate-pulse text-gray-500 dark:text-gray-400">{$t('analytics.loading_chart')}</div>
|
||||
</div>
|
||||
{:else if hasStudentData}
|
||||
<Chart.ChartContainer class="h-[300px] w-full" config={chartConfig}>
|
||||
<Chart.BarChart
|
||||
data={gradeChartData}
|
||||
x="group"
|
||||
y="value"
|
||||
c="group"
|
||||
cDomain={gradeChartColors.domain}
|
||||
cRange={gradeChartColors.range}
|
||||
props={gradeChartProps}
|
||||
/>
|
||||
</Chart.ChartContainer>
|
||||
{:else}
|
||||
<div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
{$t('analytics.no_grade_data')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen">
|
||||
<div class="px-4 py-8">
|
||||
<!-- Course Overview Cards Skeleton -->
|
||||
<div class="mb-12">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{#each Array(4) as _, i (i)}
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
|
||||
<div class="flex items-center space-x-4">
|
||||
<Skeleton class="h-[48px] w-[48px]" />
|
||||
<div class="flex-1">
|
||||
<Skeleton class="h-[48px] w-[48px]" />
|
||||
<Skeleton class="mb-2 h-[16px] w-[80px]" />
|
||||
<Skeleton class="mb-1 h-[24px] w-[40px]" />
|
||||
<Skeleton class="h-[12px] w-[60px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Section Skeleton -->
|
||||
<div class="mb-12">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
|
||||
<Skeleton class="mb-6 h-[24px] w-[200px]" />
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Skeleton class="h-[300px] w-full" />
|
||||
<Skeleton class="h-[300px] w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Students Table Skeleton -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white dark:border-neutral-700 dark:bg-neutral-800">
|
||||
<div class="border-b border-gray-200 px-6 py-4 dark:border-neutral-700">
|
||||
<Skeleton class="mb-6 h-[24px] w-[200px]" />
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
{#each Array(5) as _, i (i)}
|
||||
<div class="flex items-center space-x-4">
|
||||
<Skeleton class="h-[32px] w-[32px]" />
|
||||
<div class="flex-1">
|
||||
<Skeleton class="mb-1 h-[16px] w-[150px]" />
|
||||
<Skeleton class="h-[12px] w-[200px]" />
|
||||
</div>
|
||||
<Skeleton class="h-[16px] w-[80px]" />
|
||||
<Skeleton class="h-[16px] w-[60px]" />
|
||||
<Skeleton class="h-[20px] w-[40px]" />
|
||||
<Skeleton class="h-[12px] w-[80px]" />
|
||||
<Skeleton class="h-[32px] w-[100px]" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
import ChartBarIcon from '@lucide/svelte/icons/chart-bar';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: Component;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
title = 'No data available',
|
||||
description = 'There is no data to display at this time.',
|
||||
icon = ChartBarIcon,
|
||||
className = ''
|
||||
}: Props = $props();
|
||||
|
||||
const SvelteComponent = $derived(icon);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center justify-center px-4 py-12 {className}">
|
||||
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800">
|
||||
<SvelteComponent size="24" class="text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-medium text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
<p class="max-w-sm text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
class="h-6 w-6 {className}"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
class="h-6 w-6 {className}"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 5a2 2 0 012-2h8a2 2 0 012 2v6a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 1a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3zm-3 4a1 1 0 100 2h.01a1 1 0 100-2H7zm3 0a1 1 0 100 2h3a1 1 0 100-2h-3z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
class="h-6 w-6 {className}"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
class="h-6 w-6 {className}"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3z" />
|
||||
</svg>
|
||||
@@ -0,0 +1,210 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import * as Pagination from '@cio/ui/base/pagination';
|
||||
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
import EmptyState from './empty-state.svelte';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import type { CourseAnalytics } from '$features/course/utils/types';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
|
||||
interface Props {
|
||||
students?: CourseAnalytics['students'];
|
||||
}
|
||||
|
||||
let { students = [] }: Props = $props();
|
||||
|
||||
// Pagination state
|
||||
let currentPage = $state(1);
|
||||
const pageSize = 15;
|
||||
|
||||
// Computed values
|
||||
let totalPages = $derived(Math.ceil(students.length / pageSize));
|
||||
let startIndex = $derived((currentPage - 1) * pageSize);
|
||||
let endIndex = $derived(startIndex + pageSize);
|
||||
let paginatedStudents = $derived(students.slice(startIndex, endIndex));
|
||||
let startItem = $derived(startIndex + 1);
|
||||
let endItem = $derived(Math.min(endIndex, students.length));
|
||||
|
||||
const GotoFullProfile = (student: CourseAnalytics['students'][number]) => {
|
||||
if (!student) return;
|
||||
|
||||
goto(`/courses/${courseApi.course?.id}/people/${student.id}?back=/courses/${courseApi.course?.id}/analytics`);
|
||||
};
|
||||
|
||||
// Reset to first page when students array changes
|
||||
$effect(() => {
|
||||
if (students.length > 0 && currentPage > totalPages) {
|
||||
currentPage = 1;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if students.length === 0}
|
||||
<EmptyState
|
||||
title={$t('analytics.no_students_found')}
|
||||
description={$t('analytics.no_students_description')}
|
||||
icon={UserIcon}
|
||||
/>
|
||||
{:else}
|
||||
<!-- Responsive Table -->
|
||||
<div class="relative h-[300px] overflow-hidden">
|
||||
<div class="h-full overflow-auto">
|
||||
<table class="w-full min-w-[800px]">
|
||||
<!-- Fixed Header -->
|
||||
<thead class="sticky top-0 z-10 bg-white dark:bg-neutral-800">
|
||||
<tr class="border-b border-gray-200">
|
||||
<th
|
||||
class="sticky top-0 left-0 z-20 min-w-[200px] bg-white px-4 py-3 text-left text-sm font-medium whitespace-nowrap text-gray-500 dark:bg-neutral-800 dark:text-gray-300"
|
||||
>
|
||||
{$t('analytics.student_name')}
|
||||
</th>
|
||||
<th
|
||||
class="min-w-[150px] px-4 py-3 text-left text-sm font-medium whitespace-nowrap text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
{$t('analytics.lessons_completed')}
|
||||
</th>
|
||||
<th
|
||||
class="min-w-[140px] px-4 py-3 text-left text-sm font-medium whitespace-nowrap text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
{$t('analytics.exercises_submitted')}
|
||||
</th>
|
||||
<th
|
||||
class="min-w-[120px] px-4 py-3 text-left text-sm font-medium whitespace-nowrap text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
{$t('analytics.average_grade')}
|
||||
</th>
|
||||
<th
|
||||
class="min-w-[120px] px-4 py-3 text-left text-sm font-medium whitespace-nowrap text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
{$t('analytics.last_seen')}
|
||||
</th>
|
||||
<th
|
||||
class="min-w-[120px] px-4 py-3 text-left text-sm font-medium whitespace-nowrap text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
{$t('analytics.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-600">
|
||||
{#each paginatedStudents as student}
|
||||
<tr class="group h-[100px] border-b hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<!-- Fixed Name Column -->
|
||||
<td
|
||||
class="sticky left-0 z-10 min-w-[200px] bg-white px-4 py-3 whitespace-nowrap transition-colors group-hover:bg-gray-50 dark:bg-neutral-800 dark:group-hover:bg-gray-700"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar.Root class="size-8">
|
||||
<Avatar.Image
|
||||
src={student.profile.avatar_url ? student.profile.avatar_url : '/logo-192.png'}
|
||||
alt={student.profile.fullname ? student.profile.fullname : $t('analytics.student')}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(student.profile.fullname) || 'S'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium text-gray-900 dark:text-white">
|
||||
{student.profile.fullname}
|
||||
</p>
|
||||
<p class="truncate text-sm text-gray-500 dark:text-gray-400">
|
||||
{student.profile.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Scrollable Content -->
|
||||
<td class="min-w-[150px] px-4 py-3 whitespace-nowrap">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{student.lessonsCompleted}/{student.totalLessons}
|
||||
</span>
|
||||
<div class="w-20 flex-shrink-0">
|
||||
<div class="h-2 rounded-full bg-gray-200 dark:bg-neutral-700">
|
||||
<div class="h-2 rounded-full bg-blue-500" style="width: {student.progressPercentage}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="min-w-[140px] px-4 py-3 whitespace-nowrap">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{student.exercisesSubmitted} out of {student.totalExercises}
|
||||
</span>
|
||||
</td>
|
||||
<td class="min-w-[120px] px-4 py-3 whitespace-nowrap">
|
||||
<Badge
|
||||
class={student.averageGrade >= 80
|
||||
? 'bg-green-600'
|
||||
: student.averageGrade >= 60
|
||||
? 'bg-blue-600'
|
||||
: 'bg-red-600'}
|
||||
>
|
||||
{student.averageGrade}%
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="min-w-[120px] px-4 py-3 whitespace-nowrap">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{student.lastSeen}
|
||||
</span>
|
||||
</td>
|
||||
<td class="min-w-[120px] px-4 py-3 whitespace-nowrap">
|
||||
<button
|
||||
class="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium whitespace-nowrap text-white transition-colors hover:bg-blue-700"
|
||||
onclick={() => GotoFullProfile(student)}
|
||||
>
|
||||
{$t('analytics.view_details')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
{#if students.length > pageSize}
|
||||
<div class="mt-4 flex items-center justify-between border-t border-gray-200 px-4 py-3">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{$t('analytics.showing_students', {
|
||||
start: startItem,
|
||||
end: endItem,
|
||||
total: students.length
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Pagination.Root
|
||||
count={students.length}
|
||||
perPage={pageSize}
|
||||
page={currentPage}
|
||||
onPageChange={(page) => (currentPage = page)}
|
||||
>
|
||||
{#snippet children({ pages, currentPage: activePage })}
|
||||
<Pagination.Content>
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton />
|
||||
</Pagination.Item>
|
||||
{#each pages as page (page.key)}
|
||||
{#if page.type === 'ellipsis'}
|
||||
<Pagination.Item>
|
||||
<Pagination.Ellipsis />
|
||||
</Pagination.Item>
|
||||
{:else}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link {page} isActive={activePage === page.value}>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="ui:data-[state=open]:opacity-100 absolute right-6 top-6 z-40 flex items-center justify-center opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100"
|
||||
class="ui:data-[state=open]:opacity-100 absolute top-6 right-6 z-40 flex items-center justify-center opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<IconButton variant="outline">
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
</script>
|
||||
|
||||
<div class="mb-4 mr-4 h-[370px] w-72 rounded-md border p-2 transition ease-in-out">
|
||||
<div class="mr-4 mb-4 h-[370px] w-72 rounded-md border p-2 transition ease-in-out">
|
||||
<Skeleton class="h-[45%] w-full" />
|
||||
<div class="flex h-[50%] flex-col justify-between pb-2 pt-4">
|
||||
<div class="flex h-[50%] flex-col justify-between pt-4 pb-2">
|
||||
<!-- Title -->
|
||||
<Skeleton class="h-4 w-2/4" />
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mb-6 mt-4 flex flex-col gap-2">
|
||||
<div class="mt-4 mb-6 flex flex-col gap-2">
|
||||
<Skeleton class=" h-4 w-full" />
|
||||
<Skeleton class=" h-4 w-full" />
|
||||
<Skeleton class=" h-4 w-full" />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
import get from 'lodash/get';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
@@ -13,16 +12,19 @@
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
|
||||
import pluralize from 'pluralize';
|
||||
|
||||
import { Image } from '$features/ui';
|
||||
import { COURSE_TYPE, type Course } from '$lib/utils/types';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { calcCourseDiscount } from '$lib/utils/functions/course';
|
||||
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
|
||||
import { calcProgressRate } from '$features/course/utils/functions';
|
||||
import { calcCourseProgress, calcProgressRate } from '$features/course/utils/functions';
|
||||
import CardDropdown from './card-dropdown.svelte';
|
||||
import type { OrgCourses, UserEnrolledCourses } from '$features/course/types';
|
||||
import type { OrgPublicCourses } from '$features/org/utils/types';
|
||||
|
||||
export interface Props {
|
||||
course: Course;
|
||||
course: OrgCourses[number] | UserEnrolledCourses[number] | OrgPublicCourses[number];
|
||||
isOnLandingPage?: boolean;
|
||||
isLMS?: boolean;
|
||||
isExplore?: boolean;
|
||||
@@ -30,8 +32,6 @@
|
||||
|
||||
let { course, isOnLandingPage, isLMS, isExplore }: Props = $props();
|
||||
|
||||
$inspect('isLMS', isLMS);
|
||||
|
||||
let {
|
||||
bannerImage,
|
||||
id = '',
|
||||
@@ -56,19 +56,38 @@
|
||||
title: course.title,
|
||||
type: course.type,
|
||||
description: course.description,
|
||||
isPublished: course.is_published,
|
||||
isPublished: course.isPublished,
|
||||
pricingData: {
|
||||
cost: course.cost,
|
||||
discount: course.metadata?.discount || 0,
|
||||
showDiscount: course.metadata?.showDiscount || false
|
||||
},
|
||||
currency: course.currency,
|
||||
totalLessons: get(course, 'lessons[0].count', 0),
|
||||
progressRate: calcProgressRate(course.progress_rate, course.total_lessons),
|
||||
totalStudents: course.total_students
|
||||
totalLessons: course.lessonCount,
|
||||
progressRate: (() => {
|
||||
const c = course as { exerciseCount?: number; exercisesCompleted?: number };
|
||||
if (typeof c.exerciseCount === 'number' && typeof c.exercisesCompleted === 'number') {
|
||||
return calcCourseProgress({
|
||||
lessonsCompleted: 'progressRate' in course ? (course.progressRate ?? 0) : 0,
|
||||
totalLessons: course.lessonCount ?? 0,
|
||||
exercisesCompleted: c.exercisesCompleted,
|
||||
totalExercises: c.exerciseCount
|
||||
});
|
||||
}
|
||||
return calcProgressRate('progressRate' in course ? course.progressRate : 0, course.lessonCount);
|
||||
})(),
|
||||
totalStudents: 'totalStudents' in course ? course.totalStudents : 0
|
||||
});
|
||||
|
||||
let formatter = $derived(getCurrencyFormatter(currency));
|
||||
const courseTags = $derived(
|
||||
('tags' in course && Array.isArray(course.tags) ? course.tags : []) as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
color?: string | null;
|
||||
}>
|
||||
);
|
||||
|
||||
const COURSE_TAG: Record<
|
||||
string,
|
||||
@@ -79,13 +98,13 @@
|
||||
iconStyle?: string;
|
||||
}
|
||||
> = {
|
||||
[COURSE_TYPE.LIVE_CLASS]: {
|
||||
['LIVE_CLASS']: {
|
||||
style: '',
|
||||
label: $t('course.navItem.settings.live_class'),
|
||||
icon: CircleDotIcon,
|
||||
iconStyle: 'custom text-red-700'
|
||||
},
|
||||
[COURSE_TYPE.SELF_PACED]: {
|
||||
['SELF_PACED']: {
|
||||
style: '',
|
||||
label: $t('course.navItem.settings.self_paced'),
|
||||
icon: UserIcon,
|
||||
@@ -105,7 +124,7 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<Item.Root variant="outline" class="p-3! group relative max-w-[320px]">
|
||||
<Item.Root variant="outline" class="group relative max-w-[320px] p-3!">
|
||||
{#snippet child({ props })}
|
||||
<a href={resolve(courseUrl, {})} {...props}>
|
||||
{#if !isLMS && !isOnLandingPage}
|
||||
@@ -113,12 +132,12 @@
|
||||
{/if}
|
||||
|
||||
<Item.Header>
|
||||
<Item.Media variant="image" class="h-50! w-full! relative">
|
||||
<Item.Media variant="image" class="relative h-50! w-full!">
|
||||
<Image src={bannerImage} alt="Course banner image" className="w-full h-full rounded-sm object-cover" />
|
||||
|
||||
{#if type}
|
||||
{@const tag = COURSE_TAG[type]}
|
||||
<Badge class="rounded-md! absolute bottom-2 left-2 z-10 flex items-center capitalize" variant="secondary">
|
||||
<Badge class="absolute bottom-2 left-2 z-10 flex items-center rounded-md! capitalize" variant="secondary">
|
||||
<tag.icon class={tag.iconStyle} />
|
||||
{tag.label}
|
||||
</Badge>
|
||||
@@ -127,11 +146,25 @@
|
||||
</Item.Header>
|
||||
|
||||
<Item.Content>
|
||||
<Item.Title class="text-base! line-clamp-1 min-h-[44px]">
|
||||
<Item.Title class="line-clamp-1 min-h-[44px] text-base!">
|
||||
{title}
|
||||
</Item.Title>
|
||||
|
||||
<Item.Description class="min-h-[63px]">{description}</Item.Description>
|
||||
<div class="ui:text-muted-foreground flex min-h-[12px] flex-wrap items-center gap-1">
|
||||
{#if !isLMS && courseTags.length > 0}
|
||||
{#each courseTags as tag (tag.id)}
|
||||
<Item.SubDescription class="border-border inline-flex items-center gap-1.5 rounded-full border px-2">
|
||||
<span
|
||||
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 rounded-full"
|
||||
style={tag.color ? `background-color: ${tag.color}` : undefined}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span>{tag.name}</span>
|
||||
</Item.SubDescription>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Separator class="my-3" />
|
||||
|
||||
@@ -149,7 +182,7 @@
|
||||
{:else if pricingData.showDiscount}
|
||||
{formatter.format(cost)}
|
||||
<span class="line-through">
|
||||
{formatter?.format(pricingData?.cost)}
|
||||
{formatter?.format(pricingData?.cost ?? 0)}
|
||||
</span>
|
||||
{:else}
|
||||
{formatter.format(cost)}
|
||||
@@ -158,7 +191,7 @@
|
||||
{:else if isLMS}
|
||||
{#if !isExplore}
|
||||
<div class="flex w-3/4 items-center gap-2">
|
||||
<Progress value={50} />
|
||||
<Progress value={progressRate} />
|
||||
<p class="ui:text-muted-foreground text-xs">{progressRate}%</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -183,8 +216,7 @@
|
||||
{:else if !isOnLandingPage}
|
||||
<div class="flex flex-col justify-between">
|
||||
<p class="pl-2 text-xs dark:text-white">
|
||||
{totalStudents}
|
||||
{$t('courses.course_card.students')}
|
||||
{pluralize($t('courses.course_card.students'), totalStudents, true)}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import ZapIcon from '@lucide/svelte/icons/zap';
|
||||
import * as RadioGroup from '@cio/ui/base/radio-group';
|
||||
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
|
||||
import { saveCertificateValidation } from '$lib/utils/functions/validator';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
import Plain from './templates/plain.svelte';
|
||||
import Professional from './templates/professional.svelte';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { UpgradeBanner } from '$features/ui';
|
||||
import BlueBadgePattern from './templates/blue-badge-pattern.svelte';
|
||||
import PurpleBadgePattern from './templates/purple-badge-pattern.svelte';
|
||||
import BlueProfessionalBadge from './templates/blue-professional-badge.svelte';
|
||||
import PurpleProfessionalBadge from './templates/purple-professional-badge.svelte';
|
||||
|
||||
const studentNamePlaceholder = 'Name of student';
|
||||
const themes = [
|
||||
'professional',
|
||||
'plain',
|
||||
'purpleProfessionalBadge',
|
||||
'blueProfessionalBadge',
|
||||
'purpleBadgePattern',
|
||||
'blueBadgePattern'
|
||||
];
|
||||
|
||||
let errors = $state({
|
||||
description: ''
|
||||
});
|
||||
|
||||
const helperText = $derived(
|
||||
`${courseApi.course?.description?.length || 0}/200 ${$t('course.navItem.certificates.characters')}`
|
||||
);
|
||||
|
||||
let certificateTheme = $derived<string | undefined>(courseApi.course?.certificateTheme ?? undefined);
|
||||
|
||||
const saveCertificate = async () => {
|
||||
if (!courseApi.course) return;
|
||||
|
||||
// Prevent free plan users from bypassing UI restrictions
|
||||
if ($isFreePlan) {
|
||||
errors.description = 'Certificate customization is only available on paid plans';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = saveCertificateValidation({
|
||||
description: courseApi.course?.description || '',
|
||||
isCertificateDownloadable: courseApi.course?.isCertificateDownloadable || false,
|
||||
certificateTheme: certificateTheme || ''
|
||||
});
|
||||
|
||||
if (result && Object.keys(result).length > 0) {
|
||||
errors.description = $t(result.description) || $t('course.navItem.certificates.description_error');
|
||||
throw new Error(errors.description);
|
||||
}
|
||||
|
||||
errors.description = '';
|
||||
|
||||
await courseApi.update(courseApi.course.id!, {
|
||||
description: courseApi.course?.description || '',
|
||||
isCertificateDownloadable: courseApi.course?.isCertificateDownloadable || false,
|
||||
certificateTheme: certificateTheme || ''
|
||||
});
|
||||
|
||||
snackbar.success('snackbar.course_settings.success.saved');
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
errors.description = error.message;
|
||||
} else {
|
||||
errors.description = $t('course.navItem.certificates.unexpected_error');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Qwitcher+Grypen&family=Roboto:wght@300;400;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<UpgradeBanner>{$t('upgrade.certificate')}</UpgradeBanner>
|
||||
|
||||
<main class="mt-2 px-2 md:-mr-3 md:-ml-3">
|
||||
<div class="mb-3 flex h-4/5 w-full flex-1 flex-col justify-between gap-3 lg:flex-row">
|
||||
<section class="h-full w-full lg:w-2/5">
|
||||
<strong class="my-2 text-base font-semibold text-black dark:text-gray-100"
|
||||
>{$t('course.navItem.certificates.certificate_settings')}</strong
|
||||
>
|
||||
<p class="my-4 text-xs font-normal dark:text-gray-100">
|
||||
{$t('course.navItem.certificates.theme')}
|
||||
</p>
|
||||
|
||||
<RadioGroup.Root bind:value={certificateTheme} disabled={$isFreePlan} class="mb-10">
|
||||
<div class="flex flex-wrap justify-between gap-y-5">
|
||||
{#each themes as theme}
|
||||
<div class="mr-3 flex items-start space-x-2">
|
||||
<RadioGroup.Item value={theme} id={theme} />
|
||||
<Label for={theme} class="cursor-pointer">
|
||||
<img src={`/images/certificate_theme_${theme}.png`} alt="themes" class="h-[82px] w-[110px]" />
|
||||
</Label>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
|
||||
<div>
|
||||
<p class="my-2 text-xs font-normal text-black dark:text-gray-100">
|
||||
{$t('course.navItem.certificates.logo')}
|
||||
</p>
|
||||
<div>
|
||||
<p class="mt-1 text-base dark:text-gray-100">
|
||||
{$t('course.navItem.certificates.to_update')}
|
||||
<strong class="font-semibold">{$t('course.navItem.certificates.settings')}</strong>
|
||||
{$t('course.navItem.certificates.and_upload')}
|
||||
</p>
|
||||
|
||||
<Button variant="outline" class="mt-3" onclick={() => goto(`/org/${$currentOrg.siteName}/settings`)}>
|
||||
{$t('course.navItem.certificates.goto_settings')}
|
||||
</Button>
|
||||
</div>
|
||||
<span class="my-4">
|
||||
<p class="mt-4 mb-2 text-xs font-normal dark:text-gray-100">
|
||||
{$t('course.navItem.certificates.description')}
|
||||
</p>
|
||||
<TextareaField
|
||||
rows={6}
|
||||
placeholder={$t('course.navItem.certificates.placeholder')}
|
||||
value={courseApi.course?.description}
|
||||
onchange={(e) => (courseApi.course!.description = e.currentTarget.value)}
|
||||
errorMessage={errors.description}
|
||||
disabled={$isFreePlan}
|
||||
helperMessage={helperText}
|
||||
/>
|
||||
</span>
|
||||
<div class="my-4 flex items-center space-x-2">
|
||||
<Switch
|
||||
id="certificate-downloadable"
|
||||
checked={!!courseApi.course?.isCertificateDownloadable}
|
||||
onCheckedChange={(checked) => (courseApi.course!.isCertificateDownloadable = checked)}
|
||||
disabled={$isFreePlan}
|
||||
/>
|
||||
<Label for="certificate-downloadable" class="text-sm font-medium dark:text-gray-100">
|
||||
{$t('course.navItem.certificates.allow')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="flex w-full items-center justify-center rounded-md bg-gray-100 lg:w-3/5 dark:bg-neutral-800">
|
||||
<div class="certificate-container flex items-center justify-center">
|
||||
{#if certificateTheme === 'professional'}
|
||||
<Professional studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'plain'}
|
||||
<Plain studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'purpleProfessionalBadge'}
|
||||
<PurpleProfessionalBadge studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'blueProfessionalBadge'}
|
||||
<BlueProfessionalBadge studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'purpleBadgePattern'}
|
||||
<PurpleBadgePattern studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'blueBadgePattern'}
|
||||
<BlueBadgePattern studentName={studentNamePlaceholder} />
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="h-1/5">
|
||||
<Button variant="secondary" onclick={saveCertificate} loading={courseApi.isLoading} disabled={$isFreePlan}>
|
||||
{#if $isFreePlan}
|
||||
<ZapIcon size={16} class="filled" />
|
||||
{/if}
|
||||
{$t('course.navItem.certificates.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
p {
|
||||
color: #262626;
|
||||
}
|
||||
.certificate-container {
|
||||
width: 95%;
|
||||
max-width: 95%;
|
||||
}
|
||||
</style>
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
<script>
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
|
||||
import { issueCertificateModal, resetForm } from './store';
|
||||
import { preventDefault } from '$lib/utils/functions/svelte';
|
||||
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
let isAutomatic = $state(false);
|
||||
|
||||
const issueCertificate = () => {
|
||||
resetForm();
|
||||
};
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={$issueCertificateModal.open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) resetForm();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-3/5">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Send Certificate</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<main>
|
||||
<div>
|
||||
<div class="mb-4 flex items-center space-x-2">
|
||||
<Switch id="auto-certificate" bind:checked={isAutomatic} />
|
||||
<Label for="auto-certificate" class="text-sm font-medium">Automatic</Label>
|
||||
</div>
|
||||
<p class="my-4 text-sm font-medium">
|
||||
If you set this as automatic, certificates will be issued after the learner completes the course.
|
||||
</p>
|
||||
</div>
|
||||
<p class="my-4 text-xs font-normal text-gray-500">or send a personalised/ custom certificate below:</p>
|
||||
<form onsubmit={preventDefault(issueCertificate)}>
|
||||
<div class="flex w-full flex-col gap-2 md:flex-row">
|
||||
<InputField
|
||||
label="Email address of the student"
|
||||
className="w-full my-4"
|
||||
labelClassName="text-xs font-normal"
|
||||
placeholder="email,comma seperated"
|
||||
bind:value={$issueCertificateModal.email}
|
||||
/>
|
||||
<InputField
|
||||
label="Schedule date"
|
||||
className="w-full my-4"
|
||||
labelClassName="text-xs font-normal"
|
||||
placeholder="12/06/2023"
|
||||
bind:value={$issueCertificateModal.date}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextareaField
|
||||
label="Add a personalized message"
|
||||
labelClassName="text-xs font-normal"
|
||||
bind:value={$issueCertificateModal.message}
|
||||
rows={2}
|
||||
placeholder="your message here"
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex w-full items-end justify-end">
|
||||
<Button type="submit">Issue certificate</Button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,85 @@
|
||||
export const CertificateReportData = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Total students taking course',
|
||||
value: 100
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Number of certificates issued ',
|
||||
value: 45
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Certificates left to be issued',
|
||||
value: 55
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Personalised cetificates',
|
||||
value: 15
|
||||
}
|
||||
];
|
||||
|
||||
export const students = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: false
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: false
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: true
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: false
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: false
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: false
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: false
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: false
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: false
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: true
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,130 @@
|
||||
<script>
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
import * as Pagination from '@cio/ui/base/pagination';
|
||||
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
|
||||
import { CertificateReportData, students } from './mockData';
|
||||
|
||||
let searchValue = $state('');
|
||||
let selectAll = $state(false);
|
||||
let selectedStudents = $state(new Set());
|
||||
|
||||
let currentPage = $state(1);
|
||||
const pageSize = 10;
|
||||
|
||||
// let totalPages = $derived(Math.ceil(students.length / pageSize));
|
||||
let startIndex = $derived((currentPage - 1) * pageSize);
|
||||
let endIndex = $derived(startIndex + pageSize);
|
||||
let paginatedStudents = $derived(students.slice(startIndex, endIndex));
|
||||
|
||||
function toggleSelectAll() {
|
||||
selectAll = !selectAll;
|
||||
if (selectAll) {
|
||||
selectedStudents = new Set(paginatedStudents.map((s) => s.id));
|
||||
} else {
|
||||
selectedStudents = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleStudent(id) {
|
||||
if (selectedStudents.has(id)) {
|
||||
selectedStudents.delete(id);
|
||||
} else {
|
||||
selectedStudents.add(id);
|
||||
}
|
||||
selectedStudents = selectedStudents;
|
||||
selectAll = selectedStudents.size === paginatedStudents.length;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="-mr-4 mb-10 -ml-4 flex flex-wrap items-start gap-2">
|
||||
{#each CertificateReportData as data (data.id)}
|
||||
<div class="mb-5 flex h-[165px] w-[246px] flex-col justify-center rounded border border-gray-200 px-2">
|
||||
<p class="text-xs font-normal">{data.title}</p>
|
||||
<bold class="text-2xl font-medium">{data.value}</bold>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="flex flex-row items-center justify-between gap-4">
|
||||
<p class="w-full text-base font-semibold">Certificate issued</p>
|
||||
<div class="relative w-full max-w-sm">
|
||||
<SearchIcon class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input type="search" placeholder="Search students" bind:value={searchValue} class="pl-10" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-14">
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[50px]">
|
||||
<Checkbox checked={selectAll} onCheckedChange={toggleSelectAll} />
|
||||
</Table.Head>
|
||||
<Table.Head>Name of student</Table.Head>
|
||||
<Table.Head>Date issued</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each paginatedStudents as student (student.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<Checkbox checked={selectedStudents.has(student.id)} onCheckedChange={() => toggleStudent(student.id)} />
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-medium">{student.name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm">{student.issued}</span>
|
||||
{#if student.peronalized}
|
||||
<Badge variant="secondary" class="bg-primary-100 ui:text-primary">personalized</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
{#if students.length > pageSize}
|
||||
<div class="mt-4 flex items-center justify-end">
|
||||
<Pagination.Root
|
||||
count={students.length}
|
||||
perPage={pageSize}
|
||||
page={currentPage}
|
||||
onPageChange={(page) => (currentPage = page)}
|
||||
>
|
||||
{#snippet children({ pages, currentPage: activePage })}
|
||||
<Pagination.Content>
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton />
|
||||
</Pagination.Item>
|
||||
{#each pages as page (page.key)}
|
||||
{#if page.type === 'ellipsis'}
|
||||
<Pagination.Item>
|
||||
<Pagination.Ellipsis />
|
||||
</Pagination.Item>
|
||||
{:else}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link {page} isActive={activePage === page.value}>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const issueCertificateModal = writable({
|
||||
open: false,
|
||||
automatic: false,
|
||||
email: '',
|
||||
date: '',
|
||||
message: ''
|
||||
});
|
||||
|
||||
export const resetForm = () =>
|
||||
issueCertificateModal.update(() => ({
|
||||
open: false,
|
||||
automatic: false,
|
||||
email: '',
|
||||
date: '',
|
||||
message: ''
|
||||
}));
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<script>
|
||||
import { courseApi } from '$features/course/api';
|
||||
import UnlockedCertificate from './unlocked-certificate.svelte';
|
||||
import LockedCertificate from './locked-certificate.svelte';
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if courseApi.course?.isCertificateDownloadable === true}
|
||||
<UnlockedCertificate />
|
||||
{:else}
|
||||
<LockedCertificate />
|
||||
{/if}
|
||||
</div>
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<script>
|
||||
import Empty from '@cio/ui/custom/empty/empty.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import LockIcon from '@lucide/svelte/icons/lock';
|
||||
</script>
|
||||
|
||||
<div class="w-full flex-1">
|
||||
<Empty
|
||||
title={$t('course.navItem.certificates.locked_certificate')}
|
||||
description={`${$t('course.navItem.certificates.instructor_unlock')} ${$t('course.navItem.certificates.contact_instructor')}`}
|
||||
icon={LockIcon}
|
||||
variant="page"
|
||||
/>
|
||||
</div>
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import DownloadIcon from '@lucide/svelte/icons/download';
|
||||
|
||||
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import Empty from '@cio/ui/custom/empty/empty.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
|
||||
let isLoading = $state(false);
|
||||
let isCourseComplete = $state(false);
|
||||
|
||||
let courseId = $derived(courseApi.course?.id ?? '');
|
||||
|
||||
const downLoadCertificate = async () => {
|
||||
if (!isCourseComplete || !courseId) return;
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
const response = await classroomio.course[':courseId']['download']['certificate']['$post']({
|
||||
param: { courseId },
|
||||
json: {
|
||||
theme: `${courseApi.course?.certificateTheme}`,
|
||||
studentName: `${$profile.fullname}`,
|
||||
courseName: `${courseApi.course?.title}`,
|
||||
courseDescription: `${courseApi.course?.description}`,
|
||||
orgLogoUrl: $currentOrg.avatarUrl || `${$currentOrgDomain}/logo-512.png`,
|
||||
orgName: `${$currentOrg.name}`
|
||||
}
|
||||
});
|
||||
|
||||
const blobResponse = await response.blob();
|
||||
|
||||
const file = new Blob([blobResponse], { type: 'application/pdf' });
|
||||
const fileURL = URL.createObjectURL(file);
|
||||
|
||||
let a = document.createElement('a');
|
||||
document.body.append(a);
|
||||
a.download = 'Certificate of Completion - ' + $currentOrg.name;
|
||||
a.href = fileURL;
|
||||
a.click();
|
||||
a.remove();
|
||||
} catch (error) {
|
||||
console.error('Error downloading', error);
|
||||
snackbar.error($t('course.navItem.certificates.unexpected_error'));
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
};
|
||||
|
||||
const hasUserCompletedCourse = async () => {
|
||||
isLoading = true;
|
||||
|
||||
const response = await courseApi.getProgress(courseId, $profile.id);
|
||||
|
||||
if (courseApi.success && response?.data) {
|
||||
const progress = response.data;
|
||||
isCourseComplete =
|
||||
progress.lessonsCount === progress.lessonsCompleted && progress.exercisesCount === progress.exercisesCompleted;
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
hasUserCompletedCourse();
|
||||
});
|
||||
|
||||
let title = $derived(
|
||||
isCourseComplete
|
||||
? 'course.navItem.certificates.unlocked_certificate'
|
||||
: 'course.navItem.certificates.complete_to_download_title'
|
||||
);
|
||||
let subtitle = $derived(
|
||||
isCourseComplete
|
||||
? 'course.navItem.certificates.unlocked_certificate_subtitle'
|
||||
: 'course.navItem.certificates.complete_to_download_subtitle'
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex-1">
|
||||
<Empty title={$t(title)} description={$t(subtitle)} icon={DownloadIcon} variant="page">
|
||||
<Button onclick={downLoadCertificate} disabled={!isCourseComplete} loading={isLoading}>
|
||||
<DownloadIcon size={16} />
|
||||
{$t('course.navItem.certificates.download_certificate')}
|
||||
</Button>
|
||||
</Empty>
|
||||
</div>
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const logo = '/logo-512.png';
|
||||
|
||||
const dateIssued = new Date().toLocaleDateString();
|
||||
</script>
|
||||
|
||||
<div class="relative w-full dark:text-black">
|
||||
<img src="/images/blue-badge-pattern-background.svg" alt="" class="absolute z-0 h-full w-full object-cover" />
|
||||
<div class="relative z-20 m-2 flex h-full justify-between bg-white md:h-68">
|
||||
<div class="w-[70%] pl-3">
|
||||
<header class="absolute flex w-full items-center justify-center gap-1 text-sm">
|
||||
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="w-5" />
|
||||
<h1 class="text-sm">{$currentOrg.name}</h1>
|
||||
</header>
|
||||
|
||||
<div class="mt-16">
|
||||
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
|
||||
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#2D499F]">{studentName}</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 w-[90%]">
|
||||
<h1 class="text-[7px]">has successfully completed training on</h1>
|
||||
<h2 class="m-0 text-lg">
|
||||
{courseApi.course?.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="my-8 flex items-center justify-between text-center">
|
||||
<div>
|
||||
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
|
||||
<p class="border-b border-black text-[7px]">{dateIssued}</p>
|
||||
</div>
|
||||
<div class="w-[20%] text-[7px]">
|
||||
<div></div>
|
||||
<p class="border-t border-black text-[7px]">Signature</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="desc w-[90%] text-[8px]">
|
||||
{courseApi.course?.description}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center overflow-hidden">
|
||||
<img src="/images/blue-badge-pattern-badge.svg" alt="A badge" class="w-32 max-w-none md:w-40" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const logo = '/logo-512.png';
|
||||
|
||||
const dateIssued = new Date().toLocaleDateString();
|
||||
</script>
|
||||
|
||||
<div class="w-full bg-[#2D499F] p-3 dark:text-black">
|
||||
<div class="flex w-full bg-white pb-5">
|
||||
<div class="w-[80%] pl-3">
|
||||
<header class="flex items-center justify-start gap-1 text-sm">
|
||||
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="w-5" />
|
||||
<h1 class="text-sm">{$currentOrg.name}</h1>
|
||||
</header>
|
||||
|
||||
<div class="mt-7">
|
||||
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
|
||||
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#2D499F]">{studentName}</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 w-[90%]">
|
||||
<h1 class="text-[7px]">has succesfully completed training on</h1>
|
||||
<h2 class="m-0 text-lg">
|
||||
{courseApi.course?.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 mb-5 flex items-center justify-between text-center">
|
||||
<div class="">
|
||||
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
|
||||
<p class="border-b border-black text-[7px]">{dateIssued}</p>
|
||||
</div>
|
||||
<div class="w-[20%] text-[7px]">
|
||||
<div class=""></div>
|
||||
<p class="border-t border-black text-[7px]">Signature</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="desc w-[90%] text-[8px]">
|
||||
{courseApi.course?.description}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="w-[20%]">
|
||||
<img src="/images/blue-professional-badge.svg" alt="A badge" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const borderwidth = 'border-4';
|
||||
const bordercolor = 'border-pink-700';
|
||||
const logo = '/logo-512.png';
|
||||
</script>
|
||||
|
||||
<div class="certificate-bg flex w-full flex-col items-center justify-center {borderwidth} {bordercolor} py-3">
|
||||
<div class="my-2 flex items-center gap-1">
|
||||
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class=" h-10 w-10 rounded-md" />
|
||||
<p class="font-semibold capitalize dark:text-black">{$currentOrg.name}</p>
|
||||
</div>
|
||||
<div class="w-full px-5 py-3">
|
||||
<p class="my-2 w-full text-center text-sm font-normal tracking-widest text-gray-500 uppercase">
|
||||
Certificate of completion
|
||||
</p>
|
||||
<div class="mb-4 border-b border-gray-500">
|
||||
<p class="text-xs font-medium text-black">This is to certify that</p>
|
||||
<p class="student-name text-center text-5xl dark:text-black">{studentName}</p>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<p class="text-xs font-medium text-black">has successfully completed the course</p>
|
||||
<div>
|
||||
<p class="clamp bg-transparent text-center text-base font-semibold text-pink-700 uppercase">
|
||||
{courseApi.course?.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="desc text-center dark:text-black">{courseApi.course?.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.student-name {
|
||||
font-family: 'Qwitcher Grypen', cursive;
|
||||
}
|
||||
.desc {
|
||||
font-size: 10px;
|
||||
}
|
||||
.clamp {
|
||||
font-size: clamp(14px, 3vw, 16px);
|
||||
}
|
||||
.certificate-bg {
|
||||
background-color: #f5f8fe;
|
||||
}
|
||||
</style>
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const borderwidth = 'border-l-8';
|
||||
const bordercolor = 'border-primary-800';
|
||||
const logo = '/logo-512.png';
|
||||
</script>
|
||||
|
||||
<div class="certificate-bg border-primary-400 flex w-full items-center justify-center border border-dashed">
|
||||
<div class="{borderwidth} {bordercolor} w-full">
|
||||
<div class="w-full px-5 py-3">
|
||||
<p class="my-2 text-sm font-normal text-gray-500 uppercase">
|
||||
Certificate<br /> of completion
|
||||
</p>
|
||||
<div class="mb-4 border-b border-gray-500">
|
||||
<p class="text-xs font-medium text-black">This is to certify that</p>
|
||||
<p class="student-name text-center text-5xl dark:text-black">{studentName}</p>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<p class="text-xs font-medium text-black">has successfully completed introductory courses in</p>
|
||||
<div>
|
||||
<p class="clamp text-primary-800 my-2 bg-transparent text-center text-base font-semibold uppercase">
|
||||
{courseApi.course?.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="desc mb-4 text-justify dark:text-black">{courseApi.course?.description}</p>
|
||||
<div class="my-2 flex items-center gap-1">
|
||||
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="h-10 w-10 rounded-md" />
|
||||
<p class="font-semibold capitalize dark:text-black">{$currentOrg.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.student-name {
|
||||
font-family: 'Qwitcher Grypen', cursive;
|
||||
}
|
||||
.desc {
|
||||
font-size: 10px;
|
||||
}
|
||||
.clamp {
|
||||
font-size: clamp(14px, 3vw, 16px);
|
||||
}
|
||||
.certificate-bg {
|
||||
background-color: #f5f8fe;
|
||||
}
|
||||
</style>
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const logo = '/logo-512.png';
|
||||
|
||||
const dateIssued = new Date().toLocaleDateString();
|
||||
</script>
|
||||
|
||||
<div class="relative w-full dark:text-black">
|
||||
<img src="/images/purple-badge-pattern-background.svg" alt="" class="absolute z-0 h-full w-full object-cover" />
|
||||
<div class="relative z-20 m-2 flex h-full justify-between bg-white md:h-68">
|
||||
<div class="w-[70%] pl-3">
|
||||
<header class="absolute flex w-full items-center justify-center gap-1 text-sm">
|
||||
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="w-5" />
|
||||
<h1 class="text-sm">{$currentOrg.name}</h1>
|
||||
</header>
|
||||
|
||||
<div class="mt-16">
|
||||
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
|
||||
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#914397]">{studentName}</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 w-[90%]">
|
||||
<h1 class="text-[7px]">has successfully completed training on</h1>
|
||||
<h2 class="m-0 text-lg">
|
||||
{courseApi.course?.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="my-8 flex items-center justify-between text-center">
|
||||
<div>
|
||||
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
|
||||
<p class="border-b border-black text-[7px]">{dateIssued}</p>
|
||||
</div>
|
||||
<div class="w-[20%] text-[7px]">
|
||||
<div></div>
|
||||
<p class="border-t border-black text-[7px]">Signature</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="desc w-[90%] text-[8px]">
|
||||
{courseApi.course?.description}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center overflow-hidden">
|
||||
<img src="/images/purple-badge-pattern-badge.svg" alt="A badge" class="w-[8rem] max-w-none md:w-[10rem]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const logo = '/logo-512.png';
|
||||
|
||||
const dateIssued = new Date().toLocaleDateString();
|
||||
</script>
|
||||
|
||||
<div class="w-full bg-[#95449A] p-3 dark:text-black">
|
||||
<div class="flex w-full bg-white pb-5">
|
||||
<div class="w-[80%] pl-3">
|
||||
<header class="flex items-center justify-start gap-1 text-sm">
|
||||
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="w-5" />
|
||||
<h1 class="text-sm">{$currentOrg.name}</h1>
|
||||
</header>
|
||||
|
||||
<div class="mt-7">
|
||||
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
|
||||
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#95449A]">{studentName}</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 w-[90%]">
|
||||
<h1 class="text-[7px]">has succesfully completed training on</h1>
|
||||
<h2 class="m-0 text-lg">
|
||||
{courseApi.course?.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 mb-5 flex items-center justify-between text-center">
|
||||
<div class="">
|
||||
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
|
||||
<p class="border-b border-black text-[7px]">{dateIssued}</p>
|
||||
</div>
|
||||
<div class="w-[20%] text-[7px]">
|
||||
<div class=""></div>
|
||||
<p class="border-t border-black text-[7px]">Signature</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="desc w-[90%] text-[8px]">
|
||||
{courseApi.course?.description}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="w-[20%]">
|
||||
<img src="/images/purple-professional-badge.svg" alt="A badge" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import * as Tooltip from '@cio/ui/base/tooltip';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import CourseContentIcon from '$features/course/components/course-content-icon.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
lessons: number;
|
||||
exercises: number;
|
||||
}
|
||||
|
||||
let { lessons, exercises }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<span class="inline-flex items-center gap-1 rounded border px-1 py-0.5 text-[10px] leading-none">
|
||||
<CourseContentIcon type={ContentType.Lesson} size={12} />
|
||||
<span>{lessons}</span>
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="top" sideOffset={4}>
|
||||
{$t('course.sidebar.content_count.lessons', { count: lessons })}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<span class="inline-flex items-center gap-1 rounded border px-1 py-0.5 text-[10px] leading-none">
|
||||
<CourseContentIcon type={ContentType.Exercise} size={12} />
|
||||
<span>{exercises}</span>
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="top" sideOffset={4}>
|
||||
{$t('course.sidebar.content_count.exercises', { count: exercises })}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import type { StepperState } from './types';
|
||||
|
||||
/** Translation key for primary action (resolve with t() in components) */
|
||||
export const ADD_CONTENT_CREATE_KEY = 'course.navItem.lessons.add_content_create';
|
||||
export const ADD_CONTENT_CREATE_SECTION_KEY = 'course.navItem.lessons.add_content_create_section';
|
||||
export const ADD_CONTENT_CREATE_LESSON_KEY = 'course.navItem.lessons.add_content_create_lesson';
|
||||
|
||||
/** Default stepper state used as fallback in the modal and when resetting */
|
||||
export const DEFAULT_STEPPER_STATE: StepperState = {
|
||||
currentStep: 0,
|
||||
totalSteps: 1,
|
||||
canProceed: false,
|
||||
isSubmitting: false,
|
||||
primaryActionLabel: '' // Set from translation in component
|
||||
};
|
||||
|
||||
/** Default state for the section create stepper */
|
||||
export const SECTION_STEPPER_DEFAULT_STATE: StepperState = {
|
||||
...DEFAULT_STEPPER_STATE,
|
||||
primaryActionLabel: '' // Set from translation in section-create-stepper
|
||||
};
|
||||
|
||||
/** Default state for the lesson create stepper */
|
||||
export const LESSON_STEPPER_DEFAULT_STATE: StepperState = {
|
||||
...DEFAULT_STEPPER_STATE,
|
||||
primaryActionLabel: '' // Set from translation in lesson-create-stepper
|
||||
};
|
||||
|
||||
/** Default state for the exercise create stepper (label set at runtime from i18n) */
|
||||
export const EXERCISE_STEPPER_DEFAULT_STATE: StepperState = {
|
||||
currentStep: 0,
|
||||
totalSteps: 2,
|
||||
canProceed: false,
|
||||
isSubmitting: false,
|
||||
primaryActionLabel: ''
|
||||
};
|
||||
|
||||
/** Content type options shown in the add-content modal (title/description are translation keys) */
|
||||
export const CONTENT_OPTIONS = [
|
||||
{
|
||||
id: 'section',
|
||||
type: ContentType.Section,
|
||||
titleKey: 'course.navItem.lessons.add_content_options.section_title',
|
||||
descriptionKey: 'course.navItem.lessons.add_content_options.section_description'
|
||||
},
|
||||
{
|
||||
id: 'lesson',
|
||||
type: ContentType.Lesson,
|
||||
titleKey: 'course.navItem.lessons.add_content_options.lesson_title',
|
||||
descriptionKey: 'course.navItem.lessons.add_content_options.lesson_description'
|
||||
},
|
||||
{
|
||||
id: 'exercise',
|
||||
type: ContentType.Exercise,
|
||||
titleKey: 'course.navItem.lessons.add_content_options.exercise_title',
|
||||
descriptionKey: 'course.navItem.lessons.add_content_options.exercise_description'
|
||||
}
|
||||
] as const;
|
||||
|
||||
/** Exercise creation flow type (from scratch, template, or AI) */
|
||||
export const EXERCISE_CREATE_TYPE = {
|
||||
SCRATCH: 0,
|
||||
TEMPLATE: 1,
|
||||
AI: 2
|
||||
} as const;
|
||||
|
||||
export type ExerciseCreateType = (typeof EXERCISE_CREATE_TYPE)[keyof typeof EXERCISE_CREATE_TYPE];
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { RadioOptionCardGroup } from '@cio/ui/custom/radio-option-card';
|
||||
import { contentCreateStore, contentCreateStoreUtils } from './store';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import ExerciseCreateStepper from './exercise-create-stepper.svelte';
|
||||
import SectionCreateStepper from './section-create-stepper.svelte';
|
||||
import LessonCreateStepper from './lesson-create-stepper.svelte';
|
||||
import type { StepperRef, StepperState } from './types';
|
||||
import { DEFAULT_STEPPER_STATE, CONTENT_OPTIONS } from './constants';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
let step = $state(0);
|
||||
let selectedType = $state<ContentType>(ContentType.Lesson);
|
||||
let sectionId = $state('');
|
||||
|
||||
// ============================================
|
||||
// STEPPER COMPONENT REFERENCES
|
||||
// ============================================
|
||||
let sectionStepper: StepperRef | undefined = $state();
|
||||
let lessonStepper: StepperRef | undefined = $state();
|
||||
let exerciseStepper: StepperRef | undefined = $state();
|
||||
|
||||
// ============================================
|
||||
// UNIFIED STEPPER STATE
|
||||
// ============================================
|
||||
const activeStepper = $derived(
|
||||
selectedType === ContentType.Section
|
||||
? sectionStepper
|
||||
: selectedType === ContentType.Lesson
|
||||
? lessonStepper
|
||||
: selectedType === ContentType.Exercise
|
||||
? exerciseStepper
|
||||
: undefined
|
||||
);
|
||||
|
||||
let sectionStepperState = $state<StepperState>({ ...DEFAULT_STEPPER_STATE });
|
||||
let lessonStepperState = $state<StepperState>({ ...DEFAULT_STEPPER_STATE });
|
||||
let exerciseStepperState = $state<StepperState>({ ...DEFAULT_STEPPER_STATE });
|
||||
|
||||
const stepperState = $derived(
|
||||
selectedType === ContentType.Section
|
||||
? sectionStepperState
|
||||
: selectedType === ContentType.Lesson
|
||||
? lessonStepperState
|
||||
: selectedType === ContentType.Exercise
|
||||
? exerciseStepperState
|
||||
: DEFAULT_STEPPER_STATE
|
||||
);
|
||||
|
||||
const contentGroupingEnabled = $derived(courseApi.course?.metadata?.isContentGroupingEnabled ?? true);
|
||||
const sections = $derived(
|
||||
(courseApi.course?.content?.sections || []).filter((section) => section.id !== 'ungrouped')
|
||||
);
|
||||
const visibleContentOptions = $derived(
|
||||
$contentCreateStore.sectionId
|
||||
? CONTENT_OPTIONS.filter((option) => option.type !== ContentType.Section)
|
||||
: contentGroupingEnabled
|
||||
? CONTENT_OPTIONS
|
||||
: CONTENT_OPTIONS.filter((option) => option.type !== ContentType.Section)
|
||||
);
|
||||
const contentOptionsForGroup = $derived(
|
||||
visibleContentOptions.map((o) => ({
|
||||
id: o.id,
|
||||
title: $t(o.titleKey),
|
||||
description: $t(o.descriptionKey),
|
||||
value: o.type
|
||||
}))
|
||||
);
|
||||
const requiresSection = $derived(contentGroupingEnabled && selectedType !== ContentType.Section);
|
||||
const hasSections = $derived(sections.length > 0);
|
||||
/** True when modal was opened from a section (e.g. plus icon in sidebar), so we use store sectionId */
|
||||
const sectionFromContext = $derived(!!$contentCreateStore.sectionId);
|
||||
const canCreateLessonOrExercise = $derived(!requiresSection || (requiresSection && !!sectionId));
|
||||
|
||||
function getNextSectionOrder() {
|
||||
const orders = sections.map((section, index) => section.order ?? index + 1);
|
||||
const maxOrder = orders.length ? Math.max(...orders) : 0;
|
||||
return maxOrder + 1;
|
||||
}
|
||||
|
||||
function getNextContentOrder(targetSectionId?: string) {
|
||||
const content = courseApi.course?.content;
|
||||
if (!content) return 1;
|
||||
|
||||
let items = content.items;
|
||||
|
||||
if (content.grouped) {
|
||||
const section = targetSectionId
|
||||
? content.sections.find((entry) => entry.id === targetSectionId)
|
||||
: content.sections.find((entry) => entry.id === 'ungrouped');
|
||||
items = section?.items ?? [];
|
||||
}
|
||||
|
||||
const orders = items.map((item, index) => item.order ?? index + 1);
|
||||
const maxOrder = orders.length ? Math.max(...orders) : 0;
|
||||
|
||||
return maxOrder + 1;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($contentCreateStore.open) {
|
||||
untrack(() => {
|
||||
step = $contentCreateStore.skipTypeSelection ? 1 : 0;
|
||||
const initialType = $contentCreateStore.initialType ?? ContentType.Lesson;
|
||||
selectedType = contentGroupingEnabled || initialType !== ContentType.Section ? initialType : ContentType.Lesson;
|
||||
sectionId = contentGroupingEnabled
|
||||
? $contentCreateStore.sectionId || sections[0]?.id || ''
|
||||
: $contentCreateStore.sectionId || '';
|
||||
|
||||
sectionStepper?.actions.reset();
|
||||
lessonStepper?.actions.reset();
|
||||
exerciseStepper?.actions.reset();
|
||||
sectionStepperState = { ...DEFAULT_STEPPER_STATE };
|
||||
lessonStepperState = { ...DEFAULT_STEPPER_STATE };
|
||||
exerciseStepperState = { ...DEFAULT_STEPPER_STATE };
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function closeModal() {
|
||||
contentCreateStoreUtils.close();
|
||||
}
|
||||
|
||||
function goToDetails() {
|
||||
step = 1;
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
step = 0;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UNIFIED NAVIGATION HANDLERS
|
||||
// ============================================
|
||||
function handleUnifiedBack() {
|
||||
if (stepperState.currentStep > 0) {
|
||||
activeStepper?.actions.back();
|
||||
} else {
|
||||
goBack();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnifiedNext() {
|
||||
await activeStepper?.actions.next();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open={$contentCreateStore.open} onOpenChange={(isOpen) => !isOpen && closeModal()}>
|
||||
<Dialog.Content class="flex max-h-[680px] w-fit! max-w-2xl! min-w-[400px] flex-col overflow-hidden">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.lessons.add_content')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
{#if step === 0}
|
||||
<!-- Select a content type - Section | Lesson | Exercise -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<Field.Description>{$t('course.navItem.lessons.add_content_description')}</Field.Description>
|
||||
<RadioOptionCardGroup options={contentOptionsForGroup} bind:value={selectedType} class="md:grid-cols-3" />
|
||||
<Dialog.Footer>
|
||||
<Button onclick={goToDetails}>{$t('course.navItem.lessons.add_content_continue')}</Button>
|
||||
</Dialog.Footer>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Create content - Section | Lesson | Exercise -->
|
||||
<div class="px-1">
|
||||
{#if requiresSection}
|
||||
<div class="mb-4">
|
||||
<Label class="text-md mb-1 font-bold">{$t('course.navItem.lessons.add_content_section_label')}</Label>
|
||||
{#if sectionFromContext}
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{$t('course.navItem.lessons.add_content_adding_to')}
|
||||
<strong>
|
||||
{sections.find((s) => s.id === sectionId)?.title ??
|
||||
$t('course.navItem.lessons.add_content_section_fallback')}
|
||||
</strong>
|
||||
</p>
|
||||
{:else if hasSections}
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={sectionId}
|
||||
onValueChange={(value) => {
|
||||
if (value) sectionId = value;
|
||||
}}
|
||||
>
|
||||
<Select.Trigger class="h-10 w-full">
|
||||
{sections.find((s) => s.id === sectionId)?.title ||
|
||||
$t('course.navItem.lessons.add_content_select_section')}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
{#each sections as section (section.id)}
|
||||
<Select.Item value={section.id}>{section.title}</Select.Item>
|
||||
{/each}
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{:else}
|
||||
<p class="text-xs text-gray-500">{$t('course.navItem.lessons.add_content_create_section_first')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedType === ContentType.Section}
|
||||
<SectionCreateStepper
|
||||
bind:this={sectionStepper}
|
||||
bind:stepperState={sectionStepperState}
|
||||
courseId={courseApi.course?.id || ''}
|
||||
order={getNextSectionOrder()}
|
||||
canCreate={true}
|
||||
sections={sections.map((s) => ({ id: s.id, order: s.order ?? undefined }))}
|
||||
onCreated={() => closeModal()}
|
||||
/>
|
||||
{:else if selectedType === ContentType.Lesson}
|
||||
<LessonCreateStepper
|
||||
bind:this={lessonStepper}
|
||||
bind:stepperState={lessonStepperState}
|
||||
courseId={courseApi.course?.id || ''}
|
||||
sectionId={requiresSection ? sectionId : undefined}
|
||||
order={getNextContentOrder(requiresSection ? sectionId : undefined)}
|
||||
canCreate={canCreateLessonOrExercise}
|
||||
onCreated={(lessonId) => {
|
||||
closeModal();
|
||||
goto(resolve(`/courses/${courseApi.course?.id}/lessons/${lessonId}`, {}));
|
||||
}}
|
||||
/>
|
||||
{:else if selectedType === ContentType.Exercise}
|
||||
<ExerciseCreateStepper
|
||||
bind:this={exerciseStepper}
|
||||
bind:stepperState={exerciseStepperState}
|
||||
courseId={courseApi.course?.id || ''}
|
||||
sectionId={requiresSection ? sectionId : undefined}
|
||||
order={getNextContentOrder(requiresSection ? sectionId : undefined)}
|
||||
canCreate={canCreateLessonOrExercise}
|
||||
onCreated={(exerciseId) => {
|
||||
closeModal();
|
||||
goto(resolve(`/courses/${courseApi.course?.id}/exercises/${exerciseId}`, {}));
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Dialog.Footer class="mt-6 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={handleUnifiedBack}
|
||||
>{$t('course.navItem.lessons.add_content_back')}</Button
|
||||
>
|
||||
<Button onclick={handleUnifiedNext} loading={stepperState.isSubmitting} disabled={!stepperState.canProceed}>
|
||||
{stepperState.primaryActionLabel}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import { RadioOptionCardGroup } from '@cio/ui/custom/radio-option-card';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { exerciseTemplateApi } from '$features/course/api/exercise-template.svelte';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { Confetti, ComingSoon } from '$features/ui';
|
||||
import { EXERCISE_TEMPLATE_TAGS } from '$features/course/utils/constants';
|
||||
import { courseApi, exerciseApi } from '$features/course/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import type { StepperState, StepperActions, BaseStepperProps } from './types';
|
||||
import { EXERCISE_STEPPER_DEFAULT_STATE, EXERCISE_CREATE_TYPE } from './constants';
|
||||
|
||||
interface Props extends BaseStepperProps {
|
||||
stepperState: StepperState;
|
||||
}
|
||||
|
||||
let {
|
||||
courseId,
|
||||
sectionId,
|
||||
order,
|
||||
onCreated,
|
||||
canCreate,
|
||||
stepperState = $bindable(EXERCISE_STEPPER_DEFAULT_STATE)
|
||||
}: Props = $props();
|
||||
|
||||
const options = [
|
||||
{
|
||||
id: 'exercise-from-scratch',
|
||||
title: $t('course.navItem.lessons.exercises.new_exercise_modal.options.from_scratch'),
|
||||
description: $t('course.navItem.lessons.exercises.new_exercise_modal.options.from_scratch_subtitle'),
|
||||
type: EXERCISE_CREATE_TYPE.SCRATCH,
|
||||
isDisabled: false
|
||||
},
|
||||
{
|
||||
id: 'exercise-use-template',
|
||||
title: $t('course.navItem.lessons.exercises.new_exercise_modal.options.use_template'),
|
||||
description: $t('course.navItem.lessons.exercises.new_exercise_modal.options.use_template_subtitle'),
|
||||
type: EXERCISE_CREATE_TYPE.TEMPLATE,
|
||||
isDisabled: false
|
||||
},
|
||||
{
|
||||
id: 'exercise-ai',
|
||||
title: $t('course.navItem.lessons.exercises.new_exercise_modal.options.use_ai'),
|
||||
description: $t('course.navItem.lessons.exercises.new_exercise_modal.options.use_ai_subtitle'),
|
||||
type: EXERCISE_CREATE_TYPE.AI,
|
||||
isDisabled: true
|
||||
}
|
||||
];
|
||||
|
||||
const radioOptions = $derived(
|
||||
options.map((o) => ({
|
||||
id: o.id,
|
||||
title: o.title,
|
||||
description: o.description,
|
||||
value: String(o.type),
|
||||
disabled: o.isDisabled
|
||||
}))
|
||||
);
|
||||
|
||||
const tags = Object.values(EXERCISE_TEMPLATE_TAGS);
|
||||
|
||||
let step = $state(0);
|
||||
/** String for RadioGroup.Root (component expects string); numeric type derived for logic */
|
||||
let typeValue = $state(String(EXERCISE_CREATE_TYPE.SCRATCH));
|
||||
const type = $derived(Number(typeValue));
|
||||
let isLoading = $state(false);
|
||||
let isAIStarted = $state(false);
|
||||
let title = $state('');
|
||||
|
||||
let selectedTag = $state(tags[0]);
|
||||
let selectedTemplateId = $state('');
|
||||
let isTemplateFinishedLoading = $state(false);
|
||||
|
||||
const templateRadioOptions = $derived(
|
||||
(exerciseTemplateApi.templates ?? []).map((t) => ({
|
||||
id: String(t.id),
|
||||
title: t.title ?? '',
|
||||
description: t.description ?? '',
|
||||
value: String(t.id),
|
||||
disabled: false
|
||||
}))
|
||||
);
|
||||
|
||||
function handleNext() {
|
||||
step = step + 1;
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
step = step - 1;
|
||||
}
|
||||
|
||||
async function handleTemplateSelection() {
|
||||
if (!canCreate) return;
|
||||
isTemplateFinishedLoading = true;
|
||||
const template = exerciseTemplateApi.templates?.find((t) => t.id === Number(selectedTemplateId));
|
||||
|
||||
if (!template || !template.id) {
|
||||
isTemplateFinishedLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await exerciseApi.createFromTemplate(courseId, String(template.id), { sectionId, order });
|
||||
if (exerciseApi.success && exerciseApi.exercise) {
|
||||
const profileId = $profile?.id;
|
||||
if (profileId) {
|
||||
await courseApi.refreshCourse(courseId, profileId);
|
||||
}
|
||||
onCreated(exerciseApi.exercise.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error creating exercise from template', error);
|
||||
snackbar.error($t('course.navItem.lessons.exercises.new_exercise_modal.errors.template_fetch'));
|
||||
} finally {
|
||||
isTemplateFinishedLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
const handleTagSelection = async (tag = selectedTag) => {
|
||||
selectedTag = tag;
|
||||
selectedTemplateId = '';
|
||||
await exerciseTemplateApi.fetchTemplatesByTag(courseId, selectedTag);
|
||||
};
|
||||
|
||||
async function handleAddExercise() {
|
||||
if (!title.trim() || !canCreate || isLoading) return;
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
await exerciseApi.create(courseId, {
|
||||
title: title.trim(),
|
||||
sectionId,
|
||||
order
|
||||
});
|
||||
|
||||
if (exerciseApi.success && exerciseApi.exercise) {
|
||||
const profileId = $profile?.id;
|
||||
if (profileId) {
|
||||
await courseApi.refreshCourse(courseId, profileId);
|
||||
}
|
||||
onCreated(exerciseApi.exercise.id);
|
||||
}
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// COMPUTED VALUES FOR PARENT
|
||||
// ============================================
|
||||
|
||||
const canProceedStep0 = $derived(canCreate);
|
||||
const canProceedStep1 = $derived(
|
||||
type === EXERCISE_CREATE_TYPE.SCRATCH ? title.trim().length > 0 && canCreate : !!selectedTemplateId && canCreate
|
||||
);
|
||||
const canProceed = $derived(step === 0 ? canProceedStep0 : canProceedStep1);
|
||||
|
||||
const primaryActionLabel = $derived(
|
||||
step === 0
|
||||
? $t('course.navItem.lessons.exercises.new_exercise_modal.next')
|
||||
: $t('course.navItem.lessons.exercises.new_exercise_modal.finish')
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// SYNC STATE TO BINDABLE (parent has bind:stepperState)
|
||||
// Only assign when values change to avoid infinite re-renders.
|
||||
// ============================================
|
||||
const isSubmitting = $derived(isLoading || isTemplateFinishedLoading);
|
||||
$effect(() => {
|
||||
const next = {
|
||||
currentStep: step,
|
||||
totalSteps: 2,
|
||||
canProceed,
|
||||
isSubmitting,
|
||||
primaryActionLabel
|
||||
};
|
||||
if (
|
||||
stepperState.currentStep !== next.currentStep ||
|
||||
stepperState.canProceed !== next.canProceed ||
|
||||
stepperState.isSubmitting !== next.isSubmitting ||
|
||||
stepperState.primaryActionLabel !== next.primaryActionLabel
|
||||
) {
|
||||
stepperState = next;
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// EXPORTED ACTIONS
|
||||
// ============================================
|
||||
export const actions: StepperActions = {
|
||||
async next() {
|
||||
if (isLoading || isTemplateFinishedLoading) return;
|
||||
|
||||
if (step === 0) {
|
||||
if (type === EXERCISE_CREATE_TYPE.TEMPLATE) {
|
||||
handleTagSelection();
|
||||
}
|
||||
|
||||
handleNext();
|
||||
} else {
|
||||
if (type === EXERCISE_CREATE_TYPE.SCRATCH) {
|
||||
await handleAddExercise();
|
||||
} else if (type === EXERCISE_CREATE_TYPE.TEMPLATE) {
|
||||
await handleTemplateSelection();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
back() {
|
||||
handleBack();
|
||||
},
|
||||
|
||||
reset() {
|
||||
step = 0;
|
||||
typeValue = String(EXERCISE_CREATE_TYPE.SCRATCH);
|
||||
title = '';
|
||||
selectedTag = tags[0];
|
||||
selectedTemplateId = '';
|
||||
isLoading = false;
|
||||
isAIStarted = false;
|
||||
isTemplateFinishedLoading = false;
|
||||
stepperState = { ...EXERCISE_STEPPER_DEFAULT_STATE };
|
||||
exerciseTemplateApi.reset();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if !isLoading && isAIStarted}
|
||||
<Confetti />
|
||||
{/if}
|
||||
{#if step === 0}
|
||||
<div>
|
||||
<Label class="text-md! mb-3">{$t('course.navItem.lessons.exercises.new_exercise_modal.how')}?</Label>
|
||||
|
||||
<RadioOptionCardGroup bind:value={typeValue} options={radioOptions}>
|
||||
{#snippet titleSuffix(option)}
|
||||
{#if option.disabled}
|
||||
<ComingSoon />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</RadioOptionCardGroup>
|
||||
</div>
|
||||
{:else if step === 1}
|
||||
{#if type === EXERCISE_CREATE_TYPE.SCRATCH}
|
||||
<div class="w-full">
|
||||
<InputField
|
||||
label={$t('course.navItem.lessons.exercises.new_exercise_modal.title')}
|
||||
bind:value={title}
|
||||
autoFocus={true}
|
||||
placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.title_placeholder')}
|
||||
className="my-4 w-2/4!"
|
||||
/>
|
||||
</div>
|
||||
{:else if type === EXERCISE_CREATE_TYPE.TEMPLATE}
|
||||
<div>
|
||||
<Label class="text-md mb-1 font-bold">
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.select_template')}
|
||||
</Label>
|
||||
|
||||
<div>
|
||||
<div class="mb-5 flex items-center gap-2">
|
||||
{#each tags as tag (tag)}
|
||||
<Badge
|
||||
variant={selectedTag === tag ? 'default' : 'secondary'}
|
||||
class="cursor-pointer"
|
||||
onclick={() => handleTagSelection(tag)}
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="max-h-[320px] overflow-y-auto pr-1">
|
||||
{#if exerciseTemplateApi.isLoading}
|
||||
<div class="grid grid-cols-2 items-start gap-4 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each Array(16) as _, index (index)}
|
||||
<div class="border-border h-[140px] w-full rounded-md border p-5 dark:bg-neutral-700">
|
||||
<div class="flex h-full flex-col justify-evenly">
|
||||
<Skeleton class="h-4 w-3/4" />
|
||||
<div class="flex flex-col items-start justify-between gap-1">
|
||||
<Skeleton class="h-3 w-20" />
|
||||
<Skeleton class="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if exerciseTemplateApi.templates?.length}
|
||||
<RadioOptionCardGroup
|
||||
bind:value={selectedTemplateId}
|
||||
options={templateRadioOptions}
|
||||
class="grid-cols-2! lg:grid-cols-3! xl:grid-cols-4!"
|
||||
/>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-500">
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.no_templates_for_tag')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { lessonApi, courseApi } from '$features/course/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import type { StepperState, StepperActions, BaseStepperProps } from './types';
|
||||
import { LESSON_STEPPER_DEFAULT_STATE, ADD_CONTENT_CREATE_LESSON_KEY } from './constants';
|
||||
|
||||
interface Props extends BaseStepperProps {
|
||||
stepperState: StepperState;
|
||||
}
|
||||
|
||||
let {
|
||||
courseId,
|
||||
sectionId,
|
||||
order,
|
||||
canCreate,
|
||||
onCreated,
|
||||
stepperState = $bindable(LESSON_STEPPER_DEFAULT_STATE)
|
||||
}: Props = $props();
|
||||
|
||||
// ============================================
|
||||
// INTERNAL STATE
|
||||
// ============================================
|
||||
let title = $state('');
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
// ============================================
|
||||
// COMPUTED VALUES
|
||||
// ============================================
|
||||
const canProceed = $derived(title.trim().length > 0 && canCreate);
|
||||
|
||||
// ============================================
|
||||
// SYNC STATE TO BINDABLE (parent has bind:stepperState)
|
||||
// Only assign when values change to avoid infinite re-renders.
|
||||
// ============================================
|
||||
$effect(() => {
|
||||
const nextCanProceed = canProceed;
|
||||
const nextIsSubmitting = isSubmitting;
|
||||
const nextPrimaryActionLabel = $t(ADD_CONTENT_CREATE_LESSON_KEY);
|
||||
if (
|
||||
stepperState.canProceed !== nextCanProceed ||
|
||||
stepperState.isSubmitting !== nextIsSubmitting ||
|
||||
stepperState.primaryActionLabel !== nextPrimaryActionLabel
|
||||
) {
|
||||
stepperState = {
|
||||
...LESSON_STEPPER_DEFAULT_STATE,
|
||||
canProceed: nextCanProceed,
|
||||
isSubmitting: nextIsSubmitting,
|
||||
primaryActionLabel: nextPrimaryActionLabel
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// EXPORTED ACTIONS
|
||||
// ============================================
|
||||
export const actions: StepperActions = {
|
||||
async next() {
|
||||
if (!canProceed || isSubmitting) return;
|
||||
|
||||
isSubmitting = true;
|
||||
try {
|
||||
await lessonApi.create(courseId, {
|
||||
title: title.trim(),
|
||||
courseId,
|
||||
lessonAt: new Date().toDateString(),
|
||||
isUnlocked: true,
|
||||
order,
|
||||
sectionId
|
||||
});
|
||||
|
||||
if (lessonApi.success && lessonApi.lesson) {
|
||||
// Refresh course content
|
||||
const profileId = $profile?.id;
|
||||
if (profileId) {
|
||||
await courseApi.refreshCourse(courseId, profileId);
|
||||
}
|
||||
onCreated(lessonApi.lesson.id);
|
||||
}
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
},
|
||||
|
||||
back() {
|
||||
// Single step - nothing to go back to within stepper
|
||||
},
|
||||
|
||||
reset() {
|
||||
title = '';
|
||||
isSubmitting = false;
|
||||
stepperState = { ...LESSON_STEPPER_DEFAULT_STATE };
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- Form content only, NO buttons -->
|
||||
<InputField
|
||||
label={$t('course.navItem.lessons.add_lesson.lesson_title')}
|
||||
bind:value={title}
|
||||
autoFocus={true}
|
||||
className="w-full"
|
||||
isRequired={true}
|
||||
placeholder={$t('course.navItem.lessons.add_content_lesson_title_placeholder')}
|
||||
/>
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { lessonApi, courseApi } from '$features/course/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import type { StepperState, StepperActions, BaseStepperProps } from './types';
|
||||
import { SECTION_STEPPER_DEFAULT_STATE, ADD_CONTENT_CREATE_SECTION_KEY } from './constants';
|
||||
|
||||
interface Props extends Omit<BaseStepperProps, 'sectionId'> {
|
||||
sections: Array<{ id: string; order?: number }>;
|
||||
stepperState: StepperState;
|
||||
}
|
||||
|
||||
let {
|
||||
courseId,
|
||||
order,
|
||||
canCreate,
|
||||
onCreated,
|
||||
sections,
|
||||
stepperState = $bindable(SECTION_STEPPER_DEFAULT_STATE)
|
||||
}: Props = $props();
|
||||
|
||||
// ============================================
|
||||
// INTERNAL STATE
|
||||
// ============================================
|
||||
let title = $state('');
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
// ============================================
|
||||
// COMPUTED VALUES
|
||||
// ============================================
|
||||
|
||||
// Can the user proceed? Title must not be empty and user must have permission
|
||||
const canProceed = $derived(title.trim().length > 0 && canCreate);
|
||||
|
||||
// ============================================
|
||||
// SYNC STATE TO BINDABLE (parent has bind:stepperState)
|
||||
// Only assign when values change to avoid infinite re-renders.
|
||||
// ============================================
|
||||
$effect(() => {
|
||||
const nextCanProceed = canProceed;
|
||||
const nextIsSubmitting = isSubmitting;
|
||||
const nextPrimaryActionLabel = $t(ADD_CONTENT_CREATE_SECTION_KEY);
|
||||
if (
|
||||
stepperState.canProceed !== nextCanProceed ||
|
||||
stepperState.isSubmitting !== nextIsSubmitting ||
|
||||
stepperState.primaryActionLabel !== nextPrimaryActionLabel
|
||||
) {
|
||||
stepperState = {
|
||||
...SECTION_STEPPER_DEFAULT_STATE,
|
||||
canProceed: nextCanProceed,
|
||||
isSubmitting: nextIsSubmitting,
|
||||
primaryActionLabel: nextPrimaryActionLabel
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// EXPORTED ACTIONS (for parent modal to call)
|
||||
// ============================================
|
||||
|
||||
export const actions: StepperActions = {
|
||||
// Called when user clicks the primary button
|
||||
async next() {
|
||||
if (!canProceed || isSubmitting) return;
|
||||
|
||||
isSubmitting = true;
|
||||
try {
|
||||
// Calculate order if not provided
|
||||
const finalOrder = order ?? getNextOrder();
|
||||
|
||||
await lessonApi.createSection(courseId, {
|
||||
title: title.trim(),
|
||||
courseId,
|
||||
order: finalOrder
|
||||
});
|
||||
|
||||
if (lessonApi.success) {
|
||||
// Refresh course content so sidebar updates
|
||||
const profileId = $profile?.id;
|
||||
if (profileId) {
|
||||
await courseApi.refreshCourse(courseId, profileId);
|
||||
}
|
||||
// Notify parent that creation succeeded (section ID not needed for modal close)
|
||||
onCreated('');
|
||||
}
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Called when user clicks back (not applicable for 1-step)
|
||||
back() {
|
||||
// Single step stepper - nothing to go back to within this component
|
||||
// The modal will handle going back to type selection
|
||||
},
|
||||
|
||||
// Called when modal opens to reset state
|
||||
reset() {
|
||||
title = '';
|
||||
isSubmitting = false;
|
||||
stepperState = { ...SECTION_STEPPER_DEFAULT_STATE };
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================
|
||||
|
||||
function getNextOrder(): number {
|
||||
const orders = sections.map((s, i) => s.order ?? i + 1);
|
||||
return orders.length ? Math.max(...orders) + 1 : 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--
|
||||
IMPORTANT: This component renders ONLY the form content.
|
||||
NO buttons here - the parent modal handles all navigation buttons.
|
||||
-->
|
||||
<InputField
|
||||
label={$t('course.navItem.lessons.add_lesson.course_section_title')}
|
||||
bind:value={title}
|
||||
autoFocus={true}
|
||||
className="w-full"
|
||||
isRequired={true}
|
||||
placeholder={$t('course.navItem.lessons.add_content_section_title_placeholder')}
|
||||
/>
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const contentCreateStore = writable({
|
||||
open: false,
|
||||
sectionId: '',
|
||||
initialType: ContentType.Section,
|
||||
skipTypeSelection: false
|
||||
});
|
||||
|
||||
export const contentCreateStoreUtils = {
|
||||
openContentUnit: (sectionId: string) => {
|
||||
contentCreateStore.set({
|
||||
open: true,
|
||||
sectionId,
|
||||
initialType: ContentType.Lesson,
|
||||
skipTypeSelection: false
|
||||
});
|
||||
},
|
||||
openSection: () => {
|
||||
contentCreateStore.set({
|
||||
open: true,
|
||||
sectionId: '',
|
||||
initialType: ContentType.Section,
|
||||
skipTypeSelection: true
|
||||
});
|
||||
},
|
||||
openDefault: () => {
|
||||
contentCreateStore.set({
|
||||
open: true,
|
||||
sectionId: '',
|
||||
initialType: ContentType.Lesson,
|
||||
skipTypeSelection: false
|
||||
});
|
||||
},
|
||||
close: () => {
|
||||
contentCreateStore.set({
|
||||
open: false,
|
||||
sectionId: '',
|
||||
initialType: ContentType.Lesson,
|
||||
skipTypeSelection: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const contentEditingStore = writable<string | undefined>(undefined);
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* State exposed by stepper components to the parent modal.
|
||||
* This enables the modal to render unified navigation buttons.
|
||||
*/
|
||||
export interface StepperState {
|
||||
/** Current step index (0-based) */
|
||||
currentStep: number;
|
||||
/** Total number of steps in this stepper */
|
||||
totalSteps: number;
|
||||
/** Whether the user can proceed to next step (validation passed) */
|
||||
canProceed: boolean;
|
||||
/** Whether a submission is in progress */
|
||||
isSubmitting: boolean;
|
||||
/** Label for the primary action button (e.g., "Next", "Create", "Finish") */
|
||||
primaryActionLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callbacks that the modal can invoke to control the stepper.
|
||||
*/
|
||||
export interface StepperActions {
|
||||
/** Move to the next step. If on last step, this triggers submission. */
|
||||
next: () => void | Promise<void>;
|
||||
/** Move to the previous step */
|
||||
back: () => void;
|
||||
/** Reset the stepper to initial state */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined type for component references.
|
||||
* Use this when binding to stepper components with bind:this.
|
||||
* State is passed via bind:stepperState; ref is used for actions only.
|
||||
*/
|
||||
export interface StepperRef {
|
||||
actions: StepperActions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props interface that all stepper components should accept.
|
||||
*/
|
||||
export interface BaseStepperProps {
|
||||
/** Course ID for API calls */
|
||||
courseId: string;
|
||||
/** Optional section ID when adding content to a specific section */
|
||||
sectionId?: string;
|
||||
/** Order/position for the new content */
|
||||
order?: number;
|
||||
/** Whether the user has permission to create content */
|
||||
canCreate: boolean;
|
||||
/** Callback when content is successfully created */
|
||||
onCreated: (id: string) => void;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CourseContainer } from './index.svelte';
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '@cio/ui/tools';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { CONTENT_DEFINITIONS } from '$features/course/utils/content';
|
||||
|
||||
interface Props {
|
||||
type: ContentType;
|
||||
size?: number;
|
||||
className?: string;
|
||||
isHovered?: boolean;
|
||||
}
|
||||
|
||||
const iconColorClassByType = {
|
||||
// For now, I don't think sections need a different color. If there is a good reason for it, we can uncomment this.
|
||||
// [ContentType.Section]: 'text-violet-600 dark:text-violet-400',
|
||||
[ContentType.Lesson]: 'text-sky-600 dark:text-sky-400',
|
||||
[ContentType.Exercise]: 'text-green-600 dark:text-green-400'
|
||||
} as const;
|
||||
|
||||
let { type, size = 14, className = '', isHovered = false }: Props = $props();
|
||||
const Icon = $derived(CONTENT_DEFINITIONS[type].icon);
|
||||
const iconClass = $derived(cn('shrink-0 custom', iconColorClassByType[type], className));
|
||||
</script>
|
||||
|
||||
<Icon {size} class={iconClass} {isHovered} />
|
||||
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import { COURSE_SORT_OPTIONS } from '../utils/constants';
|
||||
import * as Popover from '@cio/ui/base/popover';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import FilterIcon from '@lucide/svelte/icons/filter';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface FilterTag {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
color: string;
|
||||
courseCount: number;
|
||||
}
|
||||
|
||||
interface FilterTagGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
tags: FilterTag[];
|
||||
}
|
||||
|
||||
type SortOption = (typeof COURSE_SORT_OPTIONS)[number];
|
||||
|
||||
interface Props {
|
||||
selectedId?: string;
|
||||
selectedTags?: string[];
|
||||
tagGroups?: FilterTagGroup[];
|
||||
sortOptions?: readonly SortOption[];
|
||||
isFiltering?: boolean;
|
||||
onToggleTag?: (tagSlug: string, checked: boolean) => void;
|
||||
onClearFilters?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
selectedId = $bindable('0'),
|
||||
selectedTags = [],
|
||||
tagGroups = [],
|
||||
sortOptions = COURSE_SORT_OPTIONS,
|
||||
isFiltering = false,
|
||||
onToggleTag = () => {},
|
||||
onClearFilters = () => {}
|
||||
}: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
|
||||
const hasActiveFilters = $derived(selectedId !== '0' || selectedTags.length > 0);
|
||||
const hasActiveTagFilters = $derived(selectedTags.length > 0);
|
||||
|
||||
function isTagSelected(tagSlug: string) {
|
||||
return selectedTags.includes(tagSlug);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open={isOpen}>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<div class="relative">
|
||||
<IconButton {...props} aria-label={$t('courses.tag_filters.filter')} tooltip={$t('courses.tag_filters.filter')}>
|
||||
{#if isFiltering}
|
||||
<Spinner class="size-4" />
|
||||
{:else}
|
||||
<FilterIcon size={16} />
|
||||
{/if}
|
||||
</IconButton>
|
||||
{#if hasActiveTagFilters}
|
||||
<span
|
||||
class="ui:bg-primary absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border border-white"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content align="end" class="w-[360px] p-3">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-sm font-semibold">{$t('courses.tag_filters.popover_title')}</p>
|
||||
<Button variant="link" class="h-auto p-0" onclick={onClearFilters} disabled={!hasActiveFilters}>
|
||||
{$t('courses.tag_filters.clear_all')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
|
||||
{$t('courses.tag_filters.sort')}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each sortOptions as option (option.value)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selectedId === option.value ? 'secondary' : 'outline'}
|
||||
onclick={() => (selectedId = option.value)}
|
||||
>
|
||||
{$t(option.label)}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
|
||||
{#if tagGroups.length === 0}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('courses.tag_filters.empty_tags')}</p>
|
||||
{:else}
|
||||
<div class="max-h-[320px] space-y-4 overflow-y-auto pr-1">
|
||||
{#each tagGroups as group (group.id)}
|
||||
{#if group.tags.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">{group.name}</p>
|
||||
{#if group.description}
|
||||
<p class="ui:text-muted-foreground text-xs">{group.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each group.tags as tag (tag.id)}
|
||||
<label
|
||||
class="hover:ui:bg-muted/30 flex cursor-pointer items-center justify-between rounded-md border px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={isTagSelected(tag.slug)}
|
||||
onCheckedChange={(checked) => onToggleTag(tag.slug, Boolean(checked))}
|
||||
/>
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 rounded-full border"
|
||||
style={`background-color: ${tag.color}`}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="text-sm">{tag.name}</span>
|
||||
</div>
|
||||
<span class="ui:text-muted-foreground text-xs">{tag.courseCount}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -9,7 +9,7 @@
|
||||
import * as Empty from '@cio/ui/base/empty';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { courseApi } from '$features/course/api';
|
||||
|
||||
const siteName = $derived($currentOrg.siteName);
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="group-has-data-[collapsible=icon]/sidebar-wrapper:h-8 border-border ui:bg-background bg-background sticky top-0 z-50 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear"
|
||||
class="border-border ui:bg-background sticky top-0 z-100 flex h-12 w-full shrink-0 items-center gap-2 border-b backdrop-blur transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-8"
|
||||
>
|
||||
<div class="flex w-full items-center gap-2 px-4">
|
||||
<Sidebar.Trigger />
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
|
||||
<p class="max-w-2xs truncate text-sm font-medium">
|
||||
{$course.title}
|
||||
{courseApi.course?.title || ''}
|
||||
</p>
|
||||
|
||||
<span class="grow"></span>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import * as Popover from '@cio/ui/base/popover';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { OrganizationTagGroups } from '$features/tag/utils/types';
|
||||
|
||||
interface Props {
|
||||
tagGroups?: OrganizationTagGroups;
|
||||
selectedTagIds?: string[];
|
||||
open?: boolean;
|
||||
onTagToggle?: (tagId: string) => void;
|
||||
}
|
||||
|
||||
let { tagGroups = [], selectedTagIds = [], open = $bindable(false), onTagToggle = () => {} }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={$t('course.navItem.settings.tags.add')}
|
||||
>
|
||||
<PlusIcon size={16} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="start" class="w-[420px] p-3">
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm font-semibold">{$t('course.navItem.settings.tags.popover_title')}</p>
|
||||
{#if !tagGroups.length}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('course.navItem.settings.tags.empty_groups')}</p>
|
||||
{:else}
|
||||
{#each tagGroups as group (group.id)}
|
||||
{#if group.tags.length > 0}
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{group.name}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each group.tags as tag (tag.id)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={selectedTagIds.includes(tag.id) ? 'secondary' : 'outline'}
|
||||
onclick={() => onTagToggle(tag.id)}
|
||||
>
|
||||
<span
|
||||
class="inline-block h-2 w-2 rounded-full border"
|
||||
style={`background-color: ${tag.color}`}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span>{tag.name}</span>
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -0,0 +1,6 @@
|
||||
export const STATUS = {
|
||||
PENDING: 0,
|
||||
SUBMITTED: 1,
|
||||
IN_PROGRESS: 2,
|
||||
GRADED: 3
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { deleteConfirmation } from './store';
|
||||
|
||||
let { onDelete = () => {}, onCancel = () => {} } = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open={$deleteConfirmation.open}>
|
||||
<Dialog.Content class="w-96">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
<p class="mt-0 text-base dark:text-white">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.sure')}
|
||||
</p>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
$deleteConfirmation.open = false;
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.no')}
|
||||
</Button>
|
||||
<Button
|
||||
onclick={() => {
|
||||
onDelete();
|
||||
$deleteConfirmation.open = false;
|
||||
}}
|
||||
>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,313 @@
|
||||
<script lang="ts">
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
|
||||
import {
|
||||
questionnaire,
|
||||
deleteConfirmation,
|
||||
questionnaireValidation,
|
||||
handleRemoveQuestion,
|
||||
handleCode
|
||||
} from './store';
|
||||
import { preventDefault } from '$lib/utils/functions/svelte';
|
||||
import { exerciseApi } from '$features/course/api';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { PREMIUM_QUESTION_TYPE_KEYS, QUESTION_TYPES } from '$features/ui/question/constants';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { ExerciseQuestion } from '@cio/ui';
|
||||
|
||||
import OrderModal from './order-modal.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import DeleteConfirmationModal from './delete-confirmation.svelte';
|
||||
import { QuestionContainer } from '$features/course/components';
|
||||
import { uploadImage } from '$lib/utils/services/upload';
|
||||
import {
|
||||
getQuestionTypeOptionById,
|
||||
questionTypeSupportsOptions,
|
||||
toExerciseQuestionModel
|
||||
} from './question-type-utils';
|
||||
import { QUESTION_TYPE_KEY } from '@cio/question-types';
|
||||
import { getExerciseQuestionLabels } from './question-labels';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import { openUpgradeModal } from '$lib/utils/functions/org';
|
||||
import { PremiumIcon } from '@cio/ui/custom/moving-icons';
|
||||
|
||||
const initialQuestionsLength = $questionnaire.questions.length;
|
||||
|
||||
interface Props {
|
||||
shouldDeleteExercise?: boolean;
|
||||
exerciseId: any;
|
||||
goBack?: any;
|
||||
}
|
||||
|
||||
let { shouldDeleteExercise = $bindable(false), exerciseId, goBack = () => {} }: Props = $props();
|
||||
|
||||
let questionIdToDelete = $state(null);
|
||||
let isDeleting = $state(false);
|
||||
let errors = $derived($questionnaireValidation);
|
||||
const questionLabels = $derived(getExerciseQuestionLabels());
|
||||
|
||||
function shouldScrollToLast(questionId, questions) {
|
||||
const [lastQuestion] = questions.slice(-1);
|
||||
const currentQuestionsLength = questions.length;
|
||||
const isLast = lastQuestion.id === questionId;
|
||||
|
||||
return isLast && initialQuestionsLength !== currentQuestionsLength;
|
||||
}
|
||||
|
||||
function onInitDeleteClicked(questionId) {
|
||||
return () => {
|
||||
questionIdToDelete = questionId;
|
||||
$deleteConfirmation.open = true;
|
||||
};
|
||||
}
|
||||
|
||||
function onFinalDeleteClicked() {
|
||||
handleRemoveQuestion(questionIdToDelete)();
|
||||
}
|
||||
|
||||
function getQuestionErrorMsg(errors, question, errorKey) {
|
||||
return errors[question.id] ? errors[question.id][errorKey] : null;
|
||||
}
|
||||
|
||||
function getQuestionTypeLabel(questionType) {
|
||||
if (!questionType) return t.get('course.navItem.lessons.exercises.all_exercises.edit_mode.select_type');
|
||||
if (questionType.labelKey) return t.get(questionType.labelKey);
|
||||
return questionType.label;
|
||||
}
|
||||
|
||||
function makeDefaultOption() {
|
||||
return {
|
||||
id: `${new Date().getTime()}-form`,
|
||||
label: '',
|
||||
value: '',
|
||||
isCorrect: false
|
||||
};
|
||||
}
|
||||
|
||||
function makeTrueFalseOptions(correctValue = true) {
|
||||
const ts = Date.now();
|
||||
return [
|
||||
{ id: `${ts}-true-form`, label: 'True', value: 'true', isCorrect: correctValue },
|
||||
{ id: `${ts}-false-form`, label: 'False', value: 'false', isCorrect: !correctValue }
|
||||
];
|
||||
}
|
||||
|
||||
function onQuestionTypeChange(questionId: string | number, value: string) {
|
||||
if (!value) return;
|
||||
|
||||
const nextQuestionType = getQuestionTypeOptionById(Number(value));
|
||||
const nextQuestionTypeKey = nextQuestionType.key;
|
||||
|
||||
questionnaire.update((q) => {
|
||||
const idx = q.questions.findIndex((qu) => String(qu.id) === String(questionId));
|
||||
if (idx === -1) return q;
|
||||
|
||||
const current = q.questions[idx];
|
||||
let nextOptions = current.options ?? [];
|
||||
|
||||
if (nextQuestionTypeKey === QUESTION_TYPE_KEY.TRUE_FALSE) {
|
||||
const correctValue = (current.settings as { correctValue?: boolean })?.correctValue ?? true;
|
||||
nextOptions = makeTrueFalseOptions(correctValue);
|
||||
} else if (questionTypeSupportsOptions(nextQuestionTypeKey)) {
|
||||
const hasActiveOptions = nextOptions.some((option) => !option.deletedAt);
|
||||
if (!hasActiveOptions) {
|
||||
nextOptions = [...nextOptions, makeDefaultOption()];
|
||||
}
|
||||
} else {
|
||||
nextOptions = [];
|
||||
}
|
||||
|
||||
const next = [...q.questions];
|
||||
next[idx] = {
|
||||
...current,
|
||||
questionType: nextQuestionType,
|
||||
questionTypeId: nextQuestionType.id,
|
||||
options: nextOptions,
|
||||
isDirty: true
|
||||
};
|
||||
|
||||
return { ...q, questions: next };
|
||||
});
|
||||
}
|
||||
|
||||
function onSharedQuestionChange(questionId: string | number, nextQuestion) {
|
||||
questionnaire.update((q) => {
|
||||
const idx = q.questions.findIndex((qu) => String(qu.id) === String(questionId));
|
||||
if (idx === -1) return q;
|
||||
|
||||
const current = q.questions[idx];
|
||||
const mappedOptions = Array.isArray(nextQuestion.options)
|
||||
? nextQuestion.options.map((option, index) => {
|
||||
const optionId = option.id ?? current.options?.[index]?.id ?? `${new Date().getTime()}-${index}-form`;
|
||||
const optionLabel = option.label ?? '';
|
||||
|
||||
return {
|
||||
id: optionId,
|
||||
label: optionLabel,
|
||||
value: option.value ?? optionLabel,
|
||||
isCorrect: option.isCorrect ?? false,
|
||||
settings:
|
||||
option.settings && typeof option.settings === 'object' && !Array.isArray(option.settings)
|
||||
? { ...option.settings }
|
||||
: undefined
|
||||
};
|
||||
})
|
||||
: (current.options ?? []);
|
||||
|
||||
const next = [...q.questions];
|
||||
next[idx] = {
|
||||
...current,
|
||||
title: nextQuestion.title,
|
||||
settings: nextQuestion.settings ?? {},
|
||||
options: mappedOptions,
|
||||
isDirty: true
|
||||
};
|
||||
|
||||
return { ...q, questions: next };
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
isDeleting = true;
|
||||
|
||||
await exerciseApi.delete(courseApi.course?.id!, exerciseId);
|
||||
|
||||
if (exerciseApi.success) {
|
||||
courseApi.removeContentItem(exerciseId, ContentType.Exercise);
|
||||
goBack();
|
||||
}
|
||||
isDeleting = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<DeleteConfirmationModal onCancel={() => (questionIdToDelete = null)} onDelete={onFinalDeleteClicked} />
|
||||
|
||||
<OrderModal />
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={shouldDeleteExercise}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) shouldDeleteExercise = false;
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-2/4">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.delete_modal')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<form onsubmit={preventDefault()}>
|
||||
<h1 class="text-xl dark:text-white">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.sure')}
|
||||
</h1>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" type="submit" onclick={() => (shouldDeleteExercise = false)}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.no')}
|
||||
</Button>
|
||||
<Button disabled={isDeleting} onclick={handleDelete} loading={isDeleting}>
|
||||
{isDeleting
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.edit_mode.deleting')
|
||||
: $t('course.navItem.lessons.exercises.all_exercises.edit_mode.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<div class="mb-20 w-full">
|
||||
{#if Object.values(errors).length}
|
||||
<div class="mb-4 flex w-full justify-center">
|
||||
<p class="text-sm text-red-500">{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.error')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="questions px-6 pt-6">
|
||||
{#each $questionnaire.questions as question, index}
|
||||
{#if !question.deletedAt}
|
||||
<QuestionContainer
|
||||
key={String(question.id ?? `new-${index}`)}
|
||||
onClose={onInitDeleteClicked(question.id)}
|
||||
scrollToQuestion={shouldScrollToLast(question.id, $questionnaire.questions)}
|
||||
bind:points={question.points}
|
||||
hasError={!!errors[question.id]}
|
||||
errorMsg={getQuestionErrorMsg(errors, question, 'points')}
|
||||
onPointsChange={() => {
|
||||
question.isDirty = true;
|
||||
}}
|
||||
>
|
||||
{#if typeof question.code === 'string'}
|
||||
<div class="my-3 flex w-3/5 items-center justify-between">
|
||||
<TextareaField
|
||||
bind:value={question.code}
|
||||
rows={2}
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.write')}
|
||||
/>
|
||||
<IconButton onclick={() => handleCode(question.id, false)}>
|
||||
<TrashIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-2 flex flex-col">
|
||||
<ExerciseQuestion.QuestionRenderer
|
||||
showContainer={false}
|
||||
contract={{
|
||||
mode: 'edit',
|
||||
question: toExerciseQuestionModel(question),
|
||||
labels: questionLabels,
|
||||
onImageUpload: uploadImage
|
||||
}}
|
||||
onQuestionChange={(nextQuestion) => onSharedQuestionChange(question.id, nextQuestion)}
|
||||
>
|
||||
{#snippet questionTypeSelect()}
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={question.questionTypeId?.toString()}
|
||||
onValueChange={(value) => onQuestionTypeChange(question.id, value)}
|
||||
>
|
||||
<Select.Trigger class="w-[180px]">
|
||||
{getQuestionTypeLabel(question?.questionType)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each QUESTION_TYPES as type}
|
||||
{#if $isFreePlan && PREMIUM_QUESTION_TYPE_KEYS.has(type.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="ui:flex ui:w-full ui:cursor-pointer ui:select-none ui:items-center ui:gap-2 ui:rounded-sm ui:py-1.5 ui:pl-2 ui:pr-8 ui:text-sm ui:outline-hidden ui:hover:bg-accent ui:hover:text-accent-foreground ui:relative"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openUpgradeModal();
|
||||
}}
|
||||
title={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.premium_question_type')}
|
||||
>
|
||||
<PremiumIcon size={16} class="ui:text-blue-700 ui:dark:text-white ui:shrink-0" />
|
||||
<span>{getQuestionTypeLabel(type)}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<Select.Item value={type.id.toString()} label={getQuestionTypeLabel(type)} />
|
||||
{/if}
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{/snippet}
|
||||
</ExerciseQuestion.QuestionRenderer>
|
||||
|
||||
{#if getQuestionErrorMsg(errors, question, 'option')}
|
||||
<p class="text-sm text-red-500">{getQuestionErrorMsg(errors, question, 'option')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</QuestionContainer>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(#dnd-action-dragged-el div) {
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,166 @@
|
||||
import { getQuestionAnswerKey, getQuestionTypeKey, questionTypeSupportsOptions } from './question-type-utils';
|
||||
|
||||
import { QUESTION_TYPE_KEY } from '@cio/question-types';
|
||||
import type { Question } from '$features/course/types';
|
||||
import type { QuestionnaireMetaData } from './store';
|
||||
import type { TExerciseUpdate } from '@cio/utils/validation/exercise';
|
||||
import { toggleConfetti } from '$features/ui/confetti/store';
|
||||
|
||||
export const isAnswerCorrect = (options, answer) => {
|
||||
return options.some((option) => option.isCorrect && option.value === answer);
|
||||
};
|
||||
|
||||
/*
|
||||
GRADING RULES
|
||||
Textarea = no validation
|
||||
Single = only one should be correct
|
||||
Multiple = all should be correct
|
||||
*/
|
||||
export function wasCorrectAnswerSelected(currentQuestion: Question, answers, isFinished?: boolean) {
|
||||
const questionTypeKey = getQuestionTypeKey(currentQuestion);
|
||||
if (!questionTypeSupportsOptions(questionTypeKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const answer = answers[getQuestionAnswerKey(currentQuestion)];
|
||||
|
||||
const formattedAnswers = typeof answer === 'string' ? [answer] : answer;
|
||||
|
||||
let isCorrect = false;
|
||||
|
||||
if (questionTypeKey === QUESTION_TYPE_KEY.CHECKBOX) {
|
||||
// Every correct answer should be in the selected answer
|
||||
isCorrect = currentQuestion.options
|
||||
.filter((o) => o.isCorrect)
|
||||
.every((option) => formattedAnswers?.includes(option.value));
|
||||
} else if (questionTypeKey === QUESTION_TYPE_KEY.RADIO) {
|
||||
// At least one correct answer should be selected
|
||||
isCorrect = formattedAnswers?.some((answer) => isAnswerCorrect(currentQuestion.options, answer));
|
||||
}
|
||||
|
||||
if (isCorrect && !isFinished) {
|
||||
toggleConfetti();
|
||||
|
||||
setTimeout(toggleConfetti, 100);
|
||||
}
|
||||
|
||||
return isCorrect;
|
||||
}
|
||||
|
||||
export function getPropsForQuestion(
|
||||
questions: Question[],
|
||||
question: Question,
|
||||
questionnaireMetaData: QuestionnaireMetaData,
|
||||
questionIndex: number,
|
||||
onSubmit,
|
||||
onPrevious,
|
||||
preview
|
||||
) {
|
||||
const { answers, isFinished } = questionnaireMetaData;
|
||||
const isLast = questionIndex === questions.length;
|
||||
const isOpenQuesiton = !questionTypeSupportsOptions(getQuestionTypeKey(question));
|
||||
const isCorrect = wasCorrectAnswerSelected(question, answers, isFinished);
|
||||
const answerKey = getQuestionAnswerKey(question, questionIndex - 1);
|
||||
|
||||
if (!isCorrect && document && document.getElementById('question')) {
|
||||
const questionElement = document.getElementById('question');
|
||||
// Shake if wrong answer was selected
|
||||
questionElement?.classList.toggle('shake');
|
||||
|
||||
setTimeout(() => {
|
||||
// Remove so we can trigger animation again
|
||||
questionElement?.classList.toggle('shake');
|
||||
}, 200);
|
||||
}
|
||||
|
||||
return {
|
||||
index: questionIndex + '. ',
|
||||
title: question.title,
|
||||
name: answerKey,
|
||||
options: question.options,
|
||||
code: question.code,
|
||||
defaultValue: isOpenQuesiton ? answers[answerKey] || '' : answers[answerKey] || [],
|
||||
onSubmit,
|
||||
onPrevious,
|
||||
disablePreviousButton: questionIndex === 1,
|
||||
isLast,
|
||||
isPreview: preview || isFinished,
|
||||
isCorrect,
|
||||
disabled: (isFinished && isCorrect) || isFinished,
|
||||
nextButtonProps: isOpenQuesiton
|
||||
? {
|
||||
label: 'Next',
|
||||
isActive: true
|
||||
}
|
||||
: isCorrect
|
||||
? {
|
||||
label: isLast ? 'Finish' : 'Next',
|
||||
isActive: true,
|
||||
disableOptionSelect: true,
|
||||
isDisabled: isLast ? false : true // Disable Next button because auto next is enabled
|
||||
}
|
||||
: {
|
||||
label: 'Check',
|
||||
isActive: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function filterOutDeleted(array) {
|
||||
return array.filter((item) => !item.deletedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms questions from the questionnaire format to the API format
|
||||
* @param questions - Array of questions from the questionnaire store
|
||||
* @param options - Configuration options
|
||||
* @param options.shouldFilterEmptyLabels - If true, filters out options with empty labels (for API submission). If false, keeps empty labels (for validation)
|
||||
* @param options.shouldIncludeDeleted - If true, includes deleted questions and options with deletedAt flag (for API updates). If false, filters them out (for validation)
|
||||
* @returns Transformed questions in API format
|
||||
*/
|
||||
export function transformQuestionsToApiFormat(
|
||||
questions: Question[],
|
||||
options: { shouldFilterEmptyLabels?: boolean; shouldIncludeDeleted?: boolean } = {}
|
||||
): TExerciseUpdate['questions'] {
|
||||
const { shouldFilterEmptyLabels = false, shouldIncludeDeleted = false } = options;
|
||||
const getQuestionSettings = (question: Question) => {
|
||||
const rawSettings = (question as Question & { settings?: Record<string, unknown> }).settings;
|
||||
return rawSettings && typeof rawSettings === 'object' ? { ...rawSettings } : undefined;
|
||||
};
|
||||
const getOptionSettings = (option: Question['options'][number]) => {
|
||||
const rawSettings = (option as Question['options'][number] & { settings?: Record<string, unknown> }).settings;
|
||||
return rawSettings && typeof rawSettings === 'object' ? { ...rawSettings } : undefined;
|
||||
};
|
||||
|
||||
const normalizeId = (id: unknown) => (id && !isNaN(Number(id)) ? Number(id) : undefined);
|
||||
const formatOptions = (opts: Question['options']) => {
|
||||
const filtered = shouldIncludeDeleted ? opts || [] : (opts || []).filter((opt) => !opt.deletedAt);
|
||||
|
||||
return filtered
|
||||
.filter((opt) => {
|
||||
// For validation, keep empty labels. For API, filter them unless deleted
|
||||
if (!shouldIncludeDeleted && opt.deletedAt) return false;
|
||||
return shouldFilterEmptyLabels && !opt.deletedAt ? opt.label?.trim() : true;
|
||||
})
|
||||
.map((opt) => ({
|
||||
id: normalizeId(opt.id),
|
||||
label: shouldFilterEmptyLabels && !opt.deletedAt ? opt.label?.trim() || '' : opt.label || '',
|
||||
isCorrect: opt.isCorrect || false,
|
||||
settings: getOptionSettings(opt),
|
||||
...(shouldIncludeDeleted && opt.deletedAt ? { deletedAt: opt.deletedAt } : {})
|
||||
}));
|
||||
};
|
||||
|
||||
const filteredQuestions = shouldIncludeDeleted ? questions : questions.filter((q) => !q.deletedAt);
|
||||
|
||||
return filteredQuestions.map((q, index) => ({
|
||||
id: normalizeId(q.id),
|
||||
question: q.title,
|
||||
points: q.points || 0,
|
||||
questionTypeId: q.questionTypeId,
|
||||
order: typeof (q as { order?: number }).order === 'number' ? (q as { order: number }).order : index + 1,
|
||||
settings: getQuestionSettings(q),
|
||||
options: questionTypeSupportsOptions(getQuestionTypeKey(q)) ? formatOptions(q.options) : [],
|
||||
...(shouldIncludeDeleted && q.deletedAt ? { deletedAt: q.deletedAt } : {})
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<script>
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<Skeleton class="h-4 w-full" />
|
||||
<div class="space-y-3">
|
||||
<Skeleton class="h-4 w-full" />
|
||||
<Skeleton class="h-4 w-full" />
|
||||
<Skeleton class="h-4 w-full" />
|
||||
<Skeleton class="h-4 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
+414
@@ -0,0 +1,414 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
|
||||
import { STATUS } from './constants';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { SubmissionIdData } from '$features/course/utils/types';
|
||||
import type { AnswerData } from '@cio/question-types';
|
||||
|
||||
import Preview from './preview.svelte';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
// import { useCompletion } from 'ai/svelte';
|
||||
// import { QUESTION_TYPE } from '$features/ui/question/constants';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
onClose?: any;
|
||||
handleSave?: any;
|
||||
isGradeWithAI?: boolean;
|
||||
data: SubmissionIdData;
|
||||
deleteSubmission?: any;
|
||||
isSaving?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onClose = () => {},
|
||||
handleSave = (_d: SubmissionIdData) => {},
|
||||
isGradeWithAI = $bindable(false),
|
||||
data = $bindable(),
|
||||
deleteSubmission = async (_id: string, _statusId: number) => {},
|
||||
isSaving = false
|
||||
}: Props = $props();
|
||||
|
||||
const SELECTABLE_STATUS = [
|
||||
{
|
||||
id: STATUS.SUBMITTED,
|
||||
text: $t('course.navItem.submissions.submission_status.submitted')
|
||||
},
|
||||
{
|
||||
id: STATUS.IN_PROGRESS,
|
||||
text: $t('course.navItem.submissions.submission_status.in_progress')
|
||||
},
|
||||
{
|
||||
id: STATUS.GRADED,
|
||||
text: $t('course.navItem.submissions.submission_status.graded')
|
||||
}
|
||||
];
|
||||
|
||||
let status = $state(SELECTABLE_STATUS[0]);
|
||||
let reasons = $state({});
|
||||
let isLoading = $state(false);
|
||||
let openDeletePrompt = $state(false);
|
||||
let isDeleting = $state(false);
|
||||
|
||||
const maxPoints = $derived(getMaxPoints(data.questions));
|
||||
const sortedQuestions = $derived(
|
||||
Array.isArray(data.questions)
|
||||
? [...data.questions]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((q) => ({
|
||||
...q,
|
||||
questionType: { id: q.questionTypeId }
|
||||
}))
|
||||
: []
|
||||
);
|
||||
|
||||
let answersToDisplay = $state<Record<string, AnswerData>>({});
|
||||
|
||||
$effect(() => {
|
||||
answersToDisplay = data?.answers ? (data.answers as Record<string, AnswerData>) : {};
|
||||
});
|
||||
|
||||
function getMaxPoints(questions) {
|
||||
return (questions || []).reduce((acc, question) => acc + question.points, 0);
|
||||
}
|
||||
|
||||
let total = $derived(calculateTotal(data.questionAnswerByPoint));
|
||||
function calculateTotal(grades: Record<string, string>): number {
|
||||
if (!grades) return 0;
|
||||
return Object.values(grades).reduce((acc, grade) => acc + parseInt(grade || '0'), 0);
|
||||
}
|
||||
|
||||
function handleStatusChange(value: string) {
|
||||
const newSelectedId = parseInt(value);
|
||||
const newStatus = SELECTABLE_STATUS.find((s) => s.id === newSelectedId);
|
||||
|
||||
if (newStatus) {
|
||||
status = newStatus;
|
||||
data.statusId = newSelectedId;
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(data: SubmissionIdData) {
|
||||
untrack(() => {
|
||||
const statusBySelectedId = SELECTABLE_STATUS.find((s) => s.id === data.statusId);
|
||||
|
||||
if (statusBySelectedId) {
|
||||
status = statusBySelectedId;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// const getMultipleAnswersGrade = (options, answer, points) => {
|
||||
// const correctOptions = options
|
||||
// .filter((option) => option.is_correct)
|
||||
// .map((option) => option.value);
|
||||
|
||||
// const correctSelections = answer.answers.filter((answer) =>
|
||||
// correctOptions.includes(answer)
|
||||
// ).length;
|
||||
|
||||
// const incorrectSelections = answer.answers.length - correctSelections;
|
||||
|
||||
// const pointsEarned = (correctSelections / correctOptions.length) * points;
|
||||
// const deduction = (incorrectSelections * points) / correctOptions.length;
|
||||
// const finalPoints = Math.max(pointsEarned - deduction, 0);
|
||||
|
||||
// return {
|
||||
// finalPoints,
|
||||
// correctOptions,
|
||||
// correctSelections,
|
||||
// incorrectSelections
|
||||
// };
|
||||
// };
|
||||
|
||||
async function handleDeleteSubmission() {
|
||||
isDeleting = true;
|
||||
await deleteSubmission(data.id, status.id);
|
||||
isDeleting = false;
|
||||
}
|
||||
|
||||
// const { input, handleSubmit, completion } = useCompletion({
|
||||
// api: '/api/completion/gradingprompt',
|
||||
// onFinish: async () => {
|
||||
// try {
|
||||
// const responseData = $completion.replace('```json', '').replace('```', '');
|
||||
|
||||
// let aiResponses: {
|
||||
// id: number;
|
||||
// score: number;
|
||||
// explanation: string;
|
||||
// }[] = [];
|
||||
// try {
|
||||
// // Parse the modified response data as JSON
|
||||
// aiResponses = JSON.parse(responseData);
|
||||
// } catch (error) {
|
||||
// console.error('Error parsing AI response', error);
|
||||
// }
|
||||
|
||||
// data?.questions.forEach((question) => {
|
||||
// const { id, points, question_type_id, options } = question;
|
||||
// if (question_type_id == QUESTION_TYPE.RADIO) {
|
||||
// const answer = data.questionAnswers.find((q) => q.question_id === id);
|
||||
|
||||
// const answerPoints = answer?.answers?.length ?? 0;
|
||||
|
||||
// reasons = {
|
||||
// ...reasons,
|
||||
// [id]: `${$t('course.navItem.submissions.grading_modal.allocated')} ${$t(
|
||||
// 'course.navItem.submissions.grading_modal.total_try'
|
||||
// )} ${answerPoints} `
|
||||
// };
|
||||
// data.questionAnswerByPoint[id] = `${Math.ceil(points / answerPoints)}`;
|
||||
// } else if (question_type_id == QUESTION_TYPE.CHECKBOX) {
|
||||
// const answer = data.questionAnswers.find((q) => q.question_id === id);
|
||||
|
||||
// const { finalPoints, correctOptions, correctSelections, incorrectSelections } =
|
||||
// getMultipleAnswersGrade(options, answer, points);
|
||||
|
||||
// reasons = {
|
||||
// ...reasons,
|
||||
// [id]: `${$t(
|
||||
// 'course.navItem.submissions.grading_modal.allocated'
|
||||
// )} ${correctSelections} ${$t('course.navItem.submissions.grading_modal.out_of')} ${
|
||||
// correctOptions.length
|
||||
// } ${$t('course.navItem.submissions.grading_modal.options_correct')} ${$t(
|
||||
// 'course.navItem.submissions.grading_modal.options_wrong'
|
||||
// )} ${incorrectSelections} `
|
||||
// };
|
||||
|
||||
// data.questionAnswerByPoint[id] = `${finalPoints}`;
|
||||
// } else if (aiResponses.length) {
|
||||
// const graded = aiResponses.find((res) => res.id === id);
|
||||
|
||||
// reasons = {
|
||||
// ...reasons,
|
||||
// [id]: `${graded?.explanation}`
|
||||
// };
|
||||
|
||||
// data.questionAnswerByPoint[id] = `${graded?.score}`;
|
||||
// }
|
||||
// });
|
||||
// } catch (error) {
|
||||
// console.error('Error', error);
|
||||
// } finally {
|
||||
// isLoading = false;
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
|
||||
function gradeWithAI() {
|
||||
// isGradeWithAI = true;
|
||||
// isLoading = true;
|
||||
// const paragraphAiInput = data.questions
|
||||
// .filter((q) => q.question_type_id === QUESTION_TYPE.TEXTAREA)
|
||||
// .map((q) => {
|
||||
// const answer = data.questionAnswers.find((qA) => qA.question_id === q.id); // { open_answer: '' }
|
||||
// return {
|
||||
// id: q.id,
|
||||
// question: q.title,
|
||||
// answer: answer?.open_answer,
|
||||
// point: q.points
|
||||
// };
|
||||
// });
|
||||
// $input = JSON.stringify(paragraphAiInput);
|
||||
// handleSubmit({ preventDefault: () => {} });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
setStatus(data);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) onClose();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="max-h-[90vh]! w-4/5 max-w-6xl!">
|
||||
<Dialog.Header class="py-2">
|
||||
<Dialog.Title class="text-base font-semibold">{data.title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div class="flex max-h-[calc(90vh-5rem)] justify-between gap-4 overflow-y-auto pr-4">
|
||||
<div class="mt-2 min-h-0 min-w-0 flex-1">
|
||||
{#if openDeletePrompt}
|
||||
<div class="mx-auto w-96 rounded-md border border-gray-300 p-3">
|
||||
<h1 class="text-lg dark:text-white">
|
||||
{$t('delete_modal.content')}
|
||||
</h1>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={() => (openDeletePrompt = false)} disabled={isDeleting}>
|
||||
{$t('delete_modal.no')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={handleDeleteSubmission} loading={isDeleting}>
|
||||
{$t('delete_modal.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Preview
|
||||
questions={sortedQuestions}
|
||||
questionnaireMetaData={{
|
||||
answers: answersToDisplay,
|
||||
isFinished: true
|
||||
}}
|
||||
bind:grades={data.questionAnswerByPoint}
|
||||
bind:reasons
|
||||
bind:isGradeWithAI
|
||||
bind:isLoading
|
||||
disableGrading={false}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="sticky top-0 mt-2 ml-4 min-h-0 w-2/5 max-w-[350px] shrink-0 self-start">
|
||||
<div class="rounded-md border border-gray-300">
|
||||
<div
|
||||
class="flex items-center justify-between gap-1 border-t-0 border-r-0 border-b border-l-0 border-gray-300 p-3"
|
||||
>
|
||||
<p class="text-base dark:text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.details')}
|
||||
{#if data.isEarly}
|
||||
<Badge variant="success" class="ml-2">
|
||||
{$t('course.navItem.submissions.grading_modal.early')}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge variant="destructive" class="ml-2">
|
||||
{$t('course.navItem.submissions.grading_modal.late')}
|
||||
</Badge>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md hover:bg-gray-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<EllipsisVerticalIcon class="h-5 w-5" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="ui:z-[250]">
|
||||
<DropdownMenu.Item
|
||||
class="text-red-600"
|
||||
onclick={() => {
|
||||
openDeletePrompt = true;
|
||||
}}
|
||||
>
|
||||
{$t('delete_modal.label')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4 px-3 py-2 text-sm">
|
||||
<p class="text-sm font-semibold text-gray-500 dark:text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.total_grade')}:
|
||||
</p>
|
||||
|
||||
<Badge class="w-fit rounded-md bg-gray-100 font-semibold text-black dark:bg-neutral-700 dark:text-white">
|
||||
{total}/{maxPoints}
|
||||
</Badge>
|
||||
</div>
|
||||
<!-- <div class="flex items-center text-sm p-3">
|
||||
<p class="dark:text-white w-1/2">Status</p>
|
||||
<div class="flex items-center">
|
||||
<p class="dark:text-white rounded-full w-5 h-5 bg-yellow-300 mr-2" />
|
||||
<p class="dark:text-white">Grading</p>
|
||||
</div>
|
||||
</div> -->
|
||||
<div class="flex items-center space-x-4 px-3 py-2 text-sm">
|
||||
<p class="text-sm font-semibold text-gray-500 dark:text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.student')}:
|
||||
</p>
|
||||
{#if data.student}
|
||||
<div class="flex flex-row items-center justify-center rounded-md bg-gray-100 p-[6px] dark:bg-neutral-700">
|
||||
<img
|
||||
alt={$t('course.navItem.submissions.grading_modal.student_avatar')}
|
||||
class="flex h-5 w-5 rounded-full"
|
||||
src={data.student.avatarUrl}
|
||||
/>
|
||||
<p class="ml-2 line-clamp-1 text-sm font-semibold dark:text-white">
|
||||
{data.student.fullname}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- <div class="flex items-center space-x-4 text-sm px-3 py-2">
|
||||
<p class="dark:text-white text-sm text-gray-500 font-semibold">Assesment Type:</p>
|
||||
<Tag
|
||||
class="dark:text-white font-semibold bg-gray-100 dark:bg-neutral-700 rounded-md text-black w-fit"
|
||||
>Paragraph</Tag
|
||||
>
|
||||
</div> -->
|
||||
|
||||
<div class="flex flex-col items-start px-3 py-2 text-sm">
|
||||
<p class="font-semibold text-gray-500 dark:text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.status')}:
|
||||
</p>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={status.id.toString()}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
handleStatusChange(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
{status.text}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="ui:z-[250]">
|
||||
{#each SELECTABLE_STATUS as statusOption}
|
||||
<Select.Item value={statusOption.id.toString()}>
|
||||
{statusOption.text}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start px-3 py-2 text-sm">
|
||||
<p class="font-semibold text-gray-500 dark:text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.add_comment')}:
|
||||
</p>
|
||||
<TextareaField
|
||||
placeholder={$t('course.navItem.submissions.grading_modal.add_comment_placeholder')}
|
||||
bind:value={data.feedback}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col space-y-3 px-3 py-2">
|
||||
<Button variant="outline" onclick={gradeWithAI} class="hidden! w-full">
|
||||
<img src="/ai.svg" alt={$t('course.navItem.submissions.grading_modal.ai_icon')} />
|
||||
<p class="text-sm font-semibold">
|
||||
{$t('course.navItem.submissions.grading_modal.grade_with_ai')}
|
||||
</p>
|
||||
</Button>
|
||||
<Button
|
||||
onclick={() => {
|
||||
handleSave(data);
|
||||
// onClose();
|
||||
}}
|
||||
loading={isSaving}
|
||||
class="w-full"
|
||||
>
|
||||
{$t('course.navItem.submissions.grading_modal.submit_grades')}
|
||||
</Button>
|
||||
</div>
|
||||
<!-- <div class="flex items-center text-sm p-3">
|
||||
<p class="dark:text-white w-1/2">Teacher</p>
|
||||
<p class="dark:text-white">rotimi-best</p>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
import SquareCheckIcon from '@lucide/svelte/icons/square-check';
|
||||
import CircleDotIcon from '@lucide/svelte/icons/circle-dot';
|
||||
import TablePropertiesIcon from '@lucide/svelte/icons/table-properties';
|
||||
|
||||
import { flip } from 'svelte/animate';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
|
||||
import { questionnaireOrder, questionnaire } from './store';
|
||||
import { filterOutDeleted } from './functions';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
const flipDurationMs = 300;
|
||||
|
||||
interface Question {
|
||||
id: number;
|
||||
name: string;
|
||||
type: number;
|
||||
}
|
||||
|
||||
let items: Array<Question> = $state([]);
|
||||
|
||||
function setItemsFromQuestions(questions: typeof $questionnaire.questions) {
|
||||
untrack(() => {
|
||||
items = filterOutDeleted(questions).map((q) => ({
|
||||
id: q.id,
|
||||
name: q.title,
|
||||
type: q.questionType.id
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function handleDndConsider(e) {
|
||||
items = e.detail.items;
|
||||
}
|
||||
|
||||
function handleDndFinalize(e) {
|
||||
items = e.detail.items;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
items.forEach((item, index) => {
|
||||
const order = index + 1;
|
||||
$questionnaire.questions.some((q) => {
|
||||
if (q.id === item.id) {
|
||||
q.isDirty = true;
|
||||
q.order = order;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
$questionnaire.questions = $questionnaire.questions.sort((a, b) => a.order - b.order);
|
||||
$questionnaireOrder.open = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
setItemsFromQuestions($questionnaire.questions);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={$questionnaireOrder.open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) handleClose();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-96">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.order_questions')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<section
|
||||
use:dndzone={{
|
||||
items,
|
||||
flipDurationMs,
|
||||
dropTargetStyle: {
|
||||
border: '2px #1d4ed8 solid',
|
||||
'border-style': 'dashed'
|
||||
}
|
||||
}}
|
||||
onconsider={handleDndConsider}
|
||||
onfinalize={handleDndFinalize}
|
||||
class="w-full"
|
||||
>
|
||||
{#each items as item (item.id)}
|
||||
<div
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
class="ui:border-primary flex items-center gap-2 rounded-md border p-4 [&_svg]:shrink-0"
|
||||
>
|
||||
{#if item.type === 1}
|
||||
<SquareCheckIcon size={16} />
|
||||
{:else if item.type === 2}
|
||||
<CircleDotIcon size={16} />
|
||||
{:else}
|
||||
<TablePropertiesIcon size={16} />
|
||||
{/if}
|
||||
{` ${item.name}`}
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<style>
|
||||
section {
|
||||
padding: 0.3em;
|
||||
overflow: auto;
|
||||
}
|
||||
div {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* If multiple questions have order 0 (or null), they are considered unordered.
|
||||
* Normalizes by sorting by id and assigning incrementing order values.
|
||||
* Returns questions with order set; original array is not mutated.
|
||||
*/
|
||||
export function normalizeQuestionOrder<T extends { id?: number | string; order?: number | null }>(questions: T[]): T[] {
|
||||
if (questions.length === 0) return questions;
|
||||
|
||||
const withOrderZero = questions.filter((q) => q.order == null || q.order === 0);
|
||||
const isUnordered = withOrderZero.length > 1;
|
||||
|
||||
const toNumericId = (id: number | string | undefined): number =>
|
||||
typeof id === 'number' ? id : typeof id === 'string' ? parseInt(id, 10) || 0 : 0;
|
||||
|
||||
if (!isUnordered) {
|
||||
return [...questions].sort((a, b) => {
|
||||
const orderA = a.order ?? 0;
|
||||
const orderB = b.order ?? 0;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return toNumericId(a.id) - toNumericId(b.id);
|
||||
});
|
||||
}
|
||||
|
||||
const sortedById = [...questions].sort((a, b) => toNumericId(a.id) - toNumericId(b.id));
|
||||
return sortedById.map((q, index) => ({ ...q, order: index + 1 }));
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionContractKey } from '@cio/question-types';
|
||||
import type { AnswerData } from '@cio/question-types';
|
||||
import { Grade, ReasonBox } from '$features/ui/question';
|
||||
import { toExerciseQuestionModel } from './question-type-utils';
|
||||
import { getExerciseQuestionLabels } from './question-labels';
|
||||
import { ExerciseQuestion } from '@cio/ui';
|
||||
import type { QuestionnaireMetaData } from './store';
|
||||
|
||||
interface Props {
|
||||
questions?: any;
|
||||
questionnaireMetaData?: Partial<QuestionnaireMetaData>;
|
||||
grades?: Record<string, number>;
|
||||
onSubmit?: any;
|
||||
onPrevious?: any;
|
||||
disableGrading?: boolean;
|
||||
isGradeWithAI?: boolean;
|
||||
isLoading?: boolean;
|
||||
reasons?: any;
|
||||
}
|
||||
|
||||
let {
|
||||
questions = [],
|
||||
questionnaireMetaData: _questionnaireMetaData = {},
|
||||
grades = $bindable({}),
|
||||
disableGrading = true,
|
||||
isGradeWithAI = $bindable(false),
|
||||
isLoading = $bindable(false),
|
||||
reasons = $bindable({})
|
||||
}: Props = $props();
|
||||
|
||||
function acceptGradeSuggestion() {
|
||||
isGradeWithAI = false;
|
||||
}
|
||||
|
||||
function rejectGradeSuggestion(questionId) {
|
||||
isGradeWithAI = false;
|
||||
grades[questionId] = 0;
|
||||
}
|
||||
|
||||
function getAnswerForQuestion(questionModel, fallbackIndex = 0) {
|
||||
const questionKey = getExerciseQuestionContractKey(questionModel, fallbackIndex);
|
||||
return _questionnaireMetaData?.answers?.[questionKey];
|
||||
}
|
||||
|
||||
const questionModels = $derived(questions.map((question) => toExerciseQuestionModel(question)));
|
||||
const questionLabels = $derived(getExerciseQuestionLabels());
|
||||
const answersByKey = $derived(
|
||||
questionModels.reduce(
|
||||
(acc, questionModel, index) => {
|
||||
const questionKey = getExerciseQuestionContractKey(questionModel, index);
|
||||
acc[questionKey] = _questionnaireMetaData?.answers?.[questionKey];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, AnswerData>
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if disableGrading && !Object.keys(grades || {}).length}
|
||||
<ExerciseQuestion.QuestionList
|
||||
contract={{ mode: 'view', questions: questionModels, answersByKey, labels: questionLabels, disabled: true }}
|
||||
itemClass="mb-4"
|
||||
/>
|
||||
{:else if disableGrading && Object.keys(grades || {}).length}
|
||||
{#each questions as currentQuestion, index}
|
||||
{@const questionModel = questionModels[index]}
|
||||
<div class="mb-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-base font-semibold text-gray-700 dark:text-gray-200">Q{index + 1}</p>
|
||||
<Grade grade={grades[currentQuestion.id]} gradeMax={currentQuestion.points} disableGrading={true} />
|
||||
</div>
|
||||
|
||||
<ExerciseQuestion.QuestionRenderer
|
||||
contract={{
|
||||
mode: 'view',
|
||||
question: questionModel,
|
||||
answer: getAnswerForQuestion(questionModel, index),
|
||||
labels: questionLabels,
|
||||
disabled: true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each questions as currentQuestion, index}
|
||||
{@const questionModel = questionModels[index]}
|
||||
<div class="mb-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-base font-semibold text-gray-700 dark:text-gray-200">Q{index + 1}</p>
|
||||
<Grade bind:grade={grades[currentQuestion.id]} gradeMax={currentQuestion.points} {disableGrading} />
|
||||
</div>
|
||||
|
||||
<ExerciseQuestion.QuestionRenderer
|
||||
contract={{
|
||||
mode: 'view',
|
||||
question: questionModel,
|
||||
answer: getAnswerForQuestion(questionModel, index),
|
||||
labels: questionLabels,
|
||||
disabled: true
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if isGradeWithAI}
|
||||
<ReasonBox
|
||||
reason={reasons[currentQuestion.id]}
|
||||
{isLoading}
|
||||
acceptGrade={acceptGradeSuggestion}
|
||||
rejectGrade={() => rejectGradeSuggestion(currentQuestion.id)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -0,0 +1,285 @@
|
||||
import type { ExerciseQuestionLabels } from '@cio/question-types';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
/**
|
||||
* Returns translated labels for ExerciseQuestion UI components.
|
||||
*
|
||||
* Note: The same label keys exist in packages/storybook/src/molecules/exercise-question/question-labels.ts
|
||||
* with hardcoded English values for Storybook demos. When adding or changing labels for a question type,
|
||||
* update both files to keep them in sync.
|
||||
*/
|
||||
export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
|
||||
return {
|
||||
'common.option_prefix': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.common.option_prefix'
|
||||
),
|
||||
'common.remove': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.common.remove'),
|
||||
'common.add_option': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.common.add_option'),
|
||||
'common.correct_badge': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.common.correct_badge'
|
||||
),
|
||||
'common.not_set': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.common.not_set'),
|
||||
'navigation.previous': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.navigation.previous'),
|
||||
'navigation.next': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.navigation.next'),
|
||||
'navigation.finish': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.navigation.finish'),
|
||||
'question.edit.title_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.title_label'
|
||||
),
|
||||
'question.edit.title_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.title_placeholder'
|
||||
),
|
||||
'question.edit.description_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.description_label'
|
||||
),
|
||||
'question.edit.description_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.description_placeholder'
|
||||
),
|
||||
'question.edit.add_description': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.add_description'
|
||||
),
|
||||
'question.edit.remove_description': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.remove_description'
|
||||
),
|
||||
'question.edit.media_menu_sr': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.media_menu_sr'
|
||||
),
|
||||
'question.edit.add_image': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.add_image'
|
||||
),
|
||||
'question.edit.add_video': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.add_video'
|
||||
),
|
||||
'question.edit.remove_image_tooltip': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.remove_image_tooltip'
|
||||
),
|
||||
'question.edit.remove_image_sr': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.remove_image_sr'
|
||||
),
|
||||
'question.edit.remove_video_tooltip': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.remove_video_tooltip'
|
||||
),
|
||||
'question.edit.remove_video_sr': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.remove_video_sr'
|
||||
),
|
||||
'question.edit.image_alt': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.image_alt'
|
||||
),
|
||||
'question.edit.video_modal_title': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.video_modal_title'
|
||||
),
|
||||
'question.edit.video_link_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.video_link_label'
|
||||
),
|
||||
'question.edit.video_link_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.video_link_placeholder'
|
||||
),
|
||||
'question.edit.video_add_button': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.video_add_button'
|
||||
),
|
||||
'question.edit.video_invalid_link': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.video_invalid_link'
|
||||
),
|
||||
'question.edit.upload_error_only_images': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.upload_error_only_images'
|
||||
),
|
||||
'question.edit.upload_error_skipped': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.upload_error_skipped'
|
||||
),
|
||||
'question.edit.upload_error_failed': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.question.edit.upload_error_failed'
|
||||
),
|
||||
'radio.edit.helper': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.radio.edit.helper'),
|
||||
'radio.edit.correct_selected_tooltip': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.radio.edit.correct_selected_tooltip'
|
||||
),
|
||||
'radio.edit.mark_correct_tooltip': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.radio.edit.mark_correct_tooltip'
|
||||
),
|
||||
'radio.edit.mark_correct_sr': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.radio.edit.mark_correct_sr'
|
||||
),
|
||||
'checkbox.edit.helper': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.checkbox.edit.helper'
|
||||
),
|
||||
'checkbox.edit.correct_selected_tooltip': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.checkbox.edit.correct_selected_tooltip'
|
||||
),
|
||||
'checkbox.edit.toggle_correct_tooltip': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.checkbox.edit.toggle_correct_tooltip'
|
||||
),
|
||||
'checkbox.edit.toggle_correct_sr': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.checkbox.edit.toggle_correct_sr'
|
||||
),
|
||||
'true_false.true_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.true_false.true_label'
|
||||
),
|
||||
'true_false.false_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.true_false.false_label'
|
||||
),
|
||||
'true_false.edit.correct_answer_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.true_false.edit.correct_answer_label'
|
||||
),
|
||||
'true_false.preview.correct_value_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.true_false.preview.correct_value_label'
|
||||
),
|
||||
'short_answer.edit.instructions_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.short_answer.edit.instructions_placeholder'
|
||||
),
|
||||
'short_answer.take.placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.short_answer.take.placeholder'
|
||||
),
|
||||
'short_answer.preview.helper': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.short_answer.preview.helper'
|
||||
),
|
||||
'numeric.edit.correct_value_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.edit.correct_value_label'
|
||||
),
|
||||
'numeric.edit.correct_value_info': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.edit.correct_value_info'
|
||||
),
|
||||
'numeric.edit.tolerance_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.edit.tolerance_label'
|
||||
),
|
||||
'numeric.edit.tolerance_info': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.edit.tolerance_info'
|
||||
),
|
||||
'numeric.edit.correct_value_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.edit.correct_value_placeholder'
|
||||
),
|
||||
'numeric.edit.tolerance_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.edit.tolerance_placeholder'
|
||||
),
|
||||
'numeric.take.placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.take.placeholder'
|
||||
),
|
||||
'numeric.preview.correct_value_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.preview.correct_value_label'
|
||||
),
|
||||
'numeric.preview.tolerance_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.preview.tolerance_label'
|
||||
),
|
||||
'fill_blank.edit.accepted_answers_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.edit.accepted_answers_placeholder'
|
||||
),
|
||||
'fill_blank.take.placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.take.placeholder'
|
||||
),
|
||||
'fill_blank.preview.accepted_answers_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.preview.accepted_answers_label'
|
||||
),
|
||||
'file_upload.edit.accepted_types_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.edit.accepted_types_placeholder'
|
||||
),
|
||||
'file_upload.edit.max_size_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.edit.max_size_placeholder'
|
||||
),
|
||||
'file_upload.take.selected_file_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.take.selected_file_label'
|
||||
),
|
||||
'file_upload.take.upload_button': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.take.upload_button'
|
||||
),
|
||||
'file_upload.take.upload_progress': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.take.upload_progress'
|
||||
),
|
||||
'file_upload.take.upload_error': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.take.upload_error'
|
||||
),
|
||||
'file_upload.take.view': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.take.view'
|
||||
),
|
||||
'file_upload.take.download': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.take.download'
|
||||
),
|
||||
'file_upload.preview.accepted_types_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.preview.accepted_types_label'
|
||||
),
|
||||
'file_upload.preview.max_size_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.preview.max_size_label'
|
||||
),
|
||||
'link.edit.instructions_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.link.edit.instructions_placeholder'
|
||||
),
|
||||
'link.edit.helper': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.link.edit.helper'),
|
||||
'link.take.helper': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.link.take.helper'),
|
||||
'link.take.placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.link.take.placeholder'
|
||||
),
|
||||
'link.take.remove_tooltip': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.link.take.remove_tooltip'
|
||||
),
|
||||
'link.take.remove_sr': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.link.take.remove_sr'),
|
||||
'link.take.invalid_url_error': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.link.take.invalid_url_error'
|
||||
),
|
||||
'link.take.add_button': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.link.take.add_button'
|
||||
),
|
||||
'link.preview.empty': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.link.preview.empty'),
|
||||
'link.preview.item_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.link.preview.item_label'
|
||||
),
|
||||
'matching.edit.helper': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.matching.edit.helper'
|
||||
),
|
||||
'matching.take.placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.matching.take.placeholder'
|
||||
),
|
||||
'matching.preview.helper': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.matching.preview.helper'
|
||||
),
|
||||
'ordering.fallback_step_prefix': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.fallback_step_prefix'
|
||||
),
|
||||
'ordering.edit.helper': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.edit.helper'
|
||||
),
|
||||
'ordering.edit.add_step': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.edit.add_step'
|
||||
),
|
||||
'ordering.edit.empty': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.ordering.edit.empty'),
|
||||
'ordering.edit.step_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.edit.step_placeholder'
|
||||
),
|
||||
'ordering.edit.remove_step_tooltip': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.edit.remove_step_tooltip'
|
||||
),
|
||||
'ordering.take.helper': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.take.helper'
|
||||
),
|
||||
'ordering.take.empty': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.ordering.take.empty'),
|
||||
'ordering.preview.submitted_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.preview.submitted_label'
|
||||
),
|
||||
'ordering.preview.expected_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.preview.expected_label'
|
||||
),
|
||||
'ordering.preview.matches': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.preview.matches'
|
||||
),
|
||||
'ordering.preview.differs': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.ordering.preview.differs'
|
||||
),
|
||||
'hotspot.edit.helper': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.hotspot.edit.helper'),
|
||||
'hotspot.take.placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.hotspot.take.placeholder'
|
||||
),
|
||||
'hotspot.preview.helper': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.hotspot.preview.helper'
|
||||
),
|
||||
'textarea.edit.placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.textarea.edit.placeholder'
|
||||
),
|
||||
'textarea.take.placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.textarea.take.placeholder'
|
||||
),
|
||||
'submission.common.no_answer': t.get('course.navItem.lessons.exercises.all_exercises.analytics.individual.no'),
|
||||
'submission.common.other': t.get('course.navItem.lessons.exercises.all_exercises.analytics.summary.other'),
|
||||
'submission.chart.responses': t.get('course.navItem.lessons.exercises.all_exercises.analytics.summary.responses'),
|
||||
'submission.chart.no_data': t.get('course.navItem.lessons.exercises.all_exercises.analytics.summary.no_responses'),
|
||||
'submission.list.responses': t.get('course.navItem.lessons.exercises.all_exercises.analytics.summary.responses'),
|
||||
'submission.list.no_responses': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.analytics.summary.no_responses'
|
||||
)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
QUESTION_TYPE_KEY,
|
||||
getExerciseQuestionContractKey,
|
||||
getQuestionTypeById,
|
||||
type ExerciseQuestionModel,
|
||||
type ExerciseQuestionOption,
|
||||
type ExerciseQuestionTypeKey
|
||||
} from '@cio/question-types';
|
||||
import type { Question } from '$features/course/types';
|
||||
import { DEFAULT_QUESTION_TYPE, QUESTION_TYPES } from '$features/ui/question/constants';
|
||||
|
||||
type QuestionTypeOption = (typeof QUESTION_TYPES)[number];
|
||||
|
||||
const OPTION_QUESTION_TYPE_KEYS = new Set<ExerciseQuestionTypeKey>([
|
||||
QUESTION_TYPE_KEY.RADIO,
|
||||
QUESTION_TYPE_KEY.CHECKBOX,
|
||||
QUESTION_TYPE_KEY.TRUE_FALSE
|
||||
]);
|
||||
|
||||
export function getQuestionTypeId(question: Partial<Question>): number {
|
||||
const value = Number(question.questionTypeId ?? question.questionType?.id ?? DEFAULT_QUESTION_TYPE?.id ?? 1);
|
||||
return Number.isNaN(value) ? (DEFAULT_QUESTION_TYPE?.id ?? 1) : value;
|
||||
}
|
||||
|
||||
export function getQuestionTypeOptionById(questionTypeId: number): QuestionTypeOption {
|
||||
return QUESTION_TYPES.find((questionType) => questionType.id === questionTypeId) ?? DEFAULT_QUESTION_TYPE;
|
||||
}
|
||||
|
||||
export function getQuestionTypeKeyFromId(questionTypeId: number): ExerciseQuestionTypeKey {
|
||||
return getQuestionTypeById(questionTypeId)?.key ?? QUESTION_TYPE_KEY.RADIO;
|
||||
}
|
||||
|
||||
export function getQuestionTypeKey(question: Partial<Question>): ExerciseQuestionTypeKey {
|
||||
return getQuestionTypeKeyFromId(getQuestionTypeId(question));
|
||||
}
|
||||
|
||||
export function questionTypeSupportsOptions(questionTypeKey: ExerciseQuestionTypeKey): boolean {
|
||||
return OPTION_QUESTION_TYPE_KEYS.has(questionTypeKey);
|
||||
}
|
||||
|
||||
export function toExerciseQuestionModel(question: Question): ExerciseQuestionModel {
|
||||
const options: ExerciseQuestionOption[] = (question.options || [])
|
||||
.filter((option) => !option.deletedAt)
|
||||
.map((option) => ({
|
||||
id: option.id,
|
||||
label: option.label || String(option.value ?? ''),
|
||||
value: option.value ?? undefined,
|
||||
isCorrect: option.isCorrect,
|
||||
settings:
|
||||
option.settings && typeof option.settings === 'object' && !Array.isArray(option.settings)
|
||||
? { ...option.settings }
|
||||
: undefined
|
||||
}));
|
||||
|
||||
const rawSettings = (question as Question & { settings?: Record<string, unknown> }).settings;
|
||||
const settings = rawSettings && typeof rawSettings === 'object' ? rawSettings : {};
|
||||
|
||||
return {
|
||||
id: question.id,
|
||||
key: question.name ? String(question.name) : question.id ? String(question.id) : undefined,
|
||||
title: question.title || '',
|
||||
questionType: getQuestionTypeKey(question),
|
||||
points: Number(question.points || 0),
|
||||
options,
|
||||
settings
|
||||
};
|
||||
}
|
||||
|
||||
export function getQuestionAnswerKey(question: Question, fallbackIndex = 0): string {
|
||||
return getExerciseQuestionContractKey(toExerciseQuestionModel(question), fallbackIndex);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { DEFAULT_QUESTION_TYPE, QUESTION_TEMPLATE } from '$features/ui/question/constants';
|
||||
import type { AnswerData } from '@cio/question-types';
|
||||
|
||||
import type { Question } from '$features/course/types';
|
||||
import { STATUS } from './constants';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { isUUID } from '$lib/utils/functions/isUUID';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const isQuestionnaireFetching = writable(false);
|
||||
export const deleteConfirmation = writable({ open: false });
|
||||
export const questionnaireOrder = writable({ open: false });
|
||||
|
||||
// {'question-id': { option: '', title: '', ... }}
|
||||
export const questionnaireValidation = writable({});
|
||||
|
||||
export interface QuestionnaireMetaData {
|
||||
answers: Record<string, AnswerData | undefined>;
|
||||
scores: Record<string, number>;
|
||||
grades: Record<string, number>;
|
||||
totalPossibleGrade: number;
|
||||
finalTotalGrade: number;
|
||||
currentQuestionIndex: number;
|
||||
isFinished: boolean;
|
||||
progressValue: number;
|
||||
status: number;
|
||||
comment: string;
|
||||
/** Set when exercise is finished so route knows not to overwrite questionnaire */
|
||||
exerciseId: string | null;
|
||||
}
|
||||
|
||||
const initAnswerState: QuestionnaireMetaData = {
|
||||
answers: {},
|
||||
scores: {},
|
||||
grades: {},
|
||||
totalPossibleGrade: 0,
|
||||
finalTotalGrade: 0,
|
||||
currentQuestionIndex: 0,
|
||||
isFinished: false,
|
||||
progressValue: 0,
|
||||
status: STATUS.PENDING,
|
||||
comment: '',
|
||||
exerciseId: null
|
||||
};
|
||||
|
||||
export const questionnaireMetaData: Writable<QuestionnaireMetaData> = writable(initAnswerState);
|
||||
|
||||
export const questionnaire: Writable<{
|
||||
title?: string | null;
|
||||
dueBy?: string | null;
|
||||
isDueByDirty?: boolean;
|
||||
isTitleDirty?: boolean;
|
||||
description?: string | null;
|
||||
isDescriptionDirty?: boolean;
|
||||
questions: Question[];
|
||||
totalSubmissions: number;
|
||||
}> = writable({
|
||||
title: '',
|
||||
dueBy: '',
|
||||
isDueByDirty: false,
|
||||
isTitleDirty: false,
|
||||
description: '',
|
||||
isDescriptionDirty: false,
|
||||
questions: [],
|
||||
totalSubmissions: 0
|
||||
});
|
||||
|
||||
export function clearQuestionnaireValidation() {
|
||||
questionnaireValidation.set({});
|
||||
}
|
||||
|
||||
export function reset() {
|
||||
questionnaireMetaData.update((metaData) => ({
|
||||
...metaData,
|
||||
answers: {},
|
||||
scores: {},
|
||||
grades: {},
|
||||
currentQuestionIndex: 0,
|
||||
isFinished: false,
|
||||
exerciseId: null,
|
||||
totalPossibleGrade: 0,
|
||||
finalTotalGrade: 0,
|
||||
status: STATUS.PENDING,
|
||||
comment: '',
|
||||
progressValue: 100
|
||||
}));
|
||||
|
||||
clearQuestionnaireValidation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps Zod validation errors to UI format: { questionId: { option: 'error message' } }
|
||||
* This is used to display errors in the edit-mode component
|
||||
*/
|
||||
export function mapZodErrorsToQuestionErrors(
|
||||
zodErrors: Record<string, string>,
|
||||
questions: Question[]
|
||||
): Record<string, { option?: string; title?: string; points?: string }> {
|
||||
const errors: Record<string, { option?: string; title?: string; points?: string }> = {};
|
||||
for (const [path, message] of Object.entries(zodErrors)) {
|
||||
const pathParts = path.split('.');
|
||||
const questionIndex = pathParts[0] === 'questions' ? parseInt(pathParts[1]) : -1;
|
||||
|
||||
if (questionIndex >= 0 && questionIndex < questions.length) {
|
||||
const question = questions[questionIndex];
|
||||
if (question && !question.deletedAt) {
|
||||
const qErrors = errors[question.id] || {};
|
||||
|
||||
// Check if error is related to options
|
||||
if (path.includes('options')) {
|
||||
qErrors.option = message;
|
||||
} else if (path.includes('question')) {
|
||||
qErrors.title = message;
|
||||
} else if (path.includes('points')) {
|
||||
qErrors.points = message;
|
||||
}
|
||||
|
||||
errors[question.id] = { ...qErrors };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function handleAddQuestion() {
|
||||
questionnaire.update((q) => {
|
||||
const { questions } = q;
|
||||
return {
|
||||
...q,
|
||||
questions: [
|
||||
...questions,
|
||||
{
|
||||
...QUESTION_TEMPLATE,
|
||||
id: `${questions.length + 1}-form`,
|
||||
name: `${questions.length + 1}-form`,
|
||||
value: '',
|
||||
points: 0,
|
||||
order: questions.length,
|
||||
questionType: DEFAULT_QUESTION_TYPE,
|
||||
questionTypeId: DEFAULT_QUESTION_TYPE.id,
|
||||
options: [
|
||||
{
|
||||
id: '1-form',
|
||||
label: '',
|
||||
value: null,
|
||||
isCorrect: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function handleAddOption(questionId) {
|
||||
return () => {
|
||||
questionnaire.update((_questionnaire) => {
|
||||
const { questions } = _questionnaire;
|
||||
return {
|
||||
..._questionnaire,
|
||||
questions: questions.map((question) => {
|
||||
if (question.id === questionId) {
|
||||
return {
|
||||
...question,
|
||||
options: [
|
||||
...question.options,
|
||||
{
|
||||
id: `${new Date().getTime()}-form`,
|
||||
label: '',
|
||||
value: '',
|
||||
isCorrect: false
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
return question;
|
||||
})
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function handleRemoveOption(questionId, optionId) {
|
||||
return () => {
|
||||
questionnaire.update((q) => {
|
||||
const questionIndex = q.questions.findIndex((qItem) => qItem.id === questionId);
|
||||
if (questionIndex === -1) return q;
|
||||
|
||||
const optionsIndex = q.questions[questionIndex].options.findIndex((oItem) => oItem.id === optionId);
|
||||
if (optionsIndex === -1) return q;
|
||||
|
||||
q.questions[questionIndex].options[optionsIndex].deletedAt = new Date().toString();
|
||||
q.questions[questionIndex].options[optionsIndex].isDirty = true;
|
||||
q.questions[questionIndex].isDirty = true; // Mark as dirty if needed
|
||||
|
||||
return q;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function handleRemoveQuestion(questionId) {
|
||||
return () => {
|
||||
questionnaire.update((q) => {
|
||||
const questionIndex = q.questions.findIndex((qItem) => qItem.id === questionId);
|
||||
if (questionIndex === -1) return q;
|
||||
|
||||
q.questions[questionIndex].deletedAt = new Date().toString();
|
||||
|
||||
return q;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function handleCode(questionId, shouldAdd = true) {
|
||||
questionnaire.update((q) => {
|
||||
const questionIndex = q.questions.findIndex((qItem) => qItem.id === questionId);
|
||||
if (questionIndex === -1) return q;
|
||||
|
||||
const question = q.questions[questionIndex];
|
||||
q.questions[questionIndex].code = shouldAdd ? question.code || '' : undefined;
|
||||
|
||||
return q;
|
||||
});
|
||||
}
|
||||
|
||||
export function handleAnswerSelect(questionId, optionId) {
|
||||
return () => {
|
||||
questionnaire.update((q) => {
|
||||
const { questions } = q;
|
||||
|
||||
return {
|
||||
...q,
|
||||
questions: questions.map((question) => {
|
||||
if (question.id === questionId) {
|
||||
return {
|
||||
...question,
|
||||
options: question.options.map((option) => {
|
||||
if (option.id === optionId) {
|
||||
option.isCorrect = !option.isCorrect;
|
||||
option.isDirty = true;
|
||||
}
|
||||
return option;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
return question;
|
||||
})
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function addDynamicValue(questionId, optionId) {
|
||||
return (e) => {
|
||||
questionnaire.update((q) => {
|
||||
const { questions } = q;
|
||||
|
||||
return {
|
||||
...q,
|
||||
questions: questions.map((question) => {
|
||||
if (question.id === questionId) {
|
||||
return {
|
||||
...question,
|
||||
options: question.options.map((option) => {
|
||||
if (option.id === optionId) {
|
||||
const label = e.target.value;
|
||||
|
||||
if (!isUUID(option.value || '')) {
|
||||
option.value = label.split(' ').join('-');
|
||||
}
|
||||
|
||||
option.label = label;
|
||||
option.isDirty = true;
|
||||
}
|
||||
|
||||
return option;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
return question;
|
||||
})
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { ExerciseQuestion } from '@cio/ui';
|
||||
|
||||
import { submissions } from './store';
|
||||
import { questionnaire } from '../store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { ExerciseSubmissions } from './types';
|
||||
import type { Question } from '$features/course/types';
|
||||
import type { AnswerData } from '@cio/question-types';
|
||||
import { toExerciseQuestionModel } from '../question-type-utils';
|
||||
import { getExerciseQuestionLabels } from '../question-labels';
|
||||
|
||||
interface Props {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
let { isLoading = $bindable(false) }: Props = $props();
|
||||
|
||||
const defaultImg =
|
||||
'https://images.unsplash.com/photo-1599566150163-29194dcaad36?auto=format&fit=crop&q=60&w=800&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8NHx8YXZhdGFyc3xlbnwwfHwwfHx8Mg%3D%3D';
|
||||
|
||||
let studentSelected = $state(0);
|
||||
const questionLabels = $derived(getExerciseQuestionLabels());
|
||||
|
||||
function toNumericQuestionId(question: Question): number | null {
|
||||
const numericId = Number(question.id);
|
||||
return Number.isNaN(numericId) ? null : numericId;
|
||||
}
|
||||
|
||||
function getStudentAnswerForQuestion(student: ExerciseSubmissions, question: Question): AnswerData | undefined {
|
||||
if (!student?.answers) return undefined;
|
||||
|
||||
const questionId = toNumericQuestionId(question);
|
||||
if (questionId === null) return undefined;
|
||||
|
||||
const submittedAnswer = student.answers.find((answer) => answer.questionId === questionId);
|
||||
if (
|
||||
!submittedAnswer?.answerData ||
|
||||
typeof submittedAnswer.answerData !== 'object' ||
|
||||
!('type' in submittedAnswer.answerData)
|
||||
)
|
||||
return undefined;
|
||||
|
||||
return submittedAnswer.answerData;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isLoading}
|
||||
<Spinner />
|
||||
{:else if $submissions?.length}
|
||||
<div class="mt-2 mb-5 flex w-full gap-1 overflow-auto">
|
||||
{#each $submissions as student, i (student.id)}
|
||||
<button onclick={() => (studentSelected = i)} class="flex w-20 flex-col items-center">
|
||||
<div
|
||||
class={`flex h-12 w-12 items-center justify-center rounded-full ${
|
||||
studentSelected == i ? 'border-primary-700 border-[3px]' : ''
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={student.groupmember?.profile.avatarUrl ? student.groupmember.profile.avatarUrl : defaultImg}
|
||||
alt={$t('course.navItem.lessons.exercises.all_exercises.analytics.individual.student_avatar')}
|
||||
class="m-1 max-h-10 w-10 rounded-full bg-white"
|
||||
/>
|
||||
</div>
|
||||
<p class="line-clamp-2 w-20 leading-4 whitespace-pre-wrap">
|
||||
{student.groupmember?.profile.fullname ?? ''}
|
||||
</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="mb-2 font-medium">
|
||||
{$submissions[studentSelected].groupmember?.profile.fullname ?? ''}'s {$t(
|
||||
'course.navItem.lessons.exercises.all_exercises.analytics.individual.answers'
|
||||
)}
|
||||
</p>
|
||||
{#if $submissions[studentSelected]}
|
||||
{#if $questionnaire.questions}
|
||||
{#each $questionnaire.questions as q, i (`${q.id}-${i}`)}
|
||||
<div class="pb-4">
|
||||
<ExerciseQuestion.QuestionRenderer
|
||||
contract={{
|
||||
mode: 'view',
|
||||
question: { ...toExerciseQuestionModel(q), title: `${i + 1}. ${q.title}` },
|
||||
answer: getStudentAnswerForQuestion($submissions[studentSelected], q),
|
||||
labels: questionLabels,
|
||||
disabled: true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { ExerciseSubmissions } from './types';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const submissions = writable<ExerciseSubmissions[]>([]);
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import * as Tabs from '@cio/ui/base/tabs';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import ClipboardListIcon from '@lucide/svelte/icons/clipboard-list';
|
||||
import Summary from './summary.svelte';
|
||||
import Individual from './individual.svelte';
|
||||
import { submissions } from './store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { ExerciseSubmissions } from './types';
|
||||
import type { SubmissionListItem } from '$features/course/utils/types';
|
||||
|
||||
interface Props {
|
||||
exerciseId: string;
|
||||
submissions: SubmissionListItem[];
|
||||
}
|
||||
|
||||
let { exerciseId = $bindable(''), submissions: submissionsData }: Props = $props();
|
||||
|
||||
type SubmissionTab = 'summary' | 'individual';
|
||||
let currentTab = $state<SubmissionTab>('summary');
|
||||
|
||||
function normalizeSubmissions(items: SubmissionListItem[]): ExerciseSubmissions[] {
|
||||
return items.map(
|
||||
(submission): ExerciseSubmissions => ({
|
||||
id: submission.id,
|
||||
statusId: submission.statusId ?? 1,
|
||||
groupmember: submission.groupmember?.profile
|
||||
? {
|
||||
profile: {
|
||||
id: submission.groupmember.profile.id ?? '',
|
||||
fullname: submission.groupmember.profile.fullname ?? '',
|
||||
avatarUrl: submission.groupmember.profile.avatarUrl ?? ''
|
||||
}
|
||||
}
|
||||
: null,
|
||||
answers: (submission.answers || []).map((answer) => ({
|
||||
id: answer.id,
|
||||
questionId: answer.questionId,
|
||||
answerData: answer.answerData,
|
||||
point: answer.point ?? 0,
|
||||
submissionId: answer.submissionId ?? submission.id,
|
||||
groupMemberId: answer.groupMemberId ?? ''
|
||||
}))
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
submissions.set(normalizeSubmissions(submissionsData));
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $submissions.length > 0}
|
||||
<Tabs.Root bind:value={currentTab} class="w-full">
|
||||
<Tabs.List class="grid w-full max-w-sm grid-cols-2">
|
||||
<Tabs.Trigger value="summary">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.analytics.summary.heading')}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="individual">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.analytics.individual.heading')}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="summary" class="pt-2">
|
||||
<Summary isLoading={false} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="individual" class="pt-2">
|
||||
<Individual isLoading={false} />
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
{:else}
|
||||
<Empty
|
||||
title={$t('course.navItem.lessons.exercises.all_exercises.analytics.empty_title')}
|
||||
description={$t('course.navItem.lessons.exercises.all_exercises.analytics.empty_description')}
|
||||
icon={ClipboardListIcon}
|
||||
variant="page"
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { ExerciseQuestion } from '@cio/ui';
|
||||
|
||||
import { submissions } from './store';
|
||||
import { questionnaire } from '../store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { Question } from '$features/course/types';
|
||||
import type { ExerciseSubmissions } from './types';
|
||||
import type { AnswerData, ExerciseQuestionLabels, ExerciseSubmissionModel } from '@cio/question-types';
|
||||
import { toExerciseQuestionModel } from '../question-type-utils';
|
||||
|
||||
interface Props {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
let { isLoading = $bindable(true) }: Props = $props();
|
||||
|
||||
function toAnswerData(answerData: unknown): AnswerData | null {
|
||||
if (!answerData || typeof answerData !== 'object' || !('type' in answerData)) return null;
|
||||
return answerData as AnswerData;
|
||||
}
|
||||
|
||||
function toSubmissionModel(submission: ExerciseSubmissions): ExerciseSubmissionModel {
|
||||
return {
|
||||
id: submission.id,
|
||||
studentName: submission.groupmember?.profile.fullname,
|
||||
studentAvatarUrl: submission.groupmember?.profile.avatarUrl,
|
||||
answers: submission.answers.map((answer) => ({
|
||||
questionId: answer.questionId,
|
||||
answerData: toAnswerData(answer.answerData)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function toSubmissionQuestionModel(question: Question, index: number) {
|
||||
return {
|
||||
...toExerciseQuestionModel(question),
|
||||
title: `${index + 1}. ${question.title || ''}`
|
||||
};
|
||||
}
|
||||
|
||||
const submissionLabels = $derived.by(
|
||||
(): ExerciseQuestionLabels => ({
|
||||
'submission.common.no_answer': $t('course.navItem.lessons.exercises.all_exercises.analytics.individual.no'),
|
||||
'submission.common.other': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.other'),
|
||||
'submission.chart.responses': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.responses'),
|
||||
'submission.chart.no_data': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.no_responses'),
|
||||
'submission.list.responses': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.responses'),
|
||||
'submission.list.no_responses': $t(
|
||||
'course.navItem.lessons.exercises.all_exercises.analytics.summary.no_responses'
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
const submissionModels = $derived($submissions.map((submission) => toSubmissionModel(submission)));
|
||||
|
||||
const submissionQuestions = $derived(
|
||||
$questionnaire.questions
|
||||
.filter((question) => !question.deletedAt)
|
||||
.map((question, index) => toSubmissionQuestionModel(question, index))
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if isLoading}
|
||||
<Spinner />
|
||||
{:else if browser}
|
||||
<div class="flex flex-col gap-6">
|
||||
<p class="mb-3 text-2xl">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.analytics.summary.question_chart')}
|
||||
</p>
|
||||
|
||||
<ExerciseQuestion.QuestionList
|
||||
contract={{
|
||||
mode: 'submission',
|
||||
questions: submissionQuestions,
|
||||
submissions: submissionModels,
|
||||
labels: submissionLabels,
|
||||
disabled: true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SubmissionAnswer, SubmissionListItem } from '$features/course/utils/types';
|
||||
|
||||
type ApiProfile = NonNullable<NonNullable<SubmissionListItem['groupmember']>['profile']>;
|
||||
type ExerciseSubmissionProfile = Required<Pick<ApiProfile, 'id' | 'fullname' | 'avatarUrl'>>;
|
||||
|
||||
type ExerciseSubmissionAnswer = {
|
||||
id: SubmissionAnswer['id'];
|
||||
questionId: SubmissionAnswer['questionId'];
|
||||
answerData?: SubmissionAnswer['answerData'];
|
||||
point: NonNullable<SubmissionAnswer['point']>;
|
||||
submissionId: NonNullable<SubmissionAnswer['submissionId']>;
|
||||
groupMemberId: NonNullable<SubmissionAnswer['groupMemberId']>;
|
||||
};
|
||||
|
||||
/** Normalized submission for exercise submissions UI (profile/answer defaults filled by transform) */
|
||||
export type ExerciseSubmissions = {
|
||||
id: SubmissionListItem['id'];
|
||||
statusId: NonNullable<SubmissionListItem['statusId']>;
|
||||
groupmember: { profile: ExerciseSubmissionProfile } | null;
|
||||
answers: ExerciseSubmissionAnswer[];
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { questionnaire } from './store';
|
||||
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { TextEditor } from '$features/ui';
|
||||
import { QuestionContainer } from '$features/course/components';
|
||||
|
||||
interface Props {
|
||||
preview: boolean;
|
||||
}
|
||||
|
||||
let { preview }: Props = $props();
|
||||
|
||||
function getTotalPossibleGrade(questions: { points: number | string }[]) {
|
||||
return questions.reduce((acc, question) => {
|
||||
acc += typeof question.points === 'string' ? parseFloat(question.points) : question.points;
|
||||
return acc;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
$inspect('$questionnaire', $questionnaire);
|
||||
</script>
|
||||
|
||||
<div class="mb-5 {!preview ? 'px-6' : 'px-2'}">
|
||||
<QuestionContainer isTitle={true}>
|
||||
{#if preview}
|
||||
<h2 class="my-1">{$questionnaire.title}</h2>
|
||||
<div class="flex items-center">
|
||||
<p class="mx-2 dark:text-white">
|
||||
<strong>{$questionnaire.questions.length}</strong>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.questions')}
|
||||
</p>
|
||||
|
|
||||
<p class="mx-2 dark:text-white">
|
||||
<strong>{getTotalPossibleGrade($questionnaire.questions)}</strong>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')}.
|
||||
</p>
|
||||
|
|
||||
<p class="mx-2 dark:text-white">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.all')}
|
||||
</p>
|
||||
{#if $questionnaire.dueBy}
|
||||
|
|
||||
<p class="mx-2 dark:text-white">
|
||||
<strong>{$t('course.navItem.lessons.exercises.all_exercises.view_mode.due')}:</strong>
|
||||
{new Date($questionnaire.dueBy).toLocaleString()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<article class="preview prose prose-sm sm:prose mt-3 p-2">
|
||||
{@html sanitizeHtml(
|
||||
$questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.description.no')
|
||||
)}
|
||||
</article>
|
||||
{:else}
|
||||
<InputField
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.description.title')}
|
||||
bind:value={$questionnaire.title}
|
||||
className="mb-2"
|
||||
onchange={() => ($questionnaire.isTitleDirty = true)}
|
||||
/>
|
||||
<InputField
|
||||
label={$t('course.navItem.lessons.exercises.all_exercises.view_mode.due')}
|
||||
className="w-50"
|
||||
type="datetime-local"
|
||||
value={$questionnaire.dueBy ?? ''}
|
||||
onchange={(e) => {
|
||||
$questionnaire.dueBy = e.currentTarget.value;
|
||||
$questionnaire.isDueByDirty = true;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="mt-3">
|
||||
<p class="mb-1">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.description.heading')}
|
||||
</p>
|
||||
|
||||
{#key $questionnaire.description}
|
||||
<TextEditor
|
||||
content={$questionnaire.description || ''}
|
||||
onChange={(html) => {
|
||||
$questionnaire.isDescriptionDirty = true;
|
||||
$questionnaire.description = html;
|
||||
}}
|
||||
editorClass="max-h-[100px]"
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.description.describe')}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
</QuestionContainer>
|
||||
</div>
|
||||
@@ -0,0 +1,523 @@
|
||||
<script lang="ts">
|
||||
import { get } from 'svelte/store';
|
||||
import { untrack } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { cubicInOut } from 'svelte/easing';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { questionnaire, questionnaireMetaData } from './store';
|
||||
import Preview from './preview.svelte';
|
||||
import { ExerciseQuestion } from '@cio/ui';
|
||||
import * as Alert from '@cio/ui/base/alert';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { RoleBasedSecurity } from '$features/ui';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import FileQuestionIcon from '@lucide/svelte/icons/file-question';
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
import { getExerciseQuestionContractKey } from '@cio/question-types';
|
||||
import { STATUS } from './constants';
|
||||
import { filterOutDeleted } from './functions';
|
||||
import { formatAnswers } from '$features/course/utils/functions';
|
||||
import { toApiPayload, type AnswerData } from '@cio/question-types';
|
||||
import { exerciseApi, presignApi } from '$features/course/api';
|
||||
import type { SubmissionListItem } from '$features/course/utils/types';
|
||||
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { toExerciseQuestionModel } from './question-type-utils';
|
||||
import { getExerciseQuestionLabels } from './question-labels';
|
||||
import axios from 'axios';
|
||||
|
||||
interface Props {
|
||||
preview?: boolean;
|
||||
exerciseId?: string;
|
||||
isFetchingExercise?: boolean;
|
||||
/** Current user's submission from server (no client fetch) */
|
||||
mySubmission?: SubmissionListItem | null;
|
||||
}
|
||||
|
||||
let { preview = false, exerciseId = '', isFetchingExercise = false, mySubmission = null }: Props = $props();
|
||||
|
||||
let isSubmitting = $state(false);
|
||||
let isLoadingAutoSavedData = $state(false);
|
||||
let alreadyCheckedAutoSavedData = $state(false);
|
||||
let prevExerciseId = $state('');
|
||||
let slideDirection = $state<'next' | 'prev'>('next');
|
||||
const questionLabels = $derived(getExerciseQuestionLabels());
|
||||
|
||||
function handleStart() {
|
||||
questionnaireMetaData.update((m) => ({ ...m, currentQuestionIndex: m.currentQuestionIndex + 1 }));
|
||||
}
|
||||
|
||||
function normalizeAnswerValue(
|
||||
previousValue: AnswerData | undefined,
|
||||
nextValue: AnswerData | undefined
|
||||
): AnswerData | undefined {
|
||||
if (nextValue && typeof nextValue === 'object' && 'type' in nextValue) {
|
||||
return nextValue;
|
||||
}
|
||||
return previousValue;
|
||||
}
|
||||
|
||||
function mapAnswerToApiPayload(question, answerData: AnswerData | null | undefined) {
|
||||
const questionId = Number(question.id);
|
||||
if (Number.isNaN(questionId) || !answerData) return null;
|
||||
return toApiPayload(answerData, questionId);
|
||||
}
|
||||
|
||||
async function onSubmit(id, value) {
|
||||
if (!courseApi.course?.id) return;
|
||||
|
||||
const { answers } = $questionnaireMetaData;
|
||||
const { questions } = $questionnaire;
|
||||
const prevAnswer = answers[id];
|
||||
const formattedAnswer = normalizeAnswerValue(prevAnswer, value);
|
||||
|
||||
if (!hasAnswerValue(formattedAnswer)) {
|
||||
snackbar.error(t.get('course.navItem.lessons.exercises.all_exercises.view_mode.answer_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
const question = questions.find((q) => getExerciseQuestionContractKey(toExerciseQuestionModel(q)) === id);
|
||||
if (question && !mapAnswerToApiPayload(question, formattedAnswer as AnswerData)) {
|
||||
snackbar.error(t.get('course.navItem.lessons.exercises.all_exercises.view_mode.answer_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
const newAnswers = { ...answers, [id]: formattedAnswer };
|
||||
questionnaireMetaData.update((m) => ({ ...m, answers: newAnswers }));
|
||||
|
||||
const currentIndex = $questionnaireMetaData.currentQuestionIndex;
|
||||
const isLastQuestion = currentIndex === questions.length;
|
||||
|
||||
if (isLastQuestion) {
|
||||
// Stay on last question, show spinner, submit to server
|
||||
if (isSubmitting) return;
|
||||
isSubmitting = true;
|
||||
|
||||
const updated = get(questionnaireMetaData);
|
||||
localStorage.removeItem(`autosave-exercise-${exerciseId}`);
|
||||
const totalPossibleGrade = getTotalPossibleGrade($questionnaire.questions);
|
||||
|
||||
const answersForApi = Object.entries(updated.answers)
|
||||
.map(([questionKey, val]) => {
|
||||
const question = questions.find(
|
||||
(item) => getExerciseQuestionContractKey(toExerciseQuestionModel(item)) === questionKey
|
||||
);
|
||||
if (!question) return null;
|
||||
return mapAnswerToApiPayload(question, val);
|
||||
})
|
||||
.filter((answer) => answer !== null) as Array<{ questionId: number; optionId?: number; answer?: string }>;
|
||||
|
||||
if (answersForApi.length === 0) {
|
||||
isSubmitting = false;
|
||||
snackbar.error(t.get('course.navItem.lessons.exercises.all_exercises.view_mode.answers_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
await exerciseApi.submit(courseApi.course?.id!, exerciseId, answersForApi);
|
||||
|
||||
if (exerciseApi.success) {
|
||||
questionnaireMetaData.update((m) => ({
|
||||
...m,
|
||||
status: 1,
|
||||
totalPossibleGrade,
|
||||
grades: {},
|
||||
comment: '',
|
||||
isFinished: true,
|
||||
exerciseId
|
||||
}));
|
||||
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true });
|
||||
}
|
||||
isSubmitting = false;
|
||||
} else {
|
||||
// Advance to next question
|
||||
slideDirection = 'next';
|
||||
questionnaireMetaData.update((m) => ({ ...m, currentQuestionIndex: m.currentQuestionIndex + 1 }));
|
||||
const updated = get(questionnaireMetaData);
|
||||
localStorage.setItem(`autosave-exercise-${exerciseId}`, JSON.stringify(updated));
|
||||
}
|
||||
}
|
||||
|
||||
function onPrevious() {
|
||||
slideDirection = 'prev';
|
||||
questionnaireMetaData.update((m) => ({ ...m, currentQuestionIndex: m.currentQuestionIndex - 1 }));
|
||||
}
|
||||
|
||||
function getProgressValue(currentQuestionIndex) {
|
||||
if ($questionnaireMetaData.isFinished) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return Math.round(((currentQuestionIndex - 1) / $questionnaire.questions.length) * 100) || 0;
|
||||
}
|
||||
|
||||
function getTotalPossibleGrade(questions) {
|
||||
return questions.reduce((acc, question) => {
|
||||
acc += parseFloat(question.points);
|
||||
return acc;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function getAutoSavedData() {
|
||||
isLoadingAutoSavedData = true;
|
||||
|
||||
const stringifiedQuestionnaireMetaData = localStorage.getItem(`autosave-exercise-${exerciseId}`);
|
||||
|
||||
if (stringifiedQuestionnaireMetaData) {
|
||||
const autoSavedData = JSON.parse(stringifiedQuestionnaireMetaData);
|
||||
if (autoSavedData) {
|
||||
questionnaireMetaData.set(autoSavedData);
|
||||
}
|
||||
}
|
||||
isLoadingAutoSavedData = false;
|
||||
alreadyCheckedAutoSavedData = true;
|
||||
}
|
||||
|
||||
async function handleFileUpload(file: File): Promise<{ fileKey: string; fileName: string; fileUrl?: string }> {
|
||||
const uploadResult = await presignApi.getDocumentUploadUrl(
|
||||
file.name,
|
||||
file.type as
|
||||
| 'application/pdf'
|
||||
| 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
| 'application/msword'
|
||||
);
|
||||
if (!uploadResult) {
|
||||
throw new Error('Failed to get upload URL');
|
||||
}
|
||||
await axios.put(uploadResult.url, file, {
|
||||
headers: { 'Content-Type': file.type },
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity
|
||||
});
|
||||
const urls = await presignApi.getDocumentDownloadUrls([uploadResult.fileKey]);
|
||||
const fileUrl = urls[uploadResult.fileKey];
|
||||
|
||||
return { fileKey: uploadResult.fileKey, fileName: file.name, fileUrl };
|
||||
}
|
||||
|
||||
function hasAnswerValue(answerValue: AnswerData | null | undefined): boolean {
|
||||
if (answerValue === null || answerValue === undefined) return false;
|
||||
if (typeof answerValue === 'object' && 'type' in answerValue) {
|
||||
switch (answerValue.type) {
|
||||
case 'RADIO':
|
||||
case 'TRUE_FALSE':
|
||||
case 'NUMERIC':
|
||||
return true;
|
||||
case 'CHECKBOX':
|
||||
return answerValue.optionIds.length > 0;
|
||||
case 'TEXTAREA':
|
||||
case 'SHORT_ANSWER':
|
||||
return answerValue.text.trim().length > 0;
|
||||
case 'FILL_BLANK':
|
||||
return answerValue.values.length > 0;
|
||||
case 'FILE_UPLOAD':
|
||||
return !!answerValue.fileKey;
|
||||
case 'MATCHING':
|
||||
return answerValue.pairs.length > 0;
|
||||
case 'ORDERING':
|
||||
return answerValue.orderedValues.length > 0;
|
||||
case 'LINK':
|
||||
return answerValue.urls.length > 0;
|
||||
case 'HOTSPOT':
|
||||
return answerValue.coordinates.length > 0;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function onSharedAnswerChange(answerValue) {
|
||||
if (!sharedCurrentQuestionKey) return;
|
||||
|
||||
questionnaireMetaData.update((metaData) => ({
|
||||
...metaData,
|
||||
answers: {
|
||||
...metaData.answers,
|
||||
[sharedCurrentQuestionKey]: answerValue
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function onSharedNext(valueOverride?: AnswerData) {
|
||||
if (!sharedCurrentQuestionKey) return;
|
||||
const valueToUse = valueOverride !== undefined ? valueOverride : sharedCurrentAnswer;
|
||||
if (!hasAnswerValue(valueToUse)) {
|
||||
snackbar.error(t.get('course.navItem.lessons.exercises.all_exercises.view_mode.answer_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(sharedCurrentQuestionKey, valueToUse);
|
||||
}
|
||||
|
||||
function handleEnterKey(e: KeyboardEvent) {
|
||||
if (e.key !== 'Enter' || isSubmitting || !currentQuestion) return;
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
if (target?.tagName === 'TEXTAREA') return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
let valueToUse: AnswerData | undefined = sharedCurrentAnswer as AnswerData | undefined;
|
||||
|
||||
if (target instanceof HTMLInputElement && sharedQuestionModel) {
|
||||
const questionTypeKey = sharedQuestionModel.questionType;
|
||||
const trimmed = target.value?.trim() ?? '';
|
||||
|
||||
if (questionTypeKey === 'SHORT_ANSWER' && trimmed) {
|
||||
valueToUse = { type: 'SHORT_ANSWER', text: trimmed };
|
||||
onSharedAnswerChange(valueToUse);
|
||||
} else if (questionTypeKey === 'NUMERIC' && trimmed) {
|
||||
const num = Number(trimmed);
|
||||
valueToUse = !Number.isNaN(num) ? { type: 'NUMERIC', value: num } : undefined;
|
||||
|
||||
if (valueToUse) onSharedAnswerChange(valueToUse);
|
||||
}
|
||||
}
|
||||
if (!hasAnswerValue(valueToUse)) {
|
||||
snackbar.error($t('course.navItem.lessons.exercises.all_exercises.view_mode.answer_required'));
|
||||
return;
|
||||
}
|
||||
onSharedNext(valueToUse);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!alreadyCheckedAutoSavedData) {
|
||||
getAutoSavedData();
|
||||
}
|
||||
});
|
||||
|
||||
const progressValue = $derived(getProgressValue($questionnaireMetaData.currentQuestionIndex));
|
||||
const currentQuestion = $derived($questionnaire.questions[$questionnaireMetaData.currentQuestionIndex - 1]);
|
||||
const isExerciseLoaded = $derived(alreadyCheckedAutoSavedData && $questionnaire.questions.length > 0);
|
||||
const isFinished = $derived(isExerciseLoaded && $questionnaireMetaData.currentQuestionIndex > 0 && !currentQuestion);
|
||||
const sharedQuestionModel = $derived(currentQuestion ? toExerciseQuestionModel(currentQuestion) : null);
|
||||
const sharedCurrentQuestionKey = $derived(
|
||||
sharedQuestionModel ? getExerciseQuestionContractKey(sharedQuestionModel) : null
|
||||
);
|
||||
const sharedCurrentAnswer = $derived(
|
||||
sharedCurrentQuestionKey ? $questionnaireMetaData.answers[sharedCurrentQuestionKey] : undefined
|
||||
);
|
||||
const canGoNextForSharedQuestion = $derived(hasAnswerValue(sharedCurrentAnswer));
|
||||
|
||||
const flyX = $derived(slideDirection === 'next' ? 300 : -300);
|
||||
const flyOutX = $derived(-flyX);
|
||||
|
||||
$effect(() => {
|
||||
const finished = isFinished;
|
||||
|
||||
untrack(() => {
|
||||
$questionnaireMetaData.isFinished = finished;
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const progress = progressValue;
|
||||
|
||||
untrack(() => {
|
||||
$questionnaireMetaData.progressValue = progress;
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isFetchingExercise || !mySubmission || $questionnaireMetaData.isFinished) return;
|
||||
applyMySubmission(mySubmission);
|
||||
});
|
||||
|
||||
function applyMySubmission(submission: SubmissionListItem) {
|
||||
const totalPossibleGrade = getTotalPossibleGrade($questionnaire.questions);
|
||||
let finalTotalGrade = 0;
|
||||
const grades = (submission.answers ?? []).reduce<Record<string, number>>((acc, answer) => {
|
||||
const point = answer.point ?? 0;
|
||||
acc[String(answer.questionId)] = point;
|
||||
finalTotalGrade += point;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const answers = formatAnswers({
|
||||
questions: $questionnaire.questions,
|
||||
answers: submission.answers ?? []
|
||||
});
|
||||
|
||||
questionnaireMetaData.update((m) => ({
|
||||
...m,
|
||||
answers,
|
||||
totalPossibleGrade,
|
||||
currentQuestionIndex: $questionnaire.questions.length,
|
||||
isFinished: true,
|
||||
status: submission.statusId ?? STATUS.PENDING,
|
||||
finalTotalGrade,
|
||||
comment: submission.feedback ?? '',
|
||||
grades,
|
||||
exerciseId
|
||||
}));
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (prevExerciseId === exerciseId) return;
|
||||
if (!!prevExerciseId) {
|
||||
isSubmitting = false;
|
||||
alreadyCheckedAutoSavedData = false;
|
||||
}
|
||||
prevExerciseId = exerciseId;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !preview && $questionnaire.questions.length && !$questionnaireMetaData.isFinished}
|
||||
<div class="mb-6">
|
||||
<Progress value={$questionnaireMetaData.progressValue} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<svelte:window onkeydown={handleEnterKey} />
|
||||
|
||||
{#if preview}
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<Preview questions={filterOutDeleted($questionnaire.questions)} questionnaireMetaData={$questionnaireMetaData} />
|
||||
</RoleBasedSecurity>
|
||||
{:else if !$questionnaire.questions.length}
|
||||
<Empty
|
||||
title={$t('course.navItem.lessons.exercises.all_exercises.view_mode.no_question')}
|
||||
icon={FileQuestionIcon}
|
||||
variant="page"
|
||||
>
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<p class="ui:text-primary text-center text-sm">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.empty_edit_hint_prefix')}
|
||||
|
||||
<span class="font-semibold">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.edit')}
|
||||
</span>
|
||||
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.empty_edit_hint_suffix')}
|
||||
</p>
|
||||
</RoleBasedSecurity>
|
||||
</Empty>
|
||||
{:else if $questionnaireMetaData.currentQuestionIndex === 0}
|
||||
<RoleBasedSecurity allowedRoles={[3]}>
|
||||
<div>
|
||||
<h2 class="my-1">{$questionnaire.title}</h2>
|
||||
|
||||
<div class="flex items-center">
|
||||
<p class="mx-2 dark:text-white">
|
||||
<strong>{$questionnaire.questions.length}</strong>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.questions')}
|
||||
</p>
|
||||
|
|
||||
<p class="mx-2 dark:text-white">
|
||||
<strong>{getTotalPossibleGrade($questionnaire.questions)}</strong>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')}.
|
||||
</p>
|
||||
|
|
||||
<p class="mx-2 dark:text-white">{$t('course.navItem.lessons.exercises.all_exercises.view_mode.all')}</p>
|
||||
{#if $questionnaire.dueBy}
|
||||
|
|
||||
<p class="mx-2 dark:text-white">
|
||||
<strong>{$t('course.navItem.lessons.exercises.all_exercises.view_mode.due')}:</strong>
|
||||
{new Date($questionnaire.dueBy).toLocaleString()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<article class="preview prose prose-sm sm:prose mt-3 p-2">
|
||||
{@html sanitizeHtml(
|
||||
$questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.view_mode.no_description')
|
||||
)}
|
||||
</article>
|
||||
|
||||
<Button onclick={handleStart} type="button" class="float-right my-5">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.start')}
|
||||
</Button>
|
||||
</div>
|
||||
</RoleBasedSecurity>
|
||||
{:else if $questionnaireMetaData.isFinished}
|
||||
{#if !isLoadingAutoSavedData}
|
||||
<div class="flex w-full flex-col items-start lg:flex-row lg:items-center lg:space-x-4">
|
||||
{#if STATUS.GRADED === $questionnaireMetaData.status}
|
||||
<div class="mb-8 w-full space-y-2">
|
||||
<Badge variant="success" title={$t('course.navItem.lessons.exercises.all_exercises.view_mode.status_graded')}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.graded')}
|
||||
</Badge>
|
||||
|
||||
<div class="flex w-full flex-col items-start gap-2 md:flex-row md:items-center">
|
||||
{#if $questionnaireMetaData.comment}
|
||||
<Alert.Callout
|
||||
variant="information"
|
||||
title={$t('course.navItem.lessons.exercises.all_exercises.view_mode.instructor_feedback')}
|
||||
description={$questionnaireMetaData.comment}
|
||||
class="h-fit! w-full! flex-1!"
|
||||
/>
|
||||
{/if}
|
||||
<div class="flex flex-col justify-between gap-2 rounded-md border p-4">
|
||||
<p>
|
||||
<span class="text-2xl font-bold">{$questionnaireMetaData.finalTotalGrade}/</span>
|
||||
|
||||
<span class="text-xl">{$questionnaireMetaData.totalPossibleGrade}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if courseApi.course?.type === 'SELF_PACED'}
|
||||
<Badge
|
||||
variant="success"
|
||||
title={$t('course.navItem.lessons.exercises.all_exercises.view_mode.status_submitted')}
|
||||
>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.submitted')}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge variant="warning" title={$t('course.navItem.lessons.exercises.all_exercises.view_mode.status_pending')}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.pending')}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Preview
|
||||
questions={$questionnaire.questions.sort((a, b) => a.order - b.order)}
|
||||
questionnaireMetaData={$questionnaireMetaData}
|
||||
grades={$questionnaireMetaData.grades}
|
||||
disableGrading={true}
|
||||
/>
|
||||
{/if}
|
||||
{:else if currentQuestion && currentQuestion?.id}
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="grid overflow-hidden" style="grid-template-areas: 'slot';">
|
||||
{#key currentQuestion.id}
|
||||
<div
|
||||
id="question"
|
||||
class="min-w-0 [grid-area:slot]"
|
||||
in:fly={{ x: flyX, duration: 350, easing: cubicInOut }}
|
||||
out:fly={{ x: flyOutX, duration: 350, easing: cubicInOut }}
|
||||
>
|
||||
{#if sharedQuestionModel}
|
||||
<ExerciseQuestion.QuestionRenderer
|
||||
contract={{
|
||||
mode: 'take',
|
||||
question: sharedQuestionModel,
|
||||
answer: sharedCurrentAnswer,
|
||||
labels: questionLabels,
|
||||
disabled: isSubmitting,
|
||||
onFileUpload: handleFileUpload
|
||||
}}
|
||||
onAnswerChange={onSharedAnswerChange}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
<div>
|
||||
<ExerciseQuestion.QuestionNavigation
|
||||
canGoBack={$questionnaireMetaData.currentQuestionIndex > 1}
|
||||
canGoNext={canGoNextForSharedQuestion && !isSubmitting}
|
||||
isLast={$questionnaireMetaData.currentQuestionIndex === $questionnaire.questions.length}
|
||||
{isSubmitting}
|
||||
previousLabel={t.get('course.navItem.lessons.exercises.all_exercises.previous')}
|
||||
nextLabel={t.get('course.navItem.lessons.exercises.all_exercises.next')}
|
||||
finishLabel={t.get('course.navItem.lessons.exercises.all_exercises.finish')}
|
||||
{onPrevious}
|
||||
onNext={onSharedNext}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -3,7 +3,10 @@ export { default as CourseCardList } from './card-list.svelte';
|
||||
export { default as CourseCardLoader } from './card-loader.svelte';
|
||||
export { default as CopyCourseModal } from './copy-course-modal.svelte';
|
||||
export { default as CourseHeader } from './course-header.svelte';
|
||||
export { default as CourseFilterPopover } from './course-filter-popover.svelte';
|
||||
export { default as CourseTagPicker } from './course-tag-picker.svelte';
|
||||
export { default as CreateCourseButton } from './create-course-button.svelte';
|
||||
export { default as CourseList } from './list.svelte';
|
||||
export { default as CourseListLoader } from './list-loader.svelte';
|
||||
export { default as NewCourseModal } from './new-course-modal.svelte';
|
||||
export { default as QuestionContainer } from './question-container.svelte';
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import ListTodoIcon from '@lucide/svelte/icons/list-todo';
|
||||
import ListChecksIcon from '@lucide/svelte/icons/list-checks';
|
||||
import NotepadTextIcon from '@lucide/svelte/icons/notepad-text';
|
||||
import * as Popover from '@cio/ui/base/popover';
|
||||
import WandSparklesIcon from '@lucide/svelte/icons/wand-sparkles';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
isLoading?: SvelteStore<boolean>;
|
||||
callAI?: (type: string) => void;
|
||||
}
|
||||
let { isLoading, callAI = () => {} }: Props = $props();
|
||||
|
||||
let openPopover = $state<boolean>(false);
|
||||
let aiButtonClass =
|
||||
'flex items-center px-5 py-2 border border-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md w-full mb-2';
|
||||
</script>
|
||||
|
||||
<!-- Update this when ai-sdk is updated -->
|
||||
<div class="hidden flex-row-reverse">
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
openPopover = !openPopover;
|
||||
}}
|
||||
loading={$isLoading}
|
||||
disabled={$isLoading}
|
||||
>
|
||||
<WandSparklesIcon size={16} />
|
||||
AI
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="start" class="w-80">
|
||||
<div class="p-2">
|
||||
<button class={aiButtonClass} onclick={() => callAI('outline')}>
|
||||
<ListChecksIcon size={16} />
|
||||
{$t('course.navItem.lessons.materials.tabs.note.ai.outline')}
|
||||
</button>
|
||||
<button class={aiButtonClass} onclick={() => callAI('note')}>
|
||||
<NotepadTextIcon size={16} />
|
||||
{$t('course.navItem.lessons.materials.tabs.note.ai.note')}
|
||||
</button>
|
||||
<button class={aiButtonClass} onclick={() => callAI('activities')}>
|
||||
<ListTodoIcon size={16} />
|
||||
{$t('course.navItem.lessons.materials.tabs.note.ai.activities')}
|
||||
</button>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
@@ -0,0 +1,208 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import { DeleteModal } from '$features/ui';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { lessonApi } from '$features/course/api';
|
||||
import { courseApi } from '$features/course/api';
|
||||
|
||||
interface Props {
|
||||
lessonId: string;
|
||||
}
|
||||
|
||||
let { lessonId }: Props = $props();
|
||||
|
||||
let comment = $state('');
|
||||
let openDeleteModal = $state(false);
|
||||
let deleteCommentId: number | null = $state(null);
|
||||
let editCommentId: number | null = $state(null);
|
||||
|
||||
const courseId = $derived(courseApi.course?.id ?? '');
|
||||
const groupmemberId = $derived(courseApi.group.memberId ?? '');
|
||||
|
||||
const paginationCount = $derived(lessonApi.commentsByLessonId[lessonId]?.totalCount || 0);
|
||||
const hasMore = $derived(lessonApi.commentsByLessonId[lessonId]?.hasMore || false);
|
||||
const isLoadingComments = $derived(lessonApi.commentsByLessonId[lessonId]?.isLoading || false);
|
||||
|
||||
const comments = $derived.by(() => {
|
||||
const commentData = lessonApi.commentsByLessonId[lessonId];
|
||||
if (!commentData || !commentData.items || !Array.isArray(commentData.items)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return commentData.items.map((apiComment) => {
|
||||
const isCurrentUser = apiComment.groupmember?.id === groupmemberId;
|
||||
return {
|
||||
id: apiComment.id,
|
||||
comment: apiComment.comment,
|
||||
avatar: apiComment.profile?.avatarUrl ?? 'AV',
|
||||
commentAt: new Date(apiComment.createdAt),
|
||||
groupmemberId: apiComment.groupmemberId,
|
||||
name: isCurrentUser ? $t('course.navItem.lessons.comments.you') : apiComment.profile?.fullname || 'Unknown'
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
async function handleSend() {
|
||||
if (!comment) {
|
||||
return;
|
||||
}
|
||||
|
||||
await lessonApi.createComment(courseId, lessonId, comment);
|
||||
|
||||
if (lessonApi.success) {
|
||||
comment = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreComments() {
|
||||
if (!lessonId) return;
|
||||
|
||||
await lessonApi.loadMoreComments(courseId, lessonId);
|
||||
}
|
||||
|
||||
// Fetch once per lessonId (even if there are 0 comments).
|
||||
$effect(() => {
|
||||
if (!courseId || !lessonId) return;
|
||||
if (lessonApi.commentsByLessonId[lessonId]) return;
|
||||
|
||||
untrack(() => {
|
||||
lessonApi.getComments(courseId, lessonId);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<DeleteModal
|
||||
bind:open={openDeleteModal}
|
||||
onDelete={() => {
|
||||
if (!deleteCommentId) return;
|
||||
lessonApi.deleteComment(courseId, lessonId, String(deleteCommentId));
|
||||
deleteCommentId = null;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="mx-auto w-full max-w-[65ch]">
|
||||
<div class="mb-5">
|
||||
<p class="text-lg capitalize">
|
||||
{$t('course.navItem.lessons.comments.title')} ({paginationCount})
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex h-full items-start gap-3">
|
||||
<Avatar.Root class="mt-2 size-6!">
|
||||
<Avatar.Image
|
||||
src={$profile.avatarUrl ? $profile.avatarUrl : '/logo-192.png'}
|
||||
alt={$profile.fullname ? $profile.fullname : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName($profile.fullname) || 'U'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="h-full w-full">
|
||||
<TextareaField
|
||||
label={$t('course.navItem.lessons.comments.text_area_title')}
|
||||
placeholder={$t('course.navItem.lessons.comments.placeholder')}
|
||||
bind:value={comment}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-row-reverse">
|
||||
<Button onclick={handleSend} disabled={!comment || !courseId || !lessonId} loading={lessonApi.isCommenting}>
|
||||
{$t('course.navItem.lessons.comments.comment_btn')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-5">
|
||||
{#each comments as commentItem (commentItem.id)}
|
||||
<div class="mt-2 flex items-start gap-3 pb-2">
|
||||
<Avatar.Root class="mt-2 size-6!">
|
||||
<Avatar.Image
|
||||
src={commentItem.avatar ? commentItem.avatar : '/logo-192.png'}
|
||||
alt={commentItem.name ? commentItem.name : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(commentItem.name) || 'U'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
<div class="w-full rounded-md border px-3 py-2 dark:border-neutral-700">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-sm dark:text-white">
|
||||
{commentItem.name}
|
||||
<span
|
||||
class="ml-1 text-xs font-normal text-gray-800 dark:text-white"
|
||||
title={commentItem.commentAt.toLocaleString()}
|
||||
>
|
||||
{calDateDiff(commentItem.commentAt)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{#if courseApi.group.memberId === commentItem.groupmemberId}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md hover:bg-gray-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<EllipsisVerticalIcon class="size-4" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => (editCommentId = commentItem.id)}>Edit</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
class="text-red-600"
|
||||
onclick={() => {
|
||||
deleteCommentId = commentItem.id;
|
||||
openDeleteModal = true;
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if editCommentId === commentItem.id}
|
||||
<TextareaField
|
||||
placeholder={$t('course.navItem.lessons.comments.placeholder')}
|
||||
bind:value={commentItem.comment}
|
||||
/>
|
||||
<div class="mt-2 flex flex-row-reverse items-center gap-2">
|
||||
<Button variant="outline" onclick={() => (editCommentId = null)} disabled={lessonApi.isUpdatingComment}>
|
||||
{$t('course.navItem.lessons.comments.cancel_btn')}
|
||||
</Button>
|
||||
<Button
|
||||
onclick={async () => {
|
||||
await lessonApi.updateComment(courseId, lessonId, String(editCommentId), commentItem.comment!);
|
||||
if (lessonApi.success) {
|
||||
editCommentId = null;
|
||||
}
|
||||
}}
|
||||
disabled={!commentItem.comment}
|
||||
loading={lessonApi.isUpdatingComment}
|
||||
>
|
||||
{$t('course.navItem.lessons.comments.comment_btn')}
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<article class="prose sm:prose-sm max-w-[300px] text-sm dark:text-white">
|
||||
{commentItem.comment}
|
||||
</article>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if hasMore}
|
||||
<div class="mt-2 flex items-center justify-center">
|
||||
<Button variant="outline" onclick={loadMoreComments} loading={isLoadingComments}>
|
||||
{$t('course.navItem.lessons.comments.load_more_btn')}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,57 @@
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import type { Component } from 'svelte';
|
||||
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
||||
import PresentationIcon from '@lucide/svelte/icons/presentation';
|
||||
import type { Tabs } from '$features/course/utils/types';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
|
||||
interface MaterialTab {
|
||||
label: string;
|
||||
icon: Component;
|
||||
value: number;
|
||||
badgeValue?: number;
|
||||
}
|
||||
|
||||
export const tabs: MaterialTab[] = [
|
||||
{
|
||||
label: 'course.navItem.lessons.materials.tabs.note.title',
|
||||
icon: BookOpenIcon,
|
||||
value: 1,
|
||||
badgeValue: 0
|
||||
},
|
||||
{
|
||||
label: 'course.navItem.lessons.materials.tabs.slide.title',
|
||||
icon: PresentationIcon,
|
||||
value: 2,
|
||||
badgeValue: 0
|
||||
},
|
||||
{
|
||||
label: 'course.navItem.lessons.materials.tabs.video.title',
|
||||
icon: VideoIcon,
|
||||
value: 3,
|
||||
badgeValue: 0
|
||||
},
|
||||
{
|
||||
label: 'course.navItem.lessons.materials.tabs.document.title',
|
||||
icon: FileTextIcon,
|
||||
value: 4,
|
||||
badgeValue: 0
|
||||
}
|
||||
];
|
||||
|
||||
export function orderedTabs(tabs: MaterialTab[], settingTabs?: Tabs[]) {
|
||||
if (!Array.isArray(settingTabs)) return tabs;
|
||||
|
||||
const reorderedTabs: MaterialTab[] = [];
|
||||
|
||||
const tabMap = new Map(tabs.map((tab) => [tab.value, tab]));
|
||||
|
||||
settingTabs.forEach((settingTab, index) => {
|
||||
const tab = tabMap.get(settingTab.id);
|
||||
if (tab) {
|
||||
reorderedTabs[index] = tab;
|
||||
}
|
||||
});
|
||||
|
||||
return reorderedTabs.filter((tab) => tab !== undefined);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
|
||||
export type ContentActionItem = {
|
||||
type: ContentType;
|
||||
contentId: string;
|
||||
title?: string | null;
|
||||
isUnlocked?: boolean | null;
|
||||
sectionId?: string | null;
|
||||
order?: number | null;
|
||||
};
|
||||
|
||||
type CourseApiErrorMap = Record<string, string> | undefined | null;
|
||||
|
||||
type LessonApi = {
|
||||
update: (courseId: string, lessonId: string, payload: Record<string, unknown>) => Promise<unknown>;
|
||||
delete: (courseId: string, lessonId: string) => Promise<unknown>;
|
||||
errors?: CourseApiErrorMap;
|
||||
success?: boolean;
|
||||
};
|
||||
|
||||
type ExerciseApi = {
|
||||
update: (courseId: string, exerciseId: string, payload: Record<string, unknown>) => Promise<unknown>;
|
||||
delete: (courseId: string, exerciseId: string) => Promise<unknown>;
|
||||
errors?: CourseApiErrorMap;
|
||||
success?: boolean;
|
||||
};
|
||||
|
||||
function normalizeErrors(errors: CourseApiErrorMap): Record<string, string> {
|
||||
if (!errors) return {};
|
||||
const entries = Object.entries(errors).filter(([, value]) => Boolean(value));
|
||||
return entries.length ? Object.fromEntries(entries) : {};
|
||||
}
|
||||
|
||||
function buildContentPayload(item: ContentActionItem, overrides: Record<string, unknown> = {}) {
|
||||
const normalizedSectionId = item.sectionId === '' ? null : item.sectionId;
|
||||
|
||||
return {
|
||||
title: item.title || '',
|
||||
isUnlocked: item.isUnlocked ?? undefined,
|
||||
sectionId: normalizedSectionId ?? undefined,
|
||||
order: item.order ?? undefined,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
export async function toggleLock({
|
||||
item,
|
||||
courseId,
|
||||
lessonApi,
|
||||
exerciseApi
|
||||
}: {
|
||||
item: ContentActionItem;
|
||||
courseId: string;
|
||||
lessonApi: LessonApi;
|
||||
exerciseApi: ExerciseApi;
|
||||
}) {
|
||||
item.isUnlocked = !(item.isUnlocked ?? false);
|
||||
const payload = buildContentPayload(item, { order: undefined });
|
||||
|
||||
if (item.type === ContentType.Exercise) {
|
||||
await exerciseApi.update(courseId, item.contentId, payload);
|
||||
return normalizeErrors(exerciseApi.errors);
|
||||
}
|
||||
|
||||
await lessonApi.update(courseId, item.contentId, payload);
|
||||
return normalizeErrors(lessonApi.errors);
|
||||
}
|
||||
|
||||
export async function saveContent({
|
||||
item,
|
||||
courseId,
|
||||
lessonApi,
|
||||
exerciseApi
|
||||
}: {
|
||||
item: ContentActionItem;
|
||||
courseId: string;
|
||||
lessonApi: LessonApi;
|
||||
exerciseApi: ExerciseApi;
|
||||
}) {
|
||||
const payload = buildContentPayload(item);
|
||||
|
||||
if (item.type === ContentType.Exercise) {
|
||||
await exerciseApi.update(courseId, item.contentId, payload);
|
||||
return normalizeErrors(exerciseApi.errors);
|
||||
}
|
||||
|
||||
await lessonApi.update(courseId, item.contentId, payload);
|
||||
return normalizeErrors(lessonApi.errors);
|
||||
}
|
||||
|
||||
export async function deleteContent({
|
||||
item,
|
||||
courseId,
|
||||
lessonApi,
|
||||
exerciseApi
|
||||
}: {
|
||||
item: ContentActionItem;
|
||||
courseId: string;
|
||||
lessonApi: LessonApi;
|
||||
exerciseApi: ExerciseApi;
|
||||
}) {
|
||||
if (item.type === ContentType.Exercise) {
|
||||
await exerciseApi.delete(courseId, item.contentId);
|
||||
return Boolean(exerciseApi.success);
|
||||
}
|
||||
|
||||
await lessonApi.delete(courseId, item.contentId);
|
||||
return Boolean(lessonApi.success);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { RoleBasedSecurity } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
isEditing?: boolean;
|
||||
disabled?: boolean;
|
||||
isSaving?: boolean;
|
||||
lockLabel?: string;
|
||||
showLock?: boolean;
|
||||
showDelete?: boolean;
|
||||
onEdit: () => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
onToggleLock: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
isEditing = false,
|
||||
disabled = false,
|
||||
isSaving = false,
|
||||
lockLabel = '',
|
||||
showLock = true,
|
||||
showDelete = true,
|
||||
onEdit,
|
||||
onSave,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onToggleLock
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<div class="flex items-center gap-1">
|
||||
{#if isEditing}
|
||||
<Button variant="outline" onclick={onCancel} disabled={isSaving}>
|
||||
{$t('course.navItem.lessons.add_lesson.cancel')}
|
||||
</Button>
|
||||
<Button onclick={onSave} loading={isSaving} disabled={isSaving}>
|
||||
{$t('course.navItem.lessons.add_lesson.save')}
|
||||
</Button>
|
||||
{:else}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" {disabled}>
|
||||
<EllipsisVerticalIcon class="h-5 w-5" />
|
||||
<span class="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#if showLock}
|
||||
<DropdownMenu.Item onclick={onToggleLock} {disabled}>
|
||||
{lockLabel}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
<DropdownMenu.Item onclick={onEdit} {disabled}>
|
||||
{$t('course.navItem.lessons.add_lesson.edit')}
|
||||
</DropdownMenu.Item>
|
||||
{#if showDelete}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="text-red-600 focus:text-red-600 dark:text-red-400" onclick={onDelete} {disabled}>
|
||||
{$t('course.navItem.lessons.add_lesson.delete')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</RoleBasedSecurity>
|
||||
@@ -0,0 +1,474 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { resolve } from '$app/paths';
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
import CalendarClockIcon from '@lucide/svelte/icons/calendar-clock';
|
||||
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
||||
import HelpCircleIcon from '@lucide/svelte/icons/help-circle';
|
||||
import LockIcon from '@lucide/svelte/icons/lock';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import UnlockIcon from '@lucide/svelte/icons/lock-open';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import { HoverableItem, AttachmentIcon } from '@cio/ui/custom/moving-icons';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Chip } from '@cio/ui/custom/chip';
|
||||
import { lessonApi, exerciseApi, courseApi, contentApi } from '$features/course/api';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import formatDate from '$lib/utils/functions/formatDate';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { CourseContentItem } from '$features/course/utils/types';
|
||||
import { getCourseContent } from '$features/course/utils/content';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { contentEditingStore } from '$features/course/components/content/store';
|
||||
import DeleteLessonConfirmation from '$features/course/components/lesson/delete-lesson-confirmation.svelte';
|
||||
import ContentRow from './content-row.svelte';
|
||||
import ContentActions from './content-actions.svelte';
|
||||
import { deleteContent, saveContent, toggleLock } from './content-action-helpers';
|
||||
|
||||
const flipDurationMs = 300;
|
||||
|
||||
interface Props {
|
||||
reorder?: boolean;
|
||||
}
|
||||
|
||||
let { reorder = false }: Props = $props();
|
||||
|
||||
let prevTitle: string | undefined = $state();
|
||||
let errors: Record<string, string> = $state({});
|
||||
let lessonToDelete: CourseContentItem | undefined = $state();
|
||||
let openDeleteModal = $state(false);
|
||||
let lockPendingItemKeys = $state<Record<string, boolean>>({});
|
||||
|
||||
let courseId = $derived(courseApi.course?.id || '');
|
||||
const profileId = $derived($profile?.id || '');
|
||||
const contentData = $derived(getCourseContent(courseApi.course));
|
||||
const isStudentView = $derived($globalStore.isStudent === true);
|
||||
const isLiveCourse = $derived(courseApi.course?.type === 'LIVE_CLASS');
|
||||
const isSelfPacedCourse = $derived(courseApi.course?.type === 'SELF_PACED');
|
||||
const metaChipClass =
|
||||
'inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-1 text-[10px] text-gray-700 dark:bg-neutral-700 dark:text-gray-200';
|
||||
|
||||
type ContentDndItem = CourseContentItem & {
|
||||
id: string;
|
||||
contentId: string;
|
||||
isUnlocked?: boolean | null;
|
||||
isComplete?: boolean | null;
|
||||
};
|
||||
|
||||
type DndEvent = CustomEvent<{ items: ContentDndItem[] }>;
|
||||
|
||||
let contentItems = $state<ContentDndItem[]>([]);
|
||||
|
||||
function buildContentItems(): ContentDndItem[] {
|
||||
return (contentData.items || []).map((item) => ({
|
||||
...item,
|
||||
id: `${item.type}-${item.id}`,
|
||||
contentId: item.id
|
||||
}));
|
||||
}
|
||||
|
||||
function syncCourseContentItems(items: ContentDndItem[]) {
|
||||
if (!courseApi.course?.content || courseApi.course.content.grouped) return;
|
||||
|
||||
const updatedItems = items.map(({ contentId, id: _dndId, ...rest }, index) => ({
|
||||
...rest,
|
||||
id: contentId,
|
||||
order: index + 1
|
||||
}));
|
||||
|
||||
courseApi.course.content = {
|
||||
...courseApi.course.content,
|
||||
items: updatedItems
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSectionId(value?: string | null) {
|
||||
return value ? value : null;
|
||||
}
|
||||
|
||||
async function applyPendingReorder() {
|
||||
if (!contentItems.length) return;
|
||||
|
||||
const contentUpdates: Array<{
|
||||
id: string;
|
||||
type: ContentType.Lesson | ContentType.Exercise;
|
||||
order: number;
|
||||
sectionId?: string | null;
|
||||
}> = [];
|
||||
|
||||
const updatedItems = contentItems.map((item, index) => {
|
||||
const order = index + 1;
|
||||
|
||||
if (item.type === ContentType.Lesson || item.type === ContentType.Exercise) {
|
||||
if (item.order !== order) {
|
||||
contentUpdates.push({
|
||||
id: item.contentId,
|
||||
type: item.type,
|
||||
order,
|
||||
sectionId: normalizeSectionId(item.sectionId)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
order
|
||||
};
|
||||
});
|
||||
|
||||
contentItems = updatedItems;
|
||||
syncCourseContentItems(updatedItems);
|
||||
|
||||
if (contentUpdates.length > 0) {
|
||||
await contentApi.updateContent(courseId, contentUpdates);
|
||||
}
|
||||
}
|
||||
|
||||
let previousReorder = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (previousReorder && !reorder) {
|
||||
void applyPendingReorder();
|
||||
}
|
||||
previousReorder = reorder;
|
||||
|
||||
if (!reorder || contentItems.length === 0) {
|
||||
contentItems = buildContentItems();
|
||||
}
|
||||
});
|
||||
|
||||
async function saveLesson(item: ContentDndItem) {
|
||||
const nextErrors = await saveContent({
|
||||
item,
|
||||
courseId,
|
||||
lessonApi,
|
||||
exerciseApi
|
||||
});
|
||||
|
||||
errors = nextErrors;
|
||||
|
||||
if (Object.keys(nextErrors).length === 0) {
|
||||
resetEdit();
|
||||
syncCourseContentItems(contentItems);
|
||||
}
|
||||
}
|
||||
|
||||
function resetEdit() {
|
||||
contentEditingStore.set(undefined);
|
||||
prevTitle = undefined;
|
||||
errors = {};
|
||||
}
|
||||
|
||||
function handleDndConsider(e: DndEvent) {
|
||||
contentItems = e.detail.items;
|
||||
syncCourseContentItems(contentItems);
|
||||
}
|
||||
|
||||
function handleDndFinalize(e: DndEvent) {
|
||||
const items = e.detail.items;
|
||||
contentItems = items;
|
||||
syncCourseContentItems(items);
|
||||
|
||||
if (!reorder) {
|
||||
contentItems = buildContentItems();
|
||||
}
|
||||
}
|
||||
|
||||
function getContentOrder(contentId: string, type: ContentType): number | string {
|
||||
const index = contentItems.findIndex((item) => item.type === type && item.contentId === contentId);
|
||||
|
||||
if (index < 9) {
|
||||
return '0' + (index + 1);
|
||||
}
|
||||
|
||||
return index + 1;
|
||||
}
|
||||
|
||||
function getItemPath(item: ContentDndItem) {
|
||||
if (item.type === ContentType.Lesson) {
|
||||
return `/courses/${courseApi.course?.id}/lessons/${item.contentId}`;
|
||||
}
|
||||
|
||||
return `/courses/${courseApi.course?.id}/exercises/${item.contentId}`;
|
||||
}
|
||||
|
||||
function formatMetaDate(value?: string | null): string {
|
||||
if (!value) return '';
|
||||
return formatDate(value);
|
||||
}
|
||||
|
||||
function isLockedForStudent(item: ContentDndItem, isStudent: boolean) {
|
||||
return isStudent && (item.isUnlocked ?? true) === false;
|
||||
}
|
||||
|
||||
function getItemLockKey(item: ContentDndItem) {
|
||||
return `${item.type}-${item.contentId}`;
|
||||
}
|
||||
|
||||
async function handleToggleItemLock(item: ContentDndItem) {
|
||||
const lockKey = getItemLockKey(item);
|
||||
if (lockPendingItemKeys[lockKey]) return;
|
||||
|
||||
lockPendingItemKeys = {
|
||||
...lockPendingItemKeys,
|
||||
[lockKey]: true
|
||||
};
|
||||
|
||||
try {
|
||||
const lockErrors = await toggleLock({
|
||||
item,
|
||||
courseId,
|
||||
lessonApi,
|
||||
exerciseApi
|
||||
});
|
||||
errors = lockErrors;
|
||||
if (Object.keys(lockErrors).length === 0) {
|
||||
syncCourseContentItems(contentItems);
|
||||
}
|
||||
} finally {
|
||||
const nextPending = { ...lockPendingItemKeys };
|
||||
delete nextPending[lockKey];
|
||||
lockPendingItemKeys = nextPending;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DeleteLessonConfirmation
|
||||
bind:openDeleteModal
|
||||
deleteLesson={async () => {
|
||||
if (!lessonToDelete?.id) return;
|
||||
const activeCourseId = courseApi.course?.id;
|
||||
if (!activeCourseId) return;
|
||||
|
||||
const wasDeleted = await deleteContent({
|
||||
item: {
|
||||
type: lessonToDelete.type,
|
||||
contentId: lessonToDelete.id,
|
||||
title: lessonToDelete.title,
|
||||
isUnlocked: lessonToDelete.isUnlocked,
|
||||
sectionId: lessonToDelete.sectionId,
|
||||
order: lessonToDelete.order
|
||||
},
|
||||
courseId: activeCourseId,
|
||||
lessonApi,
|
||||
exerciseApi
|
||||
});
|
||||
|
||||
if (wasDeleted && profileId) {
|
||||
await courseApi.refreshCourse(activeCourseId, profileId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<section
|
||||
class="mx-auto w-full p-3 lg:w-11/12 lg:px-4 {reorder ? 'cursor-grab active:cursor-grabbing' : ''}"
|
||||
use:dndzone={{
|
||||
items: contentItems,
|
||||
flipDurationMs,
|
||||
dragDisabled: !reorder,
|
||||
dropTargetStyle: {
|
||||
border: '2px #1d4ed8 solid',
|
||||
'border-style': 'dashed'
|
||||
}
|
||||
}}
|
||||
onconsider={handleDndConsider}
|
||||
onfinalize={handleDndFinalize}
|
||||
>
|
||||
{#each contentItems as item (item.id)}
|
||||
{@const isEditingItem = $contentEditingStore === item.contentId}
|
||||
{@const itemLocked = isLockedForStudent(item, isStudentView)}
|
||||
{@const lockLabel = item.isUnlocked
|
||||
? $t('course.navItem.lessons.add_lesson.lock')
|
||||
: $t('course.navItem.lessons.add_lesson.unlock')}
|
||||
{@const canRenderJoinButton =
|
||||
item.type === ContentType.Lesson && isLiveCourse && Boolean(item.callUrl) && !itemLocked}
|
||||
{@const canRenderContinueButton = isStudentView && !itemLocked}
|
||||
{@const isLockPending = Boolean(lockPendingItemKeys[getItemLockKey(item)])}
|
||||
{@const actionDisabled = Boolean($contentEditingStore && !isEditingItem)}
|
||||
<div
|
||||
class="relative m-auto mb-4 flex max-w-xl items-start rounded-md border border-gray-200 px-3 py-2 transition dark:bg-neutral-800 {reorder
|
||||
? 'border-primary-300 bg-primary-50/40 cursor-grab shadow-sm active:cursor-grabbing'
|
||||
: ''}"
|
||||
>
|
||||
<div class="mt-0.5 mr-5">
|
||||
<Chip value={getContentOrder(item.contentId, item.type)} />
|
||||
</div>
|
||||
|
||||
{#if item.type === ContentType.Lesson}
|
||||
<ContentRow
|
||||
{item}
|
||||
bind:title={item.title}
|
||||
isLocked={itemLocked}
|
||||
href={resolve(itemLocked ? page.url.pathname : getItemPath(item), {})}
|
||||
isEditing={isEditingItem}
|
||||
{errors}
|
||||
autoFocus={isEditingItem}
|
||||
>
|
||||
{#snippet meta()}
|
||||
{@const hasVideos = (item.videosCount ?? 0) > 0}
|
||||
{@const hasDocuments = (item.documentsCount ?? 0) > 0}
|
||||
{@const hasNote = item.hasNoteContent === true}
|
||||
{@const hasSlide = item.hasSlideContent === true}
|
||||
{@const hasAnyMaterials = hasVideos || hasDocuments || hasNote || hasSlide}
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{#if hasVideos}
|
||||
<span class={metaChipClass}>
|
||||
<VideoIcon size={12} />
|
||||
{item.videosCount}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if hasDocuments}
|
||||
<span class={metaChipClass}>
|
||||
<FileTextIcon size={12} />
|
||||
{item.documentsCount}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if hasNote}
|
||||
<span class={metaChipClass}>
|
||||
<FileTextIcon size={12} />
|
||||
{$t('course.navItem.lessons.materials.tabs.note.title')}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if hasSlide}
|
||||
<span class={metaChipClass}>
|
||||
<FileTextIcon size={12} />
|
||||
{$t('course.navItem.lessons.materials.tabs.slide.title')}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if !hasAnyMaterials}
|
||||
<HoverableItem>
|
||||
{#snippet children(isHovered)}
|
||||
<span class={metaChipClass}>
|
||||
<AttachmentIcon size={11} {isHovered} />
|
||||
{$t('course.navItem.lessons.materials.no_content')}
|
||||
</span>
|
||||
{/snippet}
|
||||
</HoverableItem>
|
||||
{/if}
|
||||
|
||||
{#if isLiveCourse && item.lessonAt}
|
||||
<span class={metaChipClass}>
|
||||
<CalendarClockIcon size={12} />
|
||||
{formatMetaDate(item.lessonAt)}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if isStudentView}
|
||||
<span class={metaChipClass}>
|
||||
{item.isComplete ? $t('course.navItem.lessons.complete') : $t('course.navItem.lessons.incomplete')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</ContentRow>
|
||||
{:else if item.type === ContentType.Exercise}
|
||||
<ContentRow
|
||||
{item}
|
||||
bind:title={item.title}
|
||||
isLocked={itemLocked}
|
||||
href={resolve(itemLocked ? page.url.pathname : getItemPath(item), {})}
|
||||
isEditing={isEditingItem}
|
||||
{errors}
|
||||
autoFocus={isEditingItem}
|
||||
>
|
||||
{#snippet meta()}
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{#if !isStudentView && item.questionCount !== null}
|
||||
<span class={metaChipClass}>
|
||||
<HelpCircleIcon size={12} />
|
||||
{item.questionCount}
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.questions')}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if isSelfPacedCourse && item.dueBy}
|
||||
<span class={metaChipClass}>
|
||||
<CalendarClockIcon size={12} />
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.due')}: {formatMetaDate(item.dueBy)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</ContentRow>
|
||||
{/if}
|
||||
|
||||
<div class="ml-3 flex min-w-fit items-start gap-2">
|
||||
{#if isStudentView}
|
||||
<span class="mt-1 inline-flex items-center text-gray-500 dark:text-gray-300">
|
||||
{#if itemLocked}
|
||||
<LockIcon size={16} />
|
||||
{:else}
|
||||
<UnlockIcon size={16} />
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
class="h-8 w-8 text-gray-600"
|
||||
onclick={() => handleToggleItemLock(item)}
|
||||
disabled={actionDisabled || isEditingItem || isLockPending}
|
||||
aria-label={lockLabel}
|
||||
title={lockLabel}
|
||||
>
|
||||
{#if isLockPending}
|
||||
<Spinner class="h-5 w-5" />
|
||||
{:else if item.isUnlocked}
|
||||
<UnlockIcon class="h-5 w-5" />
|
||||
{:else}
|
||||
<LockIcon class="h-5 w-5" />
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if !isStudentView && canRenderJoinButton}
|
||||
<a href={item.callUrl!} target="_blank" rel="noreferrer" class="mt-0.5">
|
||||
<Button size="sm" variant="outline">{$t('schedule.join')}</Button>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if isStudentView && canRenderContinueButton && !isEditingItem}
|
||||
{#if canRenderJoinButton}
|
||||
<a href={item.callUrl!} target="_blank" rel="noreferrer">
|
||||
<Button size="sm">{$t('schedule.join')}</Button>
|
||||
</a>
|
||||
{:else}
|
||||
<a href={resolve(getItemPath(item), {})}>
|
||||
<Button size="sm" variant="outline">{$t('dashboard.continue')}</Button>
|
||||
</a>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !isStudentView}
|
||||
<ContentActions
|
||||
isEditing={isEditingItem}
|
||||
disabled={actionDisabled}
|
||||
{lockLabel}
|
||||
showLock={false}
|
||||
onEdit={() => {
|
||||
contentEditingStore.set(item.contentId);
|
||||
prevTitle = item.title;
|
||||
}}
|
||||
onSave={() => saveLesson(item)}
|
||||
onCancel={() => {
|
||||
item.title = prevTitle ?? item.title;
|
||||
resetEdit();
|
||||
}}
|
||||
onDelete={() => {
|
||||
lessonToDelete = item;
|
||||
openDeleteModal = true;
|
||||
}}
|
||||
onToggleLock={() => handleToggleItemLock(item)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-center text-sm text-gray-500 dark:text-white">{$t('course.navItem.lessons.no_lesson')}</p>
|
||||
{/each}
|
||||
</section>
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import { CircleCheckIcon } from '$features/ui/icons';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { courseApi, lessonApi } from '$features/course/api';
|
||||
import { getOrderedNavigableContent, getContentRoute } from '$features/course/utils/content';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import type { CourseContentItem } from '$features/course/utils/types';
|
||||
|
||||
interface Props {
|
||||
lessonId?: string;
|
||||
courseId: string;
|
||||
/** When on an exercise page, pass this to show prev/next content in content order (no mark-complete). */
|
||||
exerciseId?: string;
|
||||
}
|
||||
|
||||
let { lessonId, courseId, exerciseId }: Props = $props();
|
||||
|
||||
let isMarkingComplete = $state(false);
|
||||
|
||||
const navigableContentItems = $derived(getOrderedNavigableContent(courseApi.course));
|
||||
|
||||
const lessonItems = $derived(navigableContentItems.filter((item) => item.type === ContentType.Lesson));
|
||||
|
||||
/** Prev/next content items in full content order (immediate neighbors; locked content is hidden in the UI so we don't skip). */
|
||||
const prevNextContent = $derived.by(() => {
|
||||
const currentId = lessonId ?? exerciseId;
|
||||
const currentType = lessonId ? ContentType.Lesson : ContentType.Exercise;
|
||||
if (!currentId) return { prev: null, next: null };
|
||||
const idx = navigableContentItems.findIndex((item) => item.type === currentType && item.id === currentId);
|
||||
if (idx < 0) return { prev: null, next: null };
|
||||
return {
|
||||
prev: navigableContentItems[idx - 1] ?? null,
|
||||
next: navigableContentItems[idx + 1] ?? null
|
||||
};
|
||||
});
|
||||
|
||||
function goToContent(target: CourseContentItem | null) {
|
||||
if (!target) return;
|
||||
const courseIdResolved = courseApi.course?.id;
|
||||
if (!courseIdResolved) return;
|
||||
const path = getContentRoute(courseIdResolved, target);
|
||||
if (!path) return;
|
||||
goto(resolve(path, {}));
|
||||
}
|
||||
|
||||
const isPrevDisabled = $derived(!prevNextContent.prev);
|
||||
const isNextDisabled = $derived(!prevNextContent.next);
|
||||
const isLessonComplete = $derived.by(() => {
|
||||
if (!lessonId) return false;
|
||||
const lesson = lessonItems.find((l) => l.id === lessonId);
|
||||
return lesson?.isComplete ?? false;
|
||||
});
|
||||
|
||||
const showMarkComplete = $derived(!!lessonId && !exerciseId);
|
||||
|
||||
const currentLessonItem = $derived(lessonId ? lessonItems.find((l) => l.id === lessonId) : null);
|
||||
const isLessonLocked = $derived(
|
||||
$globalStore.isStudent && currentLessonItem && !(currentLessonItem.isUnlocked ?? false)
|
||||
);
|
||||
const isMarkCompleteDisabled = $derived(isMarkingComplete || isLessonLocked);
|
||||
|
||||
async function markLessonComplete(currentLessonId: string) {
|
||||
isMarkingComplete = true;
|
||||
|
||||
const lesson = lessonItems.find((entry) => entry.id === currentLessonId);
|
||||
const currentIsComplete = lesson?.isComplete ?? lessonApi.lesson?.isComplete ?? false;
|
||||
|
||||
const isComplete = !currentIsComplete;
|
||||
|
||||
await lessonApi.updateCompletion(courseId, currentLessonId, isComplete);
|
||||
|
||||
if (lessonApi.success) {
|
||||
updateCourseContentCompletion(currentLessonId, isComplete);
|
||||
snackbar.success('snackbar.lessons.success.complete_marked');
|
||||
} else {
|
||||
snackbar.error('snackbar.lessons.error.try_later');
|
||||
}
|
||||
|
||||
isMarkingComplete = false;
|
||||
}
|
||||
|
||||
function updateCourseContentCompletion(currentLessonId: string, isComplete: boolean) {
|
||||
if (!courseApi.course?.content) return;
|
||||
|
||||
if (courseApi.course.content.grouped) {
|
||||
courseApi.course = {
|
||||
...courseApi.course,
|
||||
content: {
|
||||
...courseApi.course.content,
|
||||
sections: courseApi.course.content.sections.map((section) => ({
|
||||
...section,
|
||||
items: section.items.map((item) =>
|
||||
item.type === ContentType.Lesson && item.id === currentLessonId ? { ...item, isComplete } : item
|
||||
)
|
||||
}))
|
||||
}
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
courseApi.course = {
|
||||
...courseApi.course,
|
||||
content: {
|
||||
...courseApi.course.content,
|
||||
items: courseApi.course.content.items.map((item) =>
|
||||
item.type === ContentType.Lesson && item.id === currentLessonId ? { ...item, isComplete } : item
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{#if showMarkComplete && lessonId}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onclick={() => markLessonComplete(lessonId)}
|
||||
loading={isMarkingComplete}
|
||||
disabled={isMarkCompleteDisabled}
|
||||
>
|
||||
<CircleCheckIcon size={14} filled={isLessonComplete} />
|
||||
<span class="text-xs">{$t('course.navItem.lessons.mark_as')} {$t('course.navItem.lessons.complete')}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
onclick={() => goToContent(prevNextContent.prev)}
|
||||
disabled={isPrevDisabled}
|
||||
aria-label={$t('course.navItem.lessons.prev')}
|
||||
title={$t('course.navItem.lessons.prev')}
|
||||
>
|
||||
<ChevronLeftIcon size={14} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
onclick={() => goToContent(prevNextContent.next)}
|
||||
disabled={isNextDisabled}
|
||||
aria-label={$t('course.navItem.lessons.next')}
|
||||
title={$t('course.navItem.lessons.next')}
|
||||
>
|
||||
<ChevronRightIcon size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import LockIcon from '@lucide/svelte/icons/lock';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { CircleCheckIcon } from '$features/ui/icons';
|
||||
import type { Snippet } from 'svelte';
|
||||
import CourseContentIcon from '$features/course/components/course-content-icon.svelte';
|
||||
import type { CourseContentItem } from '$features/course/utils/types';
|
||||
import { cn } from '@cio/ui/tools';
|
||||
|
||||
interface Props {
|
||||
item: CourseContentItem;
|
||||
title: string | null | undefined;
|
||||
href: string;
|
||||
isEditing?: boolean;
|
||||
errors?: Record<string, string>;
|
||||
isLocked?: boolean;
|
||||
isComplete?: boolean | null;
|
||||
showStatus?: boolean;
|
||||
showIcon?: boolean;
|
||||
iconSize?: number;
|
||||
containerClass?: string;
|
||||
rowClass?: string;
|
||||
linkClass?: string;
|
||||
inputClass?: string;
|
||||
titleClass?: string;
|
||||
autoFocus?: boolean;
|
||||
preloadOff?: boolean;
|
||||
inline?: Snippet;
|
||||
meta?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
item,
|
||||
title = $bindable(),
|
||||
href,
|
||||
isEditing = false,
|
||||
errors = {},
|
||||
isLocked = false,
|
||||
isComplete = null,
|
||||
showStatus = false,
|
||||
showIcon = true,
|
||||
iconSize = 14,
|
||||
containerClass = '',
|
||||
rowClass = '',
|
||||
linkClass = '',
|
||||
inputClass = '',
|
||||
titleClass = '',
|
||||
autoFocus = false,
|
||||
preloadOff = false,
|
||||
inline,
|
||||
meta
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if isEditing}
|
||||
<InputField className={cn('w-4/6', inputClass)} bind:value={title} errorMessage={errors?.title} {autoFocus} />
|
||||
{:else}
|
||||
<div class={cn('w-4/5', containerClass)}>
|
||||
<div class={cn('flex items-center gap-2', rowClass)}>
|
||||
{#if showIcon}
|
||||
<CourseContentIcon type={item.type} size={iconSize} />
|
||||
{/if}
|
||||
|
||||
<a
|
||||
href={resolve(href, {})}
|
||||
class={cn(
|
||||
'flex-1 truncate text-sm text-black hover:underline dark:text-white',
|
||||
isLocked ? 'cursor-not-allowed opacity-50' : linkClass
|
||||
)}
|
||||
data-sveltekit-preload-data={preloadOff ? 'off' : undefined}
|
||||
>
|
||||
<span class={titleClass}>{title}</span>
|
||||
</a>
|
||||
|
||||
{#if showStatus}
|
||||
{#if isLocked}
|
||||
<LockIcon size={iconSize} class="shrink-0" />
|
||||
{:else if isComplete}
|
||||
<span class="shrink-0">
|
||||
<CircleCheckIcon size={iconSize} filled />
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{@render inline?.()}
|
||||
</div>
|
||||
{@render meta?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,698 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { resolve } from '$app/paths';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
import CalendarClockIcon from '@lucide/svelte/icons/calendar-clock';
|
||||
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
||||
import HelpCircleIcon from '@lucide/svelte/icons/help-circle';
|
||||
import LockIcon from '@lucide/svelte/icons/lock';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import UnlockIcon from '@lucide/svelte/icons/lock-open';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { HoverableItem, AttachmentIcon } from '@cio/ui/custom/moving-icons';
|
||||
import formatDate from '$lib/utils/functions/formatDate';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { contentApi, courseApi, exerciseApi, lessonApi } from '$features/course/api';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import DeleteLessonConfirmation from '$features/course/components/lesson/delete-lesson-confirmation.svelte';
|
||||
import type { CourseContent, CourseContentItem } from '$features/course/utils/types';
|
||||
import { contentCreateStoreUtils, contentEditingStore } from '$features/course/components/content/store';
|
||||
import { getCourseContent } from '$features/course/utils/content';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import ContentRow from './content-row.svelte';
|
||||
import ContentActions from './content-actions.svelte';
|
||||
import SectionHeader from './section-header.svelte';
|
||||
import { deleteContent, saveContent, toggleLock } from './content-action-helpers';
|
||||
|
||||
type CrudParam = {
|
||||
sectionId?: string;
|
||||
lessonId?: string;
|
||||
sectionTitle?: string;
|
||||
lessonTitle?: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
reorder?: boolean;
|
||||
}
|
||||
|
||||
let { reorder = false }: Props = $props();
|
||||
|
||||
let prevTitle: string | undefined = $state();
|
||||
let errors: Record<string, string> = $state({});
|
||||
let openDeleteModal = $state(false);
|
||||
let lockPendingItemKeys = $state<Record<string, boolean>>({});
|
||||
let deletingData = {
|
||||
id: '',
|
||||
isSection: false
|
||||
};
|
||||
const courseId = $derived(courseApi.course?.id || '');
|
||||
const profileId = $derived($profile?.id || '');
|
||||
const contentData = $derived(getCourseContent(courseApi.course));
|
||||
const isStudentView = $derived($globalStore.isStudent === true);
|
||||
const isLiveCourse = $derived(courseApi.course?.type === 'LIVE_CLASS');
|
||||
const isSelfPacedCourse = $derived(courseApi.course?.type === 'SELF_PACED');
|
||||
const metaChipClass =
|
||||
'inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-1 text-[10px] text-gray-700 dark:bg-neutral-700 dark:text-gray-200';
|
||||
|
||||
type ContentDndItem = CourseContentItem & {
|
||||
id: string;
|
||||
contentId: string;
|
||||
isUnlocked?: boolean | null;
|
||||
isComplete?: boolean | null;
|
||||
};
|
||||
|
||||
type ContentSection = CourseContent['sections'][number];
|
||||
|
||||
type SectionDnd = {
|
||||
id: string;
|
||||
title: string;
|
||||
order: number | null;
|
||||
items: ContentDndItem[];
|
||||
};
|
||||
|
||||
let sections = $state<SectionDnd[]>([]);
|
||||
|
||||
function resolveErrors(apiErrors?: Record<string, string> | null) {
|
||||
if (!apiErrors) return {};
|
||||
const entries = Object.entries(apiErrors).filter(([, value]) => Boolean(value));
|
||||
return entries.length ? Object.fromEntries(entries) : {};
|
||||
}
|
||||
|
||||
function buildSectionItems(section: ContentSection): ContentDndItem[] {
|
||||
return section.items.map((item) => ({
|
||||
...item,
|
||||
id: `${item.type}-${item.id}`,
|
||||
contentId: item.id
|
||||
}));
|
||||
}
|
||||
|
||||
function syncSections() {
|
||||
sections = (contentData.sections || []).map((section) => ({
|
||||
...section,
|
||||
items: buildSectionItems(section)
|
||||
}));
|
||||
}
|
||||
|
||||
function toCourseContentItems(items: ContentDndItem[]): CourseContentItem[] {
|
||||
return items.map(({ contentId, id: _dndId, ...rest }) => ({
|
||||
...rest,
|
||||
id: contentId
|
||||
}));
|
||||
}
|
||||
|
||||
function syncCourseContentSections(updatedSections: SectionDnd[]) {
|
||||
if (!courseApi.course?.content) return;
|
||||
|
||||
courseApi.course.content = {
|
||||
...courseApi.course.content,
|
||||
sections: updatedSections.map((section) => ({
|
||||
...section,
|
||||
items: toCourseContentItems(section.items),
|
||||
order: section.order ?? null
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function updateSectionItems(sectionId: string, items: ContentDndItem[]) {
|
||||
sections = sections.map((section) => (section.id === sectionId ? { ...section, items } : section));
|
||||
}
|
||||
|
||||
function normalizeSectionId(value?: string | null) {
|
||||
return value && value !== 'ungrouped' ? value : null;
|
||||
}
|
||||
|
||||
async function applyPendingReorder() {
|
||||
if (!sections.length) return;
|
||||
|
||||
const contentUpdates: Array<{
|
||||
id: string;
|
||||
type: ContentType.Lesson | ContentType.Exercise;
|
||||
order: number;
|
||||
sectionId?: string | null;
|
||||
}> = [];
|
||||
|
||||
const updatedSections = sections.map((section, sectionIndex) => {
|
||||
const normalizedSectionId = normalizeSectionId(section.id);
|
||||
const updatedItems = section.items.map((item, index) => {
|
||||
const order = index + 1;
|
||||
|
||||
if (item.type === ContentType.Lesson || item.type === ContentType.Exercise) {
|
||||
if (item.order !== order || item.sectionId !== normalizedSectionId) {
|
||||
contentUpdates.push({
|
||||
id: item.contentId,
|
||||
type: item.type,
|
||||
order,
|
||||
sectionId: normalizedSectionId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
order,
|
||||
sectionId: normalizedSectionId
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...section,
|
||||
order: sectionIndex + 1,
|
||||
items: updatedItems
|
||||
};
|
||||
});
|
||||
|
||||
sections = updatedSections;
|
||||
syncCourseContentSections(updatedSections);
|
||||
|
||||
const sectionsToReorder = updatedSections
|
||||
.filter((section) => section.id !== 'ungrouped')
|
||||
.map((section) => ({ id: section.id, order: section.order ?? 0 }));
|
||||
|
||||
if (sectionsToReorder.length > 0) {
|
||||
await lessonApi.reorderSections(courseId, sectionsToReorder);
|
||||
}
|
||||
|
||||
if (contentUpdates.length > 0) {
|
||||
await contentApi.updateContent(courseId, contentUpdates);
|
||||
}
|
||||
}
|
||||
|
||||
let previousReorder = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (previousReorder && !reorder) {
|
||||
void applyPendingReorder();
|
||||
}
|
||||
previousReorder = reorder;
|
||||
|
||||
if (!reorder || sections.length === 0) {
|
||||
syncSections();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const activeEditId = $contentEditingStore;
|
||||
if (!activeEditId) return;
|
||||
|
||||
const hasMatchingSection = sections.some((section) => section.id === activeEditId);
|
||||
const hasMatchingItem = sections.some((section) => section.items.some((item) => item.contentId === activeEditId));
|
||||
|
||||
if (!hasMatchingSection && !hasMatchingItem) {
|
||||
contentEditingStore.set(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
function onEdit(params: CrudParam) {
|
||||
contentEditingStore.set(params.sectionId || params.lessonId);
|
||||
prevTitle = params.sectionTitle || params.lessonTitle;
|
||||
}
|
||||
|
||||
async function onSave(params: CrudParam, item?: ContentDndItem) {
|
||||
let nextErrors: Record<string, string> = {};
|
||||
let shouldRefreshSections = false;
|
||||
|
||||
if (params.sectionId) {
|
||||
const section = sections.find((entry) => entry.id === params.sectionId);
|
||||
if (!section) return;
|
||||
|
||||
if (section.id === 'ungrouped') {
|
||||
await lessonApi.promoteUngroupedSection(courseId, {
|
||||
title: section.title!
|
||||
});
|
||||
shouldRefreshSections = true;
|
||||
} else {
|
||||
await lessonApi.updateSection(courseId, section.id, {
|
||||
title: section.title!
|
||||
});
|
||||
}
|
||||
|
||||
nextErrors = resolveErrors(lessonApi.errors);
|
||||
} else if (item) {
|
||||
nextErrors = await saveContent({
|
||||
item,
|
||||
courseId,
|
||||
lessonApi,
|
||||
exerciseApi
|
||||
});
|
||||
}
|
||||
|
||||
errors = nextErrors;
|
||||
|
||||
if (Object.keys(nextErrors).length === 0) {
|
||||
resetEdit();
|
||||
|
||||
if (shouldRefreshSections) {
|
||||
if (profileId) {
|
||||
await courseApi.refreshCourse(courseId, profileId);
|
||||
}
|
||||
syncSections();
|
||||
} else {
|
||||
syncCourseContentSections(sections);
|
||||
}
|
||||
|
||||
// Refresh course data to get updated information (especially for exercises)
|
||||
if (item?.type === ContentType.Exercise && exerciseApi.success && profileId) {
|
||||
await courseApi.refreshCourse(courseId, profileId);
|
||||
}
|
||||
|
||||
// Note: exerciseApi.update already shows a success snackbar, so we don't need to show it again
|
||||
if (item?.type !== ContentType.Exercise) {
|
||||
snackbar.success('snackbar.success_update');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete(params: CrudParam) {
|
||||
deletingData.id = params.sectionId || params.lessonId || '';
|
||||
deletingData.isSection = params.sectionId ? true : false;
|
||||
openDeleteModal = true;
|
||||
}
|
||||
|
||||
function removeSection(sectionId: string) {
|
||||
sections = sections.filter((section) => section.id !== sectionId);
|
||||
syncCourseContentSections(sections);
|
||||
}
|
||||
|
||||
async function deleteLesson() {
|
||||
if (deletingData.isSection) {
|
||||
const wasDeleted = await contentApi.deleteContent(courseId, { sectionId: deletingData.id });
|
||||
if (wasDeleted) {
|
||||
removeSection(deletingData.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const item = sections.flatMap((section) => section.items).find((entry) => entry.contentId === deletingData.id);
|
||||
|
||||
if (!item) return;
|
||||
|
||||
const wasDeleted = await deleteContent({
|
||||
item,
|
||||
courseId,
|
||||
lessonApi,
|
||||
exerciseApi
|
||||
});
|
||||
|
||||
if (wasDeleted && profileId) {
|
||||
await courseApi.refreshCourse(courseId, profileId);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSectionLock(sectionId: string) {
|
||||
const section = sections.find((entry) => entry.id === sectionId);
|
||||
if (!section) return;
|
||||
|
||||
const shouldLock = section.items.some((item) => item.isUnlocked ?? false);
|
||||
const nextIsUnlocked = !shouldLock;
|
||||
const updatedItems = section.items.map((item) => ({
|
||||
...item,
|
||||
isUnlocked: nextIsUnlocked
|
||||
}));
|
||||
const updatedSections = sections.map((entry) =>
|
||||
entry.id === sectionId
|
||||
? {
|
||||
...entry,
|
||||
items: updatedItems
|
||||
}
|
||||
: entry
|
||||
);
|
||||
|
||||
const wasUpdated = await contentApi.updateContent(
|
||||
courseId,
|
||||
updatedItems.map((item) => ({
|
||||
id: item.contentId,
|
||||
type: item.type as ContentType.Lesson | ContentType.Exercise,
|
||||
isUnlocked: nextIsUnlocked
|
||||
}))
|
||||
);
|
||||
|
||||
if (!wasUpdated) return;
|
||||
|
||||
sections = updatedSections;
|
||||
syncCourseContentSections(sections);
|
||||
}
|
||||
|
||||
function resetEdit() {
|
||||
contentEditingStore.set(undefined);
|
||||
prevTitle = undefined;
|
||||
errors = {};
|
||||
}
|
||||
|
||||
function openContentModal(sectionId: string) {
|
||||
contentCreateStoreUtils.openContentUnit(sectionId);
|
||||
}
|
||||
|
||||
function getItemPath(item: ContentDndItem) {
|
||||
if (item.type === ContentType.Lesson) {
|
||||
return `/courses/${courseApi.course?.id}/lessons/${item.contentId}`;
|
||||
}
|
||||
|
||||
return `/courses/${courseApi.course?.id}/exercises/${item.contentId}`;
|
||||
}
|
||||
|
||||
function openExternalUrl(url: string) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
function formatMetaDate(value?: string | null): string {
|
||||
if (!value) return '';
|
||||
return formatDate(value);
|
||||
}
|
||||
|
||||
function isLockedForStudent(item: ContentDndItem) {
|
||||
return isStudentView && (item.isUnlocked ?? true) === false;
|
||||
}
|
||||
|
||||
function getItemLockKey(item: ContentDndItem) {
|
||||
return `${item.type}-${item.contentId}`;
|
||||
}
|
||||
|
||||
async function handleToggleItemLock(item: ContentDndItem) {
|
||||
const lockKey = getItemLockKey(item);
|
||||
if (lockPendingItemKeys[lockKey]) return;
|
||||
|
||||
lockPendingItemKeys = {
|
||||
...lockPendingItemKeys,
|
||||
[lockKey]: true
|
||||
};
|
||||
|
||||
try {
|
||||
const lockErrors = await toggleLock({
|
||||
item,
|
||||
courseId,
|
||||
lessonApi,
|
||||
exerciseApi
|
||||
});
|
||||
errors = lockErrors;
|
||||
if (Object.keys(lockErrors).length === 0) {
|
||||
syncCourseContentSections(sections);
|
||||
}
|
||||
} finally {
|
||||
const nextPending = { ...lockPendingItemKeys };
|
||||
delete nextPending[lockKey];
|
||||
lockPendingItemKeys = nextPending;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag functionality
|
||||
*/
|
||||
const flipDurationMs = 200;
|
||||
|
||||
function handleDndConsiderColumns(e) {
|
||||
sections = e.detail.items as SectionDnd[];
|
||||
syncCourseContentSections(sections);
|
||||
}
|
||||
|
||||
function handleDndFinalizeColumns(e) {
|
||||
const updatedSections = (e.detail.items as SectionDnd[]).map((section, index) => ({
|
||||
...section,
|
||||
order: index + 1
|
||||
}));
|
||||
|
||||
sections = updatedSections;
|
||||
syncCourseContentSections(updatedSections);
|
||||
}
|
||||
|
||||
function handleDndConsiderCards(sectionId: string, e) {
|
||||
updateSectionItems(sectionId, e.detail.items as ContentDndItem[]);
|
||||
syncCourseContentSections(sections);
|
||||
}
|
||||
|
||||
function handleDndFinalizeCards(sectionId: string, e) {
|
||||
const items = e.detail.items as ContentDndItem[];
|
||||
updateSectionItems(sectionId, items);
|
||||
syncCourseContentSections(sections);
|
||||
|
||||
if (!reorder) {
|
||||
syncSections();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DeleteLessonConfirmation bind:openDeleteModal {deleteLesson} />
|
||||
|
||||
<section
|
||||
class="mx-auto w-full p-3 lg:w-11/12 lg:px-4"
|
||||
use:dndzone={{
|
||||
items: sections,
|
||||
flipDurationMs,
|
||||
type: 'rows',
|
||||
dropTargetStyle: {
|
||||
border: '2px #1d4ed8 solid',
|
||||
'border-style': 'dashed'
|
||||
},
|
||||
dragDisabled: !reorder
|
||||
}}
|
||||
onconsider={handleDndConsiderColumns}
|
||||
onfinalize={handleDndFinalizeColumns}
|
||||
>
|
||||
{#each sections as section (section.id)}
|
||||
{@const isUngrouped = section.id === 'ungrouped'}
|
||||
{@const hasSectionItems = section.items.length > 0}
|
||||
{@const sectionHasUnlocked = section.items.some((item) => item.isUnlocked ?? false)}
|
||||
{@const sectionLockLabel = hasSectionItems
|
||||
? sectionHasUnlocked
|
||||
? `${$t('course.navItem.lessons.add_lesson.lock')} ${$t('course.navItem.lessons.add_lesson.all')}`
|
||||
: `${$t('course.navItem.lessons.add_lesson.unlock')} ${$t('course.navItem.lessons.add_lesson.all')}`
|
||||
: undefined}
|
||||
<div
|
||||
class="border-border m-auto mb-3 max-w-xl rounded-md border transition dark:bg-neutral-800"
|
||||
style={reorder ? 'border: 2px #1d4ed8 solid; border-style: dashed; cursor: grab;' : ''}
|
||||
>
|
||||
<SectionHeader
|
||||
{section}
|
||||
isEditing={$contentEditingStore === section.id}
|
||||
isSaving={lessonApi.isLoading && $contentEditingStore === section.id}
|
||||
showActions={true}
|
||||
showAddContent={!isUngrouped}
|
||||
showDelete={!isUngrouped}
|
||||
disabled={Boolean($contentEditingStore && $contentEditingStore !== section.id)}
|
||||
{errors}
|
||||
lockLabel={isUngrouped ? undefined : sectionLockLabel}
|
||||
onEdit={() => onEdit({ sectionId: section.id, sectionTitle: section.title! })}
|
||||
onSave={() => onSave({ sectionId: section.id })}
|
||||
onCancel={() => {
|
||||
section.title = prevTitle ?? section.title;
|
||||
resetEdit();
|
||||
}}
|
||||
onAddContent={() => openContentModal(section.id)}
|
||||
onToggleLock={!isUngrouped && hasSectionItems ? () => toggleSectionLock(section.id) : undefined}
|
||||
onDelete={() => {
|
||||
if (!isUngrouped) {
|
||||
onDelete({ sectionId: section.id });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="m-3 {reorder ? 'cursor-grab active:cursor-grabbing' : ''}"
|
||||
use:dndzone={{
|
||||
items: section.items,
|
||||
flipDurationMs,
|
||||
dropTargetStyle: {
|
||||
border: '2px #1d4ed8 solid',
|
||||
'border-style': 'dashed'
|
||||
},
|
||||
dragDisabled: !reorder,
|
||||
type: 'section-content'
|
||||
}}
|
||||
onconsider={(e) => handleDndConsiderCards(section.id, e)}
|
||||
onfinalize={(e) => handleDndFinalizeCards(section.id, e)}
|
||||
>
|
||||
{#each section.items as item (item.id)}
|
||||
{@const isEditingItem = $contentEditingStore === item.contentId}
|
||||
{@const itemLocked = isLockedForStudent(item)}
|
||||
{@const itemLockLabel = item.isUnlocked
|
||||
? $t('course.navItem.lessons.add_lesson.lock')
|
||||
: $t('course.navItem.lessons.add_lesson.unlock')}
|
||||
{@const canRenderJoinButton =
|
||||
item.type === ContentType.Lesson && isLiveCourse && Boolean(item.callUrl) && !itemLocked}
|
||||
{@const canRenderContinueButton = isStudentView && !itemLocked}
|
||||
{@const isLockPending = Boolean(lockPendingItemKeys[getItemLockKey(item)])}
|
||||
{@const actionDisabled = Boolean($contentEditingStore && !isEditingItem)}
|
||||
|
||||
<div
|
||||
class="mb-2 flex min-h-[50px] max-w-xl items-start justify-between rounded-md border border-gray-200 px-3 py-1 transition"
|
||||
style={reorder ? 'border: 2px #1d4ed8 solid; border-style: dashed; cursor: grab;' : ''}
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
>
|
||||
{#if item.type === ContentType.Lesson}
|
||||
<ContentRow
|
||||
{item}
|
||||
bind:title={item.title}
|
||||
isLocked={itemLocked}
|
||||
href={resolve(itemLocked ? page.url.pathname : getItemPath(item), {})}
|
||||
isEditing={isEditingItem}
|
||||
{errors}
|
||||
preloadOff
|
||||
>
|
||||
{#snippet meta()}
|
||||
{@const hasVideos = (item.videosCount ?? 0) > 0}
|
||||
{@const hasDocuments = (item.documentsCount ?? 0) > 0}
|
||||
{@const hasNote = item.hasNoteContent === true}
|
||||
{@const hasSlide = item.hasSlideContent === true}
|
||||
{@const hasAnyMaterials = hasVideos || hasDocuments || hasNote || hasSlide}
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
{#if hasVideos}
|
||||
<span class={metaChipClass}>
|
||||
<VideoIcon size={11} />
|
||||
{item.videosCount}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if hasDocuments}
|
||||
<span class={metaChipClass}>
|
||||
<FileTextIcon size={11} />
|
||||
{item.documentsCount}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if hasNote}
|
||||
<span class={metaChipClass}>
|
||||
<FileTextIcon size={11} />
|
||||
{$t('course.navItem.lessons.materials.tabs.note.title')}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if hasSlide}
|
||||
<span class={metaChipClass}>
|
||||
<FileTextIcon size={11} />
|
||||
{$t('course.navItem.lessons.materials.tabs.slide.title')}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if !hasAnyMaterials}
|
||||
<HoverableItem>
|
||||
{#snippet children(isHovered)}
|
||||
<span class={metaChipClass}>
|
||||
<AttachmentIcon size={11} {isHovered} />
|
||||
{$t('course.navItem.lessons.materials.no_content')}
|
||||
</span>
|
||||
{/snippet}
|
||||
</HoverableItem>
|
||||
{/if}
|
||||
|
||||
{#if isLiveCourse && item.lessonAt}
|
||||
<span class={metaChipClass}>
|
||||
<CalendarClockIcon size={11} />
|
||||
{formatMetaDate(item.lessonAt)}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if isStudentView}
|
||||
<span class={metaChipClass}>
|
||||
{item.isComplete
|
||||
? $t('course.navItem.lessons.complete')
|
||||
: $t('course.navItem.lessons.incomplete')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</ContentRow>
|
||||
{:else if item.type === ContentType.Exercise}
|
||||
<ContentRow
|
||||
{item}
|
||||
bind:title={item.title}
|
||||
isLocked={itemLocked}
|
||||
href={resolve(itemLocked ? page.url.pathname : getItemPath(item), {})}
|
||||
isEditing={isEditingItem}
|
||||
{errors}
|
||||
>
|
||||
{#snippet meta()}
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
{#if !isStudentView && item.questionCount !== null}
|
||||
<span class={metaChipClass}>
|
||||
<HelpCircleIcon size={11} />
|
||||
{item.questionCount}
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.questions')}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if isSelfPacedCourse && item.dueBy}
|
||||
<span class={metaChipClass}>
|
||||
<CalendarClockIcon size={11} />
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.due')}: {formatMetaDate(
|
||||
item.dueBy
|
||||
)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</ContentRow>
|
||||
{/if}
|
||||
|
||||
<div class="ml-3 flex min-w-fit items-start gap-2">
|
||||
{#if isStudentView}
|
||||
<span class="mt-1 inline-flex items-center text-gray-500 dark:text-gray-300">
|
||||
{#if itemLocked}
|
||||
<LockIcon size={16} />
|
||||
{:else}
|
||||
<UnlockIcon size={16} />
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
class="h-8 w-8 text-gray-600"
|
||||
onclick={() => handleToggleItemLock(item)}
|
||||
disabled={actionDisabled || isEditingItem || isLockPending}
|
||||
aria-label={itemLockLabel}
|
||||
title={itemLockLabel}
|
||||
>
|
||||
{#if isLockPending}
|
||||
<Spinner class="h-5 w-5" />
|
||||
{:else if item.isUnlocked}
|
||||
<UnlockIcon class="h-5 w-5" />
|
||||
{:else}
|
||||
<LockIcon class="h-5 w-5" />
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if !isStudentView && canRenderJoinButton}
|
||||
<Button size="sm" variant="outline" class="mt-0.5" onclick={() => openExternalUrl(item.callUrl!)}>
|
||||
{$t('schedule.join')}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if isStudentView && canRenderContinueButton && !isEditingItem}
|
||||
{#if canRenderJoinButton}
|
||||
<Button size="sm" onclick={() => openExternalUrl(item.callUrl!)}>{$t('schedule.join')}</Button>
|
||||
{:else}
|
||||
<a href={resolve(getItemPath(item), {})}>
|
||||
<Button size="sm" variant="outline">{$t('dashboard.continue')}</Button>
|
||||
</a>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !isStudentView}
|
||||
<ContentActions
|
||||
isEditing={isEditingItem}
|
||||
disabled={actionDisabled}
|
||||
lockLabel={itemLockLabel}
|
||||
showLock={false}
|
||||
onEdit={() => onEdit({ lessonId: item.contentId, lessonTitle: item.title })}
|
||||
onSave={() => onSave({ lessonId: item.contentId }, item)}
|
||||
onCancel={() => {
|
||||
item.title = prevTitle ?? item.title;
|
||||
resetEdit();
|
||||
}}
|
||||
onDelete={() => onDelete({ lessonId: item.contentId })}
|
||||
onToggleLock={() => handleToggleItemLock(item)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{$t('course.navItem.lessons.no_lesson')}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
openDeleteModal?: boolean;
|
||||
deleteLesson?: any;
|
||||
}
|
||||
|
||||
let { openDeleteModal = $bindable(false), deleteLesson = () => {} }: Props = $props();
|
||||
|
||||
async function handleDelete() {
|
||||
deleteLesson();
|
||||
openDeleteModal = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={openDeleteModal}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) openDeleteModal = false;
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-96 px-6 pt-2 pb-6">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('delete_modal.label')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
<h1 class="text-lg dark:text-white">
|
||||
{$t('delete_modal.content')}?
|
||||
</h1>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={() => (openDeleteModal = false)}>
|
||||
{$t('delete_modal.no')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={handleDelete}>
|
||||
{$t('delete_modal.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
<script lang="ts">
|
||||
import { lessonDocUpload, resetDocumentUploadStore } from '../store';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
||||
import { DocumentUploader } from '$lib/utils/services/courses/presign';
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import { UpgradeBanner, CloseButton } from '$features/ui';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import { lessonApi } from '$features/course/api';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
import type { Lesson } from '$features/course/utils/types';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import * as FileDropZone from '@cio/ui/custom/file-drop-zone';
|
||||
import type { FileRejectedReason } from '@cio/ui/custom/file-drop-zone';
|
||||
|
||||
interface Props {
|
||||
lessonId?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
let { lessonId = '', onClose = () => {} }: Props = $props();
|
||||
|
||||
let selectedFile: File | null = $state(null);
|
||||
let errorTimeout: NodeJS.Timeout | null = $state(null);
|
||||
let isDisabled = $derived($lessonDocUpload.isUploading || $isFreePlan);
|
||||
|
||||
let uploadedDocument: NonNullable<Lesson['documents']>[number] | null = $state(null);
|
||||
|
||||
const ALLOWED_TYPES = [
|
||||
'application/pdf',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/msword'
|
||||
];
|
||||
|
||||
const MAX_DOCUMENT_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
const documentUploader = new DocumentUploader();
|
||||
|
||||
function getFileType(file: File): 'pdf' | 'docx' | 'doc' {
|
||||
if (file.type === 'application/pdf') return 'pdf';
|
||||
if (file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') return 'docx';
|
||||
if (file.type === 'application/msword') return 'doc';
|
||||
return 'pdf'; // fallback
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function validateFile(file: File): string | null {
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return $t('course.navItem.lessons.materials.tabs.document.file_type_error');
|
||||
}
|
||||
|
||||
if (file.size > MAX_DOCUMENT_SIZE) {
|
||||
return $t('course.navItem.lessons.materials.tabs.document.file_size_error', {
|
||||
size: formatFileSize(MAX_DOCUMENT_SIZE)
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function selectFile(file: File) {
|
||||
const error = validateFile(file);
|
||||
if (error) {
|
||||
snackbar.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
selectedFile = file;
|
||||
$lessonDocUpload.error = null;
|
||||
}
|
||||
|
||||
async function handleFilesUpload(files: File[]) {
|
||||
const file = files[0];
|
||||
if (file) selectFile(file);
|
||||
}
|
||||
|
||||
function handleFileRejected(opts: { reason: FileRejectedReason; file: File }) {
|
||||
if (opts.reason === 'Maximum file size exceeded') {
|
||||
snackbar.error(
|
||||
$t('course.navItem.lessons.materials.tabs.document.file_size_error', {
|
||||
size: formatFileSize(MAX_DOCUMENT_SIZE)
|
||||
})
|
||||
);
|
||||
} else if (opts.reason === 'File type not allowed') {
|
||||
snackbar.error($t('course.navItem.lessons.materials.tabs.document.file_type_error'));
|
||||
} else {
|
||||
snackbar.error($t('course.navItem.lessons.materials.tabs.document.upload_error'));
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadDocument() {
|
||||
if (!selectedFile) return;
|
||||
|
||||
// Prevent free plan users from bypassing UI restrictions
|
||||
if ($isFreePlan) {
|
||||
$lessonDocUpload.error = $t('upgrade.required');
|
||||
|
||||
snackbar.error($lessonDocUpload.error!);
|
||||
return;
|
||||
}
|
||||
|
||||
$lessonDocUpload.isUploading = true;
|
||||
|
||||
try {
|
||||
const { url: presignedUrl, fileKey } = await documentUploader.getPresignedUrl(selectedFile);
|
||||
|
||||
await documentUploader.uploadFile({
|
||||
url: presignedUrl,
|
||||
file: selectedFile
|
||||
});
|
||||
|
||||
const { urls: presignedUrls } = await documentUploader.getDownloadPresignedUrl([fileKey]);
|
||||
|
||||
const lessonDocumentPosition = Array.isArray(lessonApi.lesson?.documents) ? lessonApi.lesson.documents.length : 0;
|
||||
|
||||
let assetId: string | undefined;
|
||||
if (lessonId) {
|
||||
const asset = await mediaApi.registerUploadedLessonDocument({
|
||||
lessonId,
|
||||
position: lessonDocumentPosition,
|
||||
fileKey,
|
||||
documentUrl: presignedUrls[fileKey],
|
||||
fileName: selectedFile.name,
|
||||
mimeType: selectedFile.type,
|
||||
byteSize: selectedFile.size
|
||||
});
|
||||
assetId = asset?.id;
|
||||
} else {
|
||||
const asset = await mediaApi.createAsset({
|
||||
kind: 'document',
|
||||
provider: 'upload',
|
||||
storageProvider: 's3',
|
||||
storageKey: fileKey,
|
||||
sourceUrl: presignedUrls[fileKey],
|
||||
mimeType: selectedFile.type,
|
||||
byteSize: selectedFile.size,
|
||||
title: selectedFile.name,
|
||||
isExternal: false,
|
||||
metadata: {
|
||||
fileName: selectedFile.name,
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
assetId = asset?.id;
|
||||
}
|
||||
|
||||
const document = {
|
||||
type: getFileType(selectedFile),
|
||||
name: selectedFile.name,
|
||||
link: presignedUrls[fileKey],
|
||||
key: fileKey,
|
||||
size: selectedFile.size,
|
||||
assetId
|
||||
};
|
||||
|
||||
lessonApi.updateLessonState('documents', [document], { append: true });
|
||||
|
||||
uploadedDocument = document;
|
||||
lessonDocUpload.update((state) => ({
|
||||
...state,
|
||||
uploadProgress: 100
|
||||
}));
|
||||
|
||||
snackbar.success($t('course.navItem.lessons.materials.tabs.document.upload_success'));
|
||||
|
||||
// Reset after a short delay
|
||||
setTimeout(() => {
|
||||
resetDocumentUploadStore();
|
||||
selectedFile = null;
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
|
||||
if ($lessonDocUpload.isCancelled) {
|
||||
$lessonDocUpload.error = $t('course.navItem.lessons.materials.tabs.document.upload_cancelled');
|
||||
snackbar.info($t('course.navItem.lessons.materials.tabs.document.upload_cancelled'));
|
||||
} else {
|
||||
$lessonDocUpload.error =
|
||||
error instanceof Error ? error.message : $t('course.navItem.lessons.materials.tabs.document.upload_error');
|
||||
snackbar.error($t('course.navItem.lessons.materials.tabs.document.upload_error'));
|
||||
}
|
||||
} finally {
|
||||
$lessonDocUpload.isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeSelectedFile() {
|
||||
selectedFile = null;
|
||||
$lessonDocUpload.error = null;
|
||||
}
|
||||
|
||||
function cancelUpload() {
|
||||
documentUploader.cancelUpload();
|
||||
selectedFile = null;
|
||||
}
|
||||
|
||||
function autoClearError(error: string | null) {
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorTimeout) clearTimeout(errorTimeout);
|
||||
|
||||
errorTimeout = setTimeout(() => {
|
||||
untrack(() => {
|
||||
$lessonDocUpload.error = null;
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Auto-clear error after 5 seconds
|
||||
$effect(() => {
|
||||
autoClearError($lessonDocUpload.error);
|
||||
});
|
||||
|
||||
// Clear error timeout when component is destroyed
|
||||
onDestroy(() => {
|
||||
if (errorTimeout) clearTimeout(errorTimeout);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={$lessonDocUpload.isModalOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) onClose();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-[90%] max-w-4/5">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.lessons.materials.tabs.document.upload_title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<UpgradeBanner className="mb-3" onClick={() => ($lessonDocUpload.isModalOpen = false)}>
|
||||
{$t('course.navItem.lessons.materials.tabs.document.upgrade')}
|
||||
</UpgradeBanner>
|
||||
|
||||
<div class="p-6">
|
||||
<!-- File Upload Area -->
|
||||
{#if selectedFile}
|
||||
<div class="border-border mb-4 flex items-center justify-center space-x-3 rounded-lg border p-4">
|
||||
<FileTextIcon class="ui:text-muted-foreground size-5 shrink-0" />
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<p class="ui:text-foreground truncate font-medium">{selectedFile.name}</p>
|
||||
<p class="ui:text-muted-foreground text-sm">{formatFileSize(selectedFile.size)}</p>
|
||||
</div>
|
||||
<CloseButton onClick={removeSelectedFile} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class={isDisabled ? 'ui:opacity-50 ui:pointer-events-none' : ''}>
|
||||
<FileDropZone.Root
|
||||
accept=".pdf,.docx,.doc,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/msword"
|
||||
maxFiles={1}
|
||||
fileCount={0}
|
||||
maxFileSize={MAX_DOCUMENT_SIZE}
|
||||
onUpload={handleFilesUpload}
|
||||
onFileRejected={handleFileRejected}
|
||||
>
|
||||
<FileDropZone.Trigger
|
||||
label={$t('course.navItem.lessons.materials.tabs.document.drag_drop')}
|
||||
formatMaxFiles={(_count) => $t('course.navItem.lessons.materials.tabs.document.upload_description')}
|
||||
formatMaxFilesAndSize={(size) => `(up to ${size})`}
|
||||
formatMaxSize={(size) =>
|
||||
$t('course.navItem.lessons.materials.tabs.document.upload_description') + ` (${size})`}
|
||||
/>
|
||||
</FileDropZone.Root>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if $lessonDocUpload.error}
|
||||
<div class="mt-4 rounded-md border border-red-200 bg-red-50 p-3">
|
||||
<p class="text-sm text-red-600">{$lessonDocUpload.error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Upload Progress -->
|
||||
{#if $lessonDocUpload.isUploading}
|
||||
<div class="mt-4">
|
||||
<div class="mb-1 flex justify-between text-sm text-gray-600">
|
||||
<span>{$t('course.navItem.lessons.materials.tabs.document.uploading')}</span>
|
||||
<span>{$lessonDocUpload.uploadProgress}%</span>
|
||||
</div>
|
||||
<div class="h-2 w-full rounded-full bg-gray-200">
|
||||
<div
|
||||
class="h-2 rounded-full bg-blue-600 transition-all duration-300"
|
||||
style="width: {$lessonDocUpload.uploadProgress}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="mt-3 flex justify-center">
|
||||
<Button variant="outline" onclick={cancelUpload}>
|
||||
{$t('course.navItem.lessons.materials.tabs.document.cancel_upload')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Upload Button -->
|
||||
{#if selectedFile && !$lessonDocUpload.isUploading}
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<Button variant="outline" onclick={removeSelectedFile}>
|
||||
{$t('course.navItem.lessons.materials.tabs.document.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onclick={uploadDocument}
|
||||
loading={$lessonDocUpload.isUploading}
|
||||
disabled={$lessonDocUpload.isUploading}
|
||||
>
|
||||
{$t('course.navItem.lessons.materials.tabs.document.upload_document')}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Success Message -->
|
||||
{#if uploadedDocument}
|
||||
<div class="mt-4 rounded-md border border-green-200 bg-green-50 p-3">
|
||||
<p class="text-sm text-green-600">
|
||||
{$t('course.navItem.lessons.materials.tabs.document.upload_success')}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
let { onRemove }: Props = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="ui:data-[state=open]:opacity-100 absolute top-2 right-2 z-40 flex items-center justify-center opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<IconButton variant="outline">
|
||||
<EllipsisVerticalIcon size={16} />
|
||||
</IconButton>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item class="ui:text-red-600" onclick={onRemove}>
|
||||
{$t('course.navItem.lessons.materials.tabs.document.remove_document')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { DocumentCard } from '@cio/ui';
|
||||
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||
import DownloadIcon from '@lucide/svelte/icons/download';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import DocumentCardDropdown from './document-card-dropdown.svelte';
|
||||
import type { LessonDocument } from '$features/course/utils/types';
|
||||
|
||||
interface Props {
|
||||
document: LessonDocument;
|
||||
index: number;
|
||||
isEditMode: boolean;
|
||||
isDownloading: boolean;
|
||||
formatFileSize: (bytes: number) => string;
|
||||
onRemove: () => void;
|
||||
onViewPDF: (doc: LessonDocument) => void;
|
||||
onDownload: (doc: LessonDocument) => Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
document: doc,
|
||||
index: _index,
|
||||
isEditMode,
|
||||
isDownloading,
|
||||
formatFileSize,
|
||||
onRemove,
|
||||
onViewPDF,
|
||||
onDownload
|
||||
}: Props = $props();
|
||||
|
||||
const subtitle = $derived(
|
||||
[doc.type.toUpperCase(), doc.size != null ? formatFileSize(doc.size) : null].filter(Boolean).join(' · ') || '–'
|
||||
);
|
||||
</script>
|
||||
|
||||
<DocumentCard title={doc.name} {subtitle} class="group relative">
|
||||
{#snippet header()}
|
||||
{#if isEditMode}
|
||||
<DocumentCardDropdown {onRemove} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
{#if doc.type === 'pdf'}
|
||||
<Button variant="outline" size="sm" onclick={() => onViewPDF(doc)} class="gap-1.5">
|
||||
<EyeIcon size={14} />
|
||||
{$t('course.navItem.lessons.materials.tabs.document.view_pdf')}
|
||||
</Button>
|
||||
{:else}
|
||||
<a
|
||||
href={doc.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ui:border-input ui:bg-background ui:text-foreground hover:ui:bg-accent hover:ui:text-accent-foreground ui:shrink inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium"
|
||||
>
|
||||
{$t('course.navItem.lessons.materials.tabs.document.view_document')}
|
||||
</a>
|
||||
{/if}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => onDownload(doc)}
|
||||
disabled={isDownloading}
|
||||
class="ui:shrink! gap-1.5"
|
||||
>
|
||||
<DownloadIcon size={14} />
|
||||
{isDownloading
|
||||
? $t('course.navItem.lessons.materials.tabs.document.downloading')
|
||||
: $t('course.navItem.lessons.materials.tabs.document.download')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DocumentCard>
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import MODES from '$lib/utils/constants/mode';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import DocumentCard from './document-card.svelte';
|
||||
import type { LessonDocument } from '$features/course/utils/types';
|
||||
|
||||
interface Props {
|
||||
mode?: (typeof MODES)[keyof typeof MODES];
|
||||
displayDocuments?: LessonDocument[];
|
||||
downloadingDocuments: Set<string>;
|
||||
formatFileSize: (bytes: number) => string;
|
||||
openDocumentUploadModal: () => void;
|
||||
requestRemoveDocument: (index: number) => void;
|
||||
onViewPDF: (doc: LessonDocument) => void;
|
||||
downloadDocument: (doc: LessonDocument) => Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
mode = MODES.view,
|
||||
displayDocuments = [],
|
||||
downloadingDocuments,
|
||||
formatFileSize,
|
||||
openDocumentUploadModal,
|
||||
requestRemoveDocument,
|
||||
onViewPDF,
|
||||
downloadDocument
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if mode === MODES.edit}
|
||||
<div class="flex justify-end">
|
||||
<Button onclick={openDocumentUploadModal} class="my-4!">
|
||||
{$t('course.navItem.lessons.materials.tabs.document.add_document')}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isEmpty(displayDocuments)}
|
||||
{@const isEditMode = mode === MODES.edit}
|
||||
<Item.Group class="flex w-full gap-4">
|
||||
{#each displayDocuments as document, index}
|
||||
<DocumentCard
|
||||
{document}
|
||||
{index}
|
||||
{isEditMode}
|
||||
isDownloading={downloadingDocuments.has(document.name)}
|
||||
{formatFileSize}
|
||||
onRemove={() => (isEditMode ? requestRemoveDocument(index) : undefined)}
|
||||
{onViewPDF}
|
||||
onDownload={downloadDocument}
|
||||
/>
|
||||
{/each}
|
||||
</Item.Group>
|
||||
{:else if mode === MODES.edit}
|
||||
<Empty
|
||||
title={$t('course.navItem.lessons.materials.tabs.document.empty_title')}
|
||||
description={$t('course.navItem.lessons.materials.tabs.document.empty_description')}
|
||||
icon={FileTextIcon}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,453 @@
|
||||
<script lang="ts">
|
||||
import { lessonApi } from '$features/course/api';
|
||||
import { CloseButton, DeleteModal } from '$features/ui';
|
||||
import { lessonDocUpload } from '$features/course/components/lesson/store';
|
||||
import MODES from '$lib/utils/constants/mode';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import ZoomInIcon from '@lucide/svelte/icons/zoom-in';
|
||||
import ZoomOutIcon from '@lucide/svelte/icons/zoom-out';
|
||||
import { onMount } from 'svelte';
|
||||
import DocumentList from './document-list.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { LessonDocument } from '$features/course/utils/types';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
|
||||
interface Props {
|
||||
mode?: (typeof MODES)[keyof typeof MODES];
|
||||
}
|
||||
|
||||
let { mode = MODES.view }: Props = $props();
|
||||
|
||||
let downloadingDocuments = $state(new Set<string>());
|
||||
let openDeleteDocumentModal = $state(false);
|
||||
let documentIndexToDelete = $state<number | null>(null);
|
||||
let viewingPDF: any = $state(null);
|
||||
let pdfViewerOpen = $state(false);
|
||||
let pdfCanvas: HTMLCanvasElement | undefined = $state();
|
||||
let pdfDoc: any = null;
|
||||
let pageNum = $state(1);
|
||||
let pageCount = $state(0);
|
||||
let scale = $state(1.0);
|
||||
let isLoading = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
let pdfjsLib: any = null;
|
||||
let renderTimeout: any = null;
|
||||
let currentRenderTask: any = null;
|
||||
|
||||
onMount(() => {
|
||||
// Load PDF.js dynamically
|
||||
const script = document.createElement('script');
|
||||
script.src = '/js/pdf.js/pdf.min.js';
|
||||
script.onload = () => {
|
||||
pdfjsLib = (window as any).pdfjsLib;
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/pdf.js/pdf.worker.min.js';
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
if (script.parentNode) {
|
||||
script.parentNode.removeChild(script);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
function openDocumentUploadModal() {
|
||||
$lessonDocUpload.isModalOpen = true;
|
||||
}
|
||||
|
||||
function deleteDocument(index: number) {
|
||||
void lessonApi.deleteLessonDocument(index);
|
||||
}
|
||||
|
||||
function requestRemoveDocument(index: number) {
|
||||
documentIndexToDelete = index;
|
||||
openDeleteDocumentModal = true;
|
||||
}
|
||||
|
||||
function confirmRemoveDocument() {
|
||||
if (documentIndexToDelete !== null) {
|
||||
deleteDocument(documentIndexToDelete);
|
||||
documentIndexToDelete = null;
|
||||
}
|
||||
openDeleteDocumentModal = false;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
async function downloadDocument(doc: LessonDocument) {
|
||||
downloadingDocuments.add(doc.name);
|
||||
downloadingDocuments = downloadingDocuments;
|
||||
|
||||
if (!doc.key) {
|
||||
snackbar.error('Document not loaded correctly');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(doc.link);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch document');
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = doc.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Error downloading document:', error);
|
||||
window.open(doc.link, '_blank');
|
||||
} finally {
|
||||
downloadingDocuments.delete(doc.name);
|
||||
downloadingDocuments = downloadingDocuments;
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedRender() {
|
||||
if (renderTimeout) {
|
||||
clearTimeout(renderTimeout);
|
||||
}
|
||||
renderTimeout = setTimeout(() => {
|
||||
renderPage();
|
||||
renderTimeout = null;
|
||||
}, 150); // 150ms debounce
|
||||
}
|
||||
// Wait for PDF.js to load
|
||||
const checkPDFJS = () => {
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
if (pdfjsLib) {
|
||||
resolve(true);
|
||||
} else {
|
||||
setTimeout(check, 100);
|
||||
}
|
||||
};
|
||||
check();
|
||||
});
|
||||
};
|
||||
|
||||
async function viewPDF(document: LessonDocument) {
|
||||
// Wait for PDF.js to be fully loaded
|
||||
if (!pdfjsLib) {
|
||||
// Show loading state while waiting for PDF.js
|
||||
viewingPDF = document;
|
||||
pdfViewerOpen = true;
|
||||
isLoading = true;
|
||||
error = 'Loading PDF viewer...';
|
||||
|
||||
await checkPDFJS();
|
||||
}
|
||||
|
||||
viewingPDF = document;
|
||||
pdfViewerOpen = true;
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
if (!document.key) {
|
||||
snackbar.error('Document not loaded correctly');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(document.link);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch PDF');
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
pdfDoc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
|
||||
pageCount = pdfDoc.numPages;
|
||||
pageNum = 1;
|
||||
scale = 1.0;
|
||||
isLoading = false;
|
||||
|
||||
// Wait for the canvas to be available in the DOM
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await renderPage();
|
||||
} catch (err) {
|
||||
console.error('Error loading PDF:', err);
|
||||
error = 'course.navItem.lessons.materials.tabs.document.failed_to_load_pdf';
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPage() {
|
||||
if (!pdfDoc) return;
|
||||
|
||||
// Wait for canvas to be available
|
||||
let attempts = 0;
|
||||
while (!pdfCanvas && attempts < 50) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (!pdfCanvas) {
|
||||
console.error('Canvas not available after waiting');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Cancel any ongoing render task
|
||||
if (currentRenderTask) {
|
||||
currentRenderTask.cancel();
|
||||
}
|
||||
|
||||
const page = await pdfDoc.getPage(pageNum);
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
pdfCanvas.height = viewport.height;
|
||||
pdfCanvas.width = viewport.width;
|
||||
|
||||
const ctx = pdfCanvas.getContext('2d');
|
||||
const renderContext = {
|
||||
canvasContext: ctx,
|
||||
viewport: viewport
|
||||
};
|
||||
|
||||
currentRenderTask = page.render(renderContext);
|
||||
await currentRenderTask.promise;
|
||||
currentRenderTask = null;
|
||||
} catch (err) {
|
||||
console.error('Error rendering page:', err);
|
||||
error = 'course.navItem.lessons.materials.tabs.document.failed_to_render_pdf';
|
||||
currentRenderTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (pageNum < pageCount) {
|
||||
pageNum++;
|
||||
debouncedRender();
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (pageNum > 1) {
|
||||
pageNum--;
|
||||
debouncedRender();
|
||||
}
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
scale = Math.min(scale + 0.25, 3.0);
|
||||
debouncedRender();
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
scale = Math.max(scale - 0.25, 0.5);
|
||||
debouncedRender();
|
||||
}
|
||||
|
||||
function handleViewPDF(doc: LessonDocument) {
|
||||
viewPDF(doc);
|
||||
}
|
||||
|
||||
function closePDFViewer() {
|
||||
// Cancel any ongoing render task
|
||||
if (currentRenderTask) {
|
||||
currentRenderTask.cancel();
|
||||
}
|
||||
|
||||
// Clear any pending render timeout
|
||||
if (renderTimeout) {
|
||||
clearTimeout(renderTimeout);
|
||||
}
|
||||
|
||||
pdfViewerOpen = false;
|
||||
viewingPDF = null;
|
||||
pdfDoc = null;
|
||||
pageNum = 1;
|
||||
pageCount = 0;
|
||||
scale = 1.0;
|
||||
error = null;
|
||||
currentRenderTask = null;
|
||||
renderTimeout = null;
|
||||
}
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!pdfViewerOpen) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
prevPage();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
nextPage();
|
||||
break;
|
||||
case '+':
|
||||
case '=':
|
||||
zoomIn();
|
||||
break;
|
||||
case '-':
|
||||
zoomOut();
|
||||
break;
|
||||
case 'Escape':
|
||||
closePDFViewer();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
});
|
||||
|
||||
function handleContextMenu(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleDragStart(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
let displayDocuments = $derived(lessonApi.lesson?.documents || []);
|
||||
</script>
|
||||
|
||||
<DocumentList
|
||||
{mode}
|
||||
{displayDocuments}
|
||||
{downloadingDocuments}
|
||||
{formatFileSize}
|
||||
{openDocumentUploadModal}
|
||||
{requestRemoveDocument}
|
||||
onViewPDF={handleViewPDF}
|
||||
{downloadDocument}
|
||||
/>
|
||||
|
||||
<DeleteModal bind:open={openDeleteDocumentModal} onDelete={confirmRemoveDocument} />
|
||||
|
||||
<!-- PDF Viewer Modal -->
|
||||
{#if pdfViewerOpen}
|
||||
<div class="fixed inset-0 z-100 flex flex-col bg-white dark:bg-neutral-800">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-gray-200 bg-white px-4 py-3 dark:bg-neutral-800">
|
||||
<div class="flex items-center space-x-4">
|
||||
<h2 class="max-w-md truncate text-lg font-semibold text-gray-900 dark:text-gray-300">
|
||||
{viewingPDF?.name}
|
||||
</h2>
|
||||
{#if !isLoading && !error}
|
||||
<span class="text-sm text-gray-500">
|
||||
{$t('course.navItem.lessons.materials.tabs.document.page_of', {
|
||||
page: pageNum,
|
||||
total: pageCount
|
||||
})}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- Navigation Controls -->
|
||||
{#if !isLoading && !error}
|
||||
<div class="flex items-center space-x-1">
|
||||
<IconButton onclick={prevPage} disabled={pageNum <= 1} tooltip="Previous page (←)">
|
||||
<ChevronLeftIcon size={16} />
|
||||
</IconButton>
|
||||
|
||||
<IconButton onclick={nextPage} disabled={pageNum >= pageCount} tooltip="Next page (→)">
|
||||
<ChevronRightIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<!-- Zoom Controls -->
|
||||
<div class="flex items-center space-x-1">
|
||||
<IconButton onclick={zoomOut} disabled={scale <= 0.5} tooltip="Zoom out (-)">
|
||||
<ZoomOutIcon size={16} />
|
||||
</IconButton>
|
||||
|
||||
<span class="min-w-12 text-center text-sm text-gray-600">
|
||||
{Math.round(scale * 100)}%
|
||||
</span>
|
||||
|
||||
<IconButton onclick={zoomIn} disabled={scale >= 3.0} tooltip="Zoom in (+)">
|
||||
<ZoomInIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<CloseButton onClick={closePDFViewer} tooltip="Close (Esc)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PDF Content -->
|
||||
<div class="flex-1 overflow-auto bg-gray-100 p-4">
|
||||
{#if isLoading}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent"
|
||||
></div>
|
||||
<p class="text-gray-600">
|
||||
{$t('course.navItem.lessons.materials.tabs.document.loading_pdf')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="mb-4 text-red-500">
|
||||
<svg class="mx-auto h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="mb-2 text-red-600">{$t(error)}</p>
|
||||
<button onclick={() => viewPDF(viewingPDF)} class="text-blue-600 underline hover:text-blue-800">
|
||||
{$t('course.navItem.lessons.materials.tabs.document.try_again')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex justify-center">
|
||||
<canvas
|
||||
bind:this={pdfCanvas}
|
||||
oncontextmenu={handleContextMenu}
|
||||
ondragstart={handleDragStart}
|
||||
class="shadow-lg"
|
||||
></canvas>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer with instructions -->
|
||||
{#if !isLoading && !error}
|
||||
<div class="border-t border-gray-200 bg-gray-50 px-4 py-2">
|
||||
<p class="text-center text-xs text-gray-500">
|
||||
{$t('course.navItem.lessons.materials.tabs.document.use_arrow_keys')}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Disable text selection on the canvas */
|
||||
canvas {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
/* Disable drag and drop */
|
||||
canvas {
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
export { default as ContentNavigationActions } from './content-navigation-actions.svelte';
|
||||
export { default as LanguageSelector } from './language-selector.svelte';
|
||||
export { default as LessonPageEditHeader } from './lesson-page-edit-header.svelte';
|
||||
export { default as Loader } from './loader.svelte';
|
||||
export { default as Comments } from './comments.svelte';
|
||||
export { default as Note } from './note/note.svelte';
|
||||
export { default as Slide } from './slide/slide.svelte';
|
||||
export { default as Video } from './video/video.svelte';
|
||||
export { default as Document } from './document/document.svelte';
|
||||
export { default as AddVideoModal } from './video/add-video-modal.svelte';
|
||||
export { default as AddDocumentModal } from './document/add-document-modal.svelte';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user