feat: add sequential progression and video watch lesson completion
Gate student content by completion order, track per-video watch progress with seek enforcement, and auto-complete lessons at the configured threshold. Show ring progress in the lesson header, keep watch progress on rewatch, and sync progression locks client-side so the next item unlocks after completion.
This commit is contained in:
@@ -52,8 +52,7 @@
|
||||
hydrateExercisePageData
|
||||
// , refreshExercisePageData
|
||||
} from '$features/course/utils/exercise-page-utils';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import StudentContentLockedNotice from '$features/course/components/student-content-locked-notice.svelte';
|
||||
import {
|
||||
getQuestionTypeId,
|
||||
getQuestionTypeKeyFromId
|
||||
@@ -324,7 +323,23 @@
|
||||
(item) => item.type === ContentType.Exercise && item.id === exerciseId
|
||||
)
|
||||
);
|
||||
const isExerciseUnlocked = $derived(exerciseContentItem?.isUnlocked ?? false);
|
||||
const isCourseContentReady = $derived(courseApi.course?.id != null);
|
||||
const isExerciseTeacherLocked = $derived((exerciseContentItem?.isUnlocked ?? true) === false);
|
||||
const isExerciseProgressionLocked = $derived(
|
||||
$isOrgStudent && exerciseContentItem?.accessible === false && !isExerciseTeacherLocked
|
||||
);
|
||||
const isExerciseAccessible = $derived.by(() => {
|
||||
if ($isOrgStudent && (!isCourseContentReady || !exerciseContentItem)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !isExerciseTeacherLocked && !isExerciseProgressionLocked;
|
||||
});
|
||||
const exerciseDisplayTitle = $derived(
|
||||
exerciseContentItem?.title ||
|
||||
$questionnaire.title ||
|
||||
t.get('course.navItem.lessons.exercises.all_exercises.heading')
|
||||
);
|
||||
|
||||
const isSelfPacedCourse = $derived(isSelfPacedLikeCourse(courseApi.course?.type));
|
||||
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
|
||||
@@ -355,6 +370,11 @@
|
||||
});
|
||||
}
|
||||
|
||||
$inspect('$isStudentExperience', $isStudentExperience);
|
||||
$inspect('isExerciseAccessible', isExerciseAccessible);
|
||||
$inspect('isExerciseAccessible', isExerciseAccessible);
|
||||
$inspect('mySubmissions', mySubmissions);
|
||||
|
||||
function getFirstActiveSectionId() {
|
||||
const [firstActiveSection] = [...($questionnaire.sections ?? [])]
|
||||
.filter((section) => !section.deletedAt)
|
||||
@@ -384,7 +404,7 @@
|
||||
<Page.Header isSticky={true} class="top-12! z-100! min-h-[36px]">
|
||||
<Page.HeaderContent>
|
||||
<Page.Title class="flex flex-col gap-2">
|
||||
<span>{$questionnaire.title || 'Exercise'}</span>
|
||||
<span>{exerciseDisplayTitle}</span>
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
{#if teacherAutoGradeBadge === 'auto'}
|
||||
<Badge variant="outline" class="font-normal">
|
||||
@@ -502,15 +522,12 @@
|
||||
{#snippet child()}
|
||||
<div class="overflow-x-hidden pb-20">
|
||||
{#if $isStudentExperience}
|
||||
{#if isExerciseUnlocked}
|
||||
{#if isExerciseAccessible}
|
||||
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
|
||||
{:else}
|
||||
<Empty
|
||||
title={$t('course.navItem.lessons.content_locked_title')}
|
||||
description={$t('course.navItem.lessons.content_locked_description')}
|
||||
icon={VideoIcon}
|
||||
variant="page"
|
||||
class="text-center"
|
||||
<StudentContentLockedNotice
|
||||
reason={isExerciseProgressionLocked ? 'progression_locked' : 'teacher_locked'}
|
||||
contentType={ContentType.Exercise}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
// import HistoryIcon from '@lucide/svelte/icons/history';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import SettingsIcon from '@lucide/svelte/icons/settings';
|
||||
|
||||
import MODES from '$lib/utils/constants/mode';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
@@ -44,13 +45,16 @@
|
||||
Video,
|
||||
Document,
|
||||
AddVideoModal,
|
||||
AddDocumentModal
|
||||
AddDocumentModal,
|
||||
LessonSettingsTab
|
||||
} from '$features/course/components/lesson';
|
||||
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
import { orderedTabs, tabs as materialTabs } from '$features/course/components/lesson/constants';
|
||||
import { orderedTabs, SETTINGS_TAB_VALUE, tabs as materialTabs } from '$features/course/components/lesson/constants';
|
||||
import { getViewModeComponents } from '$features/course/components/lesson/utils';
|
||||
import { loadDraft, clearDraft } from '$features/course/utils/lesson-draft';
|
||||
import { getOrderedNavigableContent } from '$features/course/utils/content';
|
||||
import StudentContentLockedNotice from '$features/course/components/student-content-locked-notice.svelte';
|
||||
|
||||
interface Props {
|
||||
courseId: string;
|
||||
@@ -71,8 +75,23 @@
|
||||
hasUnsavedChanges = lessonApi.isDirty && mode === MODES.edit;
|
||||
});
|
||||
|
||||
const lessonTitle = $derived(lessonApi.lesson?.title || 'Lesson');
|
||||
const isLessonUnlocked = $derived(lessonApi.lesson?.isUnlocked ?? false);
|
||||
const currentLessonContentItem = $derived(
|
||||
getOrderedNavigableContent(courseApi.course).find(
|
||||
(item) => item.type === ContentType.Lesson && item.id === lessonId
|
||||
)
|
||||
);
|
||||
const lessonTitle = $derived(currentLessonContentItem?.title || lessonApi.lesson?.title || 'Lesson');
|
||||
const isTeacherLocked = $derived.by(() => {
|
||||
if ($isOrgStudent && currentLessonContentItem) {
|
||||
return (currentLessonContentItem.isUnlocked ?? false) === false;
|
||||
}
|
||||
|
||||
return (lessonApi.lesson?.isUnlocked ?? false) === false;
|
||||
});
|
||||
const isProgressionLocked = $derived(
|
||||
$isOrgStudent && currentLessonContentItem?.accessible === false && !isTeacherLocked
|
||||
);
|
||||
const isLessonUnlocked = $derived(!isTeacherLocked && !isProgressionLocked);
|
||||
const lessonSlug = $derived(lessonApi.lesson?.slug ?? '');
|
||||
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
|
||||
|
||||
@@ -145,7 +164,13 @@
|
||||
let currentTabValue = $state('');
|
||||
$effect(() => {
|
||||
if (!tabs.length || currentTabValue) return;
|
||||
|
||||
const urlTab = $page.url.searchParams.get('tab');
|
||||
if (urlTab === SETTINGS_TAB_VALUE) {
|
||||
currentTabValue = SETTINGS_TAB_VALUE;
|
||||
return;
|
||||
}
|
||||
|
||||
currentTabValue = urlTab ?? String(tabs[0].value);
|
||||
});
|
||||
|
||||
@@ -202,6 +227,8 @@
|
||||
lessonApi.update(courseApi.course?.id || '', lessonId, {
|
||||
title: lessonApi.lesson.title || undefined,
|
||||
isUnlocked: lessonApi.lesson.isUnlocked ?? undefined,
|
||||
completionPolicy: lessonApi.lesson.completionPolicy ?? undefined,
|
||||
videoWatchThreshold: lessonApi.lesson.videoWatchThreshold ?? undefined,
|
||||
slideUrl: lessonApi.lesson.slideUrl || undefined,
|
||||
videos: lessonApi.lesson.videos || [],
|
||||
documents: lessonApi.lesson.documents || [],
|
||||
@@ -363,7 +390,7 @@
|
||||
<LessonPageEditHeader
|
||||
{mode}
|
||||
title={lessonTitle}
|
||||
isUnlocked={isLessonUnlocked}
|
||||
isUnlocked={lessonApi.lesson?.isUnlocked ?? false}
|
||||
{isDeletingLesson}
|
||||
onTitleChange={handleLessonTitleChange}
|
||||
onToggleLock={handleToggleLessonLock}
|
||||
@@ -421,13 +448,10 @@
|
||||
<div
|
||||
class={`overflow-x-hidden py-6 pb-20 ${mode === MODES.edit ? 'lg:w-full xl:w-11/12' : 'mx-auto w-full max-w-3xl'}`}
|
||||
>
|
||||
{#if $isOrgStudent && lessonApi.lesson && !isLessonUnlocked}
|
||||
<Empty
|
||||
title={$t('course.navItem.lessons.content_locked_title')}
|
||||
description={$t('course.navItem.lessons.content_locked_description')}
|
||||
icon={VideoIcon}
|
||||
variant="page"
|
||||
class="text-center"
|
||||
{#if $isOrgStudent && !isLessonUnlocked}
|
||||
<StudentContentLockedNotice
|
||||
reason={isProgressionLocked ? 'progression_locked' : 'teacher_locked'}
|
||||
contentType={ContentType.Lesson}
|
||||
/>
|
||||
{:else if mode === MODES.edit}
|
||||
<UnderlineTabs.Root
|
||||
@@ -449,6 +473,10 @@
|
||||
{/if}
|
||||
</UnderlineTabs.Trigger>
|
||||
{/each}
|
||||
<UnderlineTabs.Trigger value={SETTINGS_TAB_VALUE}>
|
||||
<SettingsIcon size={16} />
|
||||
{$t('course.navItem.lessons.materials.tabs.settings.title')}
|
||||
</UnderlineTabs.Trigger>
|
||||
</UnderlineTabs.List>
|
||||
<!-- End Tabs List -->
|
||||
|
||||
@@ -466,7 +494,7 @@
|
||||
|
||||
<!-- Video Tab -->
|
||||
<UnderlineTabs.Content value={String(getValue('course.navItem.lessons.materials.tabs.video.title') || '')}>
|
||||
<Video {mode} />
|
||||
<Video {mode} {courseId} {lessonId} />
|
||||
</UnderlineTabs.Content>
|
||||
<!-- End Video Tab -->
|
||||
|
||||
@@ -475,12 +503,18 @@
|
||||
<Document {mode} />
|
||||
</UnderlineTabs.Content>
|
||||
<!-- End Document Tab -->
|
||||
|
||||
<!-- Settings Tab (fixed, not part of lessonTabsOrder) -->
|
||||
<UnderlineTabs.Content value={SETTINGS_TAB_VALUE}>
|
||||
<LessonSettingsTab />
|
||||
</UnderlineTabs.Content>
|
||||
<!-- End Settings Tab -->
|
||||
</UnderlineTabs.Root>
|
||||
{:else if lessonApi.lesson && !isMaterialsEmpty}
|
||||
{#key lessonId}
|
||||
<div class="mb-20 flex w-full flex-col gap-2" in:fade={{ delay: 500 }} out:fade>
|
||||
{#each viewModeComponents as Component, index (index)}
|
||||
<Component {mode} {lessonId} />
|
||||
<Component {mode} {lessonId} {courseId} />
|
||||
{/each}
|
||||
|
||||
{#if $currentOrg.customization?.apps?.comments}
|
||||
|
||||
Reference in New Issue
Block a user