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:
@@ -18,6 +18,7 @@ import type {
|
||||
import type { TCourseType } from '@cio/db/types';
|
||||
import { ZCourseCreate, ZCourseUpdate, type TCourseUpdate } from '@cio/utils/validation/course/course';
|
||||
import { DEFAULT_COMPLIANCE_SETTINGS } from '../utils/compliance-utils';
|
||||
import { syncProgressionAccessToCourse } from '../utils/progression-utils';
|
||||
import { capturePosthogEvent } from '$lib/utils/services/posthog';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -104,13 +105,13 @@ export class CourseApi extends BaseApiWithErrors {
|
||||
|
||||
if (!hasUpdatedItem) return false;
|
||||
|
||||
this.course = {
|
||||
this.course = syncProgressionAccessToCourse({
|
||||
...this.course,
|
||||
content: {
|
||||
...content,
|
||||
sections
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -127,13 +128,13 @@ export class CourseApi extends BaseApiWithErrors {
|
||||
|
||||
if (!hasUpdatedItem) return false;
|
||||
|
||||
this.course = {
|
||||
this.course = syncProgressionAccessToCourse({
|
||||
...this.course,
|
||||
content: {
|
||||
...content,
|
||||
items
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -654,7 +655,8 @@ export class CourseApi extends BaseApiWithErrors {
|
||||
imgUrl: ''
|
||||
},
|
||||
allowNewStudent: false,
|
||||
isContentGroupingEnabled: true
|
||||
isContentGroupingEnabled: true,
|
||||
progressionMode: 'free'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import { courseApi } from './course.svelte';
|
||||
import { toggleConfetti } from '$features/ui/confetti/store';
|
||||
import { updateLessonCompletionInCourseContent } from '../utils/content-completion';
|
||||
import type {
|
||||
CourseSectionWithLessons,
|
||||
CreateCourseSectionRequest,
|
||||
@@ -20,8 +23,13 @@ import type {
|
||||
UpdateCourseSectionRequest,
|
||||
UpdateLessonCommentRequest,
|
||||
UpdateLessonCompletionRequest,
|
||||
UpdateLessonRequest
|
||||
UpdateLessonRequest,
|
||||
UpdateLessonWatchProgressRequest,
|
||||
GetLessonWatchProgressRequest,
|
||||
type LessonWatchProgress,
|
||||
type LessonWatchProgressUpdate
|
||||
} from '../utils/types';
|
||||
import type { TUpdateLessonWatchProgress } from '@cio/utils/validation/lesson';
|
||||
import type {
|
||||
TCourseSectionCreate,
|
||||
TCourseSectionPromoteUngrouped,
|
||||
@@ -616,6 +624,68 @@ export class LessonApi extends BaseApiWithErrors {
|
||||
/**
|
||||
* Updates lesson completion
|
||||
*/
|
||||
async getWatchProgress(courseId: string, lessonId: string): Promise<LessonWatchProgress | null> {
|
||||
const response = await this.execute<GetLessonWatchProgressRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].lesson[':lessonId']['watch-progress'].$get({
|
||||
param: { courseId, lessonId }
|
||||
}),
|
||||
logContext: 'fetching lesson watch progress'
|
||||
});
|
||||
|
||||
if (!response || !('data' in response)) return null;
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async reportWatchProgress(courseId: string, lessonId: string, beat: TUpdateLessonWatchProgress) {
|
||||
const payload: TUpdateLessonWatchProgress = {
|
||||
...beat,
|
||||
durationSeconds: Math.round(beat.durationSeconds),
|
||||
playedDeltaSeconds: Math.round(beat.playedDeltaSeconds),
|
||||
positionSeconds: Math.floor(beat.positionSeconds)
|
||||
};
|
||||
|
||||
return this.execute<UpdateLessonWatchProgressRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].lesson[':lessonId']['watch-progress'].$put({
|
||||
param: { courseId, lessonId },
|
||||
json: payload
|
||||
}),
|
||||
logContext: 'reporting lesson watch progress',
|
||||
onSuccess: (response) => {
|
||||
const data = response.data as LessonWatchProgressUpdate | null;
|
||||
if (!data || !this.lesson || this.lesson.id !== lessonId) return;
|
||||
|
||||
if (data.didJustComplete) {
|
||||
toggleConfetti();
|
||||
setTimeout(() => toggleConfetti(), 3000);
|
||||
}
|
||||
|
||||
if (courseApi.course?.content && data.didJustComplete) {
|
||||
courseApi.course = updateLessonCompletionInCourseContent(courseApi.course, lessonId, true);
|
||||
}
|
||||
|
||||
this.lesson = {
|
||||
...this.lesson,
|
||||
watchProgress: {
|
||||
lastPositionSeconds: data.lastPositionSeconds,
|
||||
watchedSeconds: data.watchedSeconds,
|
||||
furthestSeconds: data.furthestSeconds,
|
||||
durationSeconds: data.durationSeconds,
|
||||
isComplete: data.isComplete,
|
||||
didJustComplete: data.didJustComplete,
|
||||
watchedPercent: data.watchedPercent,
|
||||
videosComplete: data.videosComplete,
|
||||
videosRequired: data.videosRequired,
|
||||
assets: data.assets
|
||||
},
|
||||
isComplete: data.isComplete ? true : this.lesson.isComplete
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async updateCompletion(courseId: string, lessonId: string, isComplete: boolean) {
|
||||
return this.execute<UpdateLessonCompletionRequest>({
|
||||
requestFn: () =>
|
||||
|
||||
+14
-6
@@ -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>
|
||||
|
||||
+13
-2
@@ -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}
|
||||
>
|
||||
|
||||
+1
@@ -66,6 +66,7 @@
|
||||
templateLabel={activeTemplateMeta.label}
|
||||
isDirty={store.isDirty}
|
||||
isSaving={store.isSaving}
|
||||
isSignatureUploading={store.isSignatureUploading}
|
||||
isFreePlan={$isFreePlan}
|
||||
onSave={() => store.save()}
|
||||
onDiscard={() => store.reset()}
|
||||
|
||||
+154
-28
@@ -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>
|
||||
|
||||
|
||||
+74
-20
@@ -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;
|
||||
|
||||
+4
-3
@@ -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;
|
||||
|
||||
+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: ''
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+75
-40
@@ -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>
|
||||
|
||||
+4
-1
@@ -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';
|
||||
|
||||
+1
-1
@@ -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>
|
||||
+75
-3
@@ -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>
|
||||
|
||||
+8
@@ -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>
|
||||
|
||||
+48
-17
@@ -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}
|
||||
|
||||
|
||||
+23
@@ -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" />
|
||||
@@ -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}
|
||||
|
||||
@@ -11,3 +11,13 @@ export function parseCertificateThemeId(raw: string | null | undefined): Certifi
|
||||
|
||||
return resolveTemplateId(raw);
|
||||
}
|
||||
|
||||
/** Normalizes DB/API timestamps to an ISO-8601 UTC string accepted by Zod `.datetime()`. */
|
||||
export function normalizeCertificateIssuedAt(value: string | null | undefined): string {
|
||||
if (!value) return new Date().toISOString();
|
||||
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return new Date().toISOString();
|
||||
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import type { Course } from './types';
|
||||
import { syncProgressionAccessToCourse } from './progression-utils';
|
||||
|
||||
export function updateLessonCompletionInCourseContent(course: Course, lessonId: string, isComplete: boolean): Course {
|
||||
if (!course.content) return course;
|
||||
|
||||
let updatedCourse: Course;
|
||||
|
||||
if (course.content.grouped) {
|
||||
updatedCourse = {
|
||||
...course,
|
||||
content: {
|
||||
...course.content,
|
||||
sections: course.content.sections.map((section) => ({
|
||||
...section,
|
||||
items: section.items.map((item) =>
|
||||
item.type === ContentType.Lesson && item.id === lessonId ? { ...item, isComplete } : item
|
||||
)
|
||||
}))
|
||||
}
|
||||
};
|
||||
} else {
|
||||
updatedCourse = {
|
||||
...course,
|
||||
content: {
|
||||
...course.content,
|
||||
items: course.content.items.map((item) =>
|
||||
item.type === ContentType.Lesson && item.id === lessonId ? { ...item, isComplete } : item
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return syncProgressionAccessToCourse(updatedCourse);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import type { StudentContentLockReason } from '$features/ai-assistant/utils/content-ask-ai-bar';
|
||||
|
||||
export function getStudentContentLockTitleKey(reason: StudentContentLockReason): string {
|
||||
if (reason === 'progression_locked') {
|
||||
return 'course.navItem.lessons.content_progression_locked_title';
|
||||
}
|
||||
|
||||
return 'course.navItem.lessons.content_locked_title';
|
||||
}
|
||||
|
||||
export function getStudentContentLockDescriptionKey(
|
||||
reason: StudentContentLockReason,
|
||||
contentType: typeof ContentType.Lesson | typeof ContentType.Exercise
|
||||
): string {
|
||||
if (reason === 'progression_locked') {
|
||||
if (contentType === ContentType.Exercise) {
|
||||
return 'course.navItem.lessons.content_progression_locked_description_exercise';
|
||||
}
|
||||
|
||||
return 'course.navItem.lessons.content_progression_locked_description_lesson';
|
||||
}
|
||||
|
||||
return 'course.navItem.lessons.content_locked_description';
|
||||
}
|
||||
@@ -86,6 +86,8 @@ export function hydrateExercisePageData(exercise: Exercise, exerciseId: string)
|
||||
sectionDisplayMode: exercise.sectionDisplayMode ?? 'one_question',
|
||||
totalSubmissions: 0,
|
||||
allowMultipleAttempts: !!exercise.allowMultipleAttempts,
|
||||
completionPolicy: (exercise.completionPolicy as 'submitted' | 'passed' | undefined) ?? 'submitted',
|
||||
passThreshold: exercise.passThreshold ?? 100,
|
||||
slug: exercise.slug ?? ''
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import type { Course, CourseContentItem } from './types';
|
||||
|
||||
type ProgressionLockReason = 'teacher_locked' | 'progression_blocked';
|
||||
|
||||
function sortNavigableItems(items: CourseContentItem[]): CourseContentItem[] {
|
||||
return [...items].sort((left, right) => {
|
||||
const orderDiff = (left.order ?? 0) - (right.order ?? 0);
|
||||
if (orderDiff !== 0) return orderDiff;
|
||||
|
||||
const typeDiff = (left.type === ContentType.Lesson ? 0 : 1) - (right.type === ContentType.Lesson ? 0 : 1);
|
||||
if (typeDiff !== 0) return typeDiff;
|
||||
|
||||
return new Date(left.createdAt || 0).getTime() - new Date(right.createdAt || 0).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
function getNavigableItems(course: Course): CourseContentItem[] {
|
||||
if (!course.content) return [];
|
||||
|
||||
const items = course.content.grouped
|
||||
? course.content.sections.flatMap((section) => section.items)
|
||||
: course.content.items;
|
||||
|
||||
return sortNavigableItems(
|
||||
items.filter((item) => item.type === ContentType.Lesson || item.type === ContentType.Exercise)
|
||||
);
|
||||
}
|
||||
|
||||
function lessonBlocksProgression(policy: string | null | undefined): boolean {
|
||||
return policy !== 'none';
|
||||
}
|
||||
|
||||
function isItemProgressComplete(item: CourseContentItem): boolean {
|
||||
return Boolean(item.isComplete);
|
||||
}
|
||||
|
||||
function computeProgressionAccess(
|
||||
navigableItems: CourseContentItem[],
|
||||
progressionMode: 'free' | 'sequential'
|
||||
): Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }> {
|
||||
const accessById = new Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }>();
|
||||
let priorBlockingComplete = true;
|
||||
|
||||
for (const item of navigableItems) {
|
||||
const teacherLocked = !(item.isUnlocked ?? false);
|
||||
const progressionLocked = progressionMode === 'sequential' && !priorBlockingComplete;
|
||||
|
||||
let accessible = !teacherLocked && !progressionLocked;
|
||||
let lockReason: ProgressionLockReason | null = null;
|
||||
|
||||
if (teacherLocked) {
|
||||
accessible = false;
|
||||
lockReason = 'teacher_locked';
|
||||
} else if (progressionLocked) {
|
||||
accessible = false;
|
||||
lockReason = 'progression_blocked';
|
||||
}
|
||||
|
||||
accessById.set(item.id, { accessible, lockReason });
|
||||
|
||||
const complete = isItemProgressComplete(item);
|
||||
const blocks = item.type === ContentType.Lesson ? lessonBlocksProgression(item.completionPolicy ?? 'manual') : true;
|
||||
|
||||
if (blocks && !complete) {
|
||||
priorBlockingComplete = false;
|
||||
}
|
||||
}
|
||||
|
||||
return accessById;
|
||||
}
|
||||
|
||||
function annotateContentItem(
|
||||
item: CourseContentItem,
|
||||
accessById: Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }>
|
||||
): CourseContentItem {
|
||||
const access = accessById.get(item.id);
|
||||
if (!access) return item;
|
||||
|
||||
return {
|
||||
...item,
|
||||
accessible: access.accessible,
|
||||
lockReason: access.lockReason
|
||||
};
|
||||
}
|
||||
|
||||
/** Recomputes student progression locks on in-memory course content after completion changes. */
|
||||
export function syncProgressionAccessToCourse(course: Course): Course {
|
||||
if (!course.content) return course;
|
||||
|
||||
const progressionMode = course.metadata?.progressionMode === 'sequential' ? 'sequential' : 'free';
|
||||
const accessById = computeProgressionAccess(getNavigableItems(course), progressionMode);
|
||||
|
||||
if (course.content.grouped) {
|
||||
return {
|
||||
...course,
|
||||
content: {
|
||||
...course.content,
|
||||
sections: course.content.sections.map((section) => ({
|
||||
...section,
|
||||
items: section.items.map((item) => annotateContentItem(item, accessById))
|
||||
}))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...course,
|
||||
content: {
|
||||
...course.content,
|
||||
items: course.content.items.map((item) => annotateContentItem(item, accessById))
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ type CourseSettings = {
|
||||
lessonDownload: boolean;
|
||||
isPublished: boolean;
|
||||
isContentGroupingEnabled: boolean;
|
||||
progressionMode: 'free' | 'sequential';
|
||||
callout: TCourseCallout | null;
|
||||
};
|
||||
|
||||
@@ -32,5 +33,6 @@ export const settings = writable<CourseSettings>({
|
||||
lessonDownload: false,
|
||||
isPublished: false,
|
||||
isContentGroupingEnabled: true,
|
||||
progressionMode: 'free',
|
||||
callout: null
|
||||
});
|
||||
|
||||
@@ -126,6 +126,22 @@ export type UpdateLessonCompletionResponse = InferResponseType<UpdateLessonCompl
|
||||
export type UpdateLessonCompletionSuccess = Extract<UpdateLessonCompletionResponse, { success: true }>;
|
||||
export type UpdateLessonCompletionData = UpdateLessonCompletionSuccess['data'];
|
||||
|
||||
export type GetLessonWatchProgressRequest =
|
||||
(typeof classroomio.course)[':courseId']['lesson'][':lessonId']['watch-progress']['$get'];
|
||||
export type GetLessonWatchProgressSuccess = Extract<
|
||||
InferResponseType<GetLessonWatchProgressRequest>,
|
||||
{ success: true }
|
||||
>;
|
||||
export type LessonWatchProgress = GetLessonWatchProgressSuccess['data'];
|
||||
|
||||
export type UpdateLessonWatchProgressRequest =
|
||||
(typeof classroomio.course)[':courseId']['lesson'][':lessonId']['watch-progress']['$put'];
|
||||
export type UpdateLessonWatchProgressSuccess = Extract<
|
||||
InferResponseType<UpdateLessonWatchProgressRequest>,
|
||||
{ success: true }
|
||||
>;
|
||||
export type LessonWatchProgressUpdate = UpdateLessonWatchProgressSuccess['data'];
|
||||
|
||||
// Get lesson history types
|
||||
export type GetLessonHistoryRequest =
|
||||
(typeof classroomio.course)[':courseId']['lesson'][':lessonId']['history']['$get'];
|
||||
|
||||
Reference in New Issue
Block a user