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:
+48
@@ -9,6 +9,7 @@
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { slugifyTitle } from '@cio/utils/validation';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
|
||||
type Props = {
|
||||
exerciseId: string;
|
||||
@@ -31,6 +32,8 @@
|
||||
|
||||
await exerciseApi.update(courseApi.course.id, exerciseId, {
|
||||
allowMultipleAttempts: !!$questionnaire.allowMultipleAttempts,
|
||||
completionPolicy: $questionnaire.completionPolicy ?? 'submitted',
|
||||
passThreshold: $questionnaire.passThreshold ?? 100,
|
||||
slug: isPublicCourse && slug ? slug : undefined
|
||||
});
|
||||
saving = false;
|
||||
@@ -60,6 +63,51 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-neutral-200 p-4 dark:border-neutral-700">
|
||||
<Label class="mb-2 block text-sm font-medium dark:text-gray-100">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.settings_completion_policy')}
|
||||
</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={$questionnaire.completionPolicy ?? 'submitted'}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'submitted' || value === 'passed') {
|
||||
questionnaire.update((state) => ({ ...state, completionPolicy: value }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
{($questionnaire.completionPolicy ?? 'submitted') === 'passed'
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.settings_completion_passed')
|
||||
: $t('course.navItem.lessons.exercises.all_exercises.settings_completion_submitted')}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="submitted">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.settings_completion_submitted')}
|
||||
</Select.Item>
|
||||
<Select.Item value="passed">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.settings_completion_passed')}
|
||||
</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
|
||||
{#if ($questionnaire.completionPolicy ?? 'submitted') === 'passed'}
|
||||
<div class="mt-4">
|
||||
<InputField
|
||||
type="number"
|
||||
label={$t('course.navItem.lessons.exercises.all_exercises.settings_pass_threshold')}
|
||||
value={String($questionnaire.passThreshold ?? 100)}
|
||||
onInputChange={(event) => {
|
||||
const parsed = Number(event.currentTarget.value);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
questionnaire.update((state) => ({ ...state, passThreshold: parsed }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isPublicCourse}
|
||||
<div class="rounded-lg border border-neutral-200 p-4 dark:border-neutral-700">
|
||||
<InputField
|
||||
|
||||
@@ -69,6 +69,8 @@ export interface QuestionnaireState {
|
||||
sectionDisplayMode: ExerciseSectionDisplayMode;
|
||||
totalSubmissions: number;
|
||||
allowMultipleAttempts?: boolean;
|
||||
completionPolicy?: 'submitted' | 'passed';
|
||||
passThreshold?: number | null;
|
||||
slug?: string | null;
|
||||
}
|
||||
|
||||
@@ -102,6 +104,8 @@ export const questionnaire: Writable<QuestionnaireState> = writable({
|
||||
sectionDisplayMode: 'one_question',
|
||||
totalSubmissions: 0,
|
||||
allowMultipleAttempts: false,
|
||||
completionPolicy: 'submitted',
|
||||
passThreshold: 100,
|
||||
slug: ''
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user