feat: lesson sections - v1 (#431)

* add todo

* update sidebar with sections

* add course versioning

* fix db reset issue

* v1 of working sections feature

* remove
This commit is contained in:
Rotimi Best
2024-08-14 10:27:37 +01:00
committed by GitHub
parent e7e2bc632b
commit ad421acf8d
30 changed files with 979 additions and 393 deletions
@@ -4,7 +4,6 @@
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { t } from '$lib/utils/functions/translations';
export let lessonTitle: string;
export let openDeleteModal = false;
export let deleteLesson = () => {};
@@ -18,11 +17,12 @@
onClose={() => (openDeleteModal = false)}
bind:open={openDeleteModal}
width="w-96"
containerClass="px-6 pt-2 pb-6"
modalHeading={$t('delete_modal.label')}
>
<div>
<h1 class="dark:text-white text-lg">
{$t('delete_modal.content')} <strong>{lessonTitle}</strong>?
{$t('delete_modal.content')}?
</h1>
<div class="mt-5 flex items-center justify-between">
@@ -0,0 +1,192 @@
<script lang="ts">
import { page } from '$app/stores';
import { dndzone } from 'svelte-dnd-action';
import { OverflowMenu, OverflowMenuItem } from 'carbon-components-svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import TextChip from '$lib/components/Chip/Text.svelte';
import { course } from '$lib/components/Course/store';
import { globalStore } from '$lib/utils/store/app';
import ScreenMap from 'carbon-icons-svelte/lib/ScreenMap.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { t } from '$lib/utils/functions/translations';
import {
lessons,
handleSaveLesson
} from '$lib/components/Course/components/Lesson/store/lessons';
import { updateLesson } from '$lib/utils/services/courses';
import type { Course, Lesson } from '$lib/utils/types';
import Box from '$lib/components/Box/index.svelte';
const flipDurationMs = 300;
export let reorder = false;
export let lessonEditing: string | undefined;
export let lessonToDelete: Lesson | undefined;
export let openDeleteModal = false;
let errors: Record<string, string> = {};
async function saveLesson(lesson: Lesson, courseId: Course['id']) {
const validationRes = await handleSaveLesson(lesson, courseId);
if (validationRes && Object.keys(validationRes).length) {
// @ts-ignore
errors = validationRes;
} else {
errors = {};
lessonEditing = undefined;
}
}
function handleDndConsider(e: any) {
$lessons = e.detail.items;
}
function handleDndFinalize(e: any) {
const prevLessonsByOrder = $lessons.reduce((acc, l) => ({ ...acc, [l.id]: l.order }), {});
$lessons = e.detail.items;
// Only update the lesson order that changed
e.detail.items.forEach((item: { id: string }, index: number) => {
const order = index + 1;
if (order !== prevLessonsByOrder[item.id]) {
$lessons[index].order = order;
updateLesson({ order }, item.id).then((update) =>
console.log(`updated lesson order`, update)
);
}
});
}
function getLessonOrder(id: string) {
const index = $lessons.findIndex((lesson) => lesson.id === id);
if (index < 9) {
return '0' + (index + 1);
} else {
return index + 1;
}
}
</script>
<section
class="m-auto w-full p-3 lg:w-11/12 lg:px-4"
use:dndzone={{
items: $lessons,
flipDurationMs,
dragDisabled: $globalStore.isStudent || !reorder,
dropTargetStyle: {
border: '2px #1d4ed8 solid',
'border-style': 'dashed'
}
}}
on:consider={handleDndConsider}
on:finalize={handleDndFinalize}
>
{#each $lessons as lesson (lesson.id)}
<div
class={`relative m-auto mb-4 flex max-w-xl items-center rounded-md border-2 ${
reorder ? 'border-primary-400' : 'border-gray-200'
} border-gray-200 dark:border-neutral-600 p-5 dark:bg-neutral-800`}
>
<!-- Number Chip -->
<div class="mr-5">
<TextChip
value={getLessonOrder(lesson.id)}
size="sm"
shape="rounded-full"
className="bg-primary-200 text-primary-600 text-xs"
/>
</div>
<!-- Lesson Content -->
<div class="w-4/5">
<!-- Lesson Title -->
{#if lessonEditing === lesson.id}
<TextField
bind:value={lesson.title}
autoFocus={true}
className="max-w-lg"
errorMessage={errors?.title}
/>
{:else}
<h3 class="m-0 flex items-center text-lg dark:text-white">
<a
href={$globalStore.isStudent && !lesson.is_unlocked
? $page.url.pathname
: '/courses/' + $course.id + '/lessons/' + lesson.id}
class="font-medium text-black no-underline hover:underline dark:text-white {$globalStore.isStudent &&
!lesson.is_unlocked
? 'cursor-not-allowed'
: ''}"
>
{lesson.title}
</a>
</h3>
{/if}
<div
class="mt-2 flex w-4/5 flex-col items-start justify-between lg:flex-row lg:items-center"
>
<!-- Lesson Length -->
<div class="mb-3 flex items-center lg:mb-0">
<ScreenMap size={20} class="carbon-icon dark:text-white" />
<p class="ml-2 text-sm text-gray-500 dark:text-white">
{lesson?.totalExercises ? lesson?.totalExercises?.map((c) => c.count) : 0}
{$t('exercises.heading')}
</p>
</div>
</div>
</div>
<!-- 3 dot edit -->
<div class="flex flex-row">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<OverflowMenu size="xl">
<OverflowMenuItem
disabled={$globalStore.isStudent}
text={lesson.is_unlocked
? $t('course.navItem.lessons.add_lesson.lock')
: $t('course.navItem.lessons.add_lesson.unlock')}
on:click={() => {
lesson.is_unlocked = !lesson.is_unlocked;
handleSaveLesson(lesson, $course.id);
}}
/>
<OverflowMenuItem
text={lessonEditing === lesson.id
? $t('course.navItem.lessons.add_lesson.save')
: $t('course.navItem.lessons.add_lesson.edit')}
on:click={() => {
if (lessonEditing === lesson.id) {
saveLesson(lesson, $course.id);
} else {
lessonEditing = lesson.id;
}
}}
/>
<OverflowMenuItem
danger
text={$t('course.navItem.lessons.add_lesson.delete')}
on:click={() => {
lessonToDelete = lesson;
openDeleteModal = true;
}}
/>
</OverflowMenu>
</RoleBasedSecurity>
</div>
</div>
{:else}
<Box>
<div class="flex justify-between flex-col items-center w-[90%] md:w-96">
<img src="/images/empty-lesson-icon.svg" alt="Lesson" class="my-2.5 mx-auto" />
<h2 class="text-xl my-1.5 font-normal">{$t('course.navItem.lessons.body_header')}</h2>
<p class="text-sm text-center text-slate-500">
{$t('course.navItem.lessons.body_content')}
</p>
</div>
</Box>
{/each}
</section>
@@ -0,0 +1,209 @@
<script lang="ts">
import { Edit, TrashCan, Add } from 'carbon-icons-svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { t } from '$lib/utils/functions/translations';
import { handleAddLessonWidget } from '$lib/components/Course/components/Lesson/store';
import { snackbar } from '$lib/components/Snackbar/store';
import {
lessonSections,
handleDelete,
handleSaveLesson,
handleSaveLessonSection,
handleDeleteSection
} from '$lib/components/Course/components/Lesson/store/lessons';
import type { Lesson } from '$lib/utils/types';
import DeleteLessonConfirmation from '$lib/components/Course/components/Lesson/DeleteLessonConfirmation.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
type CrudParam = {
sectionId?: string;
lessonId?: string;
sectionTitle?: string;
lessonTitle?: string;
};
// export let reorder = false;
export let lessonEditing: string | undefined;
let prevTitle: string | undefined;
let errors: Record<string, string>;
let openDeleteModal = false;
let deletingData = {
id: '',
isSection: false
};
function onEdit(params: CrudParam) {
lessonEditing = params.sectionId || params.lessonId;
prevTitle = params.sectionTitle || params.lessonTitle;
}
async function onSave(params: CrudParam, lesson?: Lesson) {
if (params.sectionId) {
const section = $lessonSections.find((s) => s.id === params.sectionId);
if (!section) return;
const validationRes = await handleSaveLessonSection(section, section.course_id);
if (validationRes && Object.keys(validationRes).length) {
errors = {
// @ts-ignore
title: validationRes?.title
};
}
} else if (lesson) {
const validationRes = await handleSaveLesson(lesson, lesson.course_id);
if (validationRes && Object.keys(validationRes).length) {
errors = {
// @ts-ignore
title: validationRes?.title
};
}
}
if (!errors?.title) {
resetEdit();
} else {
snackbar.success('snackbar.success_update');
}
}
function onDelete(params: CrudParam) {
deletingData.id = params.sectionId || params.lessonId || '';
deletingData.isSection = params.sectionId ? true : false;
openDeleteModal = true;
}
function deleteLesson() {
if (deletingData.isSection) {
handleDeleteSection(deletingData.id);
} else {
handleDelete(deletingData.id);
}
}
function resetEdit() {
lessonEditing = undefined;
prevTitle = undefined;
errors = {};
}
function handleAddLesson(id) {
handleAddLessonWidget.update(() => ({
open: true,
isSection: false,
id
}));
}
</script>
<DeleteLessonConfirmation bind:openDeleteModal {deleteLesson} />
<section class="w-full p-3 lg:w-11/12 lg:px-4">
<div class="max-w-xl mx-auto">
{#each $lessonSections as section (section.id)}
<div class="mb-3 border border-gray-200 rounded-md">
<div
class="mb-2 px-3 py-1 border-b bg-gray-50 rounded-tl-md rounded-tr-md flex justify-between items-center"
>
{#if lessonEditing === section.id}
<TextField className="w-4/6" bind:value={section.title} errorMessage={errors?.title} />
{:else}
<p class="w-4/6 font-semibold">{section.title}</p>
{/if}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="flex items-center">
{#if lessonEditing === section.id}
<PrimaryButton
label={$t('course.navItem.lessons.add_lesson.cancel')}
variant={VARIANTS.OUTLINED}
onClick={() => {
section.title = prevTitle ?? section.title;
resetEdit();
}}
/>
<PrimaryButton
label={$t('course.navItem.lessons.add_lesson.save')}
onClick={() => onSave({ sectionId: section.id })}
/>
{:else}
<IconButton onClick={() => handleAddLesson(section.id)} disabled={!!lessonEditing}>
<Add size={20} />
</IconButton>
<IconButton
onClick={() => onEdit({ sectionId: section.id, sectionTitle: section.title })}
disabled={!!lessonEditing}
>
<Edit size={20} />
</IconButton>
<IconButton
onClick={() => onDelete({ sectionId: section.id })}
disabled={!!lessonEditing}
>
<TrashCan size={20} />
</IconButton>
{/if}
</div>
</RoleBasedSecurity>
</div>
<div class="mx-3 py-1">
{#each section.lessons as lesson (lesson.id)}
<div
class="border max-w-xl border-gray-200 px-3 py-1 min-h-[50px] rounded-md mb-2 flex items-center justify-between"
>
{#if lessonEditing === lesson.id}
<TextField
className="w-4/6"
bind:value={lesson.title}
errorMessage={errors?.title}
/>
{:else}
<p class="w-4/6 text-md">{lesson.title}</p>
{/if}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="flex items-center gap-1">
{#if lessonEditing === lesson.id}
<PrimaryButton
label={$t('course.navItem.lessons.add_lesson.cancel')}
variant={VARIANTS.OUTLINED}
onClick={() => {
lesson.title = prevTitle ?? lesson.title;
resetEdit();
}}
/>
<PrimaryButton
label={$t('course.navItem.lessons.add_lesson.save')}
onClick={() => onSave({ lessonId: lesson.id }, lesson)}
/>
{:else}
<IconButton
onClick={() => onEdit({ lessonId: lesson.id, lessonTitle: lesson.title })}
disabled={!!lessonEditing}
>
<Edit size={20} />
</IconButton>
<IconButton
onClick={() => onDelete({ lessonId: lesson.id })}
disabled={!!lessonEditing}
>
<TrashCan size={20} />
</IconButton>
{/if}
</div>
</RoleBasedSecurity>
</div>
{:else}
{$t('course.navItem.lessons.no_lesson')}
{/each}
</div>
</div>
{/each}
</div>
</section>
@@ -300,7 +300,7 @@
$: initPlyr(player, $lesson.materials.videos);
$: lessonTitle = $lessons.find((les) => les.id === $lesson.id)?.title || '';
$: lessonTitle = $lesson.title;
$: editorValue = lessonFallbackNote(
$lesson.materials.note,
@@ -1,15 +1,17 @@
<script lang="ts">
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import Select from '$lib/components/Form/Select.svelte';
// import Select from '$lib/components/Form/Select.svelte';
import {
lessons,
handleSaveLesson
lessonSections,
handleSaveLesson,
handleSaveLessonSection
} from '$lib/components/Course/components/Lesson/store/lessons';
import { course, group } from '$lib/components/Course/store';
import { course } from '$lib/components/Course/store';
import Modal from '$lib/components/Modal/index.svelte';
import { goto } from '$app/navigation';
import { handleAddLessonWidget } from '../Navigation/store';
import { handleAddLessonWidget } from './store';
import { t } from '$lib/utils/functions/translations';
import { COURSE_TYPE } from '$lib/utils/types';
import type { Lesson } from '$lib/utils/types';
@@ -25,7 +27,8 @@
call_url: undefined,
lesson_at: new Date().toDateString(),
is_unlocked: true,
lesson_completion: []
lesson_completion: [],
created_at: ''
};
const handleSave = async () => {
@@ -33,26 +36,85 @@
errors.title = 'title cannot be empty';
return;
}
const savedLesson = await handleSaveLesson(lesson, $course.id);
if (Array.isArray(savedLesson) && savedLesson[0]) {
const newLesson = savedLesson[0];
lesson.id = newLesson.id;
$lessons = [...$lessons, lesson];
goto('/courses/' + $course.id + '/lessons/' + lesson.id);
if ($handleAddLessonWidget.isSection) {
const savedSection = await handleSaveLessonSection(
{
title: lesson.title,
order: $lessonSections.length
},
$course.id
);
if (Array.isArray(savedSection) && savedSection[0]) {
const newLessonSection = savedSection[0];
lessonSections.update((sections) => {
return [
...sections,
{
id: newLessonSection.id,
title: lesson.title,
order: newLessonSection.order,
course_id: newLessonSection.course_id,
lessons: []
}
];
});
}
} else {
lesson.section_id = $handleAddLessonWidget.id;
const savedLesson = await handleSaveLesson(lesson, $course.id);
if (Array.isArray(savedLesson) && savedLesson[0]) {
const newLesson = savedLesson[0];
lesson.id = newLesson.id;
$lessons = [...$lessons, lesson];
lessonSections.update((sections) =>
sections.map((s) => {
if (s.id === newLesson.section_id) {
s.lessons = [...s.lessons, lesson];
}
return s;
})
);
goto('/courses/' + $course.id + '/lessons/' + lesson.id);
}
}
$handleAddLessonWidget.open = false;
handleClose();
};
function handleClose() {
$handleAddLessonWidget.open = false;
lesson = {
id: '',
course_id: $course.id || '',
title: '',
profile: undefined,
call_url: undefined,
lesson_at: new Date().toDateString(),
is_unlocked: true,
lesson_completion: [],
created_at: ''
};
}
</script>
<Modal
onClose={() => ($handleAddLessonWidget.open = false)}
onClose={handleClose}
bind:open={$handleAddLessonWidget.open}
width="w-[80%] md:w-[65%]"
maxWidth="max-w-2xl"
containerClass="overflow-hidden"
modalHeading={$t('course.navItem.lessons.add_lesson.modal_heading')}
modalHeading={$t(
`course.navItem.lessons.add_lesson.${
$handleAddLessonWidget.isSection ? 'modal_heading_section' : 'modal_heading'
}`
)}
>
<form
on:submit|preventDefault={handleSave}
@@ -60,7 +122,11 @@
>
<div class="w-full">
<TextField
label={$t('course.navItem.lessons.add_lesson.lesson_title')}
label={$t(
`course.navItem.lessons.add_lesson.${
$handleAddLessonWidget.isSection ? 'lesson_section_title' : 'lesson_title'
}`
)}
bind:value={lesson.title}
autoFocus={true}
className="flex-1 min-w-lg max-w-lg"
@@ -68,7 +134,7 @@
errorMessage={errors.title}
/>
{#if $course.type == COURSE_TYPE.LIVE_CLASS}
<div
<!-- <div
class="flex items-start justify-evenly gap-1 flex-col lg:flex-row lg:items-center mt-2 w-4/5"
>
<div class="lg:mb-0">
@@ -91,7 +157,7 @@
<div class="flex items-center mb-3 lg:mb-0">
<TextField className="w-[179px]" placeholder="https://meet.google.com/mga-dsjs-fmb" />
</div>
</div>
</div> -->
{/if}
</div>
</form>
@@ -100,4 +166,3 @@
<PrimaryButton label={$t('course.navItem.lessons.add_lesson.save')} onClick={handleSave} />
</div>
</Modal>
@@ -0,0 +1,7 @@
import { writable } from 'svelte/store';
export const handleAddLessonWidget = writable({
id: '',
open: false,
isSection: false
});
@@ -1,7 +1,14 @@
import { writable } from 'svelte/store';
import type { Writable, Updater } from 'svelte/store';
import { createLesson, updateLesson, deleteLesson } from '$lib/utils/services/courses';
import type { Lesson, Course, LessonPage, LessonComment } from '$lib/utils/types';
import {
createLesson,
updateLesson,
deleteLesson,
createLessonSection,
updateLessonSection,
deleteLessonSection
} from '$lib/utils/services/courses';
import type { Lesson, Course, LessonPage, LessonComment, LessonSection } from '$lib/utils/types';
import { LOCALE } from '$lib/utils/types';
import { snackbar } from '$lib/components/Snackbar/store';
import { lessonValidation } from '$lib/utils/functions/validator';
@@ -12,9 +19,12 @@ export const uploadCourseVideoStore = writable({
export const lessons: Writable<Lesson[]> = writable([]);
export const lessonSections: Writable<LessonSection[]> = writable([]);
export const lesson = writable<LessonPage>({
id: null,
locale: LOCALE.EN,
title: '',
totalExercises: 0,
totalComments: 0,
isSaving: false,
@@ -54,23 +64,50 @@ export function handleAddLesson() {
export async function handleDelete(lessonId: Lesson['id'] | undefined) {
// Need to implement soft delete
if (lessonId) {
const { error } = await deleteLesson(lessonId);
if (error) {
snackbar.error(error.message);
return console.error('Error deleting course', error);
}
lessons.update((_lessons) => _lessons.filter((lesson) => lesson.id !== lessonId));
snackbar.success('snackbar.generic.success_delete');
if (!lessonId) {
return;
}
const { error } = await deleteLesson(lessonId);
if (error) {
snackbar.error(error.message);
return console.error('Error deleting course', error);
}
lessons.update((_lessons) => _lessons.filter((lesson) => lesson.id !== lessonId));
lessonSections.update((_sections) =>
_sections.map((section) => {
section.lessons = section.lessons.filter((lesson) => lesson.id !== lessonId);
return section;
})
);
snackbar.success('snackbar.generic.success_delete');
console.log(`lessonId`, lessonId);
}
export async function handleSaveLesson(lesson: Lesson, course_id: Course['id']) {
export async function handleDeleteSection(sectionId: LessonSection['id'] | undefined) {
// Need to implement soft delete
if (!sectionId) {
return;
}
const { error } = await deleteLessonSection(sectionId);
if (error) {
snackbar.error(error.message);
return console.error('Error deleting course', error);
}
lessonSections.update((_sections) => _sections.filter((section) => section.id !== sectionId));
lessons.update((_lessons) => _lessons.filter((lesson) => lesson.section_id !== sectionId));
snackbar.success('snackbar.generic.success_delete');
console.log(`sectionId`, sectionId);
}
export async function handleSaveLesson(lesson: Lesson, courseId: Course['id']) {
const result = lessonValidation(lesson);
if (Object.keys(result).length) {
@@ -83,13 +120,14 @@ export async function handleSaveLesson(lesson: Lesson, course_id: Course['id'])
lesson_at: lesson?.lesson_at,
call_url: lesson?.call_url,
teacher_id: lesson?.profile ? lesson?.profile.id : undefined,
course_id,
is_unlocked: lesson.is_unlocked
course_id: courseId,
is_unlocked: lesson.is_unlocked,
section_id: lesson.section_id
};
let newLessonData: any[] | null = null;
if (!!lesson.id) {
if (lesson.id) {
// No need to get the result of update cause we have all in local state
await updateLesson(newLesson, lesson.id);
} else {
@@ -99,6 +137,36 @@ export async function handleSaveLesson(lesson: Lesson, course_id: Course['id'])
}
return newLessonData;
}
export async function handleSaveLessonSection(
section: Partial<LessonSection>,
courseId: Course['id']
) {
const result = lessonValidation(section);
if (Object.keys(result).length) {
return result;
}
console.log(`handleSaveLessonSection lesson`, section);
const newSection: Partial<LessonSection> = {
id: section.id,
title: section.title,
course_id: courseId
};
let newSectionData: any[] | null = null;
if (newSection.id) {
// No need to get the result of update cause we have all in local state
await updateLessonSection(newSection, newSection.id);
} else {
const { data } = await createLessonSection(newSection);
newSectionData = data;
}
return newSectionData;
}
export async function handleUpdateLessonMaterials(lesson: any, lessonId: Lesson['id']) {
const materials = {
@@ -1,32 +1,46 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import ChevronDownIcon from 'carbon-icons-svelte/lib/ChevronDown.svelte';
import ChevronUpIcon from 'carbon-icons-svelte/lib/ChevronUp.svelte';
import Add from 'carbon-icons-svelte/lib/Add.svelte';
import { ChevronDown, ChevronUp, Add, FlashFilled } from 'carbon-icons-svelte';
import { SkeletonText } from 'carbon-components-svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import { NavClasses } from '$lib/utils/constants/reusableClass';
import FlashFilled from 'carbon-icons-svelte/lib/FlashFilled.svelte';
import { handleAddLessonWidget } from './store';
import { handleAddLessonWidget } from '../Lesson/store';
import NavIcons from './NavIcons.svelte';
import { isFreePlan } from '$lib/utils/store/org';
import { goto } from '$app/navigation';
import { course } from '$lib/components/Course/store';
export let handleClick = () => {};
export let id = '';
export let name = '';
export let label = '';
export let isGroupActive = false;
export let isExpanded: boolean | undefined;
export let total = 0;
export let isLoading = true;
export let isLesson = false;
export let isSection = false;
export let isStudent = true;
export let isPaidFeature = false;
// export let subMenuItems = [];
export let className = '';
export let btnPadding = 'py-3 px-4';
function addLesson() {
goto('/courses/' + $course.id + '/lessons');
$handleAddLessonWidget.open = true;
// If it is section, then add lesson to section
$handleAddLessonWidget.isSection = isSection ? false : true;
if (isSection) {
$handleAddLessonWidget.id = id;
}
}
function onClick() {
if (isSection) {
toggleIsExpanded();
}
handleClick();
}
@@ -35,22 +49,22 @@
}
</script>
<div class="">
<div class={className}>
<button
class="item relative flex items-center py-3 px-4 ml-2 mb-1 {NavClasses.item} {isGroupActive &&
class="item relative flex items-center {btnPadding} ml-2 mb-1 {NavClasses.item} {isGroupActive &&
!isLoading &&
NavClasses.active} w-[95%]"
tabindex="0"
on:click={onClick}
disabled={isLoading}
>
<NavIcons name={label} />
<NavIcons {name} />
{#if isLoading}
<div class="w-11/12 mx-auto">
<SkeletonText class="rounded-md" style="margin: 0px; height: 30px;" />
</div>
{:else}
<span class="font-bold text-md">{label}</span>
<span class="font-bold text-md text-start leading-4 line-clamp-2">{label}</span>
{#if total}
<span class="ml-1">({total})</span>
{/if}
@@ -60,17 +74,15 @@
{#if isPaidFeature && $isFreePlan}
<FlashFilled size={20} class="text-blue-700" />
{/if}
{#if isLesson && !isLoading}
{#if !isStudent}
<IconButton onClick={() => addLesson()} size="small">
<Add />
</IconButton>
{/if}
{#if (isLesson || isSection) && !isLoading && !isStudent}
<IconButton stopPropagation={true} onClick={addLesson} size="small">
<Add />
</IconButton>
<IconButton size="small" stopPropagation={true} onClick={toggleIsExpanded}>
{#if isExpanded}
<ChevronUpIcon class="carbon-icon dark:text-white" />
<ChevronUp class="carbon-icon dark:text-white" />
{:else}
<ChevronDownIcon class="carbon-icon dark:text-white" />
<ChevronDown class="carbon-icon dark:text-white" />
{/if}
</IconButton>
{/if}
@@ -9,28 +9,29 @@
import IbmWatsonKnowledgeCatalog from 'carbon-icons-svelte/lib/IbmWatsonKnowledgeCatalog.svelte';
import Settings from 'carbon-icons-svelte/lib/Settings.svelte';
import Bullhorn from 'carbon-icons-svelte/lib/Bullhorn.svelte';
import { NAV_IDS } from './constants';
export let name = '';
</script>
{#if name === 'Overview'}
<TableOfContents size={20} class="carbon-icon mr-2" />
{:else if name === 'News Feed'}
<Bullhorn size={20} class="carbon-icon mr-2" />
{:else if name === 'Lessons'}
{#if name === NAV_IDS.SECTION}
<TableOfContents size={20} class="carbon-icon mr-2" style="min-width: 20px;" />
{:else if name === NAV_IDS.NEWS_FEED}
<Bullhorn size={20} class="carbon-icon r-2" />
{:else if name === NAV_IDS.LESSONS}
<IbmWatsonKnowledgeCatalog size={20} class="carbon-icon mr-2" />
{:else if name === 'Attendance'}
{:else if name === NAV_IDS.ATTENDANCE}
<ListChecked size={20} class="carbon-icon mr-2" />
{:else if name === 'Submissions'}
{:else if name === NAV_IDS.SUBMISSIONS}
<Workspace size={20} class="carbon-icon mr-2" />
{:else if name === 'Marks'}
{:else if name === NAV_IDS.MARKS}
<Result size={20} class="carbon-icon mr-2" />
{:else if name === 'People'}
{:else if name === NAV_IDS.PEOPLE}
<Group size={20} class="carbon-icon mr-2" />
{:else if name === 'Landing Page'}
{:else if name === NAV_IDS.LANDING_PAGE}
<ApplicationWeb size={20} class="carbon-icon mr-2" />
{:else if name === 'Certificates'}
{:else if name === NAV_IDS.CERTIFICATES}
<Badge size={20} class="carbon-icon mr-2" />
{:else if name === 'Settings'}
{:else if name === NAV_IDS.SETTINGS}
<Settings size={20} class="carbon-icon mr-2" />
{/if}
@@ -0,0 +1,12 @@
export const NAV_IDS = {
NEWS_FEED: 'NEWS_FEED',
LESSONS: 'LESSONS',
ATTENDANCE: 'ATTENDANCE',
SUBMISSIONS: 'SUBMISSIONS',
MARKS: 'MARKS',
CERTIFICATES: 'CERTIFICATES',
LANDING_PAGE: 'LANDING_PAGE',
PEOPLE: 'PEOPLE',
SETTINGS: 'SETTINGS',
SECTION: 'SECTION'
};
@@ -3,12 +3,12 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import TextChip from '$lib/components/Chip/Text.svelte';
import LockedIcon from 'carbon-icons-svelte/lib/Locked.svelte';
import CheckmarkFilled from 'carbon-icons-svelte/lib/CheckmarkFilled.svelte';
import NavExpandable from './NavExpandable.svelte';
import { getNavItemRoute, getLessonsRoute, getLectureNo } from '$lib/components/Course/function';
import TextChip from '$lib/components/Chip/Text.svelte';
import { lessons } from '../Lesson/store/lessons';
import { lessons, lessonSections } from '../Lesson/store/lessons';
import { course } from '$lib/components/Course/store';
import { NavClasses } from '$lib/utils/constants/reusableClass';
import { isMobile } from '$lib/utils/store/useMobile';
@@ -17,18 +17,28 @@
import { getIsLessonComplete } from '../Lesson/functions';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { COURSE_TYPE } from '$lib/utils/types';
import { COURSE_TYPE, COURSE_VERSION, type Lesson } from '$lib/utils/types';
import { NAV_IDS } from './constants';
export let path: string;
export let isStudent: boolean = false;
interface Section {
id: string;
order: number;
label: string;
lessons: Lesson[];
isExpanded?: boolean;
}
interface NavItem {
id: string;
label: string;
to: string;
hideSortIcon: boolean;
isLesson?: boolean;
isPaidFeature: boolean;
isExpanded?: boolean;
sections?: Section[];
show?: () => boolean;
}
@@ -139,6 +149,7 @@
$: {
navItems = [
{
id: NAV_IDS.NEWS_FEED,
label: $t('course.navItems.nav_news_feed'),
to: getNavItemRoute($course.id),
hideSortIcon: true,
@@ -148,14 +159,21 @@
}
},
{
id: NAV_IDS.LESSONS,
label: $t('course.navItems.nav_lessons'),
to: getLessonsRoute($course.id),
hideSortIcon: false,
isPaidFeature: false,
isLesson: true,
sections: $lessonSections.map((section) => ({
...section,
label: section.title,
isExpanded: true
})),
isExpanded: isStudent ? true : $page.url.pathname.includes('/lessons')
},
{
id: NAV_IDS.ATTENDANCE,
label: $t('course.navItems.nav_attendance'),
to: getNavItemRoute($course.id, 'attendance'),
isPaidFeature: false,
@@ -167,6 +185,7 @@
}
},
{
id: NAV_IDS.SUBMISSIONS,
label: $t('course.navItems.nav_submissions'),
to: getNavItemRoute($course.id, 'submissions'),
hideSortIcon: true,
@@ -178,6 +197,7 @@
}
},
{
id: NAV_IDS.MARKS,
label: $t('course.navItems.nav_marks'),
to: getNavItemRoute($course.id, 'marks'),
isPaidFeature: false,
@@ -191,6 +211,7 @@
}
},
{
id: NAV_IDS.CERTIFICATES,
label: $t('course.navItems.nav_certificates'),
to: getNavItemRoute($course.id, 'certificates'),
hideSortIcon: true,
@@ -204,6 +225,7 @@
}
},
{
id: NAV_IDS.LANDING_PAGE,
label: $t('course.navItems.nav_landing_page'),
to: getNavItemRoute($course.id, 'landingpage'),
hideSortIcon: true,
@@ -213,6 +235,7 @@
}
},
{
id: NAV_IDS.PEOPLE,
label: $t('course.navItems.nav_people'),
to: getNavItemRoute($course.id, 'people'),
isPaidFeature: false,
@@ -222,6 +245,7 @@
}
},
{
id: NAV_IDS.SETTINGS,
label: $t('course.navItems.nav_settings'),
to: getNavItemRoute($course.id, 'settings'),
hideSortIcon: true,
@@ -258,6 +282,8 @@
{#each navItems as navItem}
{#if !navItem.show || (typeof navItem.show === 'function' && navItem.show())}
<NavExpandable
id={navItem.id}
name={navItem.id}
label={navItem.label}
handleClick={handleMainGroupClick(navItem.to)}
isGroupActive={(path || $page.url.pathname) === navItem.to}
@@ -268,10 +294,10 @@
isExpanded={navItem.isExpanded}
{isStudent}
>
{#if navItem.isLesson}
{#if $course.version === COURSE_VERSION.V1}
{#each $lessons as item, index}
<a
class="pl-7 w-[95%] text-[0.80rem] mb-1 text-black dark:text-white {isStudent &&
class="pl-7 w-[95%] text-[0.80rem] mb-2 text-black dark:text-white {isStudent &&
!item.is_unlocked
? 'cursor-not-allowed'
: ''}"
@@ -280,9 +306,10 @@
: getLessonsRoute($course.id, item.id)}
on:click={toggleSidebarOnMobile}
aria-disabled={!item.is_unlocked}
title={item.title}
>
<div
class="flex items-center py-3 px-4 {NavClasses.item} {(
class="flex items-center py-2 px-4 {NavClasses.item} {(
path || $page.url.pathname
).includes(item.id) && NavClasses.active}"
>
@@ -292,7 +319,7 @@
size="sm"
shape="rounded-full"
/>
<span class="w-[70%] text-ellipsis line-clamp-2">{item.title}</span>
<span class="w-[85%] text-ellipsis line-clamp-2">{item.title}</span>
<span class="grow" />
{#if !item.is_unlocked}
<span class="text-md ml-2" title="This lesson is locked.">
@@ -306,6 +333,53 @@
</div>
</a>
{/each}
{:else if navItem.sections}
{#each navItem.sections as section}
<NavExpandable
id={section.id}
name={NAV_IDS.SECTION}
label={section.label}
isLoading={!$course.id}
isSection={true}
isExpanded={section.isExpanded}
className="ml-4"
btnPadding="py-2 px-3"
{isStudent}
>
{#each section.lessons as item}
<a
class="pl-7 w-[95%] text-[0.80rem] mb-2 text-black dark:text-white {isStudent &&
!item.is_unlocked
? 'cursor-not-allowed'
: ''}"
href={isStudent && !item.is_unlocked
? $page.url.pathname
: getLessonsRoute($course.id, item.id)}
on:click={toggleSidebarOnMobile}
aria-disabled={!item.is_unlocked}
title={item.title}
>
<div
class="flex items-center py-2 px-4 {NavClasses.item} {(
path || $page.url.pathname
).includes(item.id) && NavClasses.active}"
>
<span class="w-[85%] text-ellipsis line-clamp-2">{item.title}</span>
<span class="grow" />
{#if !item.is_unlocked}
<span class="text-md ml-2" title="This lesson is locked.">
<LockedIcon class="carbon-icon dark:text-white" />
</span>
{:else if getIsLessonComplete(item.lesson_completion, $profile.id)}
<span class="ml-2" title="You have completed this lesson">
<CheckmarkFilled class="carbon-icon dark:text-white" />
</span>
{/if}
</div>
</a>
{/each}
</NavExpandable>
{/each}
{/if}
</NavExpandable>
{/if}
@@ -1,5 +0,0 @@
import { writable } from 'svelte/store';
export let handleAddLessonWidget = writable({
open: false
});
@@ -6,7 +6,7 @@ export function getGroupMemberId(people, profileId) {
return groupMember ? groupMember.id : null;
}
export function getNavItemRoute(courseId, routeId) {
export function getNavItemRoute(courseId, routeId?: string) {
const path = `/${ROUTES.COURSES}/${courseId}`;
if (!routeId) {
@@ -16,7 +16,7 @@ export function getNavItemRoute(courseId, routeId) {
return `${path}/${routeId}`;
}
export function getLessonsRoute(courseId, lessonId) {
export function getLessonsRoute(courseId, lessonId?: string) {
const path = getNavItemRoute(courseId, ROUTES.LESSONS);
if (!lessonId) {
@@ -1,8 +1,8 @@
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
import { lessons } from './components/Lesson/store/lessons';
import { lessons, lessonSections } from './components/Lesson/store/lessons';
import { ROLE } from '$lib/utils/constants/roles';
import type { Course, GroupPerson } from '$lib/utils/types';
import type { Course, GroupPerson, Lesson, LessonSection } from '$lib/utils/types';
import { COURSE_TYPE } from '$lib/utils/types';
export const defaultCourse: Course = {
@@ -116,17 +116,23 @@ export async function setCourse(data: Course, setLesson = true) {
}
if (setLesson) {
const orderedLessons = (data.lessons || [])
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
// .map((lesson) => ({
// ...lesson,
// profile: lesson.profile && tutorsById[lesson.profile.id],
// }));
const orderedLessons = sortLesson(data.lessons);
lessons.set(orderedLessons);
if (data.lesson_section) {
console.log('Sections', data.lesson_section);
const sections = data.lesson_section?.map((section) => {
const lessons = (data.lessons || []).filter((lesson) => lesson.section_id === section.id);
section.lessons = sortLesson(lessons);
return section;
});
lessonSections.set(sortLessonSection(sections));
}
}
delete data.lessons;
delete data?.lesson_section;
if (data.metadata && !Object.values(data.metadata)) {
data.metadata = {
@@ -156,3 +162,15 @@ export async function setCourse(data: Course, setLesson = true) {
}
course.set(data);
}
function sortLesson(lessons: Lesson[] = []) {
return lessons
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
function sortLessonSection(sections: LessonSection[] = []) {
return sections
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
@@ -17,7 +17,7 @@
import { goto } from '$app/navigation';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { t } from '$lib/utils/functions/translations';
import { COURSE_TYPE } from '$lib/utils/types';
import { COURSE_TYPE, COURSE_VERSION } from '$lib/utils/types';
import ComingSoon from '$lib/components/ComingSoon/index.svelte';
let isLoading = false;
@@ -85,6 +85,7 @@
title,
description,
type: type,
version: COURSE_VERSION.V2,
group_id
})
.select();
@@ -26,7 +26,7 @@ const lessonSchema = z.object({
title: z.string().nonempty({ message: `${t.get('validations.lesson_schema.empty_title')}` }),
lesson_at: z.string().optional(),
call_url: z.string().nullable().optional(),
is_unlocked: z.boolean()
is_unlocked: z.boolean().optional()
});
const createQuizValidationSchema = z.object({
@@ -148,8 +148,8 @@ export const getConfirmPasswordError = ({ password, confirmPassword }) => {
: undefined;
};
export const processErrors = (error, mapToId) => {
const errors = {};
export const processErrors = (error, mapToId?: boolean) => {
const errors: Record<string, string> = {};
if (Array.isArray(error?.issues)) {
for (const issue of error.issues) {
@@ -162,7 +162,6 @@ export const processErrors = (error, mapToId) => {
if (mapToId) {
let value = '';
path.forEach((p, i) => {
const isLast = !path[i + 1];
const formatP = isNumber(p) ? `[${p}]` : p;
value += !value ? formatP : `.${formatP}`;
@@ -9,7 +9,8 @@ import type {
Groupmember,
Exercise,
LessonCompletion,
ExerciseTemplate
ExerciseTemplate,
LessonSection
} from '$lib/utils/types';
import { STATUS } from '$lib/utils/constants/course';
import type { PostgrestSingleResponse, PostgrestError } from '@supabase/supabase-js';
@@ -70,10 +71,12 @@ const SLUG_QUERY = `
is_published,
slug,
cost,
version,
currency,
metadata,
is_certificate_downloadable,
certificate_theme,
lesson_section(id, title, order),
lessons:lesson(
id, title, order
)
@@ -87,6 +90,7 @@ const ID_QUERY = `
overview,
logo,
is_published,
version,
group(*,
members:groupmember(*,
profile(*)
@@ -98,8 +102,9 @@ const ID_QUERY = `
metadata,
is_certificate_downloadable,
certificate_theme,
lesson_section(id, title, order, created_at),
lessons:lesson(
id, title,public, lesson_at, is_unlocked, order, created_at,
id, title, public, lesson_at, is_unlocked, order, created_at, section_id,
note, videos, slide_url, call_url, totalExercises:exercise(count), totalComments:lesson_comment(count),
profile:teacher_id(id, avatar_url, fullname),
lesson_completion(id, profile_id, is_complete)
@@ -244,6 +249,7 @@ export function fetchLesson(lessonId: Lesson['id']) {
.from('lesson')
.select(
`id,
title,
note,
videos,
slide_url,
@@ -271,6 +277,15 @@ export function fetchLesssonLanguageHistory(lessonId: string, locale: string, en
export function createLesson(lesson: any) {
return supabase.from('lesson').insert(lesson).select();
}
export function createLessonSection(section: any) {
return supabase.from('lesson_section').insert(section).select();
}
export function updateLessonSection(section: any, sectionId: LessonSection['id']) {
return supabase
.from('lesson_section')
.update({ ...section, id: undefined })
.match({ id: sectionId });
}
export function updateLesson(lesson: any, lessonId: Lesson['id']) {
return supabase
@@ -297,6 +312,11 @@ export function deleteLesson(lessonId: Lesson['id']) {
return supabase.from('lesson').delete().match({ id: lessonId });
}
export function deleteLessonSection(id: LessonSection['id']) {
// Need to implement soft delete
return supabase.from('lesson_section').delete().match({ id });
}
export function createExercise(exercise: any) {
return supabase.from('exercise').insert(exercise).select();
}
@@ -754,7 +754,10 @@
"lock": "Sperren",
"unlock": "Freischalten",
"start_reorder": "Neu anordnen",
"end_reorder": "Nachbestellung beenden"
"end_reorder": "Nachbestellung beenden",
"modal_heading_section": "Neuen Abschnitt hinzufügen",
"lesson_section_title": "Abschnittstitel",
"cancel": "Stornieren"
},
"disabled": "Funktion ist deaktiviert",
"next": "Nächste",
@@ -512,14 +512,17 @@
"start_reorder": "Reorder",
"end_reorder": "End Reorder",
"modal_heading": "Add New Lesson",
"modal_heading_section": "Add New Section",
"lesson_title": "Lesson Title",
"lesson_section_title": "Section Title",
"click_to": "Click to",
"lock": "Lock",
"unlock": "Unlock",
"fullname": "fullname",
"save": "Save",
"edit": "Edit",
"delete": "Delete"
"delete": "Delete",
"cancel": "Cancel"
},
"join_lesson": "Join lesson",
"no_link": "No link",
@@ -754,7 +754,10 @@
"lock": "Cerrar",
"unlock": "desbloquear",
"start_reorder": "Reordenar",
"end_reorder": "Finalizar reorden"
"end_reorder": "Finalizar reorden",
"modal_heading_section": "Agregar nueva sección",
"lesson_section_title": "Título de la sección",
"cancel": "Cancelar"
},
"disabled": "La característica está deshabilitada",
"next": "Próximo",
@@ -754,7 +754,10 @@
"lock": "Verrouillage",
"unlock": "Ouvrir",
"start_reorder": "Réorganiser",
"end_reorder": "Terminer la réapprovisionnement"
"end_reorder": "Terminer la réapprovisionnement",
"modal_heading_section": "Ajouter une nouvelle section",
"lesson_section_title": "Titre de la section",
"cancel": "Annuler"
},
"disabled": "La fonctionnalité est désactivée",
"next": "Suivant",
@@ -754,7 +754,10 @@
"lock": "ताला",
"unlock": "अनलॉक",
"start_reorder": "पुन: व्यवस्थित करें",
"end_reorder": "पुनः क्रम समाप्त करें"
"end_reorder": "पुनः क्रम समाप्त करें",
"modal_heading_section": "नया अनुभाग जोड़ें",
"lesson_section_title": "अनुभाग शीर्षक",
"cancel": "रद्द करना"
},
"disabled": "सुविधा अक्षम है",
"next": "अगला",
@@ -754,7 +754,10 @@
"lock": "Trancar",
"unlock": "Desbloquear",
"start_reorder": "Reordenar",
"end_reorder": "Finalizar novo pedido"
"end_reorder": "Finalizar novo pedido",
"modal_heading_section": "Adicionar nova seção",
"lesson_section_title": "Título da seção",
"cancel": "Cancelar"
},
"disabled": "O recurso está desativado",
"next": "Próximo",
@@ -754,7 +754,10 @@
"lock": "Замок",
"unlock": "Разблокировать",
"start_reorder": "Изменение порядка",
"end_reorder": "Завершить изменение порядка"
"end_reorder": "Завершить изменение порядка",
"modal_heading_section": "Добавить новый раздел",
"lesson_section_title": "Название раздела",
"cancel": "Отмена"
},
"disabled": "Функция отключена",
"next": "Следующий",
@@ -754,7 +754,10 @@
"lock": "Khóa",
"unlock": "Mở khóa",
"start_reorder": "Sắp xếp lại",
"end_reorder": "Kết thúc sắp xếp lại"
"end_reorder": "Kết thúc sắp xếp lại",
"modal_heading_section": "Thêm phần mới",
"lesson_section_title": "Tiêu đề phần",
"cancel": "Hủy bỏ"
},
"disabled": "Tính năng bị vô hiệu hóa",
"next": "Kế tiếp",
@@ -194,10 +194,15 @@ export enum COURSE_TYPE {
SELF_PACED = 'SELF_PACED',
LIVE_CLASS = 'LIVE_CLASS'
}
export enum COURSE_VERSION {
V1 = 'V1', // with only lesson
V2 = 'V2' // lessons are grouped into sections
}
export interface Course {
title?: any; // type unknown;
description: string; // type unknown;
type: COURSE_TYPE;
version: COURSE_VERSION;
overview?: any; // type unknown;
id?: string /* primary key */;
created_at: string;
@@ -225,6 +230,7 @@ export interface Course {
is_present: boolean;
id: number;
}[];
lesson_section?: LessonSection[];
lessons?: Lesson[];
polls: { status: string }[];
}
@@ -271,6 +277,7 @@ export enum VideoType {
export interface LessonPage {
id?: string | null;
title: '';
totalExercises: number;
totalComments: number;
locale: LOCALE;
@@ -305,6 +312,7 @@ export interface Lesson {
videos?: []; // type unknown;
slide_url?: any; // type unknown;
course_id: string /* foreign key to course.id */;
section_id?: string /* foreign key to course.id */;
id: string /* primary key */;
created_at: string;
updated_at?: string;
@@ -318,6 +326,16 @@ export interface Lesson {
course?: Course;
profile?: Profile;
lesson_completion: LessonCompletion[];
totalExercises?: { count: number }[];
}
export interface LessonSection {
id: string;
title: string;
order: number;
course_id: string /* foreign key to course.id */;
lessons: Lesson[];
created_at: string;
}
export interface Exercise {
@@ -1,37 +1,28 @@
<script lang="ts">
import { page } from '$app/stores';
import { dndzone } from 'svelte-dnd-action';
import PageNav from '$lib/components/PageNav/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import Box from '$lib/components/Box/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { updateLesson } from '$lib/utils/services/courses';
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
import {
lessons,
handleDelete,
handleSaveLesson
lessonSections
} from '$lib/components/Course/components/Lesson/store/lessons';
import PageBody from '$lib/components/PageBody/index.svelte';
import { globalStore } from '$lib/utils/store/app';
import ScreenMap from 'carbon-icons-svelte/lib/ScreenMap.svelte';
import NewLessonModal from '$lib/components/Course/components/Lesson/NewLessonModal.svelte';
import DeleteLessonConfirmation from '$lib/components/Course/components/Lesson/DeleteLessonConfirmation.svelte';
import { handleAddLessonWidget } from '$lib/components/Course/components/Navigation/store';
import { Video, Calendar } from 'carbon-icons-svelte';
import { OverflowMenu, OverflowMenuItem } from 'carbon-components-svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import Select from '$lib/components/Form/Select.svelte';
import TextChip from '$lib/components/Chip/Text.svelte';
import Avatar from '$lib/components/Apps/components/Poll/components/Avatar.svelte';
import { course, group } from '$lib/components/Course/store';
import DateField from '$lib/components/Form/Date.svelte';
import type { Course, Lesson } from '$lib/utils/types';
import { handleAddLessonWidget } from '$lib/components/Course/components/Lesson/store';
import { course } from '$lib/components/Course/store';
import type { Lesson } from '$lib/utils/types';
import { t } from '$lib/utils/functions/translations';
import { goto } from '$app/navigation';
import { COURSE_TYPE } from '$lib/utils/types';
import { COURSE_VERSION } from '$lib/utils/types';
import { VARIANTS } from '$lib/components/PrimaryButton/constants.js';
import { profile } from '$lib/utils/store/user.js';
import LessonList from '$lib/components/Course/components/Lesson/LessonList.svelte';
import LessonSectionList from '$lib/components/Course/components/Lesson/LessonSectionList.svelte';
import NewLessonModal from '$lib/components/Course/components/Lesson/NewLessonModal.svelte';
export let data;
@@ -43,65 +34,9 @@
let isFetching: boolean = false;
let reorder = false;
const flipDurationMs = 300;
let errors: Record<string, string> = {};
async function saveLesson(lesson: Lesson, courseId: Course['id']) {
const validationRes = await handleSaveLesson(lesson, courseId);
if (validationRes && Object.keys(validationRes).length) {
errors = validationRes;
} else {
errors = {};
lessonEditing = undefined;
}
}
function addLesson() {
$handleAddLessonWidget.open = true;
}
function handleDndConsider(e: any) {
$lessons = e.detail.items;
}
function handleDndFinalize(e: any) {
const prevLessonsByOrder = $lessons.reduce((acc, l) => ({ ...acc, [l.id]: l.order }), {});
$lessons = e.detail.items;
// Only update the lesson order that changed
e.detail.items.forEach((item: { id: string }, index: number) => {
const order = index + 1;
if (order !== prevLessonsByOrder[item.id]) {
$lessons[index].order = order;
updateLesson({ order }, item.id).then((update) =>
console.log(`updated lesson order`, update)
);
}
});
}
function formatDate(date: string | undefined) {
if (!date) return '';
const d = new Date(date);
const ye = new Intl.DateTimeFormat('en', { year: 'numeric' }).format(d);
const mo = new Intl.DateTimeFormat('en', { month: '2-digit' }).format(d);
const da = new Intl.DateTimeFormat('en', { day: '2-digit' }).format(d);
return `${ye}-${mo}-${da}`;
}
function getLessonOrder(id: string) {
const index = $lessons.findIndex((lesson) => lesson.id === id);
if (index < 9) {
return '0' + (index + 1);
} else {
return index + 1;
}
$handleAddLessonWidget.isSection = true;
}
function hasUserCompletedLesson(completion) {
@@ -128,16 +63,14 @@
$: shouldGoToNextLesson = query.get('next') === 'true';
$: !isFetching && shouldGoToNextLesson && onNextQuery($lessons);
$: isLiveClass = $course.type === COURSE_TYPE.LIVE_CLASS;
$: lessonsLength =
$course.version === COURSE_VERSION.V1 ? $lessons.length : $lessonSections.length;
</script>
{#if $handleAddLessonWidget}
<NewLessonModal />
{/if}
<NewLessonModal />
<DeleteLessonConfirmation
bind:openDeleteModal
lessonTitle={lessonToDelete?.title || ''}
deleteLesson={() => handleDelete(lessonToDelete?.id)}
/>
@@ -150,14 +83,16 @@
onClick={addLesson}
isDisabled={!!lessonEditing}
/>
<PrimaryButton
label={$t(
`course.navItem.lessons.add_lesson.${reorder ? 'end_reorder' : 'start_reorder'}`
)}
variant={VARIANTS.OUTLINED}
onClick={() => (reorder = !reorder)}
isDisabled={!!lessonEditing}
/>
{#if $course.version === COURSE_VERSION.V1}
<PrimaryButton
label={$t(
`course.navItem.lessons.add_lesson.${reorder ? 'end_reorder' : 'start_reorder'}`
)}
variant={VARIANTS.OUTLINED}
onClick={() => (reorder = !reorder)}
isDisabled={!!lessonEditing}
/>
{/if}
</RoleBasedSecurity>
</div>
</PageNav>
@@ -173,210 +108,18 @@
</p>
</div>
</Box>
{:else if $lessons.length}
{:else if lessonsLength > 0}
{#if reorder}
<p class="text-gray-400 text-center dark:text-white italic text-xs">
{$t('course.navItem.lessons.drag')}
</p>
{/if}
<section
class="m-auto w-full p-3 lg:w-11/12 lg:px-4"
use:dndzone={{
items: $lessons,
flipDurationMs,
dragDisabled: $globalStore.isStudent || !reorder,
dropTargetStyle: {
border: '2px #1d4ed8 solid',
'border-style': 'dashed'
}
}}
on:consider={handleDndConsider}
on:finalize={handleDndFinalize}
>
{#each $lessons as lesson (lesson.id)}
<div
class={`relative m-auto mb-4 flex max-w-xl items-center rounded-md border-2 ${
reorder ? 'border-primary-400' : 'border-gray-200'
} border-gray-200 dark:border-neutral-600 p-5 dark:bg-neutral-800`}
>
<!-- Number Chip -->
<div class="mr-5">
<TextChip
value={getLessonOrder(lesson.id)}
size="sm"
shape="rounded-full"
className="bg-primary-200 text-primary-600 text-xs"
/>
</div>
<!-- Lesson Content -->
<div class="w-4/5">
<!-- Lesson Title -->
{#if lessonEditing === lesson.id}
<TextField
bind:value={lesson.title}
autoFocus={true}
className="max-w-lg"
errorMessage={errors?.title}
/>
{:else}
<h3 class="m-0 flex items-center text-lg dark:text-white">
<a
href={$globalStore.isStudent && !lesson.is_unlocked
? $page.url.pathname
: '/courses/' + $course.id + '/lessons/' + lesson.id}
class="font-medium text-black no-underline hover:underline dark:text-white {$globalStore.isStudent &&
!lesson.is_unlocked
? 'cursor-not-allowed'
: ''}"
>
{lesson.title}
</a>
</h3>
{/if}
<!-- Lesson Educator -->
{#if isLiveClass}
<div
class="mt-2 flex w-4/5 flex-col items-start justify-between lg:flex-row lg:items-center"
>
<div class="lg:mb-0">
{#if lessonEditing === lesson.id}
<Select
bind:value={lesson.profile}
options={$group.tutors}
labelKey="fullname"
className="sm:my-1 w-[100%]"
/>
{:else if !lesson.profile}
<p class="dark:text-white ml-2 text-sm mb-3">
{$t('course.navItem.lessons.no_tutor')}
</p>
{:else}
<a href="." class="mb-2 flex items-center hover:underline">
<Avatar
className="w-6 h-6"
src={lesson.profile.avatar_url}
name="Avatar placeholder"
/>
<p class="ml-2 text-sm dark:text-white">
{lesson.profile.fullname}
</p>
</a>
{/if}
</div>
<!-- Lesson Date -->
<div class="flex items-center lg:mb-0">
{#if lessonEditing === lesson.id}
<DateField
value={formatDate(lesson.lesson_at)}
className="p-2 my-2 rounded-md sm:w-[179px] dark:bg-neutral-800 dark:text-white"
onChange={(e) => {
lesson.lesson_at = e?.target?.value;
}}
/>
{:else}
<div class="mb-2 flex">
<Calendar size={20} class="carbon-icon text-gray-400 dark:text-white" />
<p class="ml-2 text-sm dark:text-white">
{new Date(lesson.lesson_at || '').toDateString()}
</p>
</div>
{/if}
</div>
<!-- Lesson Call url -->
<div class="mb-3 flex items-center lg:mb-0">
{#if lessonEditing === lesson.id}
<TextField
bind:value={lesson.call_url}
autoFocus={true}
className="w-[179px]"
placeholder="https://meet.google.com/mga-dsjs-fmb"
/>
{:else}
<div class="flex">
<Video size={20} class="carbon-icon ml-0.5 text-gray-400 dark:text-white" />
<a
class="text-primary-600 ml-2 text-sm"
href={lesson.call_url || '#'}
target="_blank"
>
{lesson.call_url
? $t('course.navItem.lessons.join_lesson')
: $t('course.navItem.lessons.no_link')}
</a>
</div>
{/if}
</div>
</div>
{:else}
<div
class="mt-2 flex w-4/5 flex-col items-start justify-between lg:flex-row lg:items-center"
>
<!-- Lesson Length -->
<div class="mb-3 flex items-center lg:mb-0">
<ScreenMap size={20} class="carbon-icon dark:text-white" />
<p class="ml-2 text-sm text-gray-500 dark:text-white">
{lesson?.totalExercises ? lesson?.totalExercises?.map((c) => c.count) : 0}
{$t('exercises.heading')}
</p>
</div>
</div>
{/if}
</div>
<!-- 3 dot edit -->
<div class="flex flex-row">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<OverflowMenu size="xl">
<OverflowMenuItem
disabled={$globalStore.isStudent}
text={lesson.is_unlocked
? $t('course.navItem.lessons.add_lesson.lock')
: $t('course.navItem.lessons.add_lesson.unlock')}
on:click={() => {
lesson.is_unlocked = !lesson.is_unlocked;
handleSaveLesson(lesson, $course.id);
}}
/>
<OverflowMenuItem
text={lessonEditing === lesson.id
? $t('course.navItem.lessons.add_lesson.save')
: $t('course.navItem.lessons.add_lesson.edit')}
on:click={() => {
if (lessonEditing === lesson.id) {
saveLesson(lesson, $course.id);
} else {
lessonEditing = lesson.id;
}
}}
/>
<OverflowMenuItem
danger
text={$t('course.navItem.lessons.add_lesson.delete')}
on:click={() => {
lessonToDelete = lesson;
openDeleteModal = true;
}}
/>
</OverflowMenu>
</RoleBasedSecurity>
</div>
</div>
{:else}
<Box>
<div class="flex justify-between flex-col items-center w-[90%] md:w-96">
<img src="/images/empty-lesson-icon.svg" alt="Lesson" class="my-2.5 mx-auto" />
<h2 class="text-xl my-1.5 font-normal">{$t('course.navItem.lessons.body_header')}</h2>
<p class="text-sm text-center text-slate-500">
{$t('course.navItem.lessons.body_content')}
</p>
</div>
</Box>
{/each}
</section>
{#if $course.version === COURSE_VERSION.V1}
<LessonList {reorder} {lessonEditing} bind:lessonToDelete bind:openDeleteModal />
{:else if $course.version === COURSE_VERSION.V2}
<LessonSectionList {lessonEditing} />
{/if}
{:else}
<Box className="w-full lg:w-11/12 lg:px-4 m-auto">
<div class="flex justify-between flex-col items-center">
@@ -13,7 +13,7 @@
import CourseIcon from '$lib/components/Icons/CourseIcon.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { Dropdown, Loading } from 'carbon-components-svelte';
import { Dropdown } from 'carbon-components-svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import PageNav from '$lib/components/PageNav/index.svelte';
import PageBody from '$lib/components/PageBody/index.svelte';
@@ -198,6 +198,7 @@
lesson.update((l) => ({
...l,
id: data.lessonId,
title: lessonData.title,
totalExercises,
totalComments: totalComments,
materials: {
@@ -0,0 +1,127 @@
create type "public"."COURSE_VERSION" as enum ('V1', 'V2');
alter table "public"."lesson_language_history" drop constraint "lesson_language_history_lesson_language_id_fkey";
alter table "public"."lesson_language" drop constraint "public_lesson_language_lesson_id_fkey";
create table "public"."lesson_section" (
"id" uuid not null default gen_random_uuid(),
"created_at" timestamp with time zone not null default now(),
"updated_at" timestamp with time zone default now(),
"title" character varying,
"order" bigint default '0'::bigint,
"course_id" uuid
);
alter table "public"."lesson_section" enable row level security;
alter table "public"."course" add column "version" "COURSE_VERSION" not null default 'V1'::"COURSE_VERSION";
alter table "public"."lesson" add column "section_id" uuid;
CREATE UNIQUE INDEX lesson_section_pkey ON public.lesson_section USING btree (id);
alter table "public"."lesson_section" add constraint "lesson_section_pkey" PRIMARY KEY using index "lesson_section_pkey";
alter table "public"."lesson" add constraint "public_lesson_section_id_fkey" FOREIGN KEY (section_id) REFERENCES lesson_section(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
alter table "public"."lesson" validate constraint "public_lesson_section_id_fkey";
alter table "public"."lesson_language_history" add constraint "public_lesson_language_history_lesson_language_id_fkey" FOREIGN KEY (lesson_language_id) REFERENCES lesson_language(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
alter table "public"."lesson_language_history" validate constraint "public_lesson_language_history_lesson_language_id_fkey";
alter table "public"."lesson_section" add constraint "public_lesson_section_course_id_fkey" FOREIGN KEY (course_id) REFERENCES course(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
alter table "public"."lesson_section" validate constraint "public_lesson_section_course_id_fkey";
alter table "public"."lesson_language" add constraint "public_lesson_language_lesson_id_fkey" FOREIGN KEY (lesson_id) REFERENCES lesson(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
alter table "public"."lesson_language" validate constraint "public_lesson_language_lesson_id_fkey";
grant delete on table "public"."lesson_section" to "anon";
grant insert on table "public"."lesson_section" to "anon";
grant references on table "public"."lesson_section" to "anon";
grant select on table "public"."lesson_section" to "anon";
grant trigger on table "public"."lesson_section" to "anon";
grant truncate on table "public"."lesson_section" to "anon";
grant update on table "public"."lesson_section" to "anon";
grant delete on table "public"."lesson_section" to "authenticated";
grant insert on table "public"."lesson_section" to "authenticated";
grant references on table "public"."lesson_section" to "authenticated";
grant select on table "public"."lesson_section" to "authenticated";
grant trigger on table "public"."lesson_section" to "authenticated";
grant truncate on table "public"."lesson_section" to "authenticated";
grant update on table "public"."lesson_section" to "authenticated";
grant delete on table "public"."lesson_section" to "service_role";
grant insert on table "public"."lesson_section" to "service_role";
grant references on table "public"."lesson_section" to "service_role";
grant select on table "public"."lesson_section" to "service_role";
grant trigger on table "public"."lesson_section" to "service_role";
grant truncate on table "public"."lesson_section" to "service_role";
grant update on table "public"."lesson_section" to "service_role";
create policy "Enable read access for all users"
on "public"."lesson_section"
as permissive
for select
to public
using (true);
create policy "User must be an org member to DELETE"
on "public"."lesson_section"
as permissive
for delete
to public
using (is_user_in_group_with_role(( SELECT course.group_id
FROM course
WHERE (course.id = lesson_section.course_id)
LIMIT 1)));
create policy "User must be an org member to INSERT"
on "public"."lesson_section"
as permissive
for insert
to public
with check (is_user_in_group_with_role(( SELECT course.group_id
FROM course
WHERE (course.id = lesson_section.course_id)
LIMIT 1)));
create policy "User must be an org member to UPDATE"
on "public"."lesson_section"
as permissive
for update
to public
using (is_user_in_group_with_role(( SELECT course.group_id
FROM course
WHERE (course.id = lesson_section.course_id)
LIMIT 1)))
with check (is_user_in_group_with_role(( SELECT course.group_id
FROM course
WHERE (course.id = lesson_section.course_id)
LIMIT 1)));
+1 -1
View File
@@ -19,7 +19,7 @@ INSERT INTO "auth"."users" ("instance_id", "id", "aud", "role", "email", "encryp
-- Data for Name: identities; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin
--
INSERT INTO "auth"."identities" ("id", "user_id", "identity_data", "provider", "last_sign_in_at", "created_at", "updated_at") VALUES
INSERT INTO "auth"."identities" ("provider_id", "user_id", "identity_data", "provider", "last_sign_in_at", "created_at", "updated_at") VALUES
('01676a50-bb56-4c5e-8a61-fb9e9190fb10', '01676a50-bb56-4c5e-8a61-fb9e9190fb10', '{"sub": "01676a50-bb56-4c5e-8a61-fb9e9190fb10", "email": "test@test.com"}', 'email', '2023-11-22 10:09:32.225444+00', '2023-11-22 10:09:32.225521+00', '2023-11-22 10:09:32.225521+00'),
('7ac00503-8519-43c8-a5ea-b79aeca900b1', '7ac00503-8519-43c8-a5ea-b79aeca900b1', '{"sub": "7ac00503-8519-43c8-a5ea-b79aeca900b1", "email": "admin@test.com"}', 'email', '2023-12-16 14:04:38.302264+00', '2023-12-16 14:04:38.302326+00', '2023-12-16 14:04:38.302326+00'),
('0c256e75-aa40-4f62-8d30-0217ca1c60d9', '0c256e75-aa40-4f62-8d30-0217ca1c60d9', '{"sub": "0c256e75-aa40-4f62-8d30-0217ca1c60d9", "email": "student@test.com"}', 'email', '2023-12-18 17:08:57.410285+00', '2023-12-18 17:08:57.410344+00', '2023-12-18 17:08:57.410344+00');