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:
Rotimi Best
2026-06-11 14:48:34 +01:00
parent c8154409f5
commit 52d9bf18c3
94 changed files with 3499 additions and 564 deletions
@@ -36,11 +36,15 @@
signatories: [
{
name: stored?.signatories?.[0]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].name,
role: stored?.signatories?.[0]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].role
role: stored?.signatories?.[0]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].role,
enabled: stored?.signatories?.[0]?.enabled ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].enabled,
signatureUrl: stored?.signatories?.[0]?.signatureUrl
},
{
name: stored?.signatories?.[1]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].name,
role: stored?.signatories?.[1]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].role
role: stored?.signatories?.[1]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].role,
enabled: stored?.signatories?.[1]?.enabled ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].enabled,
signatureUrl: stored?.signatories?.[1]?.signatureUrl
}
],
idFormat: stored?.idFormat ?? DEFAULT_CERTIFICATE_DESIGN.idFormat
@@ -97,10 +101,14 @@
{$t('course.navItem.certificates.editor.field_signatories')}
</span>
<div class="text-right text-xs">
<div class="font-medium">{design.signatories[0].name}</div>
<div class="ui:text-muted-foreground">{design.signatories[0].role}</div>
<div class="mt-1 font-medium">{design.signatories[1].name}</div>
<div class="ui:text-muted-foreground">{design.signatories[1].role}</div>
{#if design.signatories[0].enabled}
<div class="font-medium">{design.signatories[0].name}</div>
<div class="ui:text-muted-foreground">{design.signatories[0].role}</div>
{/if}
{#if design.signatories[1].enabled}
<div class:mt-1={design.signatories[0].enabled} class="font-medium">{design.signatories[1].name}</div>
<div class="ui:text-muted-foreground">{design.signatories[1].role}</div>
{/if}
</div>
</div>
</Card.Content>
@@ -11,12 +11,23 @@
templateLabel: string;
isDirty: boolean;
isSaving: boolean;
isSignatureUploading?: boolean;
isFreePlan: boolean;
onSave: () => void;
onDiscard: () => void;
}
let { courseId, courseTitle, templateLabel, isDirty, isSaving, isFreePlan, onSave, onDiscard }: Props = $props();
let {
courseId,
courseTitle,
templateLabel,
isDirty,
isSaving,
isSignatureUploading = false,
isFreePlan,
onSave,
onDiscard
}: Props = $props();
</script>
<header
@@ -66,7 +77,7 @@
variant="outline"
size="sm"
type="button"
disabled={!isDirty || isSaving}
disabled={!isDirty || isSaving || isSignatureUploading}
loading={isSaving}
onclick={onSave}
>
@@ -66,6 +66,7 @@
templateLabel={activeTemplateMeta.label}
isDirty={store.isDirty}
isSaving={store.isSaving}
isSignatureUploading={store.isSignatureUploading}
isFreePlan={$isFreePlan}
onSave={() => store.save()}
onDiscard={() => store.reset()}
@@ -1,8 +1,12 @@
<script lang="ts">
import * as Field from '@cio/ui/base/field';
import { Switch } from '@cio/ui/base/switch';
import { InputField } from '@cio/ui/custom/input-field';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { UploadImage } from '$features/ui';
import { snackbar } from '$features/ui/snackbar/store';
import { t } from '$lib/utils/functions/translations';
import { uploadImage } from '$lib/utils/services/upload';
import { certificateEditorStore } from '../store/certificate-editor.store.svelte';
interface Props {
@@ -10,6 +14,73 @@
}
let { disabled = false }: Props = $props();
let signatoryOneAvatar: File | undefined = $state();
let signatoryTwoAvatar: File | undefined = $state();
let signatoryOnePreview = $state('');
let signatoryTwoPreview = $state('');
let isSignatoryOneUploading = $state(false);
let isSignatoryTwoUploading = $state(false);
$effect(() => {
certificateEditorStore.isSignatureUploading = isSignatoryOneUploading || isSignatoryTwoUploading;
});
$effect(() => {
if (isSignatoryOneUploading) return;
signatoryOnePreview = certificateEditorStore.draft.signatories[0].signatureUrl;
});
$effect(() => {
if (isSignatoryTwoUploading) return;
signatoryTwoPreview = certificateEditorStore.draft.signatories[1].signatureUrl;
});
async function uploadSignatorySignature(index: 0 | 1, avatar: File) {
if (index === 0) {
isSignatoryOneUploading = true;
} else {
isSignatoryTwoUploading = true;
}
try {
const signatureUrl = await uploadImage(avatar);
certificateEditorStore.setSignatorySignatureUrl(index, signatureUrl);
if (index === 0) {
signatoryOnePreview = signatureUrl;
signatoryOneAvatar = undefined;
} else {
signatoryTwoPreview = signatureUrl;
signatoryTwoAvatar = undefined;
}
} catch (error) {
console.error('Error uploading signature image:', error);
snackbar.error('snackbar.landing_page_settings.error.try_again');
} finally {
if (index === 0) {
isSignatoryOneUploading = false;
} else {
isSignatoryTwoUploading = false;
}
}
}
$effect(() => {
const avatar = signatoryOneAvatar;
if (!avatar || isSignatoryOneUploading) return;
void uploadSignatorySignature(0, avatar);
});
$effect(() => {
const avatar = signatoryTwoAvatar;
if (!avatar || isSignatoryTwoUploading) return;
void uploadSignatorySignature(1, avatar);
});
</script>
<Field.Group>
@@ -44,34 +115,89 @@
<Field.Set>
<Field.Legend>{$t('course.navItem.certificates.editor.section_signatories')}</Field.Legend>
<Field.Group>
<Field.Field>
<InputField
label={$t('course.navItem.certificates.editor.signatory_one_name')}
bind:value={certificateEditorStore.draft.signatories[0].name}
isDisabled={disabled}
/>
</Field.Field>
<Field.Field>
<InputField
label={$t('course.navItem.certificates.editor.signatory_one_role')}
bind:value={certificateEditorStore.draft.signatories[0].role}
isDisabled={disabled}
/>
</Field.Field>
<Field.Field>
<InputField
label={$t('course.navItem.certificates.editor.signatory_two_name')}
bind:value={certificateEditorStore.draft.signatories[1].name}
isDisabled={disabled}
/>
</Field.Field>
<Field.Field>
<InputField
label={$t('course.navItem.certificates.editor.signatory_two_role')}
bind:value={certificateEditorStore.draft.signatories[1].role}
isDisabled={disabled}
/>
</Field.Field>
<Field.Set>
<Field.Field orientation="horizontal">
<Switch bind:checked={certificateEditorStore.draft.signatories[0].enabled} {disabled} />
<Field.Label>{$t('course.navItem.certificates.editor.signatory_one_toggle')}</Field.Label>
</Field.Field>
{#if certificateEditorStore.draft.signatories[0].enabled}
<Field.Group>
<Field.Field>
<InputField
label={$t('course.navItem.certificates.editor.signatory_one_name')}
bind:value={certificateEditorStore.draft.signatories[0].name}
isDisabled={disabled}
/>
</Field.Field>
<Field.Field>
<InputField
label={$t('course.navItem.certificates.editor.signatory_one_role')}
bind:value={certificateEditorStore.draft.signatories[0].role}
isDisabled={disabled}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('course.navItem.certificates.editor.signature_upload')}</Field.Label>
<UploadImage
bind:avatar={signatoryOneAvatar}
bind:src={signatoryOnePreview}
shape="rounded"
widthHeight="h-[56px] w-[120px]"
previewVariant="signature"
isDisabled={disabled}
bind:isUploading={isSignatoryOneUploading}
/>
<Field.Description>
{$t('course.navItem.certificates.editor.signature_upload_hint')}
</Field.Description>
</Field.Field>
</Field.Group>
{/if}
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Field orientation="horizontal">
<Switch bind:checked={certificateEditorStore.draft.signatories[1].enabled} {disabled} />
<Field.Label>{$t('course.navItem.certificates.editor.signatory_two_toggle')}</Field.Label>
</Field.Field>
{#if certificateEditorStore.draft.signatories[1].enabled}
<Field.Group>
<Field.Field>
<InputField
label={$t('course.navItem.certificates.editor.signatory_two_name')}
bind:value={certificateEditorStore.draft.signatories[1].name}
isDisabled={disabled}
/>
</Field.Field>
<Field.Field>
<InputField
label={$t('course.navItem.certificates.editor.signatory_two_role')}
bind:value={certificateEditorStore.draft.signatories[1].role}
isDisabled={disabled}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('course.navItem.certificates.editor.signature_upload')}</Field.Label>
<UploadImage
bind:avatar={signatoryTwoAvatar}
bind:src={signatoryTwoPreview}
shape="rounded"
widthHeight="h-[56px] w-[120px]"
previewVariant="signature"
isDisabled={disabled}
bind:isUploading={isSignatoryTwoUploading}
/>
<Field.Description>
{$t('course.navItem.certificates.editor.signature_upload_hint')}
</Field.Description>
</Field.Field>
</Field.Group>
{/if}
</Field.Set>
</Field.Group>
</Field.Set>
@@ -22,7 +22,40 @@ export interface CertificateEditorDraft {
subtitle: string;
descriptionOverride: string;
idFormat: string;
signatories: [{ name: string; role: string }, { name: string; role: string }];
signatories: [
{ name: string; role: string; enabled: boolean; signatureUrl: string },
{ name: string; role: string; enabled: boolean; signatureUrl: string }
];
}
function toDraftSignatory(
signatory: CertificateDesign['signatories'][number] | undefined,
fallback: CertificateDesign['signatories'][number]
) {
return {
name: signatory?.name ?? fallback.name,
role: signatory?.role ?? fallback.role,
enabled: signatory?.enabled ?? fallback.enabled ?? true,
signatureUrl: signatory?.signatureUrl ?? ''
};
}
function isPersistableSignatureUrl(url: string): boolean {
const trimmed = url.trim();
if (!trimmed) return false;
return !trimmed.startsWith('blob:') && !trimmed.startsWith('data:');
}
function fromDraftSignatory(signatory: CertificateEditorDraft['signatories'][number]) {
const signatureUrl = signatory.signatureUrl.trim();
return {
name: signatory.name,
role: signatory.role,
enabled: signatory.enabled,
signatureUrl: isPersistableSignatureUrl(signatureUrl) ? signatureUrl : undefined
};
}
function toDraft(design: CertificateDesign): CertificateEditorDraft {
@@ -33,8 +66,8 @@ function toDraft(design: CertificateDesign): CertificateEditorDraft {
descriptionOverride: design.descriptionOverride ?? '',
idFormat: design.idFormat ?? '',
signatories: [
{ name: design.signatories[0]?.name ?? '', role: design.signatories[0]?.role ?? '' },
{ name: design.signatories[1]?.name ?? '', role: design.signatories[1]?.role ?? '' }
toDraftSignatory(design.signatories[0], DEFAULT_CERTIFICATE_DESIGN.signatories[0]),
toDraftSignatory(design.signatories[1], DEFAULT_CERTIFICATE_DESIGN.signatories[1])
]
};
}
@@ -46,10 +79,7 @@ function fromDraft(draft: CertificateEditorDraft): CertificateDesign {
subtitle: draft.subtitle.trim() || undefined,
descriptionOverride: draft.descriptionOverride.trim() || undefined,
idFormat: draft.idFormat.trim() || undefined,
signatories: [
{ name: draft.signatories[0].name, role: draft.signatories[0].role },
{ name: draft.signatories[1].name, role: draft.signatories[1].role }
]
signatories: [fromDraftSignatory(draft.signatories[0]), fromDraftSignatory(draft.signatories[1])]
};
}
@@ -64,14 +94,8 @@ function readStoredDesign(): CertificateDesign {
subtitle: stored?.subtitle ?? DEFAULT_CERTIFICATE_DESIGN.subtitle,
descriptionOverride: stored?.descriptionOverride,
signatories: [
{
name: stored?.signatories?.[0]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].name,
role: stored?.signatories?.[0]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].role
},
{
name: stored?.signatories?.[1]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].name,
role: stored?.signatories?.[1]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].role
}
toDraftSignatory(stored?.signatories?.[0], DEFAULT_CERTIFICATE_DESIGN.signatories[0]),
toDraftSignatory(stored?.signatories?.[1], DEFAULT_CERTIFICATE_DESIGN.signatories[1])
],
idFormat: stored?.idFormat ?? DEFAULT_CERTIFICATE_DESIGN.idFormat
};
@@ -82,6 +106,7 @@ class CertificateEditorStore {
draft = $state<CertificateEditorDraft>(toDraft(DEFAULT_CERTIFICATE_DESIGN));
initial = $state<CertificateEditorDraft>(toDraft(DEFAULT_CERTIFICATE_DESIGN));
isSaving = $state(false);
isSignatureUploading = $state(false);
#initializedCourseId: string | null = null;
readonly isDirty = $derived(JSON.stringify(this.draft) !== JSON.stringify(this.initial));
@@ -107,6 +132,17 @@ class CertificateEditorStore {
this.draft.accentColor = color;
}
setSignatorySignatureUrl(index: 0 | 1, signatureUrl: string) {
const signatory = this.draft.signatories[index];
const nextSignatories = [...this.draft.signatories] as CertificateEditorDraft['signatories'];
nextSignatories[index] = { ...signatory, signatureUrl };
this.draft = {
...this.draft,
signatories: nextSignatories
};
}
/**
* Returns a render-ready design with empty optional strings collapsed to
* `undefined`, suitable for handing to `Certificate.Preview` / API payload.
@@ -119,22 +155,40 @@ class CertificateEditorStore {
const course = courseApi.course;
if (!course?.id) return;
if (this.isSignatureUploading) {
snackbar.error(t.get('course.navItem.certificates.editor.signature_upload_in_progress'));
return;
}
this.isSaving = true;
try {
const design = fromDraft(this.draft);
const certificate = {
...(course.certificate ?? {}),
design: fromDraft(this.draft),
design,
theme: this.draft.templateId
};
const updated = await courseApi.update(course.id, { certificate }, { showSuccessToast: false });
if (updated) {
this.initial = {
...this.draft,
signatories: [{ ...this.draft.signatories[0] }, { ...this.draft.signatories[1] }]
};
if (courseApi.course) {
courseApi.course.certificate = {
...(courseApi.course.certificate ?? {}),
design,
theme: this.draft.templateId
};
}
this.initial = toDraft(design);
this.#initializedCourseId = null;
this.syncFromCourse(course.id, true);
snackbar.success(t.get('course.navItem.certificates.editor.saved'));
return;
}
if (Object.keys(courseApi.errors).length > 0) {
snackbar.error(t.get('course.navItem.certificates.editor.save_failed'));
}
} finally {
this.isSaving = false;
@@ -11,6 +11,7 @@
import { snackbar } from '$features/ui/snackbar/store';
import { classroomio } from '$lib/utils/services/api';
import type { CertificationEvaluationData } from '$features/course/utils/types';
import { normalizeCertificateIssuedAt } from '$features/course/utils/certificate-utils';
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
let isLoading = $state(false);
@@ -59,7 +60,7 @@
return {
studentName: $profile.fullname || 'Recipient',
studentId: $profile.id || undefined,
issuedAt: new Date().toISOString()
issuedAt: normalizeCertificateIssuedAt(evaluation?.certificateEarnedAt)
} as const;
}
@@ -90,7 +91,7 @@
);
} catch (error) {
console.error('Error downloading', error);
snackbar.error($t('course.navItem.certificates.unexpected_error'));
snackbar.error('course.navItem.certificates.unexpected_error');
}
isLoading = false;
@@ -112,7 +113,7 @@
);
} catch (error) {
console.error('Error downloading image', error);
snackbar.error($t('course.navItem.certificates.unexpected_error'));
snackbar.error('course.navItem.certificates.unexpected_error');
}
isPngLoading = false;
@@ -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: ''
});
@@ -39,6 +39,8 @@ export const tabs: MaterialTab[] = [
}
];
export const SETTINGS_TAB_VALUE = 'settings';
export function orderedTabs(tabs: MaterialTab[], settingTabs?: Tabs[]) {
if (!Array.isArray(settingTabs)) return tabs;
@@ -200,7 +200,10 @@
}
function isLockedForStudent(item: ContentDndItem, isStudent: boolean) {
return isStudent && (item.isUnlocked ?? true) === false;
if (!isStudent) return false;
if ((item.isUnlocked ?? true) === false) return true;
return item.accessible === false;
}
function getItemLockKey(item: ContentDndItem) {
@@ -6,6 +6,7 @@
import { CircleCheckIcon } from '$features/ui/icons';
import { Button } from '@cio/ui/base/button';
import * as Tooltip from '@cio/ui/base/tooltip';
import { PercentRingProgress } from '@cio/ui/custom/percent-ring-progress';
import { isOrgStudent } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import { courseApi, lessonApi } from '$features/course/api';
@@ -14,6 +15,7 @@
import { snackbar } from '$features/ui/snackbar/store';
import type { CourseContentItem } from '$features/course/utils/types';
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
import { updateLessonCompletionInCourseContent } from '$features/course/utils/content-completion';
interface Props {
lessonId?: string;
@@ -63,8 +65,54 @@
const showMarkComplete = $derived(!!lessonId && !exerciseId);
const currentLessonItem = $derived(lessonId ? lessonItems.find((l) => l.id === lessonId) : null);
const isLessonLocked = $derived($isOrgStudent && currentLessonItem && !(currentLessonItem.isUnlocked ?? false));
const isMarkCompleteDisabled = $derived(isMarkingComplete || isLessonLocked || isLessonComplete);
const isTeacherLocked = $derived($isOrgStudent && currentLessonItem && !(currentLessonItem.isUnlocked ?? false));
const isProgressionLocked = $derived($isOrgStudent && currentLessonItem?.accessible === false && !isTeacherLocked);
const isVideoWatchLesson = $derived.by(() => {
if (!lessonId) return false;
return (
lessonApi.lesson?.completionPolicy === 'video_watch' || currentLessonItem?.completionPolicy === 'video_watch'
);
});
const watchedPercent = $derived.by(() => {
const progress = lessonApi.lesson?.watchProgress;
if (progress?.isComplete) return 100;
if (typeof progress?.watchedPercent === 'number') {
return progress.watchedPercent;
}
const durationSeconds = progress?.durationSeconds;
if (durationSeconds && durationSeconds > 0) {
return Math.min(100, ((progress?.watchedSeconds ?? 0) / durationSeconds) * 100);
}
return 0;
});
const watchedPercentDisplay = $derived(Math.round(watchedPercent));
const watchVideosRequired = $derived(lessonApi.lesson?.watchProgress?.videosRequired ?? 0);
const watchVideosComplete = $derived(lessonApi.lesson?.watchProgress?.videosComplete ?? 0);
const isLessonLocked = $derived(isTeacherLocked || isProgressionLocked);
const isVideoWatchComplete = $derived(isVideoWatchLesson && (lessonApi.lesson?.watchProgress?.isComplete ?? false));
const showVideoWatchCompleteState = $derived(isVideoWatchComplete || (isVideoWatchLesson && isLessonComplete));
const isMarkCompleteDisabled = $derived(
isMarkingComplete || isLessonLocked || isLessonComplete || isVideoWatchLesson
);
const showWatchProgress = $derived(
!!lessonId && $isOrgStudent && isVideoWatchLesson && !showVideoWatchCompleteState && !isLessonLocked
);
const watchProgressTooltip = $derived(
watchVideosRequired > 1
? t.get('course.navItem.lessons.watch_progress.videos_complete', {
complete: watchVideosComplete,
required: watchVideosRequired,
percent: watchedPercentDisplay
})
: t.get('course.navItem.lessons.watch_progress.status', { percent: watchedPercentDisplay })
);
async function markLessonComplete(currentLessonId: string) {
isMarkingComplete = true;
@@ -99,31 +147,7 @@
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
)
}
};
courseApi.course = updateLessonCompletionInCourseContent(courseApi.course, currentLessonId, isComplete);
}
const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
@@ -145,20 +169,31 @@
<svelte:window onkeydown={handleKeydown} />
<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}
<Tooltip.Provider>
{#if showWatchProgress}
<Tooltip.Root>
<Tooltip.Trigger>
<span class="inline-flex shrink-0" aria-label={watchProgressTooltip}>
<PercentRingProgress value={watchedPercent} size="small" />
</span>
</Tooltip.Trigger>
<Tooltip.Content side="bottom" sideOffset={4}>
{watchProgressTooltip}
</Tooltip.Content>
</Tooltip.Root>
{:else if showMarkComplete && lessonId && !isLessonLocked && (showVideoWatchCompleteState || !isVideoWatchLesson)}
<Button
size="sm"
variant="secondary"
onclick={() => markLessonComplete(lessonId)}
loading={isMarkingComplete}
disabled={isMarkCompleteDisabled || showVideoWatchCompleteState}
>
<CircleCheckIcon size={14} filled={isLessonComplete || showVideoWatchCompleteState} />
<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">
<Tooltip.Root>
<Tooltip.Trigger>
@@ -366,7 +366,10 @@
}
function isLockedForStudent(item: ContentDndItem) {
return isStudentView && (item.isUnlocked ?? true) === false;
if (!isStudentView) return false;
if ((item.isUnlocked ?? true) === false) return true;
return item.accessible === false;
}
function getItemLockKey(item: ContentDndItem) {
@@ -9,3 +9,4 @@ 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';
export { default as LessonSettingsTab } from './lesson-settings-tab.svelte';
@@ -79,7 +79,7 @@
{#if mode === MODES.edit && !$isOrgStudent}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center justify-end gap-2">
<IconButton onclick={onToggleLock} aria-label={lockLabel} title={lockLabel}>
{#if isUnlocked}
<UnlockIcon size={18} />
@@ -0,0 +1,183 @@
<script lang="ts">
import { lessonApi } from '$features/course/api';
import { InputField } from '@cio/ui/custom/input-field';
import { Checkbox } from '@cio/ui/base/checkbox';
import * as Field from '@cio/ui/base/field';
import * as Select from '@cio/ui/base/select';
import { t } from '$lib/utils/functions/translations';
import { getVideoTitle, isHlsUploadVideo, type LessonVideo } from './video/video-card-utils';
type LessonCompletionPolicy = 'manual' | 'video_watch' | 'none';
const completionPolicy = $derived(lessonApi.lesson?.completionPolicy ?? 'manual');
const videoWatchThreshold = $derived(lessonApi.lesson?.videoWatchThreshold ?? 95);
const videos = $derived(lessonApi.lesson?.videos ?? []);
const enforceableVideos = $derived(
videos
.map((video, index) => ({ video, index }))
.filter(({ video }) => video.type === 'upload' && Boolean(video.assetId))
);
const showWatchVideosError = $derived(
completionPolicy === 'video_watch' &&
enforceableVideos.length > 0 &&
!enforceableVideos.some(({ video }) => video.watchEnforced)
);
function policyTriggerLabel(policy: LessonCompletionPolicy): string {
if (policy === 'video_watch') return t.get('course.navItem.lessons.completion_policy.video_watch_label');
if (policy === 'none') return t.get('course.navItem.lessons.completion_policy.none_label');
return t.get('course.navItem.lessons.completion_policy.manual_label');
}
function handleCompletionPolicyChange(value: string) {
if (value !== 'manual' && value !== 'video_watch' && value !== 'none') return;
lessonApi.updateLessonState('completionPolicy', value);
}
function handleVideoWatchThresholdChange(event: Event) {
const parsed = Number((event.currentTarget as HTMLInputElement).value);
if (Number.isNaN(parsed)) return;
lessonApi.updateLessonState('videoWatchThreshold', parsed);
}
function toggleVideoWatchEnforced(index: number, checked: boolean) {
const currentVideos = lessonApi.lesson?.videos;
if (!currentVideos) return;
const updatedVideos = currentVideos.map((video, videoIndex) =>
videoIndex === index ? { ...video, watchEnforced: checked } : video
);
lessonApi.updateLessonState('videos', updatedVideos);
}
function isVideoWatchEnforced(video: LessonVideo): boolean {
return Boolean(video.watchEnforced);
}
</script>
<div class="p-4">
<Field.Group class="w-full max-w-xl">
<Field.Set>
<Field.Legend>{$t('course.navItem.lessons.settings.progression.heading')}</Field.Legend>
<Field.Description>{$t('course.navItem.lessons.settings.progression.intro')}</Field.Description>
<Field.Description class="ui:text-muted-foreground">
{$t('course.navItem.lessons.settings.progression.students_only_note')}
</Field.Description>
<Field.Group>
<Field.Field>
<Field.Label for="lesson-completion-policy">
{$t('course.navItem.lessons.completion_policy.label')}
</Field.Label>
<Select.Root type="single" value={completionPolicy} onValueChange={handleCompletionPolicyChange}>
<Select.Trigger id="lesson-completion-policy" class="w-full max-w-xs">
{policyTriggerLabel(completionPolicy)}
</Select.Trigger>
<Select.Content class="max-w-sm">
<Select.Item value="manual">
<div class="flex flex-col items-start gap-0.5">
<span>{$t('course.navItem.lessons.completion_policy.manual_label')}</span>
<span class="ui:text-muted-foreground text-xs">
{$t('course.navItem.lessons.completion_policy.manual_description')}
</span>
</div>
</Select.Item>
<Select.Item value="video_watch">
<div class="flex flex-col items-start gap-0.5">
<span>{$t('course.navItem.lessons.completion_policy.video_watch_label')}</span>
<span class="ui:text-muted-foreground text-xs">
{$t('course.navItem.lessons.completion_policy.video_watch_description')}
</span>
</div>
</Select.Item>
<Select.Item value="none">
<div class="flex flex-col items-start gap-0.5">
<span>{$t('course.navItem.lessons.completion_policy.none_label')}</span>
<span class="ui:text-muted-foreground text-xs">
{$t('course.navItem.lessons.completion_policy.none_description')}
</span>
</div>
</Select.Item>
</Select.Content>
</Select.Root>
</Field.Field>
<div class="space-y-2 text-sm">
<p class="font-medium">{$t('course.navItem.lessons.settings.progression.option_guide_heading')}</p>
<ul class="ui:text-muted-foreground list-disc space-y-1 pl-5">
<li>
<span class="ui:text-foreground font-medium">
{$t('course.navItem.lessons.completion_policy.manual_label')}:
</span>
{$t('course.navItem.lessons.completion_policy.manual_description')}
</li>
<li>
<span class="ui:text-foreground font-medium">
{$t('course.navItem.lessons.completion_policy.video_watch_label')}:
</span>
{$t('course.navItem.lessons.completion_policy.video_watch_description')}
</li>
<li>
<span class="ui:text-foreground font-medium">
{$t('course.navItem.lessons.completion_policy.none_label')}:
</span>
{$t('course.navItem.lessons.completion_policy.none_description')}
</li>
</ul>
</div>
{#if completionPolicy === 'video_watch'}
<Field.Field>
<InputField
type="number"
label={$t('course.navItem.lessons.completion_policy.watch_threshold')}
value={String(videoWatchThreshold)}
onInputChange={handleVideoWatchThresholdChange}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('course.navItem.lessons.settings.progression.watch_videos_heading')}</Field.Label>
<Field.Description
>{$t('course.navItem.lessons.settings.progression.watch_videos_helper')}</Field.Description
>
{#if enforceableVideos.length === 0}
<Field.Description
>{$t('course.navItem.lessons.settings.progression.watch_videos_empty')}</Field.Description
>
{:else}
<Field.Group class="gap-3">
{#each enforceableVideos as { video, index } (video.assetId ?? index)}
<Field.Field orientation="horizontal">
<Checkbox
id={`watch-enforced-${video.assetId}`}
checked={isVideoWatchEnforced(video)}
onCheckedChange={(checked) => toggleVideoWatchEnforced(index, checked === true)}
/>
<Field.Label for={`watch-enforced-${video.assetId}`} class="font-normal">
{getVideoTitle(video, index)}
{#if isHlsUploadVideo(video)}
<span class="ui:text-muted-foreground ml-1 text-xs">(HLS)</span>
{/if}
</Field.Label>
</Field.Field>
{/each}
</Field.Group>
{/if}
{#if showWatchVideosError}
<Field.Error>{$t('course.navItem.lessons.settings.progression.watch_videos_required')}</Field.Error>
{/if}
</Field.Field>
{/if}
</Field.Group>
</Field.Set>
</Field.Group>
</div>
@@ -7,8 +7,11 @@
import { t } from '$lib/utils/functions/translations';
import { sidePanel } from '$features/side-panel';
import { classroomio } from '$lib/utils/services/api';
import { lessonApi } from '$features/course/api';
import { snackbar } from '$features/ui/snackbar/store';
import type { AssetTranscriptPayload } from '$features/media/utils/types';
import type { LessonVideo } from './video-card-utils';
import { isOrgStudent } from '$lib/utils/store/app';
import { resolveWatchEnforcedAssetIds, type LessonVideo } from './video-card-utils';
import { lessonVideoBus } from './lesson-video-bus.svelte';
import { TRANSCRIPT_PANEL_ID } from './transcript-panel-definition';
@@ -83,9 +86,11 @@
interface Props {
video: LessonVideo;
courseId: string;
lessonId: string;
}
let { video }: Props = $props();
let { video, courseId, lessonId }: Props = $props();
let localTranscript = $state<AssetTranscriptPayload | null>(null);
let localTranscriptLoading = $state(false);
@@ -310,11 +315,76 @@
jobPoller.start();
}
const isEnforceableUpload = $derived(video.type === 'upload' || isHls);
const isWatchEnforcedForVideo = $derived.by(() => {
const lesson = lessonApi.lesson;
if (!lesson || !uploadAssetId || !isEnforceableUpload) return false;
const enforcedAssetIds = resolveWatchEnforcedAssetIds(lesson.videos, lesson.completionPolicy);
return enforcedAssetIds.includes(uploadAssetId);
});
const assetWatchProgress = $derived.by(() => {
const lesson = lessonApi.lesson;
if (!lesson?.watchProgress?.assets || !uploadAssetId) return null;
return lesson.watchProgress.assets.find((asset) => asset.assetId === uploadAssetId) ?? null;
});
const seekPolicy = $derived.by(() => {
const lesson = lessonApi.lesson;
if (!$isOrgStudent || !lesson || lesson.completionPolicy !== 'video_watch' || !isWatchEnforcedForVideo) {
return undefined;
}
if (assetWatchProgress?.isComplete) {
return undefined;
}
return {
mode: 'locked_until_complete' as const,
watchThresholdPercent: lesson.videoWatchThreshold ?? 95,
initialFurthestSeconds: assetWatchProgress?.furthestSeconds ?? 0,
pauseOnHidden: true,
onProgress: (payload: { positionSeconds: number; playedDeltaSeconds: number; durationSeconds: number }) => {
if (!uploadAssetId) return;
void lessonApi.reportWatchProgress(courseId, lessonId, {
...payload,
assetId: uploadAssetId
});
},
onSeekBlocked: () => {
snackbar.error('course.navItem.lessons.watch_progress.seek_blocked');
}
};
});
async function restoreWatchProgress(): Promise<void> {
if (!$isOrgStudent || !uploadAssetId) return;
const cachedAsset = assetWatchProgress;
if (cachedAsset?.lastPositionSeconds && cachedAsset.lastPositionSeconds > 0) {
pendingResumeSeconds = cachedAsset.lastPositionSeconds;
return;
}
const progress = await lessonApi.getWatchProgress(courseId, lessonId);
const assetProgress = progress?.assets?.find((asset) => asset.assetId === uploadAssetId);
if (assetProgress?.lastPositionSeconds && assetProgress.lastPositionSeconds > 0) {
pendingResumeSeconds = assetProgress.lastPositionSeconds;
}
}
onMount(() => {
if (lessonVideoBus.assetId === null || lessonVideoBus.assetId === uploadAssetId) {
syncVideoBus();
}
void restoreWatchProgress();
void (async () => {
await loadTranscript();
if (!isMounted) return;
@@ -399,9 +469,11 @@
onSourceLoaded: handleSourceLoaded,
onBeforeHlsLoad: isHls && uploadAssetId ? () => mintHlsCookie(uploadAssetId) : undefined,
transcriptPanelControl,
loadingLabel: $t('course.navItem.lessons.materials.tabs.video.loading'),
playbackErrorLabel: $t('course.navItem.lessons.materials.tabs.video.playback_error'),
playbackReloadLabel: $t('course.navItem.lessons.materials.tabs.video.playback_reload'),
onPlaybackReload: handlePlaybackReload
onPlaybackReload: handlePlaybackReload,
seekPolicy
}}
/>
</div>
@@ -25,6 +25,8 @@
let { video, index, isEditMode, onRemove }: Props = $props();
let isTranscribingForVideo = $state(false);
const thumbnailUrl = $derived(getVideoThumbnailUrl(video));
const title = $derived(getVideoTitle(video, index));
const durationSeconds = $derived(getVideoDurationSeconds(video));
@@ -143,6 +145,7 @@
{onRemove}
onThumbnailSaved={(url) => lessonApi.updateLessonVideoThumbnail(index, url)}
onHlsMetadataUpdated={(metadata) => lessonApi.updateLessonVideoMetadata(index, metadata)}
onTranscribingChange={(v) => (isTranscribingForVideo = v)}
menuPlacement="inline"
/>
{/if}
@@ -150,6 +153,11 @@
<p class="ui:text-muted-foreground mt-0.5 line-clamp-1 text-sm leading-snug">
{channelLine}
</p>
{#if isEditMode && isTranscribingForVideo}
<p class="mt-0.5 text-sm leading-snug text-orange-500">
{$t('course.navItem.lessons.materials.tabs.video.simple_card.transcription_in_progress')}
</p>
{/if}
<p class="ui:text-muted-foreground mt-0.5 line-clamp-1 text-sm leading-snug">{metaLine}</p>
</div>
</div>
@@ -14,7 +14,7 @@
import { sidePanel } from '$features/side-panel';
import { snackbar } from '$features/ui/snackbar/store';
import { t } from '$lib/utils/functions/translations';
import { onDestroy } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import {
canGenerateHls1080,
getHls1080Status,
@@ -32,11 +32,19 @@
onRemove: () => void;
onThumbnailSaved?: (thumbnailUrl: string) => void;
onHlsMetadataUpdated?: (metadata: Record<string, unknown>) => void;
onTranscribingChange?: (value: boolean) => void;
/** `inline` aligns with the title row (YouTube-style). `corner` is top-right overlay on the nearest `relative` ancestor. */
menuPlacement?: 'inline' | 'corner';
}
let { video, onRemove, onThumbnailSaved, onHlsMetadataUpdated, menuPlacement = 'inline' }: Props = $props();
let {
video,
onRemove,
onThumbnailSaved,
onHlsMetadataUpdated,
onTranscribingChange,
menuPlacement = 'inline'
}: Props = $props();
let manageThumbsOpen = $state(false);
let manageThumbsAsset = $state<OrganizationAsset | null>(null);
@@ -47,6 +55,11 @@
let hasTranscript = $state(false);
let hasCheckedTranscript = $state(false);
let activePoller: JobPoller<MediaJobEnvelope> | null = null;
function setIsTranscribing(value: boolean) {
isTranscribing = value;
onTranscribingChange?.(value);
}
let isGenerating1080 = $state(false);
let sourceFileInput: HTMLInputElement | undefined = $state();
@@ -61,6 +74,12 @@
isHlsUploadVideo(video) && !hasHls1080Rendition(video) && !isGenerating1080 && hls1080Status === 'unavailable'
);
onMount(() => {
if (canGenerateTranscript) {
void ensureTranscriptChecked();
}
});
onDestroy(() => {
activePoller?.stop();
});
@@ -69,8 +88,19 @@
if (hasCheckedTranscript || !assetId) return;
hasCheckedTranscript = true;
const data = await mediaApi.getAssetTranscript(assetId);
hasTranscript = !!data?.segments?.length;
const [transcriptData, runs] = await Promise.all([
mediaApi.getAssetTranscript(assetId),
jobsApi.getMediaJobsForAsset(assetId)
]);
hasTranscript = !!transcriptData?.segments?.length;
const latest = runs?.[0];
if (latest && (latest.job.status === 'pending' || latest.job.status === 'running')) {
setIsTranscribing(true);
void startTranscriptionPoll(assetId);
}
}
async function ensureHlsMetadataChecked() {
@@ -99,13 +129,13 @@
const latest = runs?.[0];
if (!latest) {
isTranscribing = false;
setIsTranscribing(false);
return;
}
if (latest.job.status === 'completed') {
isTranscribing = false;
setIsTranscribing(false);
hasTranscript = true;
snackbar.success('snackbar.media_manager.transcription_completed');
@@ -113,7 +143,7 @@
}
if (latest.job.status === 'failed' || latest.job.status === 'canceled') {
isTranscribing = false;
setIsTranscribing(false);
return;
}
@@ -124,7 +154,7 @@
if (envelope.job.status === 'completed') {
activePoller?.stop();
activePoller = null;
isTranscribing = false;
setIsTranscribing(false);
hasTranscript = true;
snackbar.success('snackbar.media_manager.transcription_completed');
}
@@ -132,7 +162,7 @@
if (envelope.job.status === 'failed' || envelope.job.status === 'canceled') {
activePoller?.stop();
activePoller = null;
isTranscribing = false;
setIsTranscribing(false);
}
}
});
@@ -143,10 +173,15 @@
if (!assetId) return;
const ok = await mediaApi.generateTranscript(assetId);
if (!ok) return;
isTranscribing = true;
await startTranscriptionPoll(assetId);
if (ok) {
setIsTranscribing(true);
await startTranscriptionPoll(assetId);
return;
}
hasCheckedTranscript = false;
await ensureTranscriptChecked();
}
async function handleViewTranscript() {
@@ -231,11 +266,7 @@
}
}
const generateLabel = $derived(
isTranscribing
? t.get('course.navItem.lessons.materials.tabs.video.generate_transcript_in_progress')
: t.get('course.navItem.lessons.materials.tabs.video.generate_transcript')
);
const generateLabel = t.get('course.navItem.lessons.materials.tabs.video.generate_transcript');
const generate1080Label = $derived(
isGenerating1080
@@ -168,3 +168,33 @@ export function formatVideoCreatedAt(isoString: string | null | undefined): stri
if (Number.isNaN(d.getTime())) return null;
return new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(d);
}
export function isEnforceableLessonVideo(video: LessonVideo): boolean {
return video.type === 'upload' && Boolean((video as LessonVideo & { assetId?: string }).assetId);
}
export function resolveWatchEnforcedAssetIds(
videos: LessonVideo[] | null | undefined,
completionPolicy: string | null | undefined
): string[] {
const lessonVideos = videos ?? [];
const flaggedAssetIds = lessonVideos
.filter((video) => video.watchEnforced && isEnforceableLessonVideo(video))
.map((video) => (video as LessonVideo & { assetId: string }).assetId);
if (flaggedAssetIds.length > 0) {
return Array.from(new Set(flaggedAssetIds));
}
if (completionPolicy !== 'video_watch') {
return [];
}
return Array.from(
new Set(
lessonVideos
.filter((video) => isEnforceableLessonVideo(video))
.map((video) => (video as LessonVideo & { assetId: string }).assetId)
)
);
}
@@ -13,9 +13,11 @@
interface Props {
mode?: (typeof MODES)[keyof typeof MODES];
courseId: string;
lessonId: string;
}
let { mode = MODES.view }: Props = $props();
let { mode = MODES.view, courseId, lessonId }: Props = $props();
const videos = $derived(lessonApi.lesson?.videos || []);
@@ -42,7 +44,7 @@
{#snippet content(video)}
{#key video.type === 'upload' ? ((video as typeof video & { assetId?: string }).assetId ?? video.link) : video.link}
<LessonVideoPlayer {video} />
<LessonVideoPlayer {video} {courseId} {lessonId} />
{/key}
{/snippet}
@@ -0,0 +1,23 @@
<script lang="ts">
import LockIcon from '@lucide/svelte/icons/lock';
import { Empty } from '@cio/ui/custom/empty';
import { ContentType } from '@cio/utils/constants/content';
import { t } from '$lib/utils/functions/translations';
import type { StudentContentLockReason } from '$features/ai-assistant/utils/content-ask-ai-bar';
import {
getStudentContentLockDescriptionKey,
getStudentContentLockTitleKey
} from '$features/course/utils/content-lock-utils';
interface Props {
reason: StudentContentLockReason;
contentType: typeof ContentType.Lesson | typeof ContentType.Exercise;
}
let { reason, contentType }: Props = $props();
const titleKey = $derived(getStudentContentLockTitleKey(reason));
const descriptionKey = $derived(getStudentContentLockDescriptionKey(reason, contentType));
</script>
<Empty title={$t(titleKey)} description={$t(descriptionKey)} icon={LockIcon} variant="page" class="text-center" />