feat: complete rewrite of course (#622)

* theming improvements

* border style improvements

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

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

* refactor: clean up course and community components

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

* refactor: fix video formatting on website and other styles

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

* more icon improvements

* refactor: streamline course and dashboard components

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

* refactor: enhance lesson completion logic

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

move all emails to the backend and

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

* get app loading

* fix ui and build error of cio/api

* refactor: move direct database queries to query layer

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

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

* exercise saving improvements

* fix and optimise exercise creation

* refactor: update dialog components for improved styling and functionality

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

* refactor: enhance course and exercise management components

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

* feat: implement course content management API and UI components

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

* refactor: remove Supabase references and implement course section management

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

* chore: update .prettierignore to exclude additional file types

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

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

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

* feat: enhance course content management and UI components

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

* student + teacher invite (not fully tested)

* refactor: streamline course team member middleware

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

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

* feat: implement unified course enrollment route and API

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

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

* chore: make prettier check ignore unknown file types

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

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

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

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

* chore: enhance Docker setup and documentation for production deployment

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

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

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

* fix: multiple refresh issue

* fix: server key not passed to api

* fix: clarify usage of PRIVATE_SERVER_KEY and AUTH_BEARER_TOKEN in documentation

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

* fix migration script

* fix: improve language handling and add database reset script

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

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

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

* chore: use root env file for docker compose

* docs: document docker no-build startup flow

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

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

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

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

* fix(email): make sender configurable via SMTP_SENDER

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

* chore(config): switch api to PUBLIC_SERVER_URL

* fix(i18n): persist selected locale across navigation

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

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

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

* debug self hosted layout setup

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

* feat: tags

* chore: tags roadmap + translations

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

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

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

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

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

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

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

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

* feat: modularize pricing card

* feat: initia implementation of pricing togge

* fix: minor amendment

* refactor: update pricing components for improved layout and styling

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

* add handle click

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

* fix(api): enable orgMemberMiddleware for stats route

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

---------

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

* pass query parameter to signup from pricing page

* ui updates to pricing cards

* commit all PRDs

* add open api spec to api prd

* make blue default theme

* more prds

* sync option id sequence on question update

* docs: more classroomio features

* chore: more ui improvements

* docs: new component docs

* chore: CSP Configuration (#638)

* feat(validation): add GitHub repository name validation

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

Fixes #637

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

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

This reverts commit 3436536bb17a0f05b3a9fe8dd7682cde64dd1dce.

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

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

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

Fixes #637

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

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

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

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

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

* docs: add CSP ALLOWED_EXTERNAL_DOMAINS to self-hosting guides

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

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

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

---------

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

* feat: add SSO and token authentication routes and services

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

* update translations

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

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

* feat: new exercise question types

* MinIO S3 storage backend (#639)

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

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

Fixes #635

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

* chore: unify exercise answer data format

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

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

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

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

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

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

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

* feat: get rid of carbon completely

* chore: license verification happens only for selfhosted instance

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

* docs: how to write math in cio editor

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

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

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

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

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

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

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

* more selfhosting improvements

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

* pr reviews

* fix: correct query parameter handling in course navigation

* pr reviews

---------

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
Co-authored-by: Abdulmuizz Hamzat <103611443+buildwithhamzat@users.noreply.github.com>
Co-authored-by: Adeyemi Adetunji <tvnji01@gmail.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Rotimi Best
2026-03-09 11:18:22 +01:00
committed by GitHub
co-authored by Rotimi Best greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Cursor Agent Abdulmuizz Hamzat Adeyemi Adetunji
parent a42565ad82
commit 1e9a82e912
1196 changed files with 118809 additions and 74705 deletions
@@ -0,0 +1,54 @@
<script lang="ts">
import ListTodoIcon from '@lucide/svelte/icons/list-todo';
import ListChecksIcon from '@lucide/svelte/icons/list-checks';
import NotepadTextIcon from '@lucide/svelte/icons/notepad-text';
import * as Popover from '@cio/ui/base/popover';
import WandSparklesIcon from '@lucide/svelte/icons/wand-sparkles';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
interface Props {
isLoading?: SvelteStore<boolean>;
callAI?: (type: string) => void;
}
let { isLoading, callAI = () => {} }: Props = $props();
let openPopover = $state<boolean>(false);
let aiButtonClass =
'flex items-center px-5 py-2 border border-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md w-full mb-2';
</script>
<!-- Update this when ai-sdk is updated -->
<div class="hidden flex-row-reverse">
<Popover.Root>
<Popover.Trigger>
<Button
variant="outline"
onclick={() => {
openPopover = !openPopover;
}}
loading={$isLoading}
disabled={$isLoading}
>
<WandSparklesIcon size={16} />
AI
</Button>
</Popover.Trigger>
<Popover.Content align="start" class="w-80">
<div class="p-2">
<button class={aiButtonClass} onclick={() => callAI('outline')}>
<ListChecksIcon size={16} />
{$t('course.navItem.lessons.materials.tabs.note.ai.outline')}
</button>
<button class={aiButtonClass} onclick={() => callAI('note')}>
<NotepadTextIcon size={16} />
{$t('course.navItem.lessons.materials.tabs.note.ai.note')}
</button>
<button class={aiButtonClass} onclick={() => callAI('activities')}>
<ListTodoIcon size={16} />
{$t('course.navItem.lessons.materials.tabs.note.ai.activities')}
</button>
</div>
</Popover.Content>
</Popover.Root>
</div>
@@ -0,0 +1,208 @@
<script lang="ts">
import { untrack } from 'svelte';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import * as Avatar from '@cio/ui/base/avatar';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { shortenName } from '$lib/utils/functions/string';
import { DeleteModal } from '$features/ui';
import { Button } from '@cio/ui/base/button';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import { calDateDiff } from '$lib/utils/functions/date';
import { lessonApi } from '$features/course/api';
import { courseApi } from '$features/course/api';
interface Props {
lessonId: string;
}
let { lessonId }: Props = $props();
let comment = $state('');
let openDeleteModal = $state(false);
let deleteCommentId: number | null = $state(null);
let editCommentId: number | null = $state(null);
const courseId = $derived(courseApi.course?.id ?? '');
const groupmemberId = $derived(courseApi.group.memberId ?? '');
const paginationCount = $derived(lessonApi.commentsByLessonId[lessonId]?.totalCount || 0);
const hasMore = $derived(lessonApi.commentsByLessonId[lessonId]?.hasMore || false);
const isLoadingComments = $derived(lessonApi.commentsByLessonId[lessonId]?.isLoading || false);
const comments = $derived.by(() => {
const commentData = lessonApi.commentsByLessonId[lessonId];
if (!commentData || !commentData.items || !Array.isArray(commentData.items)) {
return [];
}
return commentData.items.map((apiComment) => {
const isCurrentUser = apiComment.groupmember?.id === groupmemberId;
return {
id: apiComment.id,
comment: apiComment.comment,
avatar: apiComment.profile?.avatarUrl ?? 'AV',
commentAt: new Date(apiComment.createdAt),
groupmemberId: apiComment.groupmemberId,
name: isCurrentUser ? $t('course.navItem.lessons.comments.you') : apiComment.profile?.fullname || 'Unknown'
};
});
});
async function handleSend() {
if (!comment) {
return;
}
await lessonApi.createComment(courseId, lessonId, comment);
if (lessonApi.success) {
comment = '';
}
}
async function loadMoreComments() {
if (!lessonId) return;
await lessonApi.loadMoreComments(courseId, lessonId);
}
// Fetch once per lessonId (even if there are 0 comments).
$effect(() => {
if (!courseId || !lessonId) return;
if (lessonApi.commentsByLessonId[lessonId]) return;
untrack(() => {
lessonApi.getComments(courseId, lessonId);
});
});
</script>
<DeleteModal
bind:open={openDeleteModal}
onDelete={() => {
if (!deleteCommentId) return;
lessonApi.deleteComment(courseId, lessonId, String(deleteCommentId));
deleteCommentId = null;
}}
/>
<div class="mx-auto w-full max-w-[65ch]">
<div class="mb-5">
<p class="text-lg capitalize">
{$t('course.navItem.lessons.comments.title')} ({paginationCount})
</p>
</div>
<div>
<div class="flex h-full items-start gap-3">
<Avatar.Root class="mt-2 size-6!">
<Avatar.Image
src={$profile.avatarUrl ? $profile.avatarUrl : '/logo-192.png'}
alt={$profile.fullname ? $profile.fullname : 'User'}
/>
<Avatar.Fallback>{shortenName($profile.fullname) || 'U'}</Avatar.Fallback>
</Avatar.Root>
<div class="h-full w-full">
<TextareaField
label={$t('course.navItem.lessons.comments.text_area_title')}
placeholder={$t('course.navItem.lessons.comments.placeholder')}
bind:value={comment}
/>
</div>
</div>
<div class="mt-2 flex flex-row-reverse">
<Button onclick={handleSend} disabled={!comment || !courseId || !lessonId} loading={lessonApi.isCommenting}>
{$t('course.navItem.lessons.comments.comment_btn')}
</Button>
</div>
</div>
<div class="my-5">
{#each comments as commentItem (commentItem.id)}
<div class="mt-2 flex items-start gap-3 pb-2">
<Avatar.Root class="mt-2 size-6!">
<Avatar.Image
src={commentItem.avatar ? commentItem.avatar : '/logo-192.png'}
alt={commentItem.name ? commentItem.name : 'User'}
/>
<Avatar.Fallback>{shortenName(commentItem.name) || 'U'}</Avatar.Fallback>
</Avatar.Root>
<div class="w-full rounded-md border px-3 py-2 dark:border-neutral-700">
<div class="flex items-center justify-between gap-2">
<p class="text-sm dark:text-white">
{commentItem.name}
<span
class="ml-1 text-xs font-normal text-gray-800 dark:text-white"
title={commentItem.commentAt.toLocaleString()}
>
{calDateDiff(commentItem.commentAt)}
</span>
</p>
{#if courseApi.group.memberId === commentItem.groupmemberId}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="flex h-8 w-8 items-center justify-center rounded-md hover:bg-gray-100 dark:hover:bg-neutral-700"
>
<EllipsisVerticalIcon class="size-4" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => (editCommentId = commentItem.id)}>Edit</DropdownMenu.Item>
<DropdownMenu.Item
class="text-red-600"
onclick={() => {
deleteCommentId = commentItem.id;
openDeleteModal = true;
}}
>
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</div>
{#if editCommentId === commentItem.id}
<TextareaField
placeholder={$t('course.navItem.lessons.comments.placeholder')}
bind:value={commentItem.comment}
/>
<div class="mt-2 flex flex-row-reverse items-center gap-2">
<Button variant="outline" onclick={() => (editCommentId = null)} disabled={lessonApi.isUpdatingComment}>
{$t('course.navItem.lessons.comments.cancel_btn')}
</Button>
<Button
onclick={async () => {
await lessonApi.updateComment(courseId, lessonId, String(editCommentId), commentItem.comment!);
if (lessonApi.success) {
editCommentId = null;
}
}}
disabled={!commentItem.comment}
loading={lessonApi.isUpdatingComment}
>
{$t('course.navItem.lessons.comments.comment_btn')}
</Button>
</div>
{:else}
<article class="prose sm:prose-sm max-w-[300px] text-sm dark:text-white">
{commentItem.comment}
</article>
{/if}
</div>
</div>
{/each}
</div>
{#if hasMore}
<div class="mt-2 flex items-center justify-center">
<Button variant="outline" onclick={loadMoreComments} loading={isLoadingComments}>
{$t('course.navItem.lessons.comments.load_more_btn')}
</Button>
</div>
{/if}
</div>
@@ -0,0 +1,57 @@
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import type { Component } from 'svelte';
import FileTextIcon from '@lucide/svelte/icons/file-text';
import PresentationIcon from '@lucide/svelte/icons/presentation';
import type { Tabs } from '$features/course/utils/types';
import VideoIcon from '@lucide/svelte/icons/video';
interface MaterialTab {
label: string;
icon: Component;
value: number;
badgeValue?: number;
}
export const tabs: MaterialTab[] = [
{
label: 'course.navItem.lessons.materials.tabs.note.title',
icon: BookOpenIcon,
value: 1,
badgeValue: 0
},
{
label: 'course.navItem.lessons.materials.tabs.slide.title',
icon: PresentationIcon,
value: 2,
badgeValue: 0
},
{
label: 'course.navItem.lessons.materials.tabs.video.title',
icon: VideoIcon,
value: 3,
badgeValue: 0
},
{
label: 'course.navItem.lessons.materials.tabs.document.title',
icon: FileTextIcon,
value: 4,
badgeValue: 0
}
];
export function orderedTabs(tabs: MaterialTab[], settingTabs?: Tabs[]) {
if (!Array.isArray(settingTabs)) return tabs;
const reorderedTabs: MaterialTab[] = [];
const tabMap = new Map(tabs.map((tab) => [tab.value, tab]));
settingTabs.forEach((settingTab, index) => {
const tab = tabMap.get(settingTab.id);
if (tab) {
reorderedTabs[index] = tab;
}
});
return reorderedTabs.filter((tab) => tab !== undefined);
}
@@ -0,0 +1,109 @@
import { ContentType } from '@cio/utils/constants/content';
export type ContentActionItem = {
type: ContentType;
contentId: string;
title?: string | null;
isUnlocked?: boolean | null;
sectionId?: string | null;
order?: number | null;
};
type CourseApiErrorMap = Record<string, string> | undefined | null;
type LessonApi = {
update: (courseId: string, lessonId: string, payload: Record<string, unknown>) => Promise<unknown>;
delete: (courseId: string, lessonId: string) => Promise<unknown>;
errors?: CourseApiErrorMap;
success?: boolean;
};
type ExerciseApi = {
update: (courseId: string, exerciseId: string, payload: Record<string, unknown>) => Promise<unknown>;
delete: (courseId: string, exerciseId: string) => Promise<unknown>;
errors?: CourseApiErrorMap;
success?: boolean;
};
function normalizeErrors(errors: CourseApiErrorMap): Record<string, string> {
if (!errors) return {};
const entries = Object.entries(errors).filter(([, value]) => Boolean(value));
return entries.length ? Object.fromEntries(entries) : {};
}
function buildContentPayload(item: ContentActionItem, overrides: Record<string, unknown> = {}) {
const normalizedSectionId = item.sectionId === '' ? null : item.sectionId;
return {
title: item.title || '',
isUnlocked: item.isUnlocked ?? undefined,
sectionId: normalizedSectionId ?? undefined,
order: item.order ?? undefined,
...overrides
};
}
export async function toggleLock({
item,
courseId,
lessonApi,
exerciseApi
}: {
item: ContentActionItem;
courseId: string;
lessonApi: LessonApi;
exerciseApi: ExerciseApi;
}) {
item.isUnlocked = !(item.isUnlocked ?? false);
const payload = buildContentPayload(item, { order: undefined });
if (item.type === ContentType.Exercise) {
await exerciseApi.update(courseId, item.contentId, payload);
return normalizeErrors(exerciseApi.errors);
}
await lessonApi.update(courseId, item.contentId, payload);
return normalizeErrors(lessonApi.errors);
}
export async function saveContent({
item,
courseId,
lessonApi,
exerciseApi
}: {
item: ContentActionItem;
courseId: string;
lessonApi: LessonApi;
exerciseApi: ExerciseApi;
}) {
const payload = buildContentPayload(item);
if (item.type === ContentType.Exercise) {
await exerciseApi.update(courseId, item.contentId, payload);
return normalizeErrors(exerciseApi.errors);
}
await lessonApi.update(courseId, item.contentId, payload);
return normalizeErrors(lessonApi.errors);
}
export async function deleteContent({
item,
courseId,
lessonApi,
exerciseApi
}: {
item: ContentActionItem;
courseId: string;
lessonApi: LessonApi;
exerciseApi: ExerciseApi;
}) {
if (item.type === ContentType.Exercise) {
await exerciseApi.delete(courseId, item.contentId);
return Boolean(exerciseApi.success);
}
await lessonApi.delete(courseId, item.contentId);
return Boolean(lessonApi.success);
}
@@ -0,0 +1,73 @@
<script lang="ts">
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import { Button } from '@cio/ui/base/button';
import { RoleBasedSecurity } from '$features/ui';
import { t } from '$lib/utils/functions/translations';
interface Props {
isEditing?: boolean;
disabled?: boolean;
isSaving?: boolean;
lockLabel?: string;
showLock?: boolean;
showDelete?: boolean;
onEdit: () => void;
onSave: () => void;
onCancel: () => void;
onDelete: () => void;
onToggleLock: () => void;
}
let {
isEditing = false,
disabled = false,
isSaving = false,
lockLabel = '',
showLock = true,
showDelete = true,
onEdit,
onSave,
onCancel,
onDelete,
onToggleLock
}: Props = $props();
</script>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="flex items-center gap-1">
{#if isEditing}
<Button variant="outline" onclick={onCancel} disabled={isSaving}>
{$t('course.navItem.lessons.add_lesson.cancel')}
</Button>
<Button onclick={onSave} loading={isSaving} disabled={isSaving}>
{$t('course.navItem.lessons.add_lesson.save')}
</Button>
{:else}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost" size="icon" class="h-8 w-8" {disabled}>
<EllipsisVerticalIcon class="h-5 w-5" />
<span class="sr-only">Open menu</span>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#if showLock}
<DropdownMenu.Item onclick={onToggleLock} {disabled}>
{lockLabel}
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item onclick={onEdit} {disabled}>
{$t('course.navItem.lessons.add_lesson.edit')}
</DropdownMenu.Item>
{#if showDelete}
<DropdownMenu.Separator />
<DropdownMenu.Item class="text-red-600 focus:text-red-600 dark:text-red-400" onclick={onDelete} {disabled}>
{$t('course.navItem.lessons.add_lesson.delete')}
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</div>
</RoleBasedSecurity>
@@ -0,0 +1,474 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { dndzone } from 'svelte-dnd-action';
import CalendarClockIcon from '@lucide/svelte/icons/calendar-clock';
import FileTextIcon from '@lucide/svelte/icons/file-text';
import HelpCircleIcon from '@lucide/svelte/icons/help-circle';
import LockIcon from '@lucide/svelte/icons/lock';
import { Spinner } from '@cio/ui/base/spinner';
import UnlockIcon from '@lucide/svelte/icons/lock-open';
import VideoIcon from '@lucide/svelte/icons/video';
import { HoverableItem, AttachmentIcon } from '@cio/ui/custom/moving-icons';
import { Button } from '@cio/ui/base/button';
import { Chip } from '@cio/ui/custom/chip';
import { lessonApi, exerciseApi, courseApi, contentApi } from '$features/course/api';
import { globalStore } from '$lib/utils/store/app';
import formatDate from '$lib/utils/functions/formatDate';
import { t } from '$lib/utils/functions/translations';
import type { CourseContentItem } from '$features/course/utils/types';
import { getCourseContent } from '$features/course/utils/content';
import { ContentType } from '@cio/utils/constants/content';
import { profile } from '$lib/utils/store/user';
import { contentEditingStore } from '$features/course/components/content/store';
import DeleteLessonConfirmation from '$features/course/components/lesson/delete-lesson-confirmation.svelte';
import ContentRow from './content-row.svelte';
import ContentActions from './content-actions.svelte';
import { deleteContent, saveContent, toggleLock } from './content-action-helpers';
const flipDurationMs = 300;
interface Props {
reorder?: boolean;
}
let { reorder = false }: Props = $props();
let prevTitle: string | undefined = $state();
let errors: Record<string, string> = $state({});
let lessonToDelete: CourseContentItem | undefined = $state();
let openDeleteModal = $state(false);
let lockPendingItemKeys = $state<Record<string, boolean>>({});
let courseId = $derived(courseApi.course?.id || '');
const profileId = $derived($profile?.id || '');
const contentData = $derived(getCourseContent(courseApi.course));
const isStudentView = $derived($globalStore.isStudent === true);
const isLiveCourse = $derived(courseApi.course?.type === 'LIVE_CLASS');
const isSelfPacedCourse = $derived(courseApi.course?.type === 'SELF_PACED');
const metaChipClass =
'inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-1 text-[10px] text-gray-700 dark:bg-neutral-700 dark:text-gray-200';
type ContentDndItem = CourseContentItem & {
id: string;
contentId: string;
isUnlocked?: boolean | null;
isComplete?: boolean | null;
};
type DndEvent = CustomEvent<{ items: ContentDndItem[] }>;
let contentItems = $state<ContentDndItem[]>([]);
function buildContentItems(): ContentDndItem[] {
return (contentData.items || []).map((item) => ({
...item,
id: `${item.type}-${item.id}`,
contentId: item.id
}));
}
function syncCourseContentItems(items: ContentDndItem[]) {
if (!courseApi.course?.content || courseApi.course.content.grouped) return;
const updatedItems = items.map(({ contentId, id: _dndId, ...rest }, index) => ({
...rest,
id: contentId,
order: index + 1
}));
courseApi.course.content = {
...courseApi.course.content,
items: updatedItems
};
}
function normalizeSectionId(value?: string | null) {
return value ? value : null;
}
async function applyPendingReorder() {
if (!contentItems.length) return;
const contentUpdates: Array<{
id: string;
type: ContentType.Lesson | ContentType.Exercise;
order: number;
sectionId?: string | null;
}> = [];
const updatedItems = contentItems.map((item, index) => {
const order = index + 1;
if (item.type === ContentType.Lesson || item.type === ContentType.Exercise) {
if (item.order !== order) {
contentUpdates.push({
id: item.contentId,
type: item.type,
order,
sectionId: normalizeSectionId(item.sectionId)
});
}
}
return {
...item,
order
};
});
contentItems = updatedItems;
syncCourseContentItems(updatedItems);
if (contentUpdates.length > 0) {
await contentApi.updateContent(courseId, contentUpdates);
}
}
let previousReorder = $state(false);
$effect(() => {
if (previousReorder && !reorder) {
void applyPendingReorder();
}
previousReorder = reorder;
if (!reorder || contentItems.length === 0) {
contentItems = buildContentItems();
}
});
async function saveLesson(item: ContentDndItem) {
const nextErrors = await saveContent({
item,
courseId,
lessonApi,
exerciseApi
});
errors = nextErrors;
if (Object.keys(nextErrors).length === 0) {
resetEdit();
syncCourseContentItems(contentItems);
}
}
function resetEdit() {
contentEditingStore.set(undefined);
prevTitle = undefined;
errors = {};
}
function handleDndConsider(e: DndEvent) {
contentItems = e.detail.items;
syncCourseContentItems(contentItems);
}
function handleDndFinalize(e: DndEvent) {
const items = e.detail.items;
contentItems = items;
syncCourseContentItems(items);
if (!reorder) {
contentItems = buildContentItems();
}
}
function getContentOrder(contentId: string, type: ContentType): number | string {
const index = contentItems.findIndex((item) => item.type === type && item.contentId === contentId);
if (index < 9) {
return '0' + (index + 1);
}
return index + 1;
}
function getItemPath(item: ContentDndItem) {
if (item.type === ContentType.Lesson) {
return `/courses/${courseApi.course?.id}/lessons/${item.contentId}`;
}
return `/courses/${courseApi.course?.id}/exercises/${item.contentId}`;
}
function formatMetaDate(value?: string | null): string {
if (!value) return '';
return formatDate(value);
}
function isLockedForStudent(item: ContentDndItem, isStudent: boolean) {
return isStudent && (item.isUnlocked ?? true) === false;
}
function getItemLockKey(item: ContentDndItem) {
return `${item.type}-${item.contentId}`;
}
async function handleToggleItemLock(item: ContentDndItem) {
const lockKey = getItemLockKey(item);
if (lockPendingItemKeys[lockKey]) return;
lockPendingItemKeys = {
...lockPendingItemKeys,
[lockKey]: true
};
try {
const lockErrors = await toggleLock({
item,
courseId,
lessonApi,
exerciseApi
});
errors = lockErrors;
if (Object.keys(lockErrors).length === 0) {
syncCourseContentItems(contentItems);
}
} finally {
const nextPending = { ...lockPendingItemKeys };
delete nextPending[lockKey];
lockPendingItemKeys = nextPending;
}
}
</script>
<DeleteLessonConfirmation
bind:openDeleteModal
deleteLesson={async () => {
if (!lessonToDelete?.id) return;
const activeCourseId = courseApi.course?.id;
if (!activeCourseId) return;
const wasDeleted = await deleteContent({
item: {
type: lessonToDelete.type,
contentId: lessonToDelete.id,
title: lessonToDelete.title,
isUnlocked: lessonToDelete.isUnlocked,
sectionId: lessonToDelete.sectionId,
order: lessonToDelete.order
},
courseId: activeCourseId,
lessonApi,
exerciseApi
});
if (wasDeleted && profileId) {
await courseApi.refreshCourse(activeCourseId, profileId);
}
}}
/>
<section
class="mx-auto w-full p-3 lg:w-11/12 lg:px-4 {reorder ? 'cursor-grab active:cursor-grabbing' : ''}"
use:dndzone={{
items: contentItems,
flipDurationMs,
dragDisabled: !reorder,
dropTargetStyle: {
border: '2px #1d4ed8 solid',
'border-style': 'dashed'
}
}}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
>
{#each contentItems as item (item.id)}
{@const isEditingItem = $contentEditingStore === item.contentId}
{@const itemLocked = isLockedForStudent(item, isStudentView)}
{@const lockLabel = item.isUnlocked
? $t('course.navItem.lessons.add_lesson.lock')
: $t('course.navItem.lessons.add_lesson.unlock')}
{@const canRenderJoinButton =
item.type === ContentType.Lesson && isLiveCourse && Boolean(item.callUrl) && !itemLocked}
{@const canRenderContinueButton = isStudentView && !itemLocked}
{@const isLockPending = Boolean(lockPendingItemKeys[getItemLockKey(item)])}
{@const actionDisabled = Boolean($contentEditingStore && !isEditingItem)}
<div
class="relative m-auto mb-4 flex max-w-xl items-start rounded-md border border-gray-200 px-3 py-2 transition dark:bg-neutral-800 {reorder
? 'border-primary-300 bg-primary-50/40 cursor-grab shadow-sm active:cursor-grabbing'
: ''}"
>
<div class="mt-0.5 mr-5">
<Chip value={getContentOrder(item.contentId, item.type)} />
</div>
{#if item.type === ContentType.Lesson}
<ContentRow
{item}
bind:title={item.title}
isLocked={itemLocked}
href={resolve(itemLocked ? page.url.pathname : getItemPath(item), {})}
isEditing={isEditingItem}
{errors}
autoFocus={isEditingItem}
>
{#snippet meta()}
{@const hasVideos = (item.videosCount ?? 0) > 0}
{@const hasDocuments = (item.documentsCount ?? 0) > 0}
{@const hasNote = item.hasNoteContent === true}
{@const hasSlide = item.hasSlideContent === true}
{@const hasAnyMaterials = hasVideos || hasDocuments || hasNote || hasSlide}
<div class="mt-2 flex flex-wrap items-center gap-1.5">
{#if hasVideos}
<span class={metaChipClass}>
<VideoIcon size={12} />
{item.videosCount}
</span>
{/if}
{#if hasDocuments}
<span class={metaChipClass}>
<FileTextIcon size={12} />
{item.documentsCount}
</span>
{/if}
{#if hasNote}
<span class={metaChipClass}>
<FileTextIcon size={12} />
{$t('course.navItem.lessons.materials.tabs.note.title')}
</span>
{/if}
{#if hasSlide}
<span class={metaChipClass}>
<FileTextIcon size={12} />
{$t('course.navItem.lessons.materials.tabs.slide.title')}
</span>
{/if}
{#if !hasAnyMaterials}
<HoverableItem>
{#snippet children(isHovered)}
<span class={metaChipClass}>
<AttachmentIcon size={11} {isHovered} />
{$t('course.navItem.lessons.materials.no_content')}
</span>
{/snippet}
</HoverableItem>
{/if}
{#if isLiveCourse && item.lessonAt}
<span class={metaChipClass}>
<CalendarClockIcon size={12} />
{formatMetaDate(item.lessonAt)}
</span>
{/if}
{#if isStudentView}
<span class={metaChipClass}>
{item.isComplete ? $t('course.navItem.lessons.complete') : $t('course.navItem.lessons.incomplete')}
</span>
{/if}
</div>
{/snippet}
</ContentRow>
{:else if item.type === ContentType.Exercise}
<ContentRow
{item}
bind:title={item.title}
isLocked={itemLocked}
href={resolve(itemLocked ? page.url.pathname : getItemPath(item), {})}
isEditing={isEditingItem}
{errors}
autoFocus={isEditingItem}
>
{#snippet meta()}
<div class="mt-2 flex flex-wrap items-center gap-1.5">
{#if !isStudentView && item.questionCount !== null}
<span class={metaChipClass}>
<HelpCircleIcon size={12} />
{item.questionCount}
{$t('course.navItem.lessons.exercises.all_exercises.questions')}
</span>
{/if}
{#if isSelfPacedCourse && item.dueBy}
<span class={metaChipClass}>
<CalendarClockIcon size={12} />
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.due')}: {formatMetaDate(item.dueBy)}
</span>
{/if}
</div>
{/snippet}
</ContentRow>
{/if}
<div class="ml-3 flex min-w-fit items-start gap-2">
{#if isStudentView}
<span class="mt-1 inline-flex items-center text-gray-500 dark:text-gray-300">
{#if itemLocked}
<LockIcon size={16} />
{:else}
<UnlockIcon size={16} />
{/if}
</span>
{:else}
<Button
size="icon-sm"
variant="outline"
class="h-8 w-8 text-gray-600"
onclick={() => handleToggleItemLock(item)}
disabled={actionDisabled || isEditingItem || isLockPending}
aria-label={lockLabel}
title={lockLabel}
>
{#if isLockPending}
<Spinner class="h-5 w-5" />
{:else if item.isUnlocked}
<UnlockIcon class="h-5 w-5" />
{:else}
<LockIcon class="h-5 w-5" />
{/if}
</Button>
{/if}
{#if !isStudentView && canRenderJoinButton}
<a href={item.callUrl!} target="_blank" rel="noreferrer" class="mt-0.5">
<Button size="sm" variant="outline">{$t('schedule.join')}</Button>
</a>
{/if}
{#if isStudentView && canRenderContinueButton && !isEditingItem}
{#if canRenderJoinButton}
<a href={item.callUrl!} target="_blank" rel="noreferrer">
<Button size="sm">{$t('schedule.join')}</Button>
</a>
{:else}
<a href={resolve(getItemPath(item), {})}>
<Button size="sm" variant="outline">{$t('dashboard.continue')}</Button>
</a>
{/if}
{/if}
{#if !isStudentView}
<ContentActions
isEditing={isEditingItem}
disabled={actionDisabled}
{lockLabel}
showLock={false}
onEdit={() => {
contentEditingStore.set(item.contentId);
prevTitle = item.title;
}}
onSave={() => saveLesson(item)}
onCancel={() => {
item.title = prevTitle ?? item.title;
resetEdit();
}}
onDelete={() => {
lessonToDelete = item;
openDeleteModal = true;
}}
onToggleLock={() => handleToggleItemLock(item)}
/>
{/if}
</div>
</div>
{:else}
<p class="text-center text-sm text-gray-500 dark:text-white">{$t('course.navItem.lessons.no_lesson')}</p>
{/each}
</section>
@@ -0,0 +1,157 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { CircleCheckIcon } from '$features/ui/icons';
import { Button } from '@cio/ui/base/button';
import { globalStore } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import { courseApi, lessonApi } from '$features/course/api';
import { getOrderedNavigableContent, getContentRoute } from '$features/course/utils/content';
import { ContentType } from '@cio/utils/constants/content';
import { snackbar } from '$features/ui/snackbar/store';
import type { CourseContentItem } from '$features/course/utils/types';
interface Props {
lessonId?: string;
courseId: string;
/** When on an exercise page, pass this to show prev/next content in content order (no mark-complete). */
exerciseId?: string;
}
let { lessonId, courseId, exerciseId }: Props = $props();
let isMarkingComplete = $state(false);
const navigableContentItems = $derived(getOrderedNavigableContent(courseApi.course));
const lessonItems = $derived(navigableContentItems.filter((item) => item.type === ContentType.Lesson));
/** Prev/next content items in full content order (immediate neighbors; locked content is hidden in the UI so we don't skip). */
const prevNextContent = $derived.by(() => {
const currentId = lessonId ?? exerciseId;
const currentType = lessonId ? ContentType.Lesson : ContentType.Exercise;
if (!currentId) return { prev: null, next: null };
const idx = navigableContentItems.findIndex((item) => item.type === currentType && item.id === currentId);
if (idx < 0) return { prev: null, next: null };
return {
prev: navigableContentItems[idx - 1] ?? null,
next: navigableContentItems[idx + 1] ?? null
};
});
function goToContent(target: CourseContentItem | null) {
if (!target) return;
const courseIdResolved = courseApi.course?.id;
if (!courseIdResolved) return;
const path = getContentRoute(courseIdResolved, target);
if (!path) return;
goto(resolve(path, {}));
}
const isPrevDisabled = $derived(!prevNextContent.prev);
const isNextDisabled = $derived(!prevNextContent.next);
const isLessonComplete = $derived.by(() => {
if (!lessonId) return false;
const lesson = lessonItems.find((l) => l.id === lessonId);
return lesson?.isComplete ?? false;
});
const showMarkComplete = $derived(!!lessonId && !exerciseId);
const currentLessonItem = $derived(lessonId ? lessonItems.find((l) => l.id === lessonId) : null);
const isLessonLocked = $derived(
$globalStore.isStudent && currentLessonItem && !(currentLessonItem.isUnlocked ?? false)
);
const isMarkCompleteDisabled = $derived(isMarkingComplete || isLessonLocked);
async function markLessonComplete(currentLessonId: string) {
isMarkingComplete = true;
const lesson = lessonItems.find((entry) => entry.id === currentLessonId);
const currentIsComplete = lesson?.isComplete ?? lessonApi.lesson?.isComplete ?? false;
const isComplete = !currentIsComplete;
await lessonApi.updateCompletion(courseId, currentLessonId, isComplete);
if (lessonApi.success) {
updateCourseContentCompletion(currentLessonId, isComplete);
snackbar.success('snackbar.lessons.success.complete_marked');
} else {
snackbar.error('snackbar.lessons.error.try_later');
}
isMarkingComplete = false;
}
function updateCourseContentCompletion(currentLessonId: string, isComplete: boolean) {
if (!courseApi.course?.content) return;
if (courseApi.course.content.grouped) {
courseApi.course = {
...courseApi.course,
content: {
...courseApi.course.content,
sections: courseApi.course.content.sections.map((section) => ({
...section,
items: section.items.map((item) =>
item.type === ContentType.Lesson && item.id === currentLessonId ? { ...item, isComplete } : item
)
}))
}
};
return;
}
courseApi.course = {
...courseApi.course,
content: {
...courseApi.course.content,
items: courseApi.course.content.items.map((item) =>
item.type === ContentType.Lesson && item.id === currentLessonId ? { ...item, isComplete } : item
)
}
};
}
</script>
<div class="flex items-center gap-2">
{#if showMarkComplete && lessonId}
<Button
size="sm"
variant="secondary"
onclick={() => markLessonComplete(lessonId)}
loading={isMarkingComplete}
disabled={isMarkCompleteDisabled}
>
<CircleCheckIcon size={14} filled={isLessonComplete} />
<span class="text-xs">{$t('course.navItem.lessons.mark_as')} {$t('course.navItem.lessons.complete')}</span>
</Button>
{/if}
<div class="flex items-center gap-1">
<Button
size="icon-sm"
variant="outline"
onclick={() => goToContent(prevNextContent.prev)}
disabled={isPrevDisabled}
aria-label={$t('course.navItem.lessons.prev')}
title={$t('course.navItem.lessons.prev')}
>
<ChevronLeftIcon size={14} />
</Button>
<Button
size="icon-sm"
variant="outline"
onclick={() => goToContent(prevNextContent.next)}
disabled={isNextDisabled}
aria-label={$t('course.navItem.lessons.next')}
title={$t('course.navItem.lessons.next')}
>
<ChevronRightIcon size={14} />
</Button>
</div>
</div>
@@ -0,0 +1,90 @@
<script lang="ts">
import { resolve } from '$app/paths';
import LockIcon from '@lucide/svelte/icons/lock';
import { InputField } from '@cio/ui/custom/input-field';
import { CircleCheckIcon } from '$features/ui/icons';
import type { Snippet } from 'svelte';
import CourseContentIcon from '$features/course/components/course-content-icon.svelte';
import type { CourseContentItem } from '$features/course/utils/types';
import { cn } from '@cio/ui/tools';
interface Props {
item: CourseContentItem;
title: string | null | undefined;
href: string;
isEditing?: boolean;
errors?: Record<string, string>;
isLocked?: boolean;
isComplete?: boolean | null;
showStatus?: boolean;
showIcon?: boolean;
iconSize?: number;
containerClass?: string;
rowClass?: string;
linkClass?: string;
inputClass?: string;
titleClass?: string;
autoFocus?: boolean;
preloadOff?: boolean;
inline?: Snippet;
meta?: Snippet;
}
let {
item,
title = $bindable(),
href,
isEditing = false,
errors = {},
isLocked = false,
isComplete = null,
showStatus = false,
showIcon = true,
iconSize = 14,
containerClass = '',
rowClass = '',
linkClass = '',
inputClass = '',
titleClass = '',
autoFocus = false,
preloadOff = false,
inline,
meta
}: Props = $props();
</script>
{#if isEditing}
<InputField className={cn('w-4/6', inputClass)} bind:value={title} errorMessage={errors?.title} {autoFocus} />
{:else}
<div class={cn('w-4/5', containerClass)}>
<div class={cn('flex items-center gap-2', rowClass)}>
{#if showIcon}
<CourseContentIcon type={item.type} size={iconSize} />
{/if}
<a
href={resolve(href, {})}
class={cn(
'flex-1 truncate text-sm text-black hover:underline dark:text-white',
isLocked ? 'cursor-not-allowed opacity-50' : linkClass
)}
data-sveltekit-preload-data={preloadOff ? 'off' : undefined}
>
<span class={titleClass}>{title}</span>
</a>
{#if showStatus}
{#if isLocked}
<LockIcon size={iconSize} class="shrink-0" />
{:else if isComplete}
<span class="shrink-0">
<CircleCheckIcon size={iconSize} filled />
</span>
{/if}
{/if}
{@render inline?.()}
</div>
{@render meta?.()}
</div>
{/if}
@@ -0,0 +1,698 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { flip } from 'svelte/animate';
import { dndzone } from 'svelte-dnd-action';
import CalendarClockIcon from '@lucide/svelte/icons/calendar-clock';
import FileTextIcon from '@lucide/svelte/icons/file-text';
import HelpCircleIcon from '@lucide/svelte/icons/help-circle';
import LockIcon from '@lucide/svelte/icons/lock';
import { Spinner } from '@cio/ui/base/spinner';
import UnlockIcon from '@lucide/svelte/icons/lock-open';
import VideoIcon from '@lucide/svelte/icons/video';
import { Button } from '@cio/ui/base/button';
import { HoverableItem, AttachmentIcon } from '@cio/ui/custom/moving-icons';
import formatDate from '$lib/utils/functions/formatDate';
import { t } from '$lib/utils/functions/translations';
import { globalStore } from '$lib/utils/store/app';
import { contentApi, courseApi, exerciseApi, lessonApi } from '$features/course/api';
import { snackbar } from '$features/ui/snackbar/store';
import { profile } from '$lib/utils/store/user';
import DeleteLessonConfirmation from '$features/course/components/lesson/delete-lesson-confirmation.svelte';
import type { CourseContent, CourseContentItem } from '$features/course/utils/types';
import { contentCreateStoreUtils, contentEditingStore } from '$features/course/components/content/store';
import { getCourseContent } from '$features/course/utils/content';
import { ContentType } from '@cio/utils/constants/content';
import ContentRow from './content-row.svelte';
import ContentActions from './content-actions.svelte';
import SectionHeader from './section-header.svelte';
import { deleteContent, saveContent, toggleLock } from './content-action-helpers';
type CrudParam = {
sectionId?: string;
lessonId?: string;
sectionTitle?: string;
lessonTitle?: string;
};
interface Props {
reorder?: boolean;
}
let { reorder = false }: Props = $props();
let prevTitle: string | undefined = $state();
let errors: Record<string, string> = $state({});
let openDeleteModal = $state(false);
let lockPendingItemKeys = $state<Record<string, boolean>>({});
let deletingData = {
id: '',
isSection: false
};
const courseId = $derived(courseApi.course?.id || '');
const profileId = $derived($profile?.id || '');
const contentData = $derived(getCourseContent(courseApi.course));
const isStudentView = $derived($globalStore.isStudent === true);
const isLiveCourse = $derived(courseApi.course?.type === 'LIVE_CLASS');
const isSelfPacedCourse = $derived(courseApi.course?.type === 'SELF_PACED');
const metaChipClass =
'inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-1 text-[10px] text-gray-700 dark:bg-neutral-700 dark:text-gray-200';
type ContentDndItem = CourseContentItem & {
id: string;
contentId: string;
isUnlocked?: boolean | null;
isComplete?: boolean | null;
};
type ContentSection = CourseContent['sections'][number];
type SectionDnd = {
id: string;
title: string;
order: number | null;
items: ContentDndItem[];
};
let sections = $state<SectionDnd[]>([]);
function resolveErrors(apiErrors?: Record<string, string> | null) {
if (!apiErrors) return {};
const entries = Object.entries(apiErrors).filter(([, value]) => Boolean(value));
return entries.length ? Object.fromEntries(entries) : {};
}
function buildSectionItems(section: ContentSection): ContentDndItem[] {
return section.items.map((item) => ({
...item,
id: `${item.type}-${item.id}`,
contentId: item.id
}));
}
function syncSections() {
sections = (contentData.sections || []).map((section) => ({
...section,
items: buildSectionItems(section)
}));
}
function toCourseContentItems(items: ContentDndItem[]): CourseContentItem[] {
return items.map(({ contentId, id: _dndId, ...rest }) => ({
...rest,
id: contentId
}));
}
function syncCourseContentSections(updatedSections: SectionDnd[]) {
if (!courseApi.course?.content) return;
courseApi.course.content = {
...courseApi.course.content,
sections: updatedSections.map((section) => ({
...section,
items: toCourseContentItems(section.items),
order: section.order ?? null
}))
};
}
function updateSectionItems(sectionId: string, items: ContentDndItem[]) {
sections = sections.map((section) => (section.id === sectionId ? { ...section, items } : section));
}
function normalizeSectionId(value?: string | null) {
return value && value !== 'ungrouped' ? value : null;
}
async function applyPendingReorder() {
if (!sections.length) return;
const contentUpdates: Array<{
id: string;
type: ContentType.Lesson | ContentType.Exercise;
order: number;
sectionId?: string | null;
}> = [];
const updatedSections = sections.map((section, sectionIndex) => {
const normalizedSectionId = normalizeSectionId(section.id);
const updatedItems = section.items.map((item, index) => {
const order = index + 1;
if (item.type === ContentType.Lesson || item.type === ContentType.Exercise) {
if (item.order !== order || item.sectionId !== normalizedSectionId) {
contentUpdates.push({
id: item.contentId,
type: item.type,
order,
sectionId: normalizedSectionId
});
}
}
return {
...item,
order,
sectionId: normalizedSectionId
};
});
return {
...section,
order: sectionIndex + 1,
items: updatedItems
};
});
sections = updatedSections;
syncCourseContentSections(updatedSections);
const sectionsToReorder = updatedSections
.filter((section) => section.id !== 'ungrouped')
.map((section) => ({ id: section.id, order: section.order ?? 0 }));
if (sectionsToReorder.length > 0) {
await lessonApi.reorderSections(courseId, sectionsToReorder);
}
if (contentUpdates.length > 0) {
await contentApi.updateContent(courseId, contentUpdates);
}
}
let previousReorder = $state(false);
$effect(() => {
if (previousReorder && !reorder) {
void applyPendingReorder();
}
previousReorder = reorder;
if (!reorder || sections.length === 0) {
syncSections();
}
});
$effect(() => {
const activeEditId = $contentEditingStore;
if (!activeEditId) return;
const hasMatchingSection = sections.some((section) => section.id === activeEditId);
const hasMatchingItem = sections.some((section) => section.items.some((item) => item.contentId === activeEditId));
if (!hasMatchingSection && !hasMatchingItem) {
contentEditingStore.set(undefined);
}
});
function onEdit(params: CrudParam) {
contentEditingStore.set(params.sectionId || params.lessonId);
prevTitle = params.sectionTitle || params.lessonTitle;
}
async function onSave(params: CrudParam, item?: ContentDndItem) {
let nextErrors: Record<string, string> = {};
let shouldRefreshSections = false;
if (params.sectionId) {
const section = sections.find((entry) => entry.id === params.sectionId);
if (!section) return;
if (section.id === 'ungrouped') {
await lessonApi.promoteUngroupedSection(courseId, {
title: section.title!
});
shouldRefreshSections = true;
} else {
await lessonApi.updateSection(courseId, section.id, {
title: section.title!
});
}
nextErrors = resolveErrors(lessonApi.errors);
} else if (item) {
nextErrors = await saveContent({
item,
courseId,
lessonApi,
exerciseApi
});
}
errors = nextErrors;
if (Object.keys(nextErrors).length === 0) {
resetEdit();
if (shouldRefreshSections) {
if (profileId) {
await courseApi.refreshCourse(courseId, profileId);
}
syncSections();
} else {
syncCourseContentSections(sections);
}
// Refresh course data to get updated information (especially for exercises)
if (item?.type === ContentType.Exercise && exerciseApi.success && profileId) {
await courseApi.refreshCourse(courseId, profileId);
}
// Note: exerciseApi.update already shows a success snackbar, so we don't need to show it again
if (item?.type !== ContentType.Exercise) {
snackbar.success('snackbar.success_update');
}
}
}
function onDelete(params: CrudParam) {
deletingData.id = params.sectionId || params.lessonId || '';
deletingData.isSection = params.sectionId ? true : false;
openDeleteModal = true;
}
function removeSection(sectionId: string) {
sections = sections.filter((section) => section.id !== sectionId);
syncCourseContentSections(sections);
}
async function deleteLesson() {
if (deletingData.isSection) {
const wasDeleted = await contentApi.deleteContent(courseId, { sectionId: deletingData.id });
if (wasDeleted) {
removeSection(deletingData.id);
}
return;
}
const item = sections.flatMap((section) => section.items).find((entry) => entry.contentId === deletingData.id);
if (!item) return;
const wasDeleted = await deleteContent({
item,
courseId,
lessonApi,
exerciseApi
});
if (wasDeleted && profileId) {
await courseApi.refreshCourse(courseId, profileId);
}
}
async function toggleSectionLock(sectionId: string) {
const section = sections.find((entry) => entry.id === sectionId);
if (!section) return;
const shouldLock = section.items.some((item) => item.isUnlocked ?? false);
const nextIsUnlocked = !shouldLock;
const updatedItems = section.items.map((item) => ({
...item,
isUnlocked: nextIsUnlocked
}));
const updatedSections = sections.map((entry) =>
entry.id === sectionId
? {
...entry,
items: updatedItems
}
: entry
);
const wasUpdated = await contentApi.updateContent(
courseId,
updatedItems.map((item) => ({
id: item.contentId,
type: item.type as ContentType.Lesson | ContentType.Exercise,
isUnlocked: nextIsUnlocked
}))
);
if (!wasUpdated) return;
sections = updatedSections;
syncCourseContentSections(sections);
}
function resetEdit() {
contentEditingStore.set(undefined);
prevTitle = undefined;
errors = {};
}
function openContentModal(sectionId: string) {
contentCreateStoreUtils.openContentUnit(sectionId);
}
function getItemPath(item: ContentDndItem) {
if (item.type === ContentType.Lesson) {
return `/courses/${courseApi.course?.id}/lessons/${item.contentId}`;
}
return `/courses/${courseApi.course?.id}/exercises/${item.contentId}`;
}
function openExternalUrl(url: string) {
window.open(url, '_blank', 'noopener,noreferrer');
}
function formatMetaDate(value?: string | null): string {
if (!value) return '';
return formatDate(value);
}
function isLockedForStudent(item: ContentDndItem) {
return isStudentView && (item.isUnlocked ?? true) === false;
}
function getItemLockKey(item: ContentDndItem) {
return `${item.type}-${item.contentId}`;
}
async function handleToggleItemLock(item: ContentDndItem) {
const lockKey = getItemLockKey(item);
if (lockPendingItemKeys[lockKey]) return;
lockPendingItemKeys = {
...lockPendingItemKeys,
[lockKey]: true
};
try {
const lockErrors = await toggleLock({
item,
courseId,
lessonApi,
exerciseApi
});
errors = lockErrors;
if (Object.keys(lockErrors).length === 0) {
syncCourseContentSections(sections);
}
} finally {
const nextPending = { ...lockPendingItemKeys };
delete nextPending[lockKey];
lockPendingItemKeys = nextPending;
}
}
/**
* Drag functionality
*/
const flipDurationMs = 200;
function handleDndConsiderColumns(e) {
sections = e.detail.items as SectionDnd[];
syncCourseContentSections(sections);
}
function handleDndFinalizeColumns(e) {
const updatedSections = (e.detail.items as SectionDnd[]).map((section, index) => ({
...section,
order: index + 1
}));
sections = updatedSections;
syncCourseContentSections(updatedSections);
}
function handleDndConsiderCards(sectionId: string, e) {
updateSectionItems(sectionId, e.detail.items as ContentDndItem[]);
syncCourseContentSections(sections);
}
function handleDndFinalizeCards(sectionId: string, e) {
const items = e.detail.items as ContentDndItem[];
updateSectionItems(sectionId, items);
syncCourseContentSections(sections);
if (!reorder) {
syncSections();
}
}
</script>
<DeleteLessonConfirmation bind:openDeleteModal {deleteLesson} />
<section
class="mx-auto w-full p-3 lg:w-11/12 lg:px-4"
use:dndzone={{
items: sections,
flipDurationMs,
type: 'rows',
dropTargetStyle: {
border: '2px #1d4ed8 solid',
'border-style': 'dashed'
},
dragDisabled: !reorder
}}
onconsider={handleDndConsiderColumns}
onfinalize={handleDndFinalizeColumns}
>
{#each sections as section (section.id)}
{@const isUngrouped = section.id === 'ungrouped'}
{@const hasSectionItems = section.items.length > 0}
{@const sectionHasUnlocked = section.items.some((item) => item.isUnlocked ?? false)}
{@const sectionLockLabel = hasSectionItems
? sectionHasUnlocked
? `${$t('course.navItem.lessons.add_lesson.lock')} ${$t('course.navItem.lessons.add_lesson.all')}`
: `${$t('course.navItem.lessons.add_lesson.unlock')} ${$t('course.navItem.lessons.add_lesson.all')}`
: undefined}
<div
class="border-border m-auto mb-3 max-w-xl rounded-md border transition dark:bg-neutral-800"
style={reorder ? 'border: 2px #1d4ed8 solid; border-style: dashed; cursor: grab;' : ''}
>
<SectionHeader
{section}
isEditing={$contentEditingStore === section.id}
isSaving={lessonApi.isLoading && $contentEditingStore === section.id}
showActions={true}
showAddContent={!isUngrouped}
showDelete={!isUngrouped}
disabled={Boolean($contentEditingStore && $contentEditingStore !== section.id)}
{errors}
lockLabel={isUngrouped ? undefined : sectionLockLabel}
onEdit={() => onEdit({ sectionId: section.id, sectionTitle: section.title! })}
onSave={() => onSave({ sectionId: section.id })}
onCancel={() => {
section.title = prevTitle ?? section.title;
resetEdit();
}}
onAddContent={() => openContentModal(section.id)}
onToggleLock={!isUngrouped && hasSectionItems ? () => toggleSectionLock(section.id) : undefined}
onDelete={() => {
if (!isUngrouped) {
onDelete({ sectionId: section.id });
}
}}
/>
<div
class="m-3 {reorder ? 'cursor-grab active:cursor-grabbing' : ''}"
use:dndzone={{
items: section.items,
flipDurationMs,
dropTargetStyle: {
border: '2px #1d4ed8 solid',
'border-style': 'dashed'
},
dragDisabled: !reorder,
type: 'section-content'
}}
onconsider={(e) => handleDndConsiderCards(section.id, e)}
onfinalize={(e) => handleDndFinalizeCards(section.id, e)}
>
{#each section.items as item (item.id)}
{@const isEditingItem = $contentEditingStore === item.contentId}
{@const itemLocked = isLockedForStudent(item)}
{@const itemLockLabel = item.isUnlocked
? $t('course.navItem.lessons.add_lesson.lock')
: $t('course.navItem.lessons.add_lesson.unlock')}
{@const canRenderJoinButton =
item.type === ContentType.Lesson && isLiveCourse && Boolean(item.callUrl) && !itemLocked}
{@const canRenderContinueButton = isStudentView && !itemLocked}
{@const isLockPending = Boolean(lockPendingItemKeys[getItemLockKey(item)])}
{@const actionDisabled = Boolean($contentEditingStore && !isEditingItem)}
<div
class="mb-2 flex min-h-[50px] max-w-xl items-start justify-between rounded-md border border-gray-200 px-3 py-1 transition"
style={reorder ? 'border: 2px #1d4ed8 solid; border-style: dashed; cursor: grab;' : ''}
animate:flip={{ duration: flipDurationMs }}
>
{#if item.type === ContentType.Lesson}
<ContentRow
{item}
bind:title={item.title}
isLocked={itemLocked}
href={resolve(itemLocked ? page.url.pathname : getItemPath(item), {})}
isEditing={isEditingItem}
{errors}
preloadOff
>
{#snippet meta()}
{@const hasVideos = (item.videosCount ?? 0) > 0}
{@const hasDocuments = (item.documentsCount ?? 0) > 0}
{@const hasNote = item.hasNoteContent === true}
{@const hasSlide = item.hasSlideContent === true}
{@const hasAnyMaterials = hasVideos || hasDocuments || hasNote || hasSlide}
<div class="mt-1.5 flex flex-wrap items-center gap-1.5">
{#if hasVideos}
<span class={metaChipClass}>
<VideoIcon size={11} />
{item.videosCount}
</span>
{/if}
{#if hasDocuments}
<span class={metaChipClass}>
<FileTextIcon size={11} />
{item.documentsCount}
</span>
{/if}
{#if hasNote}
<span class={metaChipClass}>
<FileTextIcon size={11} />
{$t('course.navItem.lessons.materials.tabs.note.title')}
</span>
{/if}
{#if hasSlide}
<span class={metaChipClass}>
<FileTextIcon size={11} />
{$t('course.navItem.lessons.materials.tabs.slide.title')}
</span>
{/if}
{#if !hasAnyMaterials}
<HoverableItem>
{#snippet children(isHovered)}
<span class={metaChipClass}>
<AttachmentIcon size={11} {isHovered} />
{$t('course.navItem.lessons.materials.no_content')}
</span>
{/snippet}
</HoverableItem>
{/if}
{#if isLiveCourse && item.lessonAt}
<span class={metaChipClass}>
<CalendarClockIcon size={11} />
{formatMetaDate(item.lessonAt)}
</span>
{/if}
{#if isStudentView}
<span class={metaChipClass}>
{item.isComplete
? $t('course.navItem.lessons.complete')
: $t('course.navItem.lessons.incomplete')}
</span>
{/if}
</div>
{/snippet}
</ContentRow>
{:else if item.type === ContentType.Exercise}
<ContentRow
{item}
bind:title={item.title}
isLocked={itemLocked}
href={resolve(itemLocked ? page.url.pathname : getItemPath(item), {})}
isEditing={isEditingItem}
{errors}
>
{#snippet meta()}
<div class="mt-1.5 flex flex-wrap items-center gap-1.5">
{#if !isStudentView && item.questionCount !== null}
<span class={metaChipClass}>
<HelpCircleIcon size={11} />
{item.questionCount}
{$t('course.navItem.lessons.exercises.all_exercises.questions')}
</span>
{/if}
{#if isSelfPacedCourse && item.dueBy}
<span class={metaChipClass}>
<CalendarClockIcon size={11} />
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.due')}: {formatMetaDate(
item.dueBy
)}
</span>
{/if}
</div>
{/snippet}
</ContentRow>
{/if}
<div class="ml-3 flex min-w-fit items-start gap-2">
{#if isStudentView}
<span class="mt-1 inline-flex items-center text-gray-500 dark:text-gray-300">
{#if itemLocked}
<LockIcon size={16} />
{:else}
<UnlockIcon size={16} />
{/if}
</span>
{:else}
<Button
size="icon-sm"
variant="outline"
class="h-8 w-8 text-gray-600"
onclick={() => handleToggleItemLock(item)}
disabled={actionDisabled || isEditingItem || isLockPending}
aria-label={itemLockLabel}
title={itemLockLabel}
>
{#if isLockPending}
<Spinner class="h-5 w-5" />
{:else if item.isUnlocked}
<UnlockIcon class="h-5 w-5" />
{:else}
<LockIcon class="h-5 w-5" />
{/if}
</Button>
{/if}
{#if !isStudentView && canRenderJoinButton}
<Button size="sm" variant="outline" class="mt-0.5" onclick={() => openExternalUrl(item.callUrl!)}>
{$t('schedule.join')}
</Button>
{/if}
{#if isStudentView && canRenderContinueButton && !isEditingItem}
{#if canRenderJoinButton}
<Button size="sm" onclick={() => openExternalUrl(item.callUrl!)}>{$t('schedule.join')}</Button>
{:else}
<a href={resolve(getItemPath(item), {})}>
<Button size="sm" variant="outline">{$t('dashboard.continue')}</Button>
</a>
{/if}
{/if}
{#if !isStudentView}
<ContentActions
isEditing={isEditingItem}
disabled={actionDisabled}
lockLabel={itemLockLabel}
showLock={false}
onEdit={() => onEdit({ lessonId: item.contentId, lessonTitle: item.title })}
onSave={() => onSave({ lessonId: item.contentId }, item)}
onCancel={() => {
item.title = prevTitle ?? item.title;
resetEdit();
}}
onDelete={() => onDelete({ lessonId: item.contentId })}
onToggleLock={() => handleToggleItemLock(item)}
/>
{/if}
</div>
</div>
{:else}
{$t('course.navItem.lessons.no_lesson')}
{/each}
</div>
</div>
{/each}
</section>
@@ -0,0 +1,44 @@
<script lang="ts">
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
interface Props {
openDeleteModal?: boolean;
deleteLesson?: any;
}
let { openDeleteModal = $bindable(false), deleteLesson = () => {} }: Props = $props();
async function handleDelete() {
deleteLesson();
openDeleteModal = false;
}
</script>
<Dialog.Root
bind:open={openDeleteModal}
onOpenChange={(isOpen) => {
if (!isOpen) openDeleteModal = false;
}}
>
<Dialog.Content class="w-96 px-6 pt-2 pb-6">
<Dialog.Header>
<Dialog.Title>{$t('delete_modal.label')}</Dialog.Title>
</Dialog.Header>
<div>
<h1 class="text-lg dark:text-white">
{$t('delete_modal.content')}?
</h1>
<div class="mt-5 flex items-center justify-between">
<Button variant="outline" onclick={() => (openDeleteModal = false)}>
{$t('delete_modal.no')}
</Button>
<Button variant="outline" onclick={handleDelete}>
{$t('delete_modal.yes')}
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,331 @@
<script lang="ts">
import { lessonDocUpload, resetDocumentUploadStore } from '../store';
import { snackbar } from '$features/ui/snackbar/store';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import FileTextIcon from '@lucide/svelte/icons/file-text';
import { DocumentUploader } from '$lib/utils/services/courses/presign';
import { onDestroy, untrack } from 'svelte';
import { UpgradeBanner, CloseButton } from '$features/ui';
import { isFreePlan } from '$lib/utils/store/org';
import { lessonApi } from '$features/course/api';
import { mediaApi } from '$features/media/api';
import type { Lesson } from '$features/course/utils/types';
import * as Dialog from '@cio/ui/base/dialog';
import * as FileDropZone from '@cio/ui/custom/file-drop-zone';
import type { FileRejectedReason } from '@cio/ui/custom/file-drop-zone';
interface Props {
lessonId?: string;
onClose?: () => void;
}
let { lessonId = '', onClose = () => {} }: Props = $props();
let selectedFile: File | null = $state(null);
let errorTimeout: NodeJS.Timeout | null = $state(null);
let isDisabled = $derived($lessonDocUpload.isUploading || $isFreePlan);
let uploadedDocument: NonNullable<Lesson['documents']>[number] | null = $state(null);
const ALLOWED_TYPES = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/msword'
];
const MAX_DOCUMENT_SIZE = 5 * 1024 * 1024; // 5MB
const documentUploader = new DocumentUploader();
function getFileType(file: File): 'pdf' | 'docx' | 'doc' {
if (file.type === 'application/pdf') return 'pdf';
if (file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') return 'docx';
if (file.type === 'application/msword') return 'doc';
return 'pdf'; // fallback
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function validateFile(file: File): string | null {
if (!ALLOWED_TYPES.includes(file.type)) {
return $t('course.navItem.lessons.materials.tabs.document.file_type_error');
}
if (file.size > MAX_DOCUMENT_SIZE) {
return $t('course.navItem.lessons.materials.tabs.document.file_size_error', {
size: formatFileSize(MAX_DOCUMENT_SIZE)
});
}
return null;
}
function selectFile(file: File) {
const error = validateFile(file);
if (error) {
snackbar.error(error);
return;
}
selectedFile = file;
$lessonDocUpload.error = null;
}
async function handleFilesUpload(files: File[]) {
const file = files[0];
if (file) selectFile(file);
}
function handleFileRejected(opts: { reason: FileRejectedReason; file: File }) {
if (opts.reason === 'Maximum file size exceeded') {
snackbar.error(
$t('course.navItem.lessons.materials.tabs.document.file_size_error', {
size: formatFileSize(MAX_DOCUMENT_SIZE)
})
);
} else if (opts.reason === 'File type not allowed') {
snackbar.error($t('course.navItem.lessons.materials.tabs.document.file_type_error'));
} else {
snackbar.error($t('course.navItem.lessons.materials.tabs.document.upload_error'));
}
}
async function uploadDocument() {
if (!selectedFile) return;
// Prevent free plan users from bypassing UI restrictions
if ($isFreePlan) {
$lessonDocUpload.error = $t('upgrade.required');
snackbar.error($lessonDocUpload.error!);
return;
}
$lessonDocUpload.isUploading = true;
try {
const { url: presignedUrl, fileKey } = await documentUploader.getPresignedUrl(selectedFile);
await documentUploader.uploadFile({
url: presignedUrl,
file: selectedFile
});
const { urls: presignedUrls } = await documentUploader.getDownloadPresignedUrl([fileKey]);
const lessonDocumentPosition = Array.isArray(lessonApi.lesson?.documents) ? lessonApi.lesson.documents.length : 0;
let assetId: string | undefined;
if (lessonId) {
const asset = await mediaApi.registerUploadedLessonDocument({
lessonId,
position: lessonDocumentPosition,
fileKey,
documentUrl: presignedUrls[fileKey],
fileName: selectedFile.name,
mimeType: selectedFile.type,
byteSize: selectedFile.size
});
assetId = asset?.id;
} else {
const asset = await mediaApi.createAsset({
kind: 'document',
provider: 'upload',
storageProvider: 's3',
storageKey: fileKey,
sourceUrl: presignedUrls[fileKey],
mimeType: selectedFile.type,
byteSize: selectedFile.size,
title: selectedFile.name,
isExternal: false,
metadata: {
fileName: selectedFile.name,
createdAt: new Date().toISOString()
}
});
assetId = asset?.id;
}
const document = {
type: getFileType(selectedFile),
name: selectedFile.name,
link: presignedUrls[fileKey],
key: fileKey,
size: selectedFile.size,
assetId
};
lessonApi.updateLessonState('documents', [document], { append: true });
uploadedDocument = document;
lessonDocUpload.update((state) => ({
...state,
uploadProgress: 100
}));
snackbar.success($t('course.navItem.lessons.materials.tabs.document.upload_success'));
// Reset after a short delay
setTimeout(() => {
resetDocumentUploadStore();
selectedFile = null;
}, 1500);
} catch (error) {
console.error('Upload error:', error);
if ($lessonDocUpload.isCancelled) {
$lessonDocUpload.error = $t('course.navItem.lessons.materials.tabs.document.upload_cancelled');
snackbar.info($t('course.navItem.lessons.materials.tabs.document.upload_cancelled'));
} else {
$lessonDocUpload.error =
error instanceof Error ? error.message : $t('course.navItem.lessons.materials.tabs.document.upload_error');
snackbar.error($t('course.navItem.lessons.materials.tabs.document.upload_error'));
}
} finally {
$lessonDocUpload.isUploading = false;
}
}
function removeSelectedFile() {
selectedFile = null;
$lessonDocUpload.error = null;
}
function cancelUpload() {
documentUploader.cancelUpload();
selectedFile = null;
}
function autoClearError(error: string | null) {
if (!error) {
return;
}
if (errorTimeout) clearTimeout(errorTimeout);
errorTimeout = setTimeout(() => {
untrack(() => {
$lessonDocUpload.error = null;
});
}, 5000);
}
// Auto-clear error after 5 seconds
$effect(() => {
autoClearError($lessonDocUpload.error);
});
// Clear error timeout when component is destroyed
onDestroy(() => {
if (errorTimeout) clearTimeout(errorTimeout);
});
</script>
<Dialog.Root
bind:open={$lessonDocUpload.isModalOpen}
onOpenChange={(isOpen) => {
if (!isOpen) onClose();
}}
>
<Dialog.Content class="w-[90%] max-w-4/5">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.materials.tabs.document.upload_title')}</Dialog.Title>
</Dialog.Header>
<UpgradeBanner className="mb-3" onClick={() => ($lessonDocUpload.isModalOpen = false)}>
{$t('course.navItem.lessons.materials.tabs.document.upgrade')}
</UpgradeBanner>
<div class="p-6">
<!-- File Upload Area -->
{#if selectedFile}
<div class="border-border mb-4 flex items-center justify-center space-x-3 rounded-lg border p-4">
<FileTextIcon class="ui:text-muted-foreground size-5 shrink-0" />
<div class="min-w-0 flex-1 text-left">
<p class="ui:text-foreground truncate font-medium">{selectedFile.name}</p>
<p class="ui:text-muted-foreground text-sm">{formatFileSize(selectedFile.size)}</p>
</div>
<CloseButton onClick={removeSelectedFile} />
</div>
{:else}
<div class={isDisabled ? 'ui:opacity-50 ui:pointer-events-none' : ''}>
<FileDropZone.Root
accept=".pdf,.docx,.doc,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/msword"
maxFiles={1}
fileCount={0}
maxFileSize={MAX_DOCUMENT_SIZE}
onUpload={handleFilesUpload}
onFileRejected={handleFileRejected}
>
<FileDropZone.Trigger
label={$t('course.navItem.lessons.materials.tabs.document.drag_drop')}
formatMaxFiles={(_count) => $t('course.navItem.lessons.materials.tabs.document.upload_description')}
formatMaxFilesAndSize={(size) => `(up to ${size})`}
formatMaxSize={(size) =>
$t('course.navItem.lessons.materials.tabs.document.upload_description') + ` (${size})`}
/>
</FileDropZone.Root>
</div>
{/if}
<!-- Error Message -->
{#if $lessonDocUpload.error}
<div class="mt-4 rounded-md border border-red-200 bg-red-50 p-3">
<p class="text-sm text-red-600">{$lessonDocUpload.error}</p>
</div>
{/if}
<!-- Upload Progress -->
{#if $lessonDocUpload.isUploading}
<div class="mt-4">
<div class="mb-1 flex justify-between text-sm text-gray-600">
<span>{$t('course.navItem.lessons.materials.tabs.document.uploading')}</span>
<span>{$lessonDocUpload.uploadProgress}%</span>
</div>
<div class="h-2 w-full rounded-full bg-gray-200">
<div
class="h-2 rounded-full bg-blue-600 transition-all duration-300"
style="width: {$lessonDocUpload.uploadProgress}%"
></div>
</div>
<div class="mt-3 flex justify-center">
<Button variant="outline" onclick={cancelUpload}>
{$t('course.navItem.lessons.materials.tabs.document.cancel_upload')}
</Button>
</div>
</div>
{/if}
<!-- Upload Button -->
{#if selectedFile && !$lessonDocUpload.isUploading}
<div class="mt-6 flex justify-end space-x-3">
<Button variant="outline" onclick={removeSelectedFile}>
{$t('course.navItem.lessons.materials.tabs.document.cancel')}
</Button>
<Button
onclick={uploadDocument}
loading={$lessonDocUpload.isUploading}
disabled={$lessonDocUpload.isUploading}
>
{$t('course.navItem.lessons.materials.tabs.document.upload_document')}
</Button>
</div>
{/if}
<!-- Success Message -->
{#if uploadedDocument}
<div class="mt-4 rounded-md border border-green-200 bg-green-50 p-3">
<p class="text-sm text-green-600">
{$t('course.navItem.lessons.materials.tabs.document.upload_success')}
</p>
</div>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,28 @@
<script lang="ts">
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { IconButton } from '@cio/ui/custom/icon-button';
import { t } from '$lib/utils/functions/translations';
interface Props {
onRemove: () => void;
}
let { onRemove }: Props = $props();
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="ui:data-[state=open]:opacity-100 absolute top-2 right-2 z-40 flex items-center justify-center opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100"
onclick={(e) => e.stopPropagation()}
>
<IconButton variant="outline">
<EllipsisVerticalIcon size={16} />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item class="ui:text-red-600" onclick={onRemove}>
{$t('course.navItem.lessons.materials.tabs.document.remove_document')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -0,0 +1,72 @@
<script lang="ts">
import { DocumentCard } from '@cio/ui';
import EyeIcon from '@lucide/svelte/icons/eye';
import DownloadIcon from '@lucide/svelte/icons/download';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import DocumentCardDropdown from './document-card-dropdown.svelte';
import type { LessonDocument } from '$features/course/utils/types';
interface Props {
document: LessonDocument;
index: number;
isEditMode: boolean;
isDownloading: boolean;
formatFileSize: (bytes: number) => string;
onRemove: () => void;
onViewPDF: (doc: LessonDocument) => void;
onDownload: (doc: LessonDocument) => Promise<void>;
}
let {
document: doc,
index: _index,
isEditMode,
isDownloading,
formatFileSize,
onRemove,
onViewPDF,
onDownload
}: Props = $props();
const subtitle = $derived(
[doc.type.toUpperCase(), doc.size != null ? formatFileSize(doc.size) : null].filter(Boolean).join(' · ') || '–'
);
</script>
<DocumentCard title={doc.name} {subtitle} class="group relative">
{#snippet header()}
{#if isEditMode}
<DocumentCardDropdown {onRemove} />
{/if}
{/snippet}
{#snippet actions()}
{#if doc.type === 'pdf'}
<Button variant="outline" size="sm" onclick={() => onViewPDF(doc)} class="gap-1.5">
<EyeIcon size={14} />
{$t('course.navItem.lessons.materials.tabs.document.view_pdf')}
</Button>
{:else}
<a
href={doc.link}
target="_blank"
rel="noopener noreferrer"
class="ui:border-input ui:bg-background ui:text-foreground hover:ui:bg-accent hover:ui:text-accent-foreground ui:shrink inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium"
>
{$t('course.navItem.lessons.materials.tabs.document.view_document')}
</a>
{/if}
<Button
variant="outline"
size="sm"
onclick={() => onDownload(doc)}
disabled={isDownloading}
class="ui:shrink! gap-1.5"
>
<DownloadIcon size={14} />
{isDownloading
? $t('course.navItem.lessons.materials.tabs.document.downloading')
: $t('course.navItem.lessons.materials.tabs.document.download')}
</Button>
{/snippet}
</DocumentCard>
@@ -0,0 +1,65 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import { Empty } from '@cio/ui/custom/empty';
import { t } from '$lib/utils/functions/translations';
import MODES from '$lib/utils/constants/mode';
import isEmpty from 'lodash/isEmpty';
import FileTextIcon from '@lucide/svelte/icons/file-text';
import * as Item from '@cio/ui/base/item';
import DocumentCard from './document-card.svelte';
import type { LessonDocument } from '$features/course/utils/types';
interface Props {
mode?: (typeof MODES)[keyof typeof MODES];
displayDocuments?: LessonDocument[];
downloadingDocuments: Set<string>;
formatFileSize: (bytes: number) => string;
openDocumentUploadModal: () => void;
requestRemoveDocument: (index: number) => void;
onViewPDF: (doc: LessonDocument) => void;
downloadDocument: (doc: LessonDocument) => Promise<void>;
}
let {
mode = MODES.view,
displayDocuments = [],
downloadingDocuments,
formatFileSize,
openDocumentUploadModal,
requestRemoveDocument,
onViewPDF,
downloadDocument
}: Props = $props();
</script>
{#if mode === MODES.edit}
<div class="flex justify-end">
<Button onclick={openDocumentUploadModal} class="my-4!">
{$t('course.navItem.lessons.materials.tabs.document.add_document')}
</Button>
</div>
{/if}
{#if !isEmpty(displayDocuments)}
{@const isEditMode = mode === MODES.edit}
<Item.Group class="flex w-full gap-4">
{#each displayDocuments as document, index}
<DocumentCard
{document}
{index}
{isEditMode}
isDownloading={downloadingDocuments.has(document.name)}
{formatFileSize}
onRemove={() => (isEditMode ? requestRemoveDocument(index) : undefined)}
{onViewPDF}
onDownload={downloadDocument}
/>
{/each}
</Item.Group>
{:else if mode === MODES.edit}
<Empty
title={$t('course.navItem.lessons.materials.tabs.document.empty_title')}
description={$t('course.navItem.lessons.materials.tabs.document.empty_description')}
icon={FileTextIcon}
/>
{/if}
@@ -0,0 +1,453 @@
<script lang="ts">
import { lessonApi } from '$features/course/api';
import { CloseButton, DeleteModal } from '$features/ui';
import { lessonDocUpload } from '$features/course/components/lesson/store';
import MODES from '$lib/utils/constants/mode';
import { IconButton } from '@cio/ui/custom/icon-button';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import ZoomInIcon from '@lucide/svelte/icons/zoom-in';
import ZoomOutIcon from '@lucide/svelte/icons/zoom-out';
import { onMount } from 'svelte';
import DocumentList from './document-list.svelte';
import { t } from '$lib/utils/functions/translations';
import type { LessonDocument } from '$features/course/utils/types';
import { snackbar } from '$features/ui/snackbar/store';
interface Props {
mode?: (typeof MODES)[keyof typeof MODES];
}
let { mode = MODES.view }: Props = $props();
let downloadingDocuments = $state(new Set<string>());
let openDeleteDocumentModal = $state(false);
let documentIndexToDelete = $state<number | null>(null);
let viewingPDF: any = $state(null);
let pdfViewerOpen = $state(false);
let pdfCanvas: HTMLCanvasElement | undefined = $state();
let pdfDoc: any = null;
let pageNum = $state(1);
let pageCount = $state(0);
let scale = $state(1.0);
let isLoading = $state(false);
let error: string | null = $state(null);
let pdfjsLib: any = null;
let renderTimeout: any = null;
let currentRenderTask: any = null;
onMount(() => {
// Load PDF.js dynamically
const script = document.createElement('script');
script.src = '/js/pdf.js/pdf.min.js';
script.onload = () => {
pdfjsLib = (window as any).pdfjsLib;
pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/pdf.js/pdf.worker.min.js';
};
document.head.appendChild(script);
return () => {
if (script.parentNode) {
script.parentNode.removeChild(script);
}
};
});
function openDocumentUploadModal() {
$lessonDocUpload.isModalOpen = true;
}
function deleteDocument(index: number) {
void lessonApi.deleteLessonDocument(index);
}
function requestRemoveDocument(index: number) {
documentIndexToDelete = index;
openDeleteDocumentModal = true;
}
function confirmRemoveDocument() {
if (documentIndexToDelete !== null) {
deleteDocument(documentIndexToDelete);
documentIndexToDelete = null;
}
openDeleteDocumentModal = false;
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
async function downloadDocument(doc: LessonDocument) {
downloadingDocuments.add(doc.name);
downloadingDocuments = downloadingDocuments;
if (!doc.key) {
snackbar.error('Document not loaded correctly');
return;
}
try {
const response = await fetch(doc.link);
if (!response.ok) {
throw new Error('Failed to fetch document');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = doc.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Error downloading document:', error);
window.open(doc.link, '_blank');
} finally {
downloadingDocuments.delete(doc.name);
downloadingDocuments = downloadingDocuments;
}
}
function debouncedRender() {
if (renderTimeout) {
clearTimeout(renderTimeout);
}
renderTimeout = setTimeout(() => {
renderPage();
renderTimeout = null;
}, 150); // 150ms debounce
}
// Wait for PDF.js to load
const checkPDFJS = () => {
return new Promise((resolve) => {
const check = () => {
if (pdfjsLib) {
resolve(true);
} else {
setTimeout(check, 100);
}
};
check();
});
};
async function viewPDF(document: LessonDocument) {
// Wait for PDF.js to be fully loaded
if (!pdfjsLib) {
// Show loading state while waiting for PDF.js
viewingPDF = document;
pdfViewerOpen = true;
isLoading = true;
error = 'Loading PDF viewer...';
await checkPDFJS();
}
viewingPDF = document;
pdfViewerOpen = true;
isLoading = true;
error = null;
try {
if (!document.key) {
snackbar.error('Document not loaded correctly');
return;
}
const response = await fetch(document.link);
if (!response.ok) {
throw new Error('Failed to fetch PDF');
}
const arrayBuffer = await response.arrayBuffer();
pdfDoc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
pageCount = pdfDoc.numPages;
pageNum = 1;
scale = 1.0;
isLoading = false;
// Wait for the canvas to be available in the DOM
await new Promise((resolve) => setTimeout(resolve, 100));
await renderPage();
} catch (err) {
console.error('Error loading PDF:', err);
error = 'course.navItem.lessons.materials.tabs.document.failed_to_load_pdf';
isLoading = false;
}
}
async function renderPage() {
if (!pdfDoc) return;
// Wait for canvas to be available
let attempts = 0;
while (!pdfCanvas && attempts < 50) {
await new Promise((resolve) => setTimeout(resolve, 100));
attempts++;
}
if (!pdfCanvas) {
console.error('Canvas not available after waiting');
return;
}
try {
// Cancel any ongoing render task
if (currentRenderTask) {
currentRenderTask.cancel();
}
const page = await pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale });
pdfCanvas.height = viewport.height;
pdfCanvas.width = viewport.width;
const ctx = pdfCanvas.getContext('2d');
const renderContext = {
canvasContext: ctx,
viewport: viewport
};
currentRenderTask = page.render(renderContext);
await currentRenderTask.promise;
currentRenderTask = null;
} catch (err) {
console.error('Error rendering page:', err);
error = 'course.navItem.lessons.materials.tabs.document.failed_to_render_pdf';
currentRenderTask = null;
}
}
function nextPage() {
if (pageNum < pageCount) {
pageNum++;
debouncedRender();
}
}
function prevPage() {
if (pageNum > 1) {
pageNum--;
debouncedRender();
}
}
function zoomIn() {
scale = Math.min(scale + 0.25, 3.0);
debouncedRender();
}
function zoomOut() {
scale = Math.max(scale - 0.25, 0.5);
debouncedRender();
}
function handleViewPDF(doc: LessonDocument) {
viewPDF(doc);
}
function closePDFViewer() {
// Cancel any ongoing render task
if (currentRenderTask) {
currentRenderTask.cancel();
}
// Clear any pending render timeout
if (renderTimeout) {
clearTimeout(renderTimeout);
}
pdfViewerOpen = false;
viewingPDF = null;
pdfDoc = null;
pageNum = 1;
pageCount = 0;
scale = 1.0;
error = null;
currentRenderTask = null;
renderTimeout = null;
}
function handleKeydown(event: KeyboardEvent) {
if (!pdfViewerOpen) return;
switch (event.key) {
case 'ArrowLeft':
prevPage();
break;
case 'ArrowRight':
nextPage();
break;
case '+':
case '=':
zoomIn();
break;
case '-':
zoomOut();
break;
case 'Escape':
closePDFViewer();
break;
}
}
onMount(() => {
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
};
});
function handleContextMenu(event: MouseEvent) {
event.preventDefault();
}
function handleDragStart(event: DragEvent) {
event.preventDefault();
}
let displayDocuments = $derived(lessonApi.lesson?.documents || []);
</script>
<DocumentList
{mode}
{displayDocuments}
{downloadingDocuments}
{formatFileSize}
{openDocumentUploadModal}
{requestRemoveDocument}
onViewPDF={handleViewPDF}
{downloadDocument}
/>
<DeleteModal bind:open={openDeleteDocumentModal} onDelete={confirmRemoveDocument} />
<!-- PDF Viewer Modal -->
{#if pdfViewerOpen}
<div class="fixed inset-0 z-100 flex flex-col bg-white dark:bg-neutral-800">
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-200 bg-white px-4 py-3 dark:bg-neutral-800">
<div class="flex items-center space-x-4">
<h2 class="max-w-md truncate text-lg font-semibold text-gray-900 dark:text-gray-300">
{viewingPDF?.name}
</h2>
{#if !isLoading && !error}
<span class="text-sm text-gray-500">
{$t('course.navItem.lessons.materials.tabs.document.page_of', {
page: pageNum,
total: pageCount
})}
</span>
{/if}
</div>
<div class="flex items-center space-x-2">
<!-- Navigation Controls -->
{#if !isLoading && !error}
<div class="flex items-center space-x-1">
<IconButton onclick={prevPage} disabled={pageNum <= 1} tooltip="Previous page (←)">
<ChevronLeftIcon size={16} />
</IconButton>
<IconButton onclick={nextPage} disabled={pageNum >= pageCount} tooltip="Next page (→)">
<ChevronRightIcon size={16} />
</IconButton>
</div>
<!-- Zoom Controls -->
<div class="flex items-center space-x-1">
<IconButton onclick={zoomOut} disabled={scale <= 0.5} tooltip="Zoom out (-)">
<ZoomOutIcon size={16} />
</IconButton>
<span class="min-w-12 text-center text-sm text-gray-600">
{Math.round(scale * 100)}%
</span>
<IconButton onclick={zoomIn} disabled={scale >= 3.0} tooltip="Zoom in (+)">
<ZoomInIcon size={16} />
</IconButton>
</div>
{/if}
<CloseButton onClick={closePDFViewer} tooltip="Close (Esc)" />
</div>
</div>
<!-- PDF Content -->
<div class="flex-1 overflow-auto bg-gray-100 p-4">
{#if isLoading}
<div class="flex h-full items-center justify-center">
<div class="text-center">
<div
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent"
></div>
<p class="text-gray-600">
{$t('course.navItem.lessons.materials.tabs.document.loading_pdf')}
</p>
</div>
</div>
{:else if error}
<div class="flex h-full items-center justify-center">
<div class="text-center">
<div class="mb-4 text-red-500">
<svg class="mx-auto h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<p class="mb-2 text-red-600">{$t(error)}</p>
<button onclick={() => viewPDF(viewingPDF)} class="text-blue-600 underline hover:text-blue-800">
{$t('course.navItem.lessons.materials.tabs.document.try_again')}
</button>
</div>
</div>
{:else}
<div class="flex justify-center">
<canvas
bind:this={pdfCanvas}
oncontextmenu={handleContextMenu}
ondragstart={handleDragStart}
class="shadow-lg"
></canvas>
</div>
{/if}
</div>
<!-- Footer with instructions -->
{#if !isLoading && !error}
<div class="border-t border-gray-200 bg-gray-50 px-4 py-2">
<p class="text-center text-xs text-gray-500">
{$t('course.navItem.lessons.materials.tabs.document.use_arrow_keys')}
</p>
</div>
{/if}
</div>
{/if}
<style>
/* Disable text selection on the canvas */
canvas {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* Disable drag and drop */
canvas {
pointer-events: auto;
}
</style>
@@ -0,0 +1,11 @@
export { default as ContentNavigationActions } from './content-navigation-actions.svelte';
export { default as LanguageSelector } from './language-selector.svelte';
export { default as LessonPageEditHeader } from './lesson-page-edit-header.svelte';
export { default as Loader } from './loader.svelte';
export { default as Comments } from './comments.svelte';
export { default as Note } from './note/note.svelte';
export { default as Slide } from './slide/slide.svelte';
export { default as Video } from './video/video.svelte';
export { default as Document } from './document/document.svelte';
export { default as AddVideoModal } from './video/add-video-modal.svelte';
export { default as AddDocumentModal } from './document/add-document-modal.svelte';
@@ -0,0 +1,18 @@
<script lang="ts">
import * as Select from '@cio/ui/base/select';
import { LANGUAGES } from '$lib/utils/constants/translation';
import { lessonApi } from '$features/course/api';
</script>
<Select.Root type="single" bind:value={lessonApi.currentLocale}>
<Select.Trigger class="h-9 w-[120px]">
{LANGUAGES.find((lang) => lang.id === lessonApi.currentLocale)?.text || 'Language'}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each LANGUAGES as lang (lang.id)}
<Select.Item value={lang.id}>{lang.text}</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
@@ -0,0 +1,86 @@
<script lang="ts">
import LockIcon from '@lucide/svelte/icons/lock';
import { Spinner } from '@cio/ui/base/spinner';
import UnlockIcon from '@lucide/svelte/icons/lock-open';
import TrashIcon from '@lucide/svelte/icons/trash';
import { IconButton } from '@cio/ui/custom/icon-button';
import { InputField } from '@cio/ui/custom/input-field';
import * as Page from '@cio/ui/base/page';
import MODES from '$lib/utils/constants/mode';
import { globalStore } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import { RoleBasedSecurity } from '$features/ui';
import DeleteLessonConfirmation from '$features/course/components/lesson/delete-lesson-confirmation.svelte';
interface Props {
mode: (typeof MODES)[keyof typeof MODES];
title: string;
isUnlocked?: boolean | null;
isDeletingLesson?: boolean;
onTitleChange: (value: string) => void;
onToggleLock: () => void;
onDeleteLesson: () => Promise<void> | void;
}
let {
mode,
title,
isUnlocked = false,
isDeletingLesson = false,
onTitleChange,
onToggleLock,
onDeleteLesson
}: Props = $props();
let openDeleteModal = $state(false);
const lockLabel = $derived(
isUnlocked ? $t('course.navItem.lessons.add_lesson.lock') : $t('course.navItem.lessons.add_lesson.unlock')
);
</script>
<div class="flex w-full items-center justify-between gap-2">
<div class="min-w-0 flex-1">
{#if mode === MODES.edit && !$globalStore.isStudent}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<InputField
className="max-w-xl"
value={title}
placeholder={$t('course.navItem.lessons.add_lesson.lesson_title')}
onInputChange={(e) => onTitleChange(e.currentTarget.value)}
/>
</RoleBasedSecurity>
{:else}
<Page.Title>{title}</Page.Title>
{/if}
</div>
{#if mode === MODES.edit && !$globalStore.isStudent}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="flex items-center gap-2">
<IconButton onclick={onToggleLock} aria-label={lockLabel} title={lockLabel}>
{#if isUnlocked}
<UnlockIcon size={18} />
{:else}
<LockIcon size={18} />
{/if}
</IconButton>
<IconButton
onclick={() => (openDeleteModal = true)}
disabled={isDeletingLesson}
aria-label={$t('course.navItem.lessons.add_lesson.delete')}
title={$t('course.navItem.lessons.add_lesson.delete')}
>
{#if isDeletingLesson}
<Spinner class="h-4 w-4" />
{:else}
<TrashIcon size={18} />
{/if}
</IconButton>
</div>
</RoleBasedSecurity>
{/if}
</div>
<DeleteLessonConfirmation bind:openDeleteModal deleteLesson={onDeleteLesson} />
@@ -0,0 +1,279 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { createEventDispatcher } from 'svelte';
import { HTMLRender } from '$features/ui';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import { Button } from '@cio/ui/base/button';
import PlayIcon from '@lucide/svelte/icons/play';
import { lessonApi } from '$features/course/api';
import { diffLines } from 'diff';
import { courseApi } from '$features/course/api';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import type { TLocale } from '@cio/db/types';
interface LessonHistory {
new_content: string;
old_content: string;
timestamp: Date;
locale: TLocale;
lesson_id: string;
}
interface Props {
open?: boolean;
}
let { open = false }: Props = $props();
let lessonHistory: LessonHistory[] = $state([]);
let content = '';
let selectedVersion: LessonHistory = {
new_content: '',
old_content: '',
timestamp: new Date(),
locale: 'en',
lesson_id: ''
};
let selectedVersionIndex = $state(0);
let contentRestoreLoading = $state(false);
let versionsToFetch = $state(9);
let isMoreHistoryLoading = $state(false);
let mounted = false;
const lessonId = $derived(lessonApi.lesson?.id || '');
const lessonTitle = $derived(lessonApi.lesson?.title || '');
const dispatch = createEventDispatcher();
function scrollLock(open) {
if (mounted) {
const body = document.querySelector('body');
if (!body) return;
body.style.overflow = open ? 'hidden' : 'auto';
}
}
function formatTimestamp(timestamp) {
const options: Intl.DateTimeFormatOptions = {
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
};
const date = new Date(timestamp + 'Z');
return new Intl.DateTimeFormat('en-US', options).format(date);
}
function handleDrawerClose() {
dispatch('close');
}
function getMinutes(time: Date) {
return new Date(time).getMinutes();
}
function removeDuplicate(history: LessonHistory[]) {
return history.filter(
(obj1, i, arr) => arr.findIndex((obj2) => getMinutes(obj2.timestamp) === getMinutes(obj1.timestamp)) === i
);
}
function fetchLessonHistory(lessonId: string, locale: string, endRange: number) {
untrack(async () => {
try {
isMoreHistoryLoading = true;
if (!courseApi.course?.id) {
throw new Error('Course ID not available');
}
const response = await lessonApi.getHistory(courseApi.course?.id, lessonId, locale, endRange);
if (!response || !lessonApi.success || !response.data) {
throw new Error('Failed to fetch lesson history');
}
const data = response.data;
// Filter out duplicates based on timestamp
const existingTimestamps = new Set(lessonHistory.map((item) => new Date(item.timestamp).getMinutes()));
const newEntries = data.filter((item) => !existingTimestamps.has(new Date(item.timestamp!).getMinutes()));
lessonHistory = removeDuplicate([...lessonHistory, ...newEntries]);
if (lessonHistory.length > 0) {
updateContentVersion(lessonHistory[0], 0);
}
} catch (error) {
console.error(error);
snackbar.error('Failed to fetch history');
} finally {
isMoreHistoryLoading = false;
}
});
}
onMount(() => {
mounted = true;
scrollLock(open);
});
function updateContentVersion(content: LessonHistory, index: number) {
selectedVersionIndex = index;
selectedVersion = content;
const display = document.getElementById('display');
display!.innerHTML = '';
const diff = diffLines(content.old_content, content.new_content);
const fragment = document.createDocumentFragment();
diff.forEach((part) => {
const span = document.createElement('span');
if (part.added) {
span.classList.add('text-green-500');
} else if (part.removed) {
span.classList.add('text-red-500', 'line-through');
} else {
span.classList.add('text-black', 'dark:text-white');
}
span.innerHTML = part.value;
fragment.appendChild(span);
});
display!.appendChild(fragment);
}
async function restoreSelectedVersion() {
try {
contentRestoreLoading = true;
if (!courseApi.course?.id || !selectedVersion) return;
await lessonApi.upsertLanguage(
courseApi.course.id,
selectedVersion.lesson_id,
selectedVersion.locale,
selectedVersion.new_content
);
} catch (error) {
console.error(error);
snackbar.error('Failed to restore');
} finally {
contentRestoreLoading = false;
dispatch('restore');
}
}
function loadMoreHistory() {
versionsToFetch += 10;
}
$effect(() => {
scrollLock(open);
});
$effect(() => {
fetchLessonHistory(lessonId, lessonApi.currentLocale, versionsToFetch);
});
</script>
<aside class="drawer bg-gray-100 dark:bg-neutral-800" class:open>
<div class="panel bg-white dark:bg-black">
<div class="w-full p-10 pr-80">
<div class="flex items-start gap-x-10">
<Button variant="outline" onclick={handleDrawerClose}>
<ArrowLeftIcon size={16} />
</Button>
{#if selectedVersionIndex != 0}
<div class="">
<Button loading={contentRestoreLoading} onclick={restoreSelectedVersion}>
{$t('course.navItem.lessons.version_history.restore_version')}
</Button>
>
</div>
{/if}
</div>
<div class="flex h-full w-full flex-col items-start">
<HTMLRender className="m-auto text-center mt-6 flex items-center justify-center">
<h1 class="mt-0 text-2xl capitalize md:text-4xl">
{lessonTitle}
</h1>
</HTMLRender>
{#key lessonId}
<HTMLRender id="display" className="m-auto">
<div class="amen">
{@html sanitizeHtml(content)}
</div>
</HTMLRender>
{/key}
</div>
</div>
</div>
<div
id="scroll-container"
class="fixed top-0 right-0 z-10 h-full min-h-screen w-80 space-y-6 overflow-x-auto overflow-y-scroll bg-gray-100 py-10 dark:bg-neutral-800"
>
<p class="flex items-start justify-start px-10 text-left text-xl font-medium">
{$t('course.navItem.lessons.version_history.title')}
</p>
<div>
{#each lessonHistory as version, index}
<button
onclick={() => updateContentVersion(version, index)}
class="flex w-full cursor-pointer items-start p-4 px-10 hover:bg-gray-200 dark:hover:bg-neutral-700 {index ==
selectedVersionIndex
? 'bg-gray-200 dark:bg-neutral-700'
: ''}"
>
<PlayIcon size={16} class="mt-1" />
<div>
<span class="inline-block text-base font-medium">{formatTimestamp(version.timestamp)}</span>
{#if index == 0}
<span class="block text-start text-xs italic"
>{$t('course.navItem.lessons.version_history.current_version')}</span
>
{/if}
</div>
</button>
{/each}
<div class="mt-2 flex h-10 items-center justify-start px-10">
<Button loading={isMoreHistoryLoading} onclick={loadMoreHistory}>
{$t('course.navItem.lessons.version_history.fetch_more_versions')}
</Button>
>
</div>
</div>
</div>
</aside>
<style>
.drawer {
position: fixed;
top: 0;
left: 0;
height: 100%;
width: 100%;
z-index: -1;
}
.drawer.open {
z-index: 99;
}
.panel {
position: fixed;
width: 100%;
height: 100%;
z-index: 3;
overflow: auto;
}
.drawer.open .panel {
transform: translate(0, 0);
}
</style>
@@ -0,0 +1,12 @@
<script lang="ts">
import { Skeleton } from '@cio/ui/base/skeleton';
</script>
<div class="w-full">
<Skeleton class="h-40 w-full" />
<div class="my-3">
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-full" />
</div>
</div>
@@ -0,0 +1,80 @@
<script lang="ts">
import { HTMLRender, TextEditor } from '$features/ui';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import { lessonApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
import MODES from '$lib/utils/constants/mode';
import type { Content } from '@cio/ui/custom/editor';
import type { TLocale } from '@cio/db/types';
import AIButton from '$features/course/components/lesson/ai-button.svelte';
import type { Writable } from 'svelte/store';
interface Props {
mode?: (typeof MODES)[keyof typeof MODES];
lessonId?: string;
isLoading?: Writable<boolean>;
callAI?: (type: string) => void;
}
let { mode = MODES.view, lessonId = '', isLoading, callAI = () => {} }: Props = $props();
let hasAtLeastOneTranslation = $derived(
Object.values(lessonApi.translations[lessonId] || {}).some((content) => {
return content && !!content.length;
})
);
function onEditorChange(content: Content) {
if (mode === MODES.view) return;
if (!lessonApi.translations[lessonId]) {
lessonApi.translations[lessonId] = {} as Record<TLocale, string>;
}
lessonApi.translations[lessonId][lessonApi.currentLocale] = `${content}`;
try {
localStorage.setItem(`lesson-${lessonId}-${lessonApi.currentLocale}`, `${content}`);
} catch (error) {
console.error('Error saving lesson note to localStorage', error);
}
lessonApi.isDirty = true;
}
const content = $derived(lessonApi.translations[lessonId]?.[lessonApi.currentLocale] || '');
$effect(() => {
console.log('content', content);
});
$effect(() => {
console.log('lessonApi.currentLocale', lessonApi.currentLocale);
});
</script>
{#if mode === MODES.edit}
<!-- AI Button -->
<div class="flex justify-end gap-1">
<AIButton {isLoading} {callAI} />
</div>
<!-- End AI Button -->
<div class="mt-5 h-[60vh]">
<TextEditor
{content}
onChange={(content) => onEditorChange(content)}
placeholder={$t('course.navItem.lessons.materials.tabs.note.placeholder')}
/>
</div>
{:else}
<!-- View Mode -->
{#if !isHtmlValueEmpty(content)}
<HTMLRender>
{@html sanitizeHtml(content)}
</HTMLRender>
{:else if hasAtLeastOneTranslation}
<p class="text-md py-2 font-normal italic dark:text-white">
{$t('course.navItem.lessons.materials.no_translation')}
</p>
{/if}
{/if}
@@ -0,0 +1,100 @@
<script lang="ts">
import PlusIcon from '@lucide/svelte/icons/plus';
import { IconButton } from '@cio/ui/custom/icon-button';
import { InputField } from '@cio/ui/custom/input-field';
import { RoleBasedSecurity } from '$features/ui';
import CourseContentIcon from '$features/course/components/course-content-icon.svelte';
import type { CourseContent } from '$features/course/utils/types';
import { ContentType } from '@cio/utils/constants/content';
import ContentActions from './content-actions.svelte';
import ContentCountBadges from '../content-count-badges.svelte';
type ContentSection = CourseContent['sections'][number];
const sectionCount = $derived.by(() => {
const items = section.items ?? [];
return {
lessons: items.filter((item) => item.type === ContentType.Lesson).length,
exercises: items.filter((item) => item.type === ContentType.Exercise).length
};
});
interface Props {
section: ContentSection;
isEditing?: boolean;
disabled?: boolean;
isSaving?: boolean;
showActions?: boolean;
showAddContent?: boolean;
showDelete?: boolean;
errors?: Record<string, string>;
lockLabel?: string;
onEdit: () => void;
onSave: () => void;
onCancel: () => void;
onAddContent: () => void;
onToggleLock?: () => void;
onDelete: () => void;
}
let {
section,
isEditing = false,
disabled = false,
isSaving = false,
showActions = true,
showAddContent = true,
showDelete = true,
errors = {},
lockLabel,
onEdit,
onSave,
onCancel,
onAddContent,
onToggleLock,
onDelete
}: Props = $props();
</script>
<div
class="ui:bg-secondary flex min-h-[50px] items-center justify-between rounded-tl-md rounded-tr-md border-b px-3 dark:bg-neutral-700"
>
{#if isEditing}
<div class="flex w-4/6 items-center gap-2">
<CourseContentIcon type={ContentType.Section} size={16} />
<InputField className="flex-1" bind:value={section.title} errorMessage={errors?.title} />
</div>
{:else}
<div class="flex w-4/6 items-center gap-2">
<CourseContentIcon type={ContentType.Section} size={16} />
<p class="font-semibold">{section.title}</p>
<ContentCountBadges lessons={sectionCount.lessons} exercises={sectionCount.exercises} />
</div>
{/if}
{#if showActions}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="flex items-center gap-1">
{#if !isEditing && showAddContent}
<IconButton variant="outline" onclick={onAddContent} {disabled}>
<PlusIcon size={16} />
</IconButton>
{/if}
<ContentActions
{disabled}
{isEditing}
{isSaving}
lockLabel={lockLabel || ''}
showLock={Boolean(lockLabel && onToggleLock)}
{showDelete}
{onEdit}
{onSave}
{onCancel}
{onDelete}
onToggleLock={() => onToggleLock?.()}
/>
</div>
</RoleBasedSecurity>
{/if}
</div>
@@ -0,0 +1,53 @@
<script lang="ts">
import { lessonApi } from '$features/course/api';
import { InputField } from '@cio/ui/custom/input-field';
import { t } from '$lib/utils/functions/translations';
import MODES from '$lib/utils/constants/mode';
interface Props {
mode?: (typeof MODES)[keyof typeof MODES];
}
let { mode = MODES.view }: Props = $props();
let url = $derived(getUrl(lessonApi.lesson?.slideUrl || ''));
function canvaHandler(_url): string {
if (_url.includes('?embed')) return _url;
return `${_url}?embed`;
}
function getUrl(_url: string | null): string | undefined {
if (!_url) return;
if (_url.includes('www.canva.com')) {
return canvaHandler(_url);
}
return _url;
}
</script>
{#if mode === MODES.edit}
<!-- Edit Mode -->
{@const slideUrlValue = lessonApi.lesson?.slideUrl || ''}
<InputField
label={$t('course.navItem.lessons.materials.tabs.slide.slide_link')}
value={slideUrlValue}
onInputChange={(e) => {
lessonApi.updateLessonState('slideUrl', e.currentTarget.value);
}}
helperMessage={$t('course.navItem.lessons.materials.tabs.slide.helper_message')}
/>
{:else}
<!-- View Mode -->
{#if url}
<iframe
title="Embeded Slides"
src={url}
frameborder="0"
width="100%"
height="569"
class="iframe my-3"
allowfullscreen={true}
></iframe>
{/if}
{/if}
@@ -0,0 +1,127 @@
import type { CourseSection, Lesson, ListLessons } from '$features/course/utils/types';
import { get, writable } from 'svelte/store';
import type { Course } from '$features/course/types';
import { courseApi } from '$features/course/api';
import { lessonApi } from '$features/course/api';
import { lessonValidation } from '$lib/utils/functions/validator';
import { profile } from '$lib/utils/store/user';
import { snackbar } from '$features/ui/snackbar/store';
export const lessonVideoUpload = writable({
error: null as string | null,
isCancelled: false,
isModalOpen: false,
isUploading: false,
uploadProgress: 0
});
export const lessonDocUpload = writable({
error: null as string | null,
isCancelled: false,
isModalOpen: false,
isUploading: false,
uploadProgress: 0
});
export async function handleDelete(lessonId: Lesson['id'] | undefined) {
// Need to implement soft delete
if (!lessonId) {
return;
}
const courseId = courseApi.course?.id;
if (!courseId) return;
await lessonApi.delete(courseId, lessonId);
if (lessonApi.success) {
const profileId = get(profile)?.id;
if (profileId) {
await courseApi.refreshCourse(courseId, profileId);
}
snackbar.success('snackbar.generic.success_delete');
}
}
export async function handleSaveLesson(lesson: Partial<ListLessons[number]>, courseId: Course['id']) {
const result = lessonValidation(lesson);
if (Object.keys(result).length) {
return { validationRes: result, lesson: null };
}
const lessonData = {
title: lesson.title || '',
lessonAt: lesson.lessonAt!,
callUrl: lesson.callUrl!,
teacherId: lesson.teacherId!,
isUnlocked: lesson.isUnlocked!,
sectionId: lesson.sectionId!
};
if (lesson.id) {
await lessonApi.update(courseId, lesson.id, lessonData);
if (lessonApi.success && lessonApi.lesson) {
return { lesson: lessonApi.lesson as Lesson };
}
} else {
await lessonApi.create(courseId, { ...lessonData, courseId });
if (lessonApi.success && lessonApi.lesson) {
return { lesson: lessonApi.lesson as Lesson };
}
}
return { validationRes: null, lesson: null };
}
export async function handleSaveCourseSection(section: Partial<CourseSection>, courseId: Course['id']) {
const result = lessonValidation(section);
if (Object.keys(result).length) {
return result;
}
const sectionData = {
title: section.title!
};
if (section.id) {
await lessonApi.updateSection(courseId, section.id, sectionData);
if (lessonApi.success && lessonApi.lesson) {
return [lessonApi.lesson];
}
} else {
await lessonApi.createSection(courseId, { ...sectionData, courseId });
if (lessonApi.success && lessonApi.lesson) {
return [lessonApi.lesson];
}
}
return null;
}
export function resetDocumentUploadStore() {
lessonDocUpload.set({
isUploading: false,
isModalOpen: false,
uploadProgress: 0,
error: null,
isCancelled: false
});
}
export function cancelDocumentUpload() {
lessonDocUpload.update((store) => ({
...store,
isCancelled: true,
isUploading: false,
error: 'Upload cancelled by user'
}));
}
export function cancelVideoUpload() {
lessonVideoUpload.update((store) => ({
...store,
isCancelled: true,
isUploading: false
}));
}
@@ -0,0 +1,25 @@
import * as CONSTANTS from './constants';
import type { Component } from 'svelte';
import Document from './document/document.svelte';
import Note from './note/note.svelte';
import Slide from './slide/slide.svelte';
import Video from './video/video.svelte';
export function getViewModeComponents(tabs = CONSTANTS.tabs): Component[] {
const componentMap: Record<number, Component> = {
1: Note,
2: Slide,
3: Video,
4: Document
};
const componentNames = tabs
.map((tab) => {
const component = componentMap[tab.value];
return component || null;
})
.filter((component): component is Component => component !== null);
return componentNames;
}
@@ -0,0 +1,62 @@
<script lang="ts">
import YoutubeVideo from './youtube-video.svelte';
import UploadVideo from './upload-video.svelte';
import EmbedLink from './embed-link.svelte';
import LibraryVideo from './library-video.svelte';
import * as CONSTANTS from './constants';
import { t } from '$lib/utils/functions/translations';
import { isFreePlan } from '$lib/utils/store/org';
import ZapIcon from '@lucide/svelte/icons/zap';
import * as Dialog from '@cio/ui/base/dialog';
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
import { lessonVideoUpload } from '$features/course/components/lesson/store';
interface Props {
lessonId?: string;
onClose?: () => void;
}
let { lessonId = '', onClose = () => {} }: Props = $props();
const tabs = CONSTANTS.videoTabs;
let currentTab = $state(String(tabs[0].value));
</script>
<Dialog.Root
bind:open={$lessonVideoUpload.isModalOpen}
onOpenChange={(isOpen) => {
if (!isOpen) onClose();
}}
>
<Dialog.Content class="flex max-h-[680px] max-w-2xl! flex-col overflow-hidden">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.materials.tabs.video.add_video.title')}</Dialog.Title>
</Dialog.Header>
<UnderlineTabs.Root bind:value={currentTab} class="">
<UnderlineTabs.List>
{#each tabs as item (item.value)}
<UnderlineTabs.Trigger value={String(item.value)} class="ui:flex ui:items-center ui:gap-2">
{#if $isFreePlan && item.value === 3}
<ZapIcon size={16} class="filled" />
{:else}
<item.icon />
{/if}
<span>{$t(item.title)}</span>
</UnderlineTabs.Trigger>
{/each}
</UnderlineTabs.List>
<UnderlineTabs.Content value="1" class="mt-3">
<YoutubeVideo {lessonId} />
</UnderlineTabs.Content>
<UnderlineTabs.Content value="2" class="mt-3">
<EmbedLink {lessonId} />
</UnderlineTabs.Content>
<UnderlineTabs.Content value="3" class="mt-3">
<UploadVideo {lessonId} />
</UnderlineTabs.Content>
<UnderlineTabs.Content value="4" class="mt-3">
<LibraryVideo {lessonId} />
</UnderlineTabs.Content>
</UnderlineTabs.Root>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,27 @@
import FileSymlinkIcon from '@lucide/svelte/icons/file-symlink';
import LibraryIcon from '@lucide/svelte/icons/library';
import VideoIcon from '@lucide/svelte/icons/video';
import YoutubeIcon from '@lucide/svelte/icons/youtube';
export const videoTabs = [
{
value: 1,
title: 'course.navItem.lessons.materials.tabs.video.add_video.youtube_link',
icon: YoutubeIcon
},
{
value: 2,
title: 'course.navItem.lessons.materials.tabs.video.embed_link',
icon: FileSymlinkIcon
},
{
value: 3,
title: 'course.navItem.lessons.materials.tabs.video.upload',
icon: VideoIcon
},
{
value: 4,
title: 'course.navItem.lessons.materials.tabs.video.library',
icon: LibraryIcon
}
];
@@ -0,0 +1,125 @@
<script lang="ts">
import { Badge } from '@cio/ui/base/badge';
import CopyIcon from '@lucide/svelte/icons/copy';
import TrashIcon from '@lucide/svelte/icons/trash';
import { t } from '$lib/utils/functions/translations';
import type { LessonVideoType } from '$features/course/utils/types';
import { lessonApi } from '$features/course/api';
import { mediaApi } from '$features/media/api';
import { copyToClipboard, getVideoUrls, removeVideo } from '$lib/utils/functions/formatYoutubeVideo';
import { IconButton } from '@cio/ui/custom/icon-button';
import { InputField } from '@cio/ui/custom/input-field';
import { Button } from '@cio/ui/base/button';
interface Props {
lessonId?: string;
}
let { lessonId = '' }: Props = $props();
let genericLinks = $state('');
let error = $state('');
async function addVideo() {
const links = getVideoUrls(genericLinks);
const validLinks = links.filter(isValidLink);
if (validLinks.length === 0) {
error = $t('course.navItem.lessons.materials.tabs.video.add_video.invalid_link');
} else {
if (!lessonApi.lesson) return;
const existingCount = Array.isArray(lessonApi.lesson.videos) ? lessonApi.lesson.videos.length : 0;
const newVideos = await Promise.all(
validLinks.map(async (link = '', index) => {
const createdAt = new Date().toISOString();
const asset = await mediaApi.createAsset({
kind: 'video',
provider: 'generic',
storageProvider: 'external',
sourceUrl: link,
isExternal: true,
title: link,
metadata: { createdAt }
});
if (asset && lessonId) {
await mediaApi.attachAsset(asset.id, {
targetType: 'lesson',
targetId: lessonId,
slotType: 'lesson_video',
position: existingCount + index
});
}
return {
type: 'generic' as LessonVideoType,
link,
assetId: asset?.id,
metadata: { createdAt } as { svid?: string; createdAt?: string }
};
})
);
lessonApi.updateLessonState('videos', newVideos, { append: true });
genericLinks = '';
error = '';
}
}
function isValidLink(link = '') {
// Basic URL validation
if (!link) return false;
try {
const url = new URL(link.trim());
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
// If URL construction fails, try prepending https://
try {
return !!new URL(`https://${link.trim()}`);
} catch {
return false;
}
}
}
</script>
<div class="flex w-full items-{error ? 'center' : 'end'} justify-between gap-5">
<InputField
label={$t('course.navItem.lessons.materials.tabs.video.embed_link')}
bind:value={genericLinks}
className="flex-1"
onchange={() => (lessonApi.isDirty = true)}
placeholder="https://www.videoplayer.com/"
errorMessage={error}
/>
<Button onclick={addVideo}>
{$t('course.navItem.lessons.materials.tabs.video.add_video.add_video')}
</Button>
</div>
<p class="mt-4 pl-2 text-sm">
{$t('course.navItem.lessons.materials.tabs.video.add_video.videos_added')}:
<strong>
{lessonApi.lesson?.videos?.filter((v) => v.type === 'generic').length || 0}
</strong>
</p>
<div class="">
{#each lessonApi.lesson?.videos || [] as video, index}
{#if video.type === 'generic'}
<div class="flex items-center gap-1">
<Badge class="max-w-md truncate" variant="secondary">
{video.link}
</Badge>
<IconButton onclick={() => copyToClipboard(video.link)}>
<CopyIcon size={16} />
</IconButton>
<IconButton onclick={() => removeVideo(index)}>
<TrashIcon size={16} />
</IconButton>
</div>
{/if}
{/each}
</div>
@@ -0,0 +1,91 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import { InputField } from '@cio/ui/custom/input-field';
import { lessonApi } from '$features/course/api';
import { mediaApi } from '$features/media/api';
import type { OrganizationAsset } from '$features/media/utils';
import { onMount } from 'svelte';
import { t } from '$lib/utils/functions/translations';
interface Props {
lessonId?: string;
}
let { lessonId = '' }: Props = $props();
let search = $state('');
let isLoading = $state(false);
const assets = $derived(
mediaApi.assets.filter((asset) => asset.kind === 'video' && (asset.status ?? 'active') !== 'archived')
);
async function loadLibrary() {
isLoading = true;
await mediaApi.listAssets({
kind: 'video',
limit: 100,
search: search.trim() || undefined
});
isLoading = false;
}
async function addFromLibrary(asset: OrganizationAsset) {
if (!lessonApi.lesson) return;
const position = Array.isArray(lessonApi.lesson.videos) ? lessonApi.lesson.videos.length : 0;
const lessonVideo = await mediaApi.buildLessonVideoFromAsset(asset, {
lessonId: lessonId || undefined,
position,
slotType: 'lesson_video'
});
if (!lessonVideo) {
return;
}
lessonApi.updateLessonState('videos', [lessonVideo], { append: true });
}
onMount(async () => {
await loadLibrary();
});
</script>
<div class="space-y-3">
<div class="flex items-end gap-3">
<InputField
label={$t('course.navItem.lessons.materials.tabs.video.add_video.search_library')}
bind:value={search}
className="flex-1"
placeholder={$t('course.navItem.lessons.materials.tabs.video.add_video.search_library')}
/>
<Button onclick={loadLibrary} loading={isLoading} disabled={isLoading}>
{$t('course.navItem.lessons.materials.tabs.video.add_video.search_library_action')}
</Button>
</div>
{#if isLoading}
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.lessons.materials.tabs.video.add_video.loading_library')}
</p>
{:else if assets.length === 0}
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.lessons.materials.tabs.video.add_video.no_library_videos')}
</p>
{:else}
<div class="max-h-[360px] space-y-2 overflow-auto pr-1">
{#each assets as asset (asset.id)}
<div class="flex items-center justify-between rounded-md border p-3">
<div class="min-w-0">
<p class="truncate text-sm font-medium">{asset.title || asset.storageKey || asset.id}</p>
<p class="ui:text-muted-foreground text-xs">{asset.provider}</p>
</div>
<Button size="sm" onclick={() => addFromLibrary(asset)}>
{$t('course.navItem.lessons.materials.tabs.video.add_video.add_from_library')}
</Button>
</div>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,33 @@
import { classroomio } from '$lib/utils/services/api';
import { generateVideoThumbnail } from './video-thumbnail-utils';
export type VideoThumbnailUploadResult = { url: string; durationSeconds: number };
/**
* Handles media uploads (e.g. video thumbnails) via the media API.
*/
export class MediaUploader {
/**
* Generates a thumbnail from the video file and uploads it to the media bucket.
* Returns the public thumbnail URL and video duration, or null if generation or upload fails.
*/
async uploadVideoThumbnail(videoFile: File): Promise<VideoThumbnailUploadResult | null> {
const thumbnailResult = await generateVideoThumbnail(videoFile);
if (!thumbnailResult) return null;
const thumbnailFileName = videoFile.name.replace(/\.[^.]+$/, '') + '-thumbnail.jpg';
const file = new File([thumbnailResult.blob], thumbnailFileName, { type: 'image/jpeg' });
try {
const response = await classroomio.media.image.$post({ form: { file } });
const data = await response.json();
if (!data.success || !data.url) return null;
return {
url: data.url,
durationSeconds: thumbnailResult.durationSeconds
};
} catch {
return null;
}
}
}
@@ -0,0 +1,308 @@
<script lang="ts">
import { Progress } from '@cio/ui/base/progress';
import { UpgradeBanner } from '$features/ui';
import { lessonVideoUpload, cancelVideoUpload } from '$features/course/components/lesson/store';
import { lessonApi } from '$features/course/api';
import { isFreePlan } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { VideoUploader } from '$lib/utils/services/courses/presign';
import { MediaUploader } from './media-uploader';
import { Button } from '@cio/ui/base/button';
import * as FileDropZone from '@cio/ui/custom/file-drop-zone';
import { mediaApi } from '$features/media/api';
const ADD_VIDEO = 'course.navItem.lessons.materials.tabs.video.add_video';
interface Props {
lessonId?: string;
}
let { lessonId: _lessonId = '' }: Props = $props();
let fileSize: number | undefined = $state();
let formRes: {
url?: string;
fileKey?: string;
status?: number;
type?: string;
message?: string;
thumbnailUrl?: string;
} | null = $state(null);
let isLoaded = $state(false);
let fileName = $state('');
const isDisabled = $derived($lessonVideoUpload.isUploading || $isFreePlan);
const videoUploader = new VideoUploader();
const mediaUploader = new MediaUploader();
async function handleUpload(files: File[]) {
const videoFile = files[0];
if (!videoFile) return;
if ($isFreePlan) {
formRes = {
type: 'AUTHORIZATION_ERROR',
status: 403,
message: t.get('upgrade.required')
};
isLoaded = true;
return;
}
videoUploader.initUpload();
fileSize = videoFile.size / (1024 * 1024);
fileName = videoFile.name;
try {
const { url: presignedUrl, fileKey } = await videoUploader.getPresignedUrl(videoFile);
const [, thumbnailResult] = await Promise.all([
videoUploader.uploadFile({
url: presignedUrl,
file: videoFile
}),
mediaUploader.uploadVideoThumbnail(videoFile)
]);
const { urls: presignedUrls } = await videoUploader.getDownloadPresignedUrl([fileKey]);
formRes = {
url: presignedUrls[fileKey],
fileKey: fileKey,
status: 200,
thumbnailUrl: thumbnailResult?.url
};
const thumbnailUrl = thumbnailResult?.url;
const durationSeconds = thumbnailResult?.durationSeconds;
const normalizedDurationSeconds =
durationSeconds != null && Number.isFinite(durationSeconds)
? Math.max(0, Math.round(durationSeconds))
: undefined;
const metadata: {
fileName?: string;
createdAt?: string;
thumbnailUrl?: string;
duration?: number;
} = {
fileName: videoFile.name,
createdAt: new Date().toISOString()
};
if (thumbnailUrl) metadata.thumbnailUrl = thumbnailUrl;
if (normalizedDurationSeconds != null) metadata.duration = normalizedDurationSeconds;
const lessonVideoPosition = Array.isArray(lessonApi.lesson?.videos) ? lessonApi.lesson?.videos.length : 0;
let assetId: string | undefined;
if (_lessonId) {
const asset = await mediaApi.registerUploadedLessonVideo({
lessonId: _lessonId,
position: lessonVideoPosition,
fileKey,
videoUrl: formRes.url!,
fileName: videoFile.name,
mimeType: videoFile.type,
byteSize: videoFile.size,
thumbnailUrl,
durationSeconds: normalizedDurationSeconds
});
assetId = asset?.id;
} else {
const asset = await mediaApi.createAsset({
kind: 'video',
provider: 'upload',
storageProvider: 's3',
storageKey: fileKey,
sourceUrl: formRes.url!,
mimeType: videoFile.type,
byteSize: videoFile.size,
title: videoFile.name,
thumbnailUrl,
durationSeconds: normalizedDurationSeconds,
isExternal: false,
metadata
});
assetId = asset?.id;
}
lessonApi.updateLessonState(
'videos',
[
{
type: 'upload',
link: formRes.url!,
key: formRes?.fileKey,
assetId,
fileName: videoFile.name,
metadata
} as {
type: 'upload';
link: string;
key?: string;
assetId?: string;
metadata?: Record<string, unknown>;
fileName?: string;
}
],
{ append: true }
);
isLoaded = false;
} catch (err: any) {
console.error('Error uploading video', err, '\n\n', err.response);
if ($lessonVideoUpload.isCancelled) {
formRes = {
type: 'CANCELLED',
status: 0,
message: t.get(`${ADD_VIDEO}.upload_cancelled`)
};
} else if (err.response) {
formRes = err.response.data;
} else {
let message = 'An error occurred while uploading the video';
if (err instanceof Error) {
message = err.message;
}
formRes = {
type: 'INTERNAL_ERROR',
status: 500,
message
};
}
} finally {
videoUploader.abortController = null;
isLoaded = true;
$lessonVideoUpload.isUploading = false;
}
}
function onFileRejected() {
formRes = {
type: 'FILE_TOO_LARGE',
status: 413,
message: t.get(`${ADD_VIDEO}.maximum_size`)
};
isLoaded = true;
}
function tryAgain() {
formRes = null;
isLoaded = false;
$lessonVideoUpload.isUploading = false;
$lessonVideoUpload.uploadProgress = 0;
}
function cancelUpload() {
videoUploader.abortController?.abort();
cancelVideoUpload();
formRes = null;
isLoaded = false;
$lessonVideoUpload.uploadProgress = 0;
}
let helperText = $derived($lessonVideoUpload.uploadProgress + '% of ' + Math.round(fileSize || 0) + 'MB');
</script>
<UpgradeBanner className="mb-3" onClick={() => ($lessonVideoUpload.isModalOpen = false)}>
{$t(`${ADD_VIDEO}.upgrade`)}
</UpgradeBanner>
{#if !isLoaded}
{#if $lessonVideoUpload.isUploading}
<div
class="border-primary-300 flex h-full w-full flex-col items-center justify-center rounded-xl border border-dashed"
>
<div class="flex w-[60%] max-w-[500px] flex-col justify-center gap-5">
<p class="mt-5 text-center">
{$t(`${ADD_VIDEO}.uploading`)}
</p>
<Progress class="w-full" value={$lessonVideoUpload.uploadProgress} max={100} />
<p class="text-sm">{helperText}</p>
<div class="mt-3">
<Button variant="outline" onclick={cancelUpload}>
{$t(`${ADD_VIDEO}.cancel_upload`)}
</Button>
</div>
</div>
</div>
{:else}
<div class="h-full w-full {isDisabled ? 'ui:opacity-50 ui:pointer-events-none' : ''}">
<FileDropZone.Root
accept="video/*"
maxFiles={1}
fileCount={0}
maxFileSize={500 * FileDropZone.MEGABYTE}
onUpload={handleUpload}
{onFileRejected}
>
<FileDropZone.Trigger
label={$t(`${ADD_VIDEO}.select_file`)}
formatMaxFiles={(count) => $t(`${ADD_VIDEO}.max_files`, { count })}
formatMaxFilesAndSize={(size) => $t(`${ADD_VIDEO}.max_files_and_size`, { size })}
formatMaxSize={() => $t(`${ADD_VIDEO}.size`)}
/>
</FileDropZone.Root>
</div>
{/if}
{:else if formRes?.type === 'FILE_TOO_LARGE'}
<div class="flex h-full w-full flex-col items-center justify-center rounded-xl">
<img src="/video-upload-error.svg" alt="upload error" />
<span class="pt-3 pb-2">
<h3 class="text-center text-base font-normal dark:text-white">
{$t(`${ADD_VIDEO}.oops`)}
</h3>
<p class="text-center text-xs font-normal text-[#ADADAD]">
{$t(`${ADD_VIDEO}.big_file`)}<br />
{$t(`${ADD_VIDEO}.maximum_size`)}
</p>
</span>
<Button onclick={tryAgain}>
{$t(`${ADD_VIDEO}.button`)}
</Button>
</div>
{:else if formRes?.status !== 200}
<div class="flex h-full w-full flex-col items-center justify-center rounded-xl">
<img src="/video-upload-error.svg" alt="upload error" />
<span class="pt-3 pb-2">
<h3 class="text-center text-base font-normal dark:text-white">
{$t(`${ADD_VIDEO}.oops`)}
</h3>
<p class="text-center text-xs font-normal text-[#ADADAD]">
{$t(`${ADD_VIDEO}.unsupported_format`)}<br />
{$t(`${ADD_VIDEO}.format`)}
</p>
</span>
<Button onclick={tryAgain}>
{$t(`${ADD_VIDEO}.try_again`)}
</Button>
</div>
{:else}
<div class=" w-full rounded-md border px-8 py-3">
<div class="flex flex-col items-center gap-8">
<div class="flex flex-col items-center gap-2">
{#if formRes.thumbnailUrl}
<img
src={formRes.thumbnailUrl}
alt={fileName}
class="border-primary-300 h-auto w-[200px] rounded-md border object-cover"
/>
{:else}
<video class="border-primary-300 w-[200px] rounded-md border">
<source src={formRes.url} type="video/mp4" />
<track kind="captions" />
<p>{$t('generic.loading')}</p>
</video>
{/if}
<p>{fileName}</p>
</div>
<Button onclick={() => ($lessonVideoUpload.isModalOpen = false)}>
{$t('course.navItem.lessons.materials.button_done')}
</Button>
</div>
</div>
{/if}
@@ -0,0 +1,28 @@
<script lang="ts">
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { IconButton } from '@cio/ui/custom/icon-button';
import { t } from '$lib/utils/functions/translations';
interface Props {
onRemove: () => void;
}
let { onRemove }: Props = $props();
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="ui:data-[state=open]:opacity-100 absolute top-2 right-2 z-40 flex items-center justify-center opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100"
onclick={(e) => e.stopPropagation()}
>
<IconButton variant="outline">
<EllipsisVerticalIcon size={16} />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item class="ui:text-red-600" onclick={onRemove}>
{$t('course.navItem.lessons.materials.tabs.video.remove_video')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -0,0 +1,100 @@
import { getYoutubeVideoId } from '@cio/ui/custom/media-player';
export type LessonVideo = NonNullable<import('$features/course/utils/types').Lesson['videos']>[number];
type VideoMetadata = {
title?: string;
fileName?: string;
aspectRatio?: string;
duration?: number;
createdAt?: string;
thumbnailUrl?: string;
};
/**
* Thumbnail URL for a lesson video. YouTube uses img.youtube.com; uploads use metadata.thumbnailUrl when present.
*/
export function getVideoThumbnailUrl(video: LessonVideo): string | null {
if (video.type === 'youtube' && video.link) {
const id = getYoutubeVideoId(video.link);
return id ? `https://img.youtube.com/vi/${id}/mqdefault.jpg` : null;
}
if (video.type === 'upload') {
const meta = video.metadata as VideoMetadata | undefined;
if (meta?.thumbnailUrl && typeof meta.thumbnailUrl === 'string') return meta.thumbnailUrl;
}
return null;
}
/**
* Format duration in seconds as M:SS. Returns null if no duration.
*/
export function formatVideoDuration(seconds: number | undefined | null): string | null {
if (seconds == null || typeof seconds !== 'number' || seconds < 0) return null;
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
/**
* Fallback: derive display name from upload key (format: "nanoid-filename.ext").
* Used only for older uploads that don't have top-level fileName.
*/
function getFileNameFromKey(key: string | undefined): string | null {
if (!key || typeof key !== 'string') return null;
const dashIndex = key.indexOf('-');
if (dashIndex === -1) return null;
return key.slice(dashIndex + 1).trim() || null;
}
/**
* Display title for a video.
* Order: top-level fileName → metadata.title → (upload only) derive from key → type fallback → "Video N".
*/
export function getVideoTitle(video: LessonVideo, index: number): string {
const topLevelName = (video as LessonVideo & { fileName?: string }).fileName;
if (topLevelName && typeof topLevelName === 'string') return topLevelName;
const meta = video.metadata as VideoMetadata | undefined;
if (meta?.title && typeof meta.title === 'string') return meta.title;
if (video.type === 'upload' && video.key) {
const fromKey = getFileNameFromKey(video.key);
if (fromKey) return fromKey;
}
if (video.type === 'youtube') return 'YouTube video';
if (video.type === 'generic') return 'Embedded video';
return `Video ${index + 1}`;
}
/**
* Duration in seconds from metadata, if present.
*/
export function getVideoDurationSeconds(video: LessonVideo): number | null {
const meta = video.metadata as VideoMetadata | undefined;
if (meta?.duration != null && typeof meta.duration === 'number') return meta.duration;
return null;
}
/**
* Created at ISO string from metadata, if present.
*/
export function getVideoCreatedAt(video: LessonVideo): string | null {
const meta = video.metadata as VideoMetadata | undefined;
if (meta?.createdAt && typeof meta.createdAt === 'string') return meta.createdAt;
return null;
}
/**
* Format created-at for display (e.g. "Feb 15, 2026").
*/
export function formatVideoCreatedAt(isoString: string | null | undefined): string | null {
if (!isoString || typeof isoString !== 'string') return null;
const d = new Date(isoString);
if (Number.isNaN(d.getTime())) return null;
return new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(d);
}
@@ -0,0 +1,96 @@
<script lang="ts">
import * as Item from '@cio/ui/base/item';
import { Image } from '$features/ui';
import VideoIcon from '@lucide/svelte/icons/video';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import VideoCardDropdown from './video-card-dropdown.svelte';
import {
getVideoThumbnailUrl,
getVideoTitle,
getVideoDurationSeconds,
formatVideoDuration,
getVideoCreatedAt,
formatVideoCreatedAt,
type LessonVideo
} from './video-card-utils';
interface Props {
video: LessonVideo;
index: number;
isEditMode: boolean;
onRemove: () => void;
}
let { video, index, isEditMode, onRemove }: Props = $props();
const thumbnailUrl = $derived(getVideoThumbnailUrl(video));
const title = $derived(getVideoTitle(video, index));
const durationSeconds = $derived(getVideoDurationSeconds(video));
const durationFormatted = $derived(formatVideoDuration(durationSeconds ?? undefined));
const createdAtIso = $derived(getVideoCreatedAt(video));
const createdAtFormatted = $derived(formatVideoCreatedAt(createdAtIso ?? undefined));
const isYoutubeWithLink = $derived(video.type === 'youtube' && !!video.link);
</script>
<Item.Root variant="outline" class="group relative max-w-[320px] cursor-default! p-3!">
<div class="flex flex-col">
{#if isEditMode}
<VideoCardDropdown {onRemove} />
{/if}
<Item.Header>
<Item.Media variant="image" class="relative h-50! w-full! overflow-hidden rounded-sm">
{#if thumbnailUrl}
<Image src={thumbnailUrl} alt={title} className="h-full w-full object-cover" />
{:else}
<div
class="ui:bg-muted flex h-full w-full flex-col items-center justify-center gap-2 px-3 py-4"
role="img"
aria-label={title}
>
<VideoIcon class="ui:text-muted-foreground size-10 shrink-0" />
<span class="ui:text-muted-foreground line-clamp-2 max-w-full text-center text-xs font-medium">
{title}
</span>
</div>
{/if}
{#if durationFormatted}
<div class="absolute right-2 bottom-2 rounded bg-black/70 px-1.5 py-0.5 text-xs font-medium text-white">
{durationFormatted}
</div>
{/if}
</Item.Media>
</Item.Header>
<Item.Content>
<Item.Title class="line-clamp-2 min-h-[44px] text-base!" {title}>
{#if isYoutubeWithLink}
<a
href={video.link}
target="_blank"
rel="noopener noreferrer"
class="ui:text-foreground hover:ui:underline focus:ui:underline focus:ui:outline-none inline-flex items-center gap-1.5"
>
<span class="line-clamp-2">{title}</span>
<ExternalLinkIcon class="ui:text-muted-foreground size-3.5 shrink-0" aria-hidden="true" />
</a>
{:else}
{title}
{/if}
</Item.Title>
<p class="ui:text-muted-foreground mt-1 text-xs">
{#if createdAtFormatted && durationFormatted}
{createdAtFormatted} · {durationFormatted}
{:else if createdAtFormatted}
{createdAtFormatted}
{:else if durationFormatted}
{durationFormatted}
{:else}
–
{/if}
</p>
</Item.Content>
</div>
</Item.Root>
@@ -0,0 +1,79 @@
const JPEG_QUALITY = 0.85;
const DEFAULT_SEEK_SECONDS = 1;
const THUMBNAIL_TIMEOUT_MS = 15_000;
export type ThumbnailResult = { blob: Blob; durationSeconds: number };
/**
* Generates a thumbnail from a video file by capturing a frame to canvas.
* Seeks to 1s or duration/4 for short clips. Returns null on failure.
*/
export function generateVideoThumbnail(videoFile: File): Promise<ThumbnailResult | null> {
return new Promise((resolve) => {
const video = document.createElement('video');
video.muted = true;
video.playsInline = true;
video.preload = 'metadata';
const objectUrl = URL.createObjectURL(videoFile);
video.src = objectUrl;
const cleanup = () => {
video.removeAttribute('src');
video.load();
URL.revokeObjectURL(objectUrl);
};
const timeoutId = setTimeout(() => {
cleanup();
resolve(null);
}, THUMBNAIL_TIMEOUT_MS);
const finish = (result: ThumbnailResult | null) => {
clearTimeout(timeoutId);
cleanup();
resolve(result);
};
video.addEventListener('error', () => finish(null));
video.addEventListener('loadedmetadata', () => {
const duration = video.duration;
const seekTo = !Number.isFinite(duration) || duration <= 0 ? 0 : Math.min(DEFAULT_SEEK_SECONDS, duration / 4);
video.currentTime = seekTo;
});
video.addEventListener('seeked', () => {
try {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
if (canvas.width <= 0 || canvas.height <= 0) {
finish(null);
return;
}
const ctx = canvas.getContext('2d');
if (!ctx) {
finish(null);
return;
}
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const durationSeconds = video.duration;
canvas.toBlob(
(blob) => {
if (blob) {
finish({ blob, durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : 0 });
} else {
finish(null);
}
},
'image/jpeg',
JPEG_QUALITY
);
} catch {
finish(null);
}
});
video.load();
});
}
@@ -0,0 +1,93 @@
<script lang="ts">
import { lessonApi } from '$features/course/api';
import { MediaPlayer } from '@cio/ui/custom/media-player';
import { DeleteModal } from '$features/ui';
import { Button } from '@cio/ui/base/button';
import { Empty } from '@cio/ui/custom/empty';
import * as Item from '@cio/ui/base/item';
import { lessonVideoUpload } from '$features/course/components/lesson/store';
import { t } from '$lib/utils/functions/translations';
import MODES from '$lib/utils/constants/mode';
import VideoIcon from '@lucide/svelte/icons/video';
import VideoCard from './video-card.svelte';
interface Props {
mode?: (typeof MODES)[keyof typeof MODES];
}
let { mode = MODES.view }: Props = $props();
const videos = $derived(lessonApi.lesson?.videos || []);
let openDeleteVideoModal = $state(false);
let videoIndexToDelete = $state<number | null>(null);
const openAddVideoModal = () => {
$lessonVideoUpload.isModalOpen = true;
};
function requestRemoveVideo(index: number) {
videoIndexToDelete = index;
openDeleteVideoModal = true;
}
function confirmRemoveVideo() {
if (videoIndexToDelete !== null) {
lessonApi.deleteLessonVideo(videoIndexToDelete);
videoIndexToDelete = null;
}
openDeleteVideoModal = false;
}
</script>
{#snippet content(video)}
{#key video.link}
<MediaPlayer
source={{
type: video.type,
url: video.link,
metadata: video.metadata
}}
options={{
maxHeight: '569px',
width: '100%',
controls: true,
playsinline: true
}}
/>
{/key}
{/snippet}
{#if mode === MODES.edit}
<!-- Edit Mode: grid of video cards with remove + delete confirmation -->
<Button onclick={openAddVideoModal} class="float-end my-4">
{$t('course.navItem.lessons.materials.tabs.video.button')}
</Button>
{#if videos.length}
<Item.Group class="grid! w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{#each videos as video, index}
<VideoCard {video} {index} isEditMode={true} onRemove={() => requestRemoveVideo(index)} />
{/each}
</Item.Group>
{:else}
<Empty
title={$t('course.navItem.lessons.materials.tabs.video.empty_title')}
description={$t('course.navItem.lessons.materials.tabs.video.empty_description')}
icon={VideoIcon}
/>
{/if}
<DeleteModal bind:open={openDeleteVideoModal} onDelete={confirmRemoveVideo} />
{:else}
<!-- View Mode -->
{#if videos.length}
<div class="w-full">
{#each videos as video}
<div class="mb-5 w-full overflow-hidden">
{@render content(video)}
</div>
{/each}
</div>
{/if}
{/if}
@@ -0,0 +1,103 @@
<script lang="ts">
import { Badge } from '@cio/ui/base/badge';
import CopyIcon from '@lucide/svelte/icons/copy';
import TrashIcon from '@lucide/svelte/icons/trash';
import { t } from '$lib/utils/functions/translations';
import { IconButton } from '@cio/ui/custom/icon-button';
import { YoutubeLinkForm, isValidYoutubeLink } from '@cio/ui/custom/youtube-link-form';
import type { LessonVideoType } from '$features/course/utils/types';
import { copyToClipboard, removeVideo } from '$lib/utils/functions/formatYoutubeVideo';
import { lessonApi } from '$features/course/api';
import { mediaApi } from '$features/media/api';
interface Props {
lessonId?: string;
}
let { lessonId = '' }: Props = $props();
async function addVideo(validLinks: string[]) {
if (!lessonApi.lesson || validLinks.length === 0) return;
const existingCount = Array.isArray(lessonApi.lesson.videos) ? lessonApi.lesson.videos.length : 0;
const newVideos = await Promise.all(
validLinks.map(async (link = '', index) => {
const createdAt = new Date().toISOString();
const metadata = await mediaApi.getYouTubeMetadata(link);
const sourceUrl = metadata?.sourceUrl ?? link;
const title = metadata?.title ?? $t('media_manager.provider.youtube');
const videoMetadata = {
createdAt,
videoId: metadata?.videoId,
title,
thumbnailUrl: metadata?.thumbnailUrl ?? undefined,
duration: metadata?.durationSeconds ?? undefined
};
const asset = await mediaApi.createAsset({
kind: 'video',
provider: 'youtube',
storageProvider: 'external',
sourceUrl,
isExternal: true,
title,
thumbnailUrl: metadata?.thumbnailUrl ?? undefined,
durationSeconds: metadata?.durationSeconds ?? undefined,
metadata: videoMetadata
});
if (asset && lessonId) {
await mediaApi.attachAsset(asset.id, {
targetType: 'lesson',
targetId: lessonId,
slotType: 'lesson_video',
position: existingCount + index
});
}
return {
type: 'youtube' as LessonVideoType,
link: sourceUrl,
assetId: asset?.id,
fileName: title,
metadata: videoMetadata
};
})
);
lessonApi.updateLessonState('videos', newVideos, { append: true });
}
</script>
<YoutubeLinkForm
inputLabel={$t('course.navItem.lessons.materials.tabs.video.add_video.youtube_link')}
inputPlaceholder={$t('course.navItem.lessons.materials.tabs.video.add_video.youtube_link')}
addButtonLabel={$t('course.navItem.lessons.materials.tabs.video.add_video.add_video')}
invalidYoutubeMessage={$t('course.navItem.lessons.materials.tabs.video.add_video.invalid_youtube')}
onInputChange={() => (lessonApi.isDirty = true)}
onSubmit={addVideo}
/>
<p class="mt-4 pl-2 text-sm">
{$t('course.navItem.lessons.materials.tabs.video.add_video.videos_added')}:
<strong>
{lessonApi.lesson?.videos?.filter((v) => v.type === 'youtube' && isValidYoutubeLink(v.link)).length || 0}
</strong>
</p>
<div class="">
{#each lessonApi.lesson?.videos || [] as video, index}
{#if video.type === 'youtube'}
<div class="flex items-center gap-1">
<Badge class="max-w-md truncate" variant="secondary">
{video.link}
</Badge>
<IconButton onclick={() => copyToClipboard(video.link)}>
<CopyIcon size={16} />
</IconButton>
<IconButton onclick={() => removeVideo(index)}>
<TrashIcon size={16} />
</IconButton>
</div>
{/if}
{/each}
</div>