feat: certification rules, content sanitization, handle draft course updates and reset content, better redis logging
This commit is contained in:
@@ -9,6 +9,7 @@ import type {
|
||||
EnrollCourseRequest,
|
||||
GetCourseAnalyticsRequest,
|
||||
GetCourseBySlugRequest,
|
||||
GetCertificationEvaluationRequest,
|
||||
GetCourseProgressRequest,
|
||||
GetCourseRequest,
|
||||
UpdateCourseData,
|
||||
@@ -543,6 +544,27 @@ export class CourseApi extends BaseApiWithErrors {
|
||||
});
|
||||
}
|
||||
|
||||
async getCertificationEvaluation(courseId: string) {
|
||||
return this.execute<GetCertificationEvaluationRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId']['certification-evaluation'].$get({
|
||||
param: { courseId }
|
||||
}),
|
||||
logContext: 'fetching certification evaluation',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to load certification status');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets course analytics including student progress, completion rates, and grades
|
||||
* @param courseId Course ID
|
||||
@@ -635,7 +657,7 @@ export class CourseApi extends BaseApiWithErrors {
|
||||
const lessonTabsOrder = metadata?.lessonTabsOrder;
|
||||
if (lessonTabsOrder) {
|
||||
const existingTabIds = lessonTabsOrder.map((tab) => tab.id);
|
||||
const allTabs = [
|
||||
const allTabs: Array<{ id: 1 | 2 | 3 | 4; name: string }> = [
|
||||
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
|
||||
{ id: 2, name: 'course.navItem.lessons.materials.tabs.slide.title' },
|
||||
{ id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' },
|
||||
@@ -661,9 +683,15 @@ export class CourseApi extends BaseApiWithErrors {
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure certificateTheme has default value
|
||||
if (!data.certificateTheme) {
|
||||
data.certificateTheme = 'professional';
|
||||
if (!data.certificate) {
|
||||
data.certificate = {
|
||||
isDownloadable: false,
|
||||
theme: 'professional'
|
||||
};
|
||||
}
|
||||
|
||||
if (!data.certificate.theme) {
|
||||
data.certificate.theme = 'professional';
|
||||
}
|
||||
|
||||
// Set the course data
|
||||
|
||||
@@ -32,7 +32,7 @@ export class ExerciseApi extends BaseApiWithErrors {
|
||||
logContext: 'fetching exercise',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.exercise = response.data;
|
||||
this.exercise = response.data as Exercise;
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export class ExerciseApi extends BaseApiWithErrors {
|
||||
logContext: 'creating exercise',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.exercise = response.data;
|
||||
this.exercise = response.data as Exercise;
|
||||
snackbar.success('Exercise created successfully');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
@@ -106,7 +106,7 @@ export class ExerciseApi extends BaseApiWithErrors {
|
||||
logContext: 'updating exercise',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.exercise = response.data;
|
||||
this.exercise = response.data as Exercise;
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
@@ -163,7 +163,7 @@ export class ExerciseApi extends BaseApiWithErrors {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<SubmitExerciseRequest>({
|
||||
return await this.execute<SubmitExerciseRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].exercise[':exerciseId'].submission.$post({
|
||||
param: { courseId, exerciseId },
|
||||
@@ -220,7 +220,7 @@ export class ExerciseApi extends BaseApiWithErrors {
|
||||
logContext: 'creating exercise from template',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.exercise = response.data;
|
||||
this.exercise = response.data as Exercise;
|
||||
snackbar.success('Exercise created successfully');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
import {
|
||||
ZLessonCommentCreate,
|
||||
ZLessonCommentUpdate,
|
||||
ZLessonCompletionUpdate,
|
||||
ZLessonCreate,
|
||||
ZLessonReorder,
|
||||
ZLessonUpdate
|
||||
@@ -46,8 +45,8 @@ import {
|
||||
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
import { get } from 'svelte/store';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
|
||||
@@ -607,17 +606,11 @@ export class LessonApi extends BaseApiWithErrors {
|
||||
* Updates lesson completion
|
||||
*/
|
||||
async updateCompletion(courseId: string, lessonId: string, isComplete: boolean) {
|
||||
const result = ZLessonCompletionUpdate.safeParse({ isComplete });
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'lesson');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<UpdateLessonCompletionRequest>({
|
||||
return this.execute<UpdateLessonCompletionRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].lesson[':lessonId'].completion.$put({
|
||||
param: { courseId, lessonId },
|
||||
json: result.data
|
||||
json: { isComplete }
|
||||
}),
|
||||
logContext: 'updating lesson completion',
|
||||
onSuccess: (response) => {
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import * as RadioGroup from '@cio/ui/base/radio-group';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { CERTIFICATE_THEME_IDS, type CertificateThemeId } from '$features/course/utils/constants';
|
||||
import { parseCertificateThemeId } from '$features/course/utils/certificate-utils';
|
||||
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
|
||||
import Plain from './templates/plain.svelte';
|
||||
import Professional from './templates/professional.svelte';
|
||||
import BlueBadgePattern from './templates/blue-badge-pattern.svelte';
|
||||
import PurpleBadgePattern from './templates/purple-badge-pattern.svelte';
|
||||
import BlueProfessionalBadge from './templates/blue-professional-badge.svelte';
|
||||
import PurpleProfessionalBadge from './templates/purple-professional-badge.svelte';
|
||||
|
||||
type Props = {
|
||||
errors: Record<string, string>;
|
||||
};
|
||||
|
||||
let { errors }: Props = $props();
|
||||
|
||||
const studentNamePlaceholder = 'Name of student';
|
||||
|
||||
const certificateTheme = $derived(parseCertificateThemeId(courseApi.course?.certificate?.theme));
|
||||
|
||||
const helperText = $derived(
|
||||
`${courseApi.course?.description?.length || 0}/200 ${$t('course.navItem.certificates.characters')}`
|
||||
);
|
||||
|
||||
function onDescriptionInput(e: Event) {
|
||||
if (!courseApi.course) return;
|
||||
courseApi.course.description = (e.currentTarget as HTMLTextAreaElement).value;
|
||||
}
|
||||
|
||||
function goToOrgSettings() {
|
||||
goto(
|
||||
resolve('/org/[slug]/settings', {
|
||||
slug: $currentOrg.siteName || ''
|
||||
})
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-3 flex h-4/5 w-full flex-1 flex-col justify-between gap-3 lg:flex-row">
|
||||
<section class="h-full w-full lg:w-2/5">
|
||||
<Field.Group class="w-full max-w-md! px-2">
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.navItem.certificates.certificate_settings')}</Field.Legend>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('course.navItem.certificates.theme')}</Field.Label>
|
||||
<RadioGroup.Root
|
||||
value={certificateTheme}
|
||||
onValueChange={(value) => {
|
||||
if (!courseApi.course) return;
|
||||
|
||||
courseApi.course.certificate = {
|
||||
...(courseApi.course.certificate ?? {}),
|
||||
theme: value as CertificateThemeId
|
||||
};
|
||||
}}
|
||||
disabled={$isFreePlan}
|
||||
class="mb-6"
|
||||
>
|
||||
<div class="flex flex-wrap justify-between gap-y-5">
|
||||
{#each CERTIFICATE_THEME_IDS as theme (theme)}
|
||||
<div class="mr-3 flex items-start space-x-2">
|
||||
<RadioGroup.Item value={theme} id={theme} />
|
||||
<Label for={theme} class="cursor-pointer">
|
||||
<img src={`/images/certificate_theme_${theme}.png`} alt="" class="h-[82px] w-[110px]" />
|
||||
</Label>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('course.navItem.certificates.logo')}</Field.Label>
|
||||
<Field.Description>
|
||||
{$t('course.navItem.certificates.to_update')}
|
||||
<strong class="font-semibold">{$t('course.navItem.certificates.settings')}</strong>
|
||||
{$t('course.navItem.certificates.and_upload')}
|
||||
</Field.Description>
|
||||
<Button variant="outline" class="mt-3" onclick={goToOrgSettings}>
|
||||
{$t('course.navItem.certificates.goto_settings')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label for="cert-design-description">{$t('course.navItem.certificates.description')}</Field.Label>
|
||||
<Textarea
|
||||
id="cert-design-description"
|
||||
rows={6}
|
||||
class="w-full"
|
||||
placeholder={$t('course.navItem.certificates.placeholder')}
|
||||
value={courseApi.course?.description ?? ''}
|
||||
oninput={onDescriptionInput}
|
||||
disabled={$isFreePlan}
|
||||
/>
|
||||
<Field.Description>{helperText}</Field.Description>
|
||||
{#if errors.description}
|
||||
<Field.Error>{errors.description}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
</section>
|
||||
<section class="flex w-full rounded-md lg:w-3/5">
|
||||
<div class="w-[95%]">
|
||||
{#if certificateTheme === 'professional'}
|
||||
<Professional studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'plain'}
|
||||
<Plain studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'purpleProfessionalBadge'}
|
||||
<PurpleProfessionalBadge studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'blueProfessionalBadge'}
|
||||
<BlueProfessionalBadge studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'purpleBadgePattern'}
|
||||
<PurpleBadgePattern studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'blueBadgePattern'}
|
||||
<BlueBadgePattern studentName={studentNamePlaceholder} />
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
import { UpgradeBanner } from '$features/ui';
|
||||
|
||||
import CertificateDesign from './certificate-design.svelte';
|
||||
import CertificateSettings from './certificate-settings.svelte';
|
||||
|
||||
type Props = {
|
||||
errors: Record<string, string>;
|
||||
};
|
||||
|
||||
let { errors }: Props = $props();
|
||||
|
||||
let activeTab = $state('design');
|
||||
</script>
|
||||
|
||||
<UpgradeBanner>{$t('upgrade.certificate')}</UpgradeBanner>
|
||||
|
||||
<main class="px-2 md:-mr-3 md:-ml-3">
|
||||
<UnderlineTabs.Root bind:value={activeTab} class="w-full">
|
||||
<UnderlineTabs.List>
|
||||
<UnderlineTabs.Trigger value="design">
|
||||
{$t('course.navItem.certificates.tab_design')}
|
||||
</UnderlineTabs.Trigger>
|
||||
<UnderlineTabs.Trigger value="settings">
|
||||
{$t('course.navItem.certificates.tab_settings')}
|
||||
</UnderlineTabs.Trigger>
|
||||
</UnderlineTabs.List>
|
||||
<UnderlineTabs.Content value="design" class="mt-4">
|
||||
<CertificateDesign {errors} />
|
||||
</UnderlineTabs.Content>
|
||||
<UnderlineTabs.Content value="settings" class="mt-4">
|
||||
<CertificateSettings {errors} />
|
||||
</UnderlineTabs.Content>
|
||||
</UnderlineTabs.Root>
|
||||
</main>
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
<script lang="ts">
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import { getOrderedNavigableContent } from '$features/course/utils/content';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
|
||||
type Props = {
|
||||
errors: Record<string, string>;
|
||||
};
|
||||
|
||||
let { errors }: Props = $props();
|
||||
|
||||
function isoToDatetimeLocal(iso: string | null | undefined): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function updateCertificate(patch: Partial<NonNullable<typeof courseApi.course>['certificate']>) {
|
||||
if (!courseApi.course) return;
|
||||
|
||||
courseApi.course.certificate = {
|
||||
...(courseApi.course.certificate ?? {}),
|
||||
...patch
|
||||
};
|
||||
}
|
||||
|
||||
function onCertDeadlineChange(e: Event) {
|
||||
const v = (e.currentTarget as HTMLInputElement).value;
|
||||
updateCertificate({ deadline: v ? new Date(v).toISOString() : null });
|
||||
}
|
||||
|
||||
const certExercises = $derived(
|
||||
getOrderedNavigableContent(courseApi.course).filter((item) => item.type === ContentType.Exercise)
|
||||
);
|
||||
|
||||
const finalExerciseSelectValue = $derived(courseApi.course?.certificate?.requiredExerciseId ?? 'none');
|
||||
|
||||
function onFinalExerciseChange(e: Event) {
|
||||
const v = (e.currentTarget as HTMLSelectElement).value;
|
||||
if (!v || v === 'none') {
|
||||
updateCertificate({ requiredExerciseId: null, exerciseMinScorePercent: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const currentMin = courseApi.course?.certificate?.exerciseMinScorePercent;
|
||||
updateCertificate({
|
||||
requiredExerciseId: v,
|
||||
exerciseMinScorePercent: currentMin ?? 100
|
||||
});
|
||||
}
|
||||
|
||||
function onThresholdInput(e: Event) {
|
||||
const n = Number((e.currentTarget as HTMLInputElement).value);
|
||||
updateCertificate({ threshold: Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 100 });
|
||||
}
|
||||
|
||||
function onMinExerciseScoreInput(e: Event) {
|
||||
const n = Number((e.currentTarget as HTMLInputElement).value);
|
||||
updateCertificate({ exerciseMinScorePercent: Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 100 });
|
||||
}
|
||||
|
||||
function onEmailMessageInput(e: Event) {
|
||||
updateCertificate({ emailMessage: (e.currentTarget as HTMLTextAreaElement).value || null });
|
||||
}
|
||||
</script>
|
||||
|
||||
<Field.Group class="w-full max-w-md! px-2">
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.certification.rules_heading')}</Field.Legend>
|
||||
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch
|
||||
id="certificate-downloadable"
|
||||
checked={!!courseApi.course?.certificate?.isDownloadable}
|
||||
onCheckedChange={(checked) => {
|
||||
updateCertificate({ isDownloadable: checked });
|
||||
}}
|
||||
disabled={$isFreePlan}
|
||||
/>
|
||||
<Field.Label for="certificate-downloadable" class="text-gray-600">
|
||||
{$t('course.navItem.certificates.allow')}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label for="cert-deadline">{$t('course.certification.deadline_label')}</Field.Label>
|
||||
<Input
|
||||
id="cert-deadline"
|
||||
type="datetime-local"
|
||||
class="w-full"
|
||||
value={isoToDatetimeLocal(courseApi.course?.certificate?.deadline ?? null)}
|
||||
onchange={onCertDeadlineChange}
|
||||
disabled={$isFreePlan}
|
||||
/>
|
||||
<Field.Description>{$t('course.certification.deadline_helper')}</Field.Description>
|
||||
{#if errors['certificate.deadline']}
|
||||
<Field.Error>{errors['certificate.deadline']}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label for="cert-threshold">{$t('course.certification.threshold_label')}</Field.Label>
|
||||
<Input
|
||||
id="cert-threshold"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
class="w-full"
|
||||
value={String(courseApi.course?.certificate?.threshold ?? 100)}
|
||||
oninput={onThresholdInput}
|
||||
disabled={$isFreePlan}
|
||||
/>
|
||||
<Field.Description>{$t('course.certification.threshold_helper')}</Field.Description>
|
||||
{#if errors['certificate.threshold']}
|
||||
<Field.Error>{errors['certificate.threshold']}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label for="cert-final-exercise">{$t('course.certification.final_exercise_label')}</Field.Label>
|
||||
<select
|
||||
id="cert-final-exercise"
|
||||
class="ui:border-input ui:bg-background ui:text-foreground ui:ring-offset-background ui:shadow-xs ui:flex ui:h-9 ui:w-full ui:min-w-0 ui:rounded-md ui:border ui:px-3 ui:py-1 ui:text-base ui:outline-none ui:transition-[color,box-shadow] ui:disabled:cursor-not-allowed ui:disabled:opacity-50 ui:md:text-sm ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:focus-visible:ring-[3px]"
|
||||
value={finalExerciseSelectValue}
|
||||
onchange={onFinalExerciseChange}
|
||||
disabled={$isFreePlan}
|
||||
>
|
||||
<option value="none">{$t('course.certification.final_exercise_none')}</option>
|
||||
{#each certExercises as item (item.id)}
|
||||
<option value={item.id}>{item.title}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Field.Description>{$t('course.certification.final_exercise_helper')}</Field.Description>
|
||||
</Field.Field>
|
||||
|
||||
{#if courseApi.course?.certificate?.requiredExerciseId}
|
||||
<Field.Field>
|
||||
<Field.Label for="cert-min-exercise-score">{$t('course.certification.min_exercise_score_label')}</Field.Label>
|
||||
<Input
|
||||
id="cert-min-exercise-score"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
class="w-full"
|
||||
value={String(courseApi.course?.certificate?.exerciseMinScorePercent ?? 100)}
|
||||
oninput={onMinExerciseScoreInput}
|
||||
disabled={$isFreePlan}
|
||||
/>
|
||||
<Field.Description>{$t('course.certification.min_exercise_score_helper')}</Field.Description>
|
||||
{#if errors['certificate.exerciseMinScorePercent']}
|
||||
<Field.Error>{errors['certificate.exerciseMinScorePercent']}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
{/if}
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.certification.email_message_label')}</Field.Legend>
|
||||
<Field.Field>
|
||||
<Textarea
|
||||
id="cert-email-message"
|
||||
class="w-full"
|
||||
rows={4}
|
||||
placeholder={$t('course.certification.email_message_placeholder')}
|
||||
value={courseApi.course?.certificate?.emailMessage ?? ''}
|
||||
oninput={onEmailMessageInput}
|
||||
disabled={$isFreePlan}
|
||||
/>
|
||||
{#if errors['certificate.emailMessage']}
|
||||
<Field.Error>{errors['certificate.emailMessage']}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Confetti } from '$features/ui';
|
||||
import { setConfetti } from '$features/ui/confetti/store';
|
||||
import { courseCompletionModal, closeCourseCompletionModal } from '$features/course/store/course-completion-modal';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
let open = $state(false);
|
||||
let activeCourseId = $state('');
|
||||
|
||||
onMount(() => {
|
||||
return courseCompletionModal.subscribe((v) => {
|
||||
if (v?.open && v.courseId) {
|
||||
activeCourseId = v.courseId;
|
||||
open = true;
|
||||
setConfetti(true);
|
||||
setTimeout(() => setConfetti(false), 5500);
|
||||
} else {
|
||||
open = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function onOpenChange(next: boolean) {
|
||||
open = next;
|
||||
if (!next) {
|
||||
closeCourseCompletionModal();
|
||||
}
|
||||
}
|
||||
|
||||
function goToCertificate() {
|
||||
if (!activeCourseId) return;
|
||||
onOpenChange(false);
|
||||
goto(resolve(`/courses/${activeCourseId}/certificates`, {}));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="pointer-events-none fixed inset-0 z-100">
|
||||
<Confetti />
|
||||
</div>
|
||||
|
||||
<Dialog.Root {open} {onOpenChange}>
|
||||
<Dialog.Content class="ui:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.completion.modal.title')}</Dialog.Title>
|
||||
<Dialog.Description>{$t('course.completion.modal.subtitle')}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer class="ui:gap-2">
|
||||
<Button variant="outline" onclick={() => onOpenChange(false)}>{$t('course.completion.modal.later')}</Button>
|
||||
<Button onclick={goToCertificate}>{$t('course.completion.modal.view_certificate')}</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -1,196 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import ZapIcon from '@lucide/svelte/icons/zap';
|
||||
import * as RadioGroup from '@cio/ui/base/radio-group';
|
||||
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
|
||||
import { saveCertificateValidation } from '$lib/utils/functions/validator';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
import Plain from './templates/plain.svelte';
|
||||
import Professional from './templates/professional.svelte';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { UpgradeBanner } from '$features/ui';
|
||||
import BlueBadgePattern from './templates/blue-badge-pattern.svelte';
|
||||
import PurpleBadgePattern from './templates/purple-badge-pattern.svelte';
|
||||
import BlueProfessionalBadge from './templates/blue-professional-badge.svelte';
|
||||
import PurpleProfessionalBadge from './templates/purple-professional-badge.svelte';
|
||||
|
||||
const studentNamePlaceholder = 'Name of student';
|
||||
const themes = [
|
||||
'professional',
|
||||
'plain',
|
||||
'purpleProfessionalBadge',
|
||||
'blueProfessionalBadge',
|
||||
'purpleBadgePattern',
|
||||
'blueBadgePattern'
|
||||
];
|
||||
|
||||
let errors = $state({
|
||||
description: ''
|
||||
});
|
||||
|
||||
const helperText = $derived(
|
||||
`${courseApi.course?.description?.length || 0}/200 ${$t('course.navItem.certificates.characters')}`
|
||||
);
|
||||
|
||||
let certificateTheme = $derived<string | undefined>(courseApi.course?.certificateTheme ?? undefined);
|
||||
|
||||
const saveCertificate = async () => {
|
||||
if (!courseApi.course) return;
|
||||
|
||||
// Prevent free plan users from bypassing UI restrictions
|
||||
if ($isFreePlan) {
|
||||
errors.description = 'Certificate customization is only available on paid plans';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = saveCertificateValidation({
|
||||
description: courseApi.course?.description || '',
|
||||
isCertificateDownloadable: courseApi.course?.isCertificateDownloadable || false,
|
||||
certificateTheme: certificateTheme || ''
|
||||
});
|
||||
|
||||
if (result && Object.keys(result).length > 0) {
|
||||
errors.description = $t(result.description) || $t('course.navItem.certificates.description_error');
|
||||
throw new Error(errors.description);
|
||||
}
|
||||
|
||||
errors.description = '';
|
||||
|
||||
await courseApi.update(courseApi.course.id!, {
|
||||
description: courseApi.course?.description || '',
|
||||
isCertificateDownloadable: courseApi.course?.isCertificateDownloadable || false,
|
||||
certificateTheme: certificateTheme || ''
|
||||
});
|
||||
|
||||
snackbar.success('snackbar.course_settings.success.saved');
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
errors.description = error.message;
|
||||
} else {
|
||||
errors.description = $t('course.navItem.certificates.unexpected_error');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Qwitcher+Grypen&family=Roboto:wght@300;400;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<UpgradeBanner>{$t('upgrade.certificate')}</UpgradeBanner>
|
||||
|
||||
<main class="mt-2 px-2 md:-mr-3 md:-ml-3">
|
||||
<div class="mb-3 flex h-4/5 w-full flex-1 flex-col justify-between gap-3 lg:flex-row">
|
||||
<section class="h-full w-full lg:w-2/5">
|
||||
<strong class="my-2 text-base font-semibold text-black dark:text-gray-100"
|
||||
>{$t('course.navItem.certificates.certificate_settings')}</strong
|
||||
>
|
||||
<p class="my-4 text-xs font-normal dark:text-gray-100">
|
||||
{$t('course.navItem.certificates.theme')}
|
||||
</p>
|
||||
|
||||
<RadioGroup.Root bind:value={certificateTheme} disabled={$isFreePlan} class="mb-10">
|
||||
<div class="flex flex-wrap justify-between gap-y-5">
|
||||
{#each themes as theme}
|
||||
<div class="mr-3 flex items-start space-x-2">
|
||||
<RadioGroup.Item value={theme} id={theme} />
|
||||
<Label for={theme} class="cursor-pointer">
|
||||
<img src={`/images/certificate_theme_${theme}.png`} alt="themes" class="h-[82px] w-[110px]" />
|
||||
</Label>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
|
||||
<div>
|
||||
<p class="my-2 text-xs font-normal text-black dark:text-gray-100">
|
||||
{$t('course.navItem.certificates.logo')}
|
||||
</p>
|
||||
<div>
|
||||
<p class="mt-1 text-base dark:text-gray-100">
|
||||
{$t('course.navItem.certificates.to_update')}
|
||||
<strong class="font-semibold">{$t('course.navItem.certificates.settings')}</strong>
|
||||
{$t('course.navItem.certificates.and_upload')}
|
||||
</p>
|
||||
|
||||
<Button variant="outline" class="mt-3" onclick={() => goto(`/org/${$currentOrg.siteName}/settings`)}>
|
||||
{$t('course.navItem.certificates.goto_settings')}
|
||||
</Button>
|
||||
</div>
|
||||
<span class="my-4">
|
||||
<p class="mt-4 mb-2 text-xs font-normal dark:text-gray-100">
|
||||
{$t('course.navItem.certificates.description')}
|
||||
</p>
|
||||
<TextareaField
|
||||
rows={6}
|
||||
placeholder={$t('course.navItem.certificates.placeholder')}
|
||||
value={courseApi.course?.description}
|
||||
onchange={(e) => (courseApi.course!.description = e.currentTarget.value)}
|
||||
errorMessage={errors.description}
|
||||
disabled={$isFreePlan}
|
||||
helperMessage={helperText}
|
||||
/>
|
||||
</span>
|
||||
<div class="my-4 flex items-center space-x-2">
|
||||
<Switch
|
||||
id="certificate-downloadable"
|
||||
checked={!!courseApi.course?.isCertificateDownloadable}
|
||||
onCheckedChange={(checked) => (courseApi.course!.isCertificateDownloadable = checked)}
|
||||
disabled={$isFreePlan}
|
||||
/>
|
||||
<Label for="certificate-downloadable" class="text-sm font-medium dark:text-gray-100">
|
||||
{$t('course.navItem.certificates.allow')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="flex w-full items-center justify-center rounded-md bg-gray-100 lg:w-3/5 dark:bg-neutral-800">
|
||||
<div class="certificate-container flex items-center justify-center">
|
||||
{#if certificateTheme === 'professional'}
|
||||
<Professional studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'plain'}
|
||||
<Plain studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'purpleProfessionalBadge'}
|
||||
<PurpleProfessionalBadge studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'blueProfessionalBadge'}
|
||||
<BlueProfessionalBadge studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'purpleBadgePattern'}
|
||||
<PurpleBadgePattern studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'blueBadgePattern'}
|
||||
<BlueBadgePattern studentName={studentNamePlaceholder} />
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="h-1/5">
|
||||
<Button variant="secondary" onclick={saveCertificate} loading={courseApi.isLoading} disabled={$isFreePlan}>
|
||||
{#if $isFreePlan}
|
||||
<ZapIcon size={16} class="filled" />
|
||||
{/if}
|
||||
{$t('course.navItem.certificates.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
p {
|
||||
color: #262626;
|
||||
}
|
||||
.certificate-container {
|
||||
width: 95%;
|
||||
max-width: 95%;
|
||||
}
|
||||
</style>
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if courseApi.course?.isCertificateDownloadable === true}
|
||||
{#if courseApi.course?.certificate?.isDownloadable === true}
|
||||
<UnlockedCertificate />
|
||||
{:else}
|
||||
<LockedCertificate />
|
||||
|
||||
+82
-17
@@ -5,17 +5,55 @@
|
||||
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import Empty from '@cio/ui/custom/empty/empty.svelte';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import type { CertificationEvaluationData } from '$features/course/utils/types';
|
||||
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
|
||||
|
||||
let isLoading = $state(false);
|
||||
let isCourseComplete = $state(false);
|
||||
let evaluation = $state<CertificationEvaluationData | null>(null);
|
||||
|
||||
let courseId = $derived(courseApi.course?.id ?? '');
|
||||
|
||||
function formatBlockerMessage(blocker: CertificationEvaluationData['blockers'][number]): string {
|
||||
const p = blocker.params ?? {};
|
||||
switch (blocker.code) {
|
||||
case 'CERT_PROGRESS':
|
||||
return t.get('course.certification.blocker_progress', {
|
||||
current: Number(p.current ?? 0),
|
||||
required: Number(p.required ?? 0)
|
||||
});
|
||||
case 'CERT_DEADLINE_PASSED':
|
||||
return t.get('course.certification.blocker_deadline');
|
||||
case 'CERT_NO_CONTENT':
|
||||
return t.get('course.certification.blocker_no_content');
|
||||
case 'CERT_FINAL_EXERCISE_NOT_SUBMITTED':
|
||||
return t.get('course.certification.blocker_final_not_submitted', {
|
||||
exerciseTitle: String(p.exerciseTitle ?? '')
|
||||
});
|
||||
case 'CERT_FINAL_EXERCISE_PENDING_GRADE':
|
||||
return t.get('course.certification.blocker_final_pending', {
|
||||
exerciseTitle: String(p.exerciseTitle ?? '')
|
||||
});
|
||||
case 'CERT_FINAL_EXERCISE_SCORE':
|
||||
return t.get('course.certification.blocker_final_score', {
|
||||
exerciseTitle: String(p.exerciseTitle ?? ''),
|
||||
bestPercent: Number(p.bestPercent ?? 0),
|
||||
requiredPercent: Number(p.requiredPercent ?? 0)
|
||||
});
|
||||
case 'CERT_FINAL_EXERCISE_MISCONFIGURED':
|
||||
return t.get('course.certification.blocker_final_misconfigured', {
|
||||
exerciseTitle: String(p.exerciseTitle ?? '')
|
||||
});
|
||||
default:
|
||||
return blocker.code;
|
||||
}
|
||||
}
|
||||
|
||||
const downLoadCertificate = async () => {
|
||||
if (!isCourseComplete || !courseId) return;
|
||||
|
||||
@@ -24,7 +62,7 @@
|
||||
const response = await classroomio.course[':courseId']['download']['certificate']['$post']({
|
||||
param: { courseId },
|
||||
json: {
|
||||
theme: `${courseApi.course?.certificateTheme}`,
|
||||
theme: `${courseApi.course?.certificate?.theme ?? 'professional'}`,
|
||||
studentName: `${$profile.fullname}`,
|
||||
courseName: `${courseApi.course?.title}`,
|
||||
courseDescription: `${courseApi.course?.description}`,
|
||||
@@ -52,22 +90,24 @@
|
||||
isLoading = false;
|
||||
};
|
||||
|
||||
const hasUserCompletedCourse = async () => {
|
||||
const loadEvaluation = async () => {
|
||||
if (!courseId) return;
|
||||
isLoading = true;
|
||||
const res = await courseApi.getCertificationEvaluation(courseId);
|
||||
if (res?.data) {
|
||||
evaluation = res.data;
|
||||
const hasEarned = res.data.certificateEarnedAt != null;
|
||||
isCourseComplete = hasEarned || !!res.data.eligibleForCertificate;
|
||||
|
||||
const response = await courseApi.getProgress(courseId, $profile.id);
|
||||
|
||||
if (courseApi.success && response?.data) {
|
||||
const progress = response.data;
|
||||
isCourseComplete =
|
||||
progress.lessonsCount === progress.lessonsCompleted && progress.exercisesCount === progress.exercisesCompleted;
|
||||
if (res.data.isNewCompletion && courseId) {
|
||||
openCourseCompletionModal(courseId);
|
||||
}
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
hasUserCompletedCourse();
|
||||
loadEvaluation();
|
||||
});
|
||||
|
||||
let title = $derived(
|
||||
@@ -75,15 +115,40 @@
|
||||
? 'course.navItem.certificates.unlocked_certificate'
|
||||
: 'course.navItem.certificates.complete_to_download_title'
|
||||
);
|
||||
let subtitle = $derived(
|
||||
isCourseComplete
|
||||
? 'course.navItem.certificates.unlocked_certificate_subtitle'
|
||||
: 'course.navItem.certificates.complete_to_download_subtitle'
|
||||
);
|
||||
|
||||
let progressLines = $derived.by(() => {
|
||||
if (!evaluation) {
|
||||
return [
|
||||
t.get('course.certificates.progress_hint', {
|
||||
current: 0,
|
||||
required: courseApi.course?.certificate?.threshold ?? 100
|
||||
})
|
||||
];
|
||||
}
|
||||
const blockers = evaluation.blockers ?? [];
|
||||
if (blockers.length > 0) {
|
||||
return blockers.map((b) => formatBlockerMessage(b));
|
||||
}
|
||||
return [
|
||||
t.get('course.certificates.progress_hint', {
|
||||
current: evaluation.progressPercent,
|
||||
required: evaluation.certificationThreshold
|
||||
})
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex-1">
|
||||
<Empty title={$t(title)} description={$t(subtitle)} icon={DownloadIcon} variant="page">
|
||||
<Empty title={$t(title)} icon={DownloadIcon} variant="page">
|
||||
<div class="ui:text-muted-foreground mx-auto mb-4 max-w-md space-y-2 text-center text-sm">
|
||||
{#if isCourseComplete}
|
||||
<p>{$t('course.navItem.certificates.unlocked_certificate_subtitle')}</p>
|
||||
{:else}
|
||||
{#each progressLines as line (line)}
|
||||
<p>{line}</p>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<Button onclick={downLoadCertificate} disabled={!isCourseComplete} loading={isLoading}>
|
||||
<DownloadIcon size={16} />
|
||||
{$t('course.navItem.certificates.download_certificate')}
|
||||
|
||||
+1
-1
@@ -155,7 +155,7 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open={$contentCreateStore.open} onOpenChange={(isOpen) => !isOpen && closeModal()}>
|
||||
<Dialog.Content class="flex max-h-[680px] w-fit! max-w-2xl! min-w-[400px] flex-col overflow-hidden">
|
||||
<Dialog.Content class="flex max-h-[680px] w-full! max-w-xl! min-w-[400px] flex-col overflow-hidden">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.lessons.add_content')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
+1
-1
@@ -252,7 +252,7 @@
|
||||
bind:value={title}
|
||||
autoFocus={true}
|
||||
placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.title_placeholder')}
|
||||
className="my-4 w-2/4!"
|
||||
className="my-4 w-full"
|
||||
/>
|
||||
</div>
|
||||
{:else if type === EXERCISE_CREATE_TYPE.TEMPLATE}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { PREMIUM_QUESTION_TYPE_KEYS, QUESTION_TYPES } from '$features/ui/question/constants';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { ExerciseQuestion } from '@cio/ui';
|
||||
|
||||
import OrderModal from './order-modal.svelte';
|
||||
@@ -40,11 +41,20 @@
|
||||
|
||||
interface Props {
|
||||
shouldDeleteExercise?: boolean;
|
||||
exerciseId: any;
|
||||
goBack?: any;
|
||||
exerciseId: string;
|
||||
goBack?: () => void;
|
||||
/** Self-paced + all auto-gradable question types: enforce points > 0 */
|
||||
requiresPositivePointsForAutoGrade?: boolean;
|
||||
selfPacedCourse?: boolean;
|
||||
}
|
||||
|
||||
let { shouldDeleteExercise = $bindable(false), exerciseId, goBack = () => {} }: Props = $props();
|
||||
let {
|
||||
shouldDeleteExercise = $bindable(false),
|
||||
exerciseId,
|
||||
goBack = () => {},
|
||||
requiresPositivePointsForAutoGrade = false,
|
||||
selfPacedCourse = false
|
||||
}: Props = $props();
|
||||
|
||||
let questionIdToDelete = $state(null);
|
||||
let isDeleting = $state(false);
|
||||
@@ -173,9 +183,10 @@
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!courseApi.course?.id) return;
|
||||
isDeleting = true;
|
||||
|
||||
await exerciseApi.delete(courseApi.course?.id!, exerciseId);
|
||||
await exerciseApi.delete(courseApi.course.id, exerciseId);
|
||||
|
||||
if (exerciseApi.success) {
|
||||
courseApi.removeContentItem(exerciseId, ContentType.Exercise);
|
||||
@@ -234,6 +245,9 @@
|
||||
bind:points={question.points}
|
||||
hasError={!!errors[question.id]}
|
||||
errorMsg={getQuestionErrorMsg(errors, question, 'points')}
|
||||
pointsHint={requiresPositivePointsForAutoGrade && Number(question.points) === 0
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.points_required_auto_grade')
|
||||
: null}
|
||||
onPointsChange={() => {
|
||||
question.isDirty = true;
|
||||
}}
|
||||
@@ -272,7 +286,7 @@
|
||||
{getQuestionTypeLabel(question?.questionType)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each QUESTION_TYPES as type, i (type.key)}
|
||||
{#each QUESTION_TYPES as type (type.key)}
|
||||
{#if $isFreePlan && PREMIUM_QUESTION_TYPE_KEYS.has(type.key)}
|
||||
<button
|
||||
type="button"
|
||||
@@ -288,7 +302,27 @@
|
||||
<span>{getQuestionTypeLabel(type)}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<Select.Item value={type.id.toString()} label={getQuestionTypeLabel(type)} />
|
||||
<Select.Item value={type.id.toString()} label={getQuestionTypeLabel(type)}>
|
||||
{#snippet children({ selected: _sel })}
|
||||
<span class="flex w-full min-w-0 items-center justify-between gap-2">
|
||||
<span class="truncate">{getQuestionTypeLabel(type)}</span>
|
||||
{#if selfPacedCourse}
|
||||
<Badge
|
||||
variant={type.autoGradable ? 'success' : 'warning'}
|
||||
class="ui:shrink-0 ui:text-[10px] ui:font-normal"
|
||||
>
|
||||
{type.autoGradable
|
||||
? $t(
|
||||
'course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_auto_gradable'
|
||||
)
|
||||
: $t(
|
||||
'course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_manual_grading'
|
||||
)}
|
||||
</Badge>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
</Select.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
</Select.Content>
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { questionnaire } from './store';
|
||||
import { exerciseApi } from '$features/course/api';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
|
||||
type Props = {
|
||||
exerciseId: string;
|
||||
};
|
||||
|
||||
let { exerciseId }: Props = $props();
|
||||
|
||||
let saving = $state(false);
|
||||
|
||||
async function saveSettings() {
|
||||
if (!courseApi.course?.id) return;
|
||||
saving = true;
|
||||
|
||||
await exerciseApi.update(courseApi.course.id, exerciseId, {
|
||||
allowMultipleAttempts: !!$questionnaire.allowMultipleAttempts
|
||||
});
|
||||
saving = false;
|
||||
|
||||
if (exerciseApi.success) {
|
||||
snackbar.success('snackbar.exercise.success');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-xl space-y-6">
|
||||
<div class="flex items-center justify-between gap-4 rounded-lg border border-neutral-200 p-4 dark:border-neutral-700">
|
||||
<div class="space-y-1">
|
||||
<Label for="allow-multiple" class="text-sm font-medium dark:text-gray-100">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.settings_allow_multiple')}
|
||||
</Label>
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.settings_allow_multiple_helper')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="allow-multiple"
|
||||
checked={!!$questionnaire.allowMultipleAttempts}
|
||||
onCheckedChange={(checked) => {
|
||||
questionnaire.update((q) => ({ ...q, allowMultipleAttempts: checked }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onclick={saveSettings} loading={saving} disabled={!courseApi.course?.id}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.settings_save')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -54,6 +54,7 @@ export const questionnaire: Writable<{
|
||||
isDescriptionDirty?: boolean;
|
||||
questions: Question[];
|
||||
totalSubmissions: number;
|
||||
allowMultipleAttempts?: boolean;
|
||||
}> = writable({
|
||||
title: '',
|
||||
dueBy: '',
|
||||
@@ -62,7 +63,8 @@ export const questionnaire: Writable<{
|
||||
description: '',
|
||||
isDescriptionDirty: false,
|
||||
questions: [],
|
||||
totalSubmissions: 0
|
||||
totalSubmissions: 0,
|
||||
allowMultipleAttempts: false
|
||||
});
|
||||
|
||||
export function clearQuestionnaireValidation() {
|
||||
@@ -88,6 +90,15 @@ export function reset() {
|
||||
clearQuestionnaireValidation();
|
||||
}
|
||||
|
||||
/** Clears take-exercise state so the student can start another attempt (when allowed). */
|
||||
export function resetStudentExerciseTake() {
|
||||
questionnaireMetaData.set({
|
||||
...initAnswerState,
|
||||
exerciseId: null
|
||||
});
|
||||
clearQuestionnaireValidation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps Zod validation errors to UI format: { questionId: { option: 'error message' } }
|
||||
* This is used to display errors in the edit-mode component
|
||||
|
||||
@@ -29,9 +29,10 @@
|
||||
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { getOrderedNavigableContent } from '$features/course/utils/content';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { toExerciseQuestionModel } from './question-type-utils';
|
||||
import { getExerciseQuestionLabels } from './question-labels';
|
||||
import axios from 'axios';
|
||||
@@ -150,17 +151,13 @@
|
||||
}));
|
||||
}
|
||||
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true });
|
||||
if (
|
||||
$globalStore.isStudent &&
|
||||
courseApi.course?.id &&
|
||||
submitResult?.data &&
|
||||
'certification' in submitResult.data
|
||||
) {
|
||||
const certification = (submitResult.data as { certification?: { isNewCompletion?: boolean } | null })
|
||||
.certification;
|
||||
if (certification?.isNewCompletion) {
|
||||
openCourseCompletionModal(courseApi.course.id);
|
||||
}
|
||||
|
||||
const allContentItems = getOrderedNavigableContent(courseApi.course);
|
||||
const allComplete =
|
||||
$globalStore.isStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete);
|
||||
|
||||
if (allComplete && courseApi.course?.id) {
|
||||
openCourseCompletionModal(courseApi.course.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-2
@@ -13,6 +13,7 @@
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import type { CourseContentItem } from '$features/course/utils/types';
|
||||
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
|
||||
|
||||
interface Props {
|
||||
lessonId?: string;
|
||||
@@ -65,7 +66,7 @@
|
||||
const isLessonLocked = $derived(
|
||||
$globalStore.isStudent && currentLessonItem && !(currentLessonItem.isUnlocked ?? false)
|
||||
);
|
||||
const isMarkCompleteDisabled = $derived(isMarkingComplete || isLessonLocked);
|
||||
const isMarkCompleteDisabled = $derived(isMarkingComplete || isLessonLocked || isLessonComplete);
|
||||
|
||||
async function markLessonComplete(currentLessonId: string) {
|
||||
isMarkingComplete = true;
|
||||
@@ -78,8 +79,18 @@
|
||||
await lessonApi.updateCompletion(courseId, currentLessonId, isComplete);
|
||||
|
||||
if (lessonApi.success) {
|
||||
updateCourseContentCompletion(currentLessonId, isComplete);
|
||||
snackbar.success('snackbar.lessons.success.complete_marked');
|
||||
updateCourseContentCompletion(currentLessonId, isComplete);
|
||||
|
||||
const allComplete =
|
||||
$globalStore.isStudent &&
|
||||
isComplete &&
|
||||
navigableContentItems.length > 0 &&
|
||||
navigableContentItems.every((item) => item.isComplete);
|
||||
|
||||
if (allComplete) {
|
||||
openCourseCompletionModal(courseId);
|
||||
}
|
||||
} else {
|
||||
snackbar.error('snackbar.lessons.error.try_later');
|
||||
}
|
||||
|
||||
@@ -12,12 +12,15 @@
|
||||
|
||||
interface Props {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
title?: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
isPublished?: boolean;
|
||||
totalLessons?: number;
|
||||
totalStudents?: number;
|
||||
isExplore?: boolean;
|
||||
isLMS?: boolean;
|
||||
tags?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -28,15 +31,32 @@
|
||||
|
||||
let {
|
||||
id = '',
|
||||
slug = '',
|
||||
title = '',
|
||||
type = '',
|
||||
description = '',
|
||||
isPublished = false,
|
||||
totalLessons = 0,
|
||||
totalStudents = 0,
|
||||
isExplore = false,
|
||||
isLMS = false,
|
||||
tags = []
|
||||
}: Props = $props();
|
||||
|
||||
function handleRowClick() {
|
||||
if (isExplore) {
|
||||
goto(resolve(`/course/${slug}`, {}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLMS) {
|
||||
goto(resolve(`/courses/${id}/lessons?next=true`, {}));
|
||||
return;
|
||||
}
|
||||
|
||||
goto(resolve(`/courses/[id]`, { id }));
|
||||
}
|
||||
|
||||
function handleCloneCourse(e) {
|
||||
e.stopPropagation();
|
||||
// TODO: Clone course functionality
|
||||
@@ -62,7 +82,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Table.Row class="cursor-pointer" onclick={() => goto(resolve(`/courses/[id]`, { id }))}>
|
||||
<Table.Row class="cursor-pointer" onclick={handleRowClick}>
|
||||
<Table.Cell class="truncate"><p class="font-semibold">{title}</p></Table.Cell>
|
||||
<Table.Cell class="truncate">
|
||||
<p>{description}</p>
|
||||
@@ -77,34 +97,38 @@
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{totalLessons}</Table.Cell>
|
||||
<Table.Cell>{totalStudents}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<CoursePublishBadge {isPublished} />
|
||||
{#if !isLMS}
|
||||
<Table.Cell>{totalStudents}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<CoursePublishBadge {isPublished} />
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if !isLMS}
|
||||
<Table.Cell class="text-center">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="flex items-center justify-center rounded-md p-1 hover:bg-gray-100"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVerticalIcon size={16} />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={handleCloneCourse}>
|
||||
{$t('courses.course_card.context_menu.clone')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleShareCourse}>
|
||||
{$t('courses.course_card.context_menu.share')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleInvite}>
|
||||
{$t('courses.course_card.context_menu.invite')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="text-red-600" onclick={handleDeleteCourse}>
|
||||
{$t('courses.course_card.context_menu.delete')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
<Table.Cell class="text-center">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="flex items-center justify-center rounded-md p-1 hover:bg-gray-100"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVerticalIcon size={16} />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={handleCloneCourse}>
|
||||
{$t('courses.course_card.context_menu.clone')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleShareCourse}>
|
||||
{$t('courses.course_card.context_menu.share')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleInvite}>
|
||||
{$t('courses.course_card.context_menu.invite')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="text-red-600" onclick={handleDeleteCourse}>
|
||||
{$t('courses.course_card.context_menu.delete')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
|
||||
+118
-66
@@ -1,11 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { CheckboxField } from '@cio/ui/custom/checkbox-field';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import { MultiSelectList } from '@cio/ui/custom/multi-select-list';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import type { OrgStudent } from './types';
|
||||
@@ -20,29 +23,39 @@
|
||||
let { courseId, students, isLoading = false, onAssigned }: Props = $props();
|
||||
|
||||
let searchValue = $state('');
|
||||
let isSearching = $state(false);
|
||||
let lastSearchValue = $state('');
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let activeSearchRequestId = $state(0);
|
||||
const selectedProfileIds = new SvelteSet<string>();
|
||||
let sendEmail = $state(true);
|
||||
let isSubmitting = $state(false);
|
||||
const SEARCH_DEBOUNCE_MS = 350;
|
||||
|
||||
const filteredStudents = $derived.by(() => {
|
||||
const query = searchValue.trim().toLowerCase();
|
||||
const sortedStudents = [...students].sort((a, b) =>
|
||||
const sortedStudents = $derived.by(() =>
|
||||
[...students].sort((a, b) =>
|
||||
(a.name || a.email || '').localeCompare(b.name || b.email || '', undefined, { sensitivity: 'base' })
|
||||
);
|
||||
|
||||
if (!query) {
|
||||
return sortedStudents;
|
||||
}
|
||||
|
||||
return sortedStudents.filter((student) => {
|
||||
const name = student.name?.toLowerCase() || '';
|
||||
const email = student.email?.toLowerCase() || '';
|
||||
return name.includes(query) || email.includes(query);
|
||||
});
|
||||
});
|
||||
)
|
||||
);
|
||||
const showLoadingState = $derived(isLoading || isSearching);
|
||||
const emptyMessage = $derived(
|
||||
searchValue.trim()
|
||||
? $t('course.navItem.people.invite_modal.no_matching_existing_students')
|
||||
: $t('course.navItem.people.invite_modal.no_existing_students')
|
||||
);
|
||||
|
||||
const selectedCount = $derived(selectedProfileIds.size);
|
||||
|
||||
const multiSelectItems = $derived(
|
||||
sortedStudents
|
||||
.filter((s): s is OrgStudent & { profileId: string } => Boolean(s.profileId))
|
||||
.map((s) => ({
|
||||
id: s.profileId,
|
||||
label: s.name || s.email,
|
||||
description: s.email
|
||||
}))
|
||||
);
|
||||
|
||||
function toggleStudent(profileId: string) {
|
||||
if (selectedProfileIds.has(profileId)) {
|
||||
selectedProfileIds.delete(profileId);
|
||||
@@ -51,8 +64,70 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function searchStudents(value: string) {
|
||||
const normalizedValue = value.trim();
|
||||
if (normalizedValue === lastSearchValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastSearchValue = normalizedValue;
|
||||
const orgId = $currentOrg.id;
|
||||
if (!orgId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = activeSearchRequestId + 1;
|
||||
activeSearchRequestId = requestId;
|
||||
isSearching = true;
|
||||
|
||||
try {
|
||||
await orgApi.getOrgAudience(
|
||||
orgId,
|
||||
{
|
||||
...DEFAULT_ORG_AUDIENCE_QUERY,
|
||||
search: normalizedValue || undefined
|
||||
},
|
||||
{
|
||||
abortPrevious: true
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
if (requestId === activeSearchRequestId) {
|
||||
isSearching = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearchValueChange(value: string) {
|
||||
searchValue = value;
|
||||
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
|
||||
if (!value.trim()) {
|
||||
void searchStudents(value);
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(() => {
|
||||
void searchStudents(value);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
orgApi.cancelAudienceRequest();
|
||||
|
||||
if (!searchTimeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(searchTimeout);
|
||||
});
|
||||
|
||||
async function assignStudents() {
|
||||
if (!courseId) return;
|
||||
|
||||
if (selectedProfileIds.size === 0) {
|
||||
snackbar.error('course.navItem.people.invite_modal.select_at_least_one_student');
|
||||
return;
|
||||
@@ -82,56 +157,33 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border p-4">
|
||||
<div class="space-y-1">
|
||||
<p class="text-base font-semibold">{$t('course.navItem.people.invite_modal.existing_students_title')}</p>
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('course.navItem.people.invite_modal.existing_students_description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
<Search placeholder={$t('course.navItem.people.invite_modal.existing_students_search')} bind:value={searchValue} />
|
||||
|
||||
{#if selectedCount > 0}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('audience.selected_count', { count: selectedCount })}</p>
|
||||
{/if}
|
||||
|
||||
<div class="max-h-72 space-y-2 overflow-y-auto rounded-md border p-3">
|
||||
{#if isLoading}
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('course.navItem.people.invite_modal.loading_existing_students')}
|
||||
</p>
|
||||
{:else if students.length === 0}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('course.navItem.people.invite_modal.no_existing_students')}</p>
|
||||
{:else if filteredStudents.length === 0}
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('course.navItem.people.invite_modal.no_matching_existing_students')}
|
||||
</p>
|
||||
{:else}
|
||||
{#each filteredStudents as student (student.profileId)}
|
||||
<div
|
||||
class="ui:hover:bg-muted/40 rounded-md px-2 py-1"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => student.profileId && toggleStudent(student.profileId)}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
if (student.profileId) toggleStudent(student.profileId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CheckboxField
|
||||
name={`existing-student-${student.profileId}`}
|
||||
label={student.name || student.email}
|
||||
checked={student.profileId ? selectedProfileIds.has(student.profileId) : false}
|
||||
>
|
||||
<span class="ui:text-muted-foreground text-sm">{student.email}</span>
|
||||
</CheckboxField>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
<div class="">
|
||||
<div class="space-y-2">
|
||||
<div class="max-h-72 overflow-y-auto">
|
||||
<MultiSelectList
|
||||
class="border-0"
|
||||
{emptyMessage}
|
||||
items={multiSelectItems}
|
||||
isLoading={showLoadingState}
|
||||
isSelected={(id) => selectedProfileIds.has(id)}
|
||||
onSearchValueChange={handleSearchValueChange}
|
||||
onToggle={(id) => toggleStudent(id)}
|
||||
namePrefix="existing-student"
|
||||
listClass="max-h-60"
|
||||
searchPlaceholder={$t('course.navItem.people.invite_modal.existing_students_search')}
|
||||
bind:searchValue
|
||||
>
|
||||
{#snippet headingSnippet()}
|
||||
<p class="ui:text-sm ui:font-medium">
|
||||
{$t('course.navItem.people.invite_modal.existing_students_title')} ({multiSelectItems.length})
|
||||
{#if selectedCount > 0}
|
||||
<span class="ui:text-muted-foreground text-xs"
|
||||
>{$t('audience.selected_count', { count: selectedCount })}</span
|
||||
>
|
||||
{/if}
|
||||
</p>
|
||||
{/snippet}
|
||||
</MultiSelectList>
|
||||
</div>
|
||||
|
||||
<CheckboxField
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { peopleApi } from '$features/course/api';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
|
||||
import type { OrgTeamMember } from '$lib/utils/types/org';
|
||||
import type { OrgStudent, Tutor } from './types';
|
||||
|
||||
@@ -75,10 +76,11 @@
|
||||
|
||||
function loadStudents(orgId: string | undefined) {
|
||||
if (!orgId) return;
|
||||
|
||||
untrack(async () => {
|
||||
isLoadingStudents = true;
|
||||
try {
|
||||
await orgApi.getOrgAudience(orgId);
|
||||
await orgApi.getOrgAudience(orgId, DEFAULT_ORG_AUDIENCE_QUERY, { abortPrevious: true });
|
||||
} finally {
|
||||
isLoadingStudents = false;
|
||||
}
|
||||
@@ -125,7 +127,7 @@
|
||||
if (!open) closeModal();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="max-h-[80vh] w-[96vw] max-w-6xl overflow-y-auto">
|
||||
<Dialog.Content class="max-h-[80vh] w-[96vw] max-w-3xl! overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.people.invite_modal.title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
points?: any;
|
||||
hasError?: boolean;
|
||||
errorMsg?: string | null;
|
||||
/** Extra hint under points row (e.g. auto-grade requires non-zero points) */
|
||||
pointsHint?: string | null;
|
||||
onPointsChange?: any;
|
||||
key?: string;
|
||||
children?: import('svelte').Snippet;
|
||||
@@ -23,6 +25,7 @@
|
||||
points = $bindable(undefined),
|
||||
hasError = false,
|
||||
errorMsg = null,
|
||||
pointsHint = null,
|
||||
onPointsChange = () => {},
|
||||
children
|
||||
}: Props = $props();
|
||||
@@ -67,6 +70,8 @@
|
||||
|
||||
{#if errorMsg}
|
||||
<p class="text-xs text-red-500">{errorMsg}</p>
|
||||
{:else if pointsHint}
|
||||
<p class="ui:text-muted-foreground max-w-[min(100%,12rem)] text-xs">{pointsHint}</p>
|
||||
{/if}
|
||||
|
||||
{#if onClose && !isTitle}
|
||||
|
||||
+13
-11
@@ -64,15 +64,17 @@
|
||||
<span>{$t('course.navItems.nav_content')}</span>
|
||||
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<Plus
|
||||
size={20}
|
||||
class="rounded-full p-1"
|
||||
onclick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openContentModal(id);
|
||||
}}
|
||||
/>
|
||||
{#if !isStudent}
|
||||
<Plus
|
||||
size={20}
|
||||
class="rounded-full p-1"
|
||||
onclick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openContentModal(id);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/snippet}
|
||||
@@ -85,8 +87,8 @@
|
||||
{id}
|
||||
{isStudent}
|
||||
className="mt-1"
|
||||
onOpenContentModal={(sectionId) => openContentModal(id, sectionId)}
|
||||
onEditSection={(sectionId) => openSectionEditor(id, sectionId)}
|
||||
onOpenContentModal={isStudent ? undefined : (sectionId) => openContentModal(id, sectionId)}
|
||||
onEditSection={isStudent ? undefined : (sectionId) => openSectionEditor(id, sectionId)}
|
||||
/>
|
||||
</Sidebar.Group>
|
||||
|
||||
|
||||
+2
-1
@@ -26,6 +26,7 @@
|
||||
let { path, id, isStudent = false, className = '', onOpenContentModal }: Props = $props();
|
||||
|
||||
const contentData = $derived(getCourseContent(courseApi.course));
|
||||
const canOpenContentModal = $derived(Boolean(onOpenContentModal) && !isStudent);
|
||||
</script>
|
||||
|
||||
<Sidebar.MenuSub class={className}>
|
||||
@@ -42,7 +43,7 @@
|
||||
<span class="flex-1 truncate">{section.title}</span>
|
||||
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
{#if onOpenContentModal}
|
||||
{#if canOpenContentModal}
|
||||
<IconButton
|
||||
variant="ghost-outline"
|
||||
size="icon-xs"
|
||||
|
||||
+2
-2
@@ -285,8 +285,8 @@
|
||||
{id}
|
||||
{isStudent}
|
||||
className="mt-1 ml-2"
|
||||
onOpenContentModal={(sectionId) => openContentModal(id, sectionId)}
|
||||
onEditSection={(sectionId) => openSectionEditor(id, sectionId)}
|
||||
onOpenContentModal={isStudent ? undefined : (sectionId) => openContentModal(id, sectionId)}
|
||||
onEditSection={isStudent ? undefined : (sectionId) => openSectionEditor(id, sectionId)}
|
||||
/>
|
||||
{/if}
|
||||
</Sidebar.MenuItem>
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
<script lang="ts">
|
||||
import IssueCertificateModal from '$features/course/components/ceritficate/issue-certificate-modal.svelte';
|
||||
import { RoleBasedSecurity } from '$features/ui';
|
||||
import Design from '$features/course/components/ceritficate/design.svelte';
|
||||
import CertificateEdit from '$features/course/components/ceritficate/certificate-edit.svelte';
|
||||
import StudentCertificate from '$features/course/components/ceritficate/student-certificate/index.svelte';
|
||||
|
||||
type Props = {
|
||||
errors: Record<string, string>;
|
||||
};
|
||||
|
||||
let { errors }: Props = $props();
|
||||
</script>
|
||||
|
||||
<IssueCertificateModal />
|
||||
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<Design />
|
||||
<CertificateEdit {errors} />
|
||||
</RoleBasedSecurity>
|
||||
<RoleBasedSecurity onlyStudent allowedRoles={[3]}>
|
||||
<StudentCertificate />
|
||||
|
||||
@@ -110,20 +110,18 @@
|
||||
{/if}
|
||||
{@render filterControls?.()}
|
||||
|
||||
{#if !isLMS}
|
||||
{#if $courseMetaDeta.view === 'list'}
|
||||
<IconButton onclick={() => setViewPreference('grid')}>
|
||||
<GridIcon size={16} />
|
||||
</IconButton>
|
||||
{:else}
|
||||
<IconButton onclick={() => setViewPreference('list')}>
|
||||
<ListIcon size={16} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
{#if $courseMetaDeta.view === 'list'}
|
||||
<IconButton onclick={() => setViewPreference('grid')}>
|
||||
<GridIcon size={16} />
|
||||
</IconButton>
|
||||
{:else}
|
||||
<IconButton onclick={() => setViewPreference('list')}>
|
||||
<ListIcon size={16} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
</Page.BodyHeader>
|
||||
|
||||
<div class="mx-auto w-full flex-1">
|
||||
<div class="mx-auto mt-4 w-full flex-1">
|
||||
{#if isLoading}
|
||||
<section class={`${isLoading || courses ? 'cards-container' : ''} `}>
|
||||
<CourseCardLoader />
|
||||
@@ -146,15 +144,18 @@
|
||||
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.type')}</Table.Head>
|
||||
<Table.Head class="w-3/12 min-w-0">{$t('courses.course_card.list_view.tags')}</Table.Head>
|
||||
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.lessons')}</Table.Head>
|
||||
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.students')}</Table.Head>
|
||||
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.published')}</Table.Head>
|
||||
<Table.Head class="w-[6%]"></Table.Head>
|
||||
{#if !isLMS}
|
||||
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.students')}</Table.Head>
|
||||
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.published')}</Table.Head>
|
||||
<Table.Head class="w-[6%]"></Table.Head>
|
||||
{/if}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each courses as courseData (courseData.id)}
|
||||
<CourseList
|
||||
id={courseData.id}
|
||||
slug={courseData.slug ?? ''}
|
||||
title={courseData.title}
|
||||
type={$t(`course.navItem.settings.${courseData.type?.toLowerCase()}`)}
|
||||
description={courseData.description}
|
||||
@@ -167,6 +168,8 @@
|
||||
slug: string;
|
||||
color?: string | null;
|
||||
}>}
|
||||
{isExplore}
|
||||
{isLMS}
|
||||
/>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
import { exerciseApi } from '$features/course/api';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import EditMode from '$features/course/components/exercise/edit-mode.svelte';
|
||||
import ExerciseSettingsTab from '$features/course/components/exercise/exercise-settings-tab.svelte';
|
||||
import ViewMode from '$features/course/components/exercise/view-mode.svelte';
|
||||
import Submissions from '$features/course/components/exercise/submissions/submissions.svelte';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
@@ -41,17 +42,19 @@
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import {
|
||||
getQuestionTypeId,
|
||||
getQuestionTypeKeyFromId,
|
||||
getQuestionTypeOptionById
|
||||
} from '$features/course/components/exercise/question-type-utils';
|
||||
import type { SubmissionListItem } from '$features/course/utils/types';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { isAutoGradableQuestionType } from '@cio/question-types';
|
||||
|
||||
interface Props {
|
||||
exerciseId?: string;
|
||||
goBack?: () => void;
|
||||
isFetching?: boolean;
|
||||
submissions: SubmissionListItem[];
|
||||
mySubmission: SubmissionListItem | null;
|
||||
mySubmissions?: SubmissionListItem[];
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -59,15 +62,18 @@
|
||||
goBack = () => {},
|
||||
isFetching = false,
|
||||
submissions,
|
||||
mySubmission
|
||||
mySubmissions = []
|
||||
}: Props = $props();
|
||||
|
||||
type ExerciseTab = 'questions' | 'submissions';
|
||||
type ExerciseTab = 'questions' | 'settings' | 'submissions';
|
||||
|
||||
function normalizeExerciseTab(tabParam: string | null): ExerciseTab {
|
||||
if (tabParam === 'submissions' || tabParam === 'analytics') {
|
||||
return 'submissions';
|
||||
}
|
||||
if (tabParam === 'settings') {
|
||||
return 'settings';
|
||||
}
|
||||
|
||||
return 'questions';
|
||||
}
|
||||
@@ -109,6 +115,26 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (requiresPositivePointsForAutoGrade) {
|
||||
const zeroPointQuestions = ($questionnaire.questions ?? []).filter((q) => !q.deletedAt && Number(q.points) === 0);
|
||||
|
||||
if (zeroPointQuestions.length > 0) {
|
||||
const pointErrors: Record<string, { option?: string; title?: string; points?: string }> = {};
|
||||
|
||||
for (const q of zeroPointQuestions) {
|
||||
pointErrors[q.id] = {
|
||||
points: t.get('course.navItem.lessons.exercises.all_exercises.points_required_auto_grade')
|
||||
};
|
||||
}
|
||||
|
||||
questionnaireValidation.set(pointErrors);
|
||||
|
||||
snackbar.error('snackbar.exercise.points_required_auto_grade');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
|
||||
reset();
|
||||
@@ -204,11 +230,41 @@
|
||||
)
|
||||
);
|
||||
const isExerciseUnlocked = $derived(exerciseContentItem?.isUnlocked ?? false);
|
||||
|
||||
const isSelfPacedCourse = $derived(courseApi.course?.type === 'SELF_PACED');
|
||||
const activeQuestionTypeIds = $derived(
|
||||
($questionnaire.questions ?? []).filter((q) => !q.deletedAt).map((q) => getQuestionTypeId(q))
|
||||
);
|
||||
function isExerciseFullyAutoGradable(typeIds: number[]): boolean {
|
||||
return typeIds.length > 0 && typeIds.every((id) => isAutoGradableQuestionType(getQuestionTypeKeyFromId(id)));
|
||||
}
|
||||
|
||||
/** When true, every question must have points > 0 before save */
|
||||
const requiresPositivePointsForAutoGrade = $derived(
|
||||
isSelfPacedCourse && isExerciseFullyAutoGradable(activeQuestionTypeIds)
|
||||
);
|
||||
const teacherAutoGradeBadge = $derived.by(() => {
|
||||
if ($globalStore.isStudent || !isSelfPacedCourse || activeQuestionTypeIds.length === 0) return null;
|
||||
return isExerciseFullyAutoGradable(activeQuestionTypeIds) ? ('auto' as const) : ('manual' as const);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Page.Header isSticky={true} class="z-250! min-h-[36px]">
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$questionnaire.title || 'Exercise'}</Page.Title>
|
||||
<Page.Title class="flex flex-wrap items-center gap-2">
|
||||
<span>{$questionnaire.title || 'Exercise'}</span>
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
{#if teacherAutoGradeBadge === 'auto'}
|
||||
<Badge variant="success" class="font-normal">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.auto_graded_badge')}
|
||||
</Badge>
|
||||
{:else if teacherAutoGradeBadge === 'manual'}
|
||||
<Badge variant="warning" class="font-normal">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.manual_grading_required_badge')}
|
||||
</Badge>
|
||||
{/if}
|
||||
</RoleBasedSecurity>
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -278,7 +334,7 @@
|
||||
<div class="overflow-x-hidden">
|
||||
{#if $globalStore.isStudent}
|
||||
{#if isExerciseUnlocked}
|
||||
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmission} />
|
||||
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
|
||||
{:else}
|
||||
<Empty
|
||||
title={$t('course.navItem.lessons.content_locked_title')}
|
||||
@@ -290,10 +346,13 @@
|
||||
{/if}
|
||||
{:else}
|
||||
<UnderlineTabs.Root bind:value={selectedTab} class="mb-4">
|
||||
<UnderlineTabs.List class="grid w-full max-w-xs grid-cols-2">
|
||||
<UnderlineTabs.List class="grid w-full max-w-lg grid-cols-3">
|
||||
<UnderlineTabs.Trigger value="questions">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.questions')}
|
||||
</UnderlineTabs.Trigger>
|
||||
<UnderlineTabs.Trigger value="settings">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.settings')}
|
||||
</UnderlineTabs.Trigger>
|
||||
<UnderlineTabs.Trigger value="submissions">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.analytics.submissions')}
|
||||
|
||||
@@ -303,11 +362,20 @@
|
||||
<UnderlineTabs.Content value="questions">
|
||||
<UpdateDescription {preview} />
|
||||
{#if !preview}
|
||||
<EditMode bind:shouldDeleteExercise {exerciseId} {goBack} />
|
||||
<EditMode
|
||||
bind:shouldDeleteExercise
|
||||
{exerciseId}
|
||||
{goBack}
|
||||
{requiresPositivePointsForAutoGrade}
|
||||
selfPacedCourse={isSelfPacedCourse}
|
||||
/>
|
||||
{:else}
|
||||
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmission} />
|
||||
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
|
||||
{/if}
|
||||
</UnderlineTabs.Content>
|
||||
<UnderlineTabs.Content value="settings">
|
||||
<ExerciseSettingsTab {exerciseId} />
|
||||
</UnderlineTabs.Content>
|
||||
<UnderlineTabs.Content value="submissions">
|
||||
<Submissions bind:exerciseId {submissions} />
|
||||
</UnderlineTabs.Content>
|
||||
|
||||
@@ -35,6 +35,12 @@
|
||||
import { currentOrgDomain, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
interface Props {
|
||||
hasUnsavedChanges?: boolean;
|
||||
}
|
||||
|
||||
let { hasUnsavedChanges = $bindable(false) }: Props = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let isDeleting = $state(false);
|
||||
let errors: {
|
||||
@@ -45,7 +51,6 @@
|
||||
description: undefined
|
||||
});
|
||||
let avatar: string | undefined;
|
||||
let hasUnsavedChanges = $state(false);
|
||||
let openDeleteModal = $state(false);
|
||||
let selectedTagIds = $state<string[]>([]);
|
||||
let initialTagIds = $state<string[]>([]);
|
||||
|
||||
@@ -3,7 +3,14 @@ import type { Course } from '$features/course/types';
|
||||
import type { CourseMembers, CourseSectionWithLessons, ListLessons } from '$features/course/utils/types';
|
||||
import { writable, type Writable } from 'svelte/store';
|
||||
|
||||
export const defaultCourse = {
|
||||
const defaultLessonTabsOrder: NonNullable<NonNullable<Course['metadata']>['lessonTabsOrder']> = [
|
||||
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
|
||||
{ id: 2, name: 'course.navItem.lessons.materials.tabs.slide.title' },
|
||||
{ id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' },
|
||||
{ id: 4, name: 'course.navItem.lessons.materials.tabs.document.title' }
|
||||
];
|
||||
|
||||
export const defaultCourse: Course = {
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
@@ -12,8 +19,10 @@ export const defaultCourse = {
|
||||
cost: 0,
|
||||
currency: 'USD',
|
||||
status: 'ACTIVE',
|
||||
isCertificateDownloadable: false,
|
||||
certificateTheme: 'professional',
|
||||
certificate: {
|
||||
isDownloadable: false,
|
||||
theme: 'professional'
|
||||
},
|
||||
isPublished: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
@@ -24,10 +33,6 @@ export const defaultCourse = {
|
||||
bannerImage: null,
|
||||
attendance: [],
|
||||
group: null,
|
||||
course_section: [],
|
||||
sections: [],
|
||||
lessons: [],
|
||||
exercises: [],
|
||||
content: {
|
||||
grouped: false,
|
||||
sections: [],
|
||||
@@ -52,12 +57,7 @@ export const defaultCourse = {
|
||||
description: '',
|
||||
imgUrl: ''
|
||||
},
|
||||
lessonTabsOrder: [
|
||||
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
|
||||
{ id: 2, name: 'course.navItem.lessons.materials.tabs.slide.title' },
|
||||
{ id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' },
|
||||
{ id: 4, name: 'course.navItem.lessons.materials.tabs.document.title' }
|
||||
],
|
||||
lessonTabsOrder: defaultLessonTabsOrder,
|
||||
grading: false,
|
||||
lessonDownload: true,
|
||||
allowNewStudent: false,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export type CourseCompletionModalState = {
|
||||
open: boolean;
|
||||
courseId: string;
|
||||
};
|
||||
|
||||
export const courseCompletionModal = writable<CourseCompletionModalState | null>(null);
|
||||
|
||||
export function openCourseCompletionModal(courseId: string) {
|
||||
courseCompletionModal.set({ open: true, courseId });
|
||||
}
|
||||
|
||||
export function closeCourseCompletionModal() {
|
||||
courseCompletionModal.set(null);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { CERTIFICATE_THEME_IDS, DEFAULT_CERTIFICATE_THEME_ID, type CertificateThemeId } from './constants';
|
||||
|
||||
const CERTIFICATE_THEME_ID_SET = new Set<string>(CERTIFICATE_THEME_IDS);
|
||||
|
||||
export function parseCertificateThemeId(raw: string | null | undefined): CertificateThemeId {
|
||||
if (raw != null && raw !== '' && CERTIFICATE_THEME_ID_SET.has(raw)) {
|
||||
return raw as CertificateThemeId;
|
||||
}
|
||||
return DEFAULT_CERTIFICATE_THEME_ID;
|
||||
}
|
||||
@@ -37,6 +37,19 @@ export type CourseSortOrder = (typeof CourseSortOrder)[keyof typeof CourseSortOr
|
||||
|
||||
export const DEFAULT_SORT_ORDER: CourseSortOrder = CourseSortOrder.Desc;
|
||||
|
||||
export const CERTIFICATE_THEME_IDS = [
|
||||
'professional',
|
||||
'plain',
|
||||
'purpleProfessionalBadge',
|
||||
'blueProfessionalBadge',
|
||||
'purpleBadgePattern',
|
||||
'blueBadgePattern'
|
||||
] as const;
|
||||
|
||||
export type CertificateThemeId = (typeof CERTIFICATE_THEME_IDS)[number];
|
||||
|
||||
export const DEFAULT_CERTIFICATE_THEME_ID: CertificateThemeId = 'professional';
|
||||
|
||||
const COURSE_SORT_ORDER_VALUES = new Set<string>(Object.values(CourseSortOrder));
|
||||
|
||||
export function parseCourseSortOrder(value: string | null | undefined): CourseSortOrder {
|
||||
|
||||
@@ -71,16 +71,17 @@ export function formatAnswers(data: {
|
||||
answers: SubmissionAnswer[];
|
||||
}): Record<string, AnswerData> {
|
||||
const answers: Record<string, AnswerData> = {};
|
||||
const questionByIdAndName: Record<string, string> = {};
|
||||
const questionKeyById: Record<string, string> = {};
|
||||
|
||||
for (const question of data.questions) {
|
||||
questionByIdAndName[String(question.id)] = question.name ?? '';
|
||||
const key = question.name ? String(question.name) : String(question.id);
|
||||
questionKeyById[String(question.id)] = key;
|
||||
}
|
||||
|
||||
for (const answer of data.answers) {
|
||||
const questionName = questionByIdAndName[String(answer.questionId)];
|
||||
if (questionName && answer.answerData) {
|
||||
answers[questionName] = answer.answerData;
|
||||
const questionKey = questionKeyById[String(answer.questionId)];
|
||||
if (questionKey && answer.answerData) {
|
||||
answers[questionKey] = answer.answerData;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -266,6 +266,13 @@ export type DeleteCourseRequest = (typeof classroomio.course)[':courseId']['$del
|
||||
export type UpdateCourseContentRequest = (typeof classroomio.course)[':courseId']['content']['$put'];
|
||||
export type DeleteCourseContentRequest = (typeof classroomio.course)[':courseId']['content']['$delete'];
|
||||
export type GetCourseProgressRequest = (typeof classroomio.course)[':courseId']['progress']['$get'];
|
||||
export type GetCertificationEvaluationRequest =
|
||||
(typeof classroomio.course)[':courseId']['certification-evaluation']['$get'];
|
||||
export type GetCertificationEvaluationSuccess = Extract<
|
||||
InferResponseType<GetCertificationEvaluationRequest>,
|
||||
{ success: true }
|
||||
>;
|
||||
export type CertificationEvaluationData = GetCertificationEvaluationSuccess['data'];
|
||||
export type CloneCourseRequest = (typeof classroomio.course)[':courseId']['clone']['$post'];
|
||||
export type EnrollCourseRequest = (typeof classroomio.course)[':courseId']['enroll']['$post'];
|
||||
|
||||
@@ -280,12 +287,17 @@ export type CourseMetadata = (NonNullable<ApiCourse['metadata']> extends object
|
||||
: Record<string, unknown>) & {
|
||||
isContentGroupingEnabled?: boolean;
|
||||
};
|
||||
export type CourseCertificate =
|
||||
NonNullable<ApiCourse['certificate']> extends object
|
||||
? NonNullable<ApiCourse['certificate']>
|
||||
: Record<string, unknown>;
|
||||
export type CourseContent = NonNullable<ApiCourse['content']>;
|
||||
export type CourseContentSection = CourseContent['sections'][number];
|
||||
export type CourseContentItem = CourseContent['items'][number];
|
||||
|
||||
export type Course = Omit<ApiCourse, 'metadata' | 'lessons' | 'sections' | 'exercises'> & {
|
||||
metadata?: CourseMetadata | null;
|
||||
certificate?: CourseCertificate | null;
|
||||
content: CourseContent;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user