feat: migrate course UI from supabase (#615)

* chore: fix storybook build

* chore: implement theming

* community list not showing properly

* refactor: migrate Chip component to @cio/ui/custom/chip

- Updated imports for the Chip component across multiple files to use the new location in the @cio/ui package.
- Replaced the previous Chip implementation with a new version that utilizes a Badge for better styling and functionality.
- Adjusted related components to ensure consistent usage of the updated Chip component.

* fix: align course sidebar design to match organization design (#611)

* fix: align course sidebar design to match organization design

* feat: add hideSearch prop to AppHeader and update sidebar layout

---------

Co-authored-by: Rotimi Best <best@classroomio.com>

* refactor: replace PrimaryButton with Button component across multiple files

- Updated various components to use the new Button component from @cio/ui/base/button instead of the deprecated PrimaryButton.
- Ensured consistent button usage throughout the application for improved styling and functionality.
- Removed the PrimaryButton component and its associated constants from the codebase.

* refactor: standardize button and input usage across components

- Replaced instances of deprecated components such as TextField, TextArea, and IconButton with their updated counterparts (InputField, TextareaField, and updated IconButton).
- Ensured consistent event handling by changing `onClick` to `onclick` for better alignment with Svelte's event binding conventions.
- Removed unused components and streamlined imports to enhance code maintainability and readability.

* chore: themeing, course card, use $features

* refactor: centralize UI components and update modal implementations

- Refactored various components to centralize UI elements, improving discoverability and maintainability.
- Replaced deprecated modal components with the new Dialog component for consistent modal behavior across the application.
- Removed unused components and streamlined imports to enhance code clarity and reduce complexity.

* feat: add new documentation and refactor org and lms UI components

- Introduced new documentation files for Auth Flow, Breadcrumbs Refactoring, Page and Route Creation, and Route Page Setup to enhance developer onboarding and reference.
- Refactored various UI components, including the Course Sidebar and Lesson Modal, to improve layout and functionality.
- Removed deprecated components and streamlined imports for better maintainability.
- Updated translations across multiple languages to ensure consistency in user experience.

* refactor: update UI components and improve styling consistency

- Refactored various components to enhance styling consistency, including updates to button and text color classes.
- Removed deprecated components and streamlined imports for better maintainability.
- Replaced instances of PageBody with a more flexible Page component structure across multiple routes.
- Updated translations and UI elements to align with the new design system.
- Deleted unused components to reduce code complexity and improve clarity.

* update output.css

---------

Co-authored-by: Abdulmuizz Hamzat <103611443+canhamzacode@users.noreply.github.com>
This commit is contained in:
Rotimi Best
2025-12-22 02:36:48 +01:00
committed by GitHub
co-authored by Abdulmuizz Hamzat
parent e6a3cf9332
commit 9fb6c7cbaa
401 changed files with 10489 additions and 11679 deletions
@@ -9,7 +9,7 @@ import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { resolve } from '$app/paths';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import { t } from '$lib/utils/functions/translations';
/**
@@ -0,0 +1,69 @@
<script lang="ts">
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { copyCourseModal, deleteCourseModal } from '$features/course/utils/store';
import { IconButton } from '@cio/ui/custom/icon-button';
import { t } from '$lib/utils/functions/translations';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
interface Props {
id: string;
title: string;
description: string;
}
let { id, title, description }: Props = $props();
function redirect(url: string) {
goto(resolve(url, {}));
}
function handleShareCourse() {
redirect(`/courses/${id}/settings#share`);
}
function handleInvite() {
redirect(`/courses/${id}/people?add=true`);
}
function handleDeleteCourse() {
$deleteCourseModal.open = true;
$deleteCourseModal.id = id;
$deleteCourseModal.title = title;
}
function handleCloneCourse() {
$copyCourseModal.open = true;
$copyCourseModal.id = id;
$copyCourseModal.title = title;
$copyCourseModal.description = description;
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="ui:data-[state=open]:opacity-100 absolute right-6 top-6 z-40 flex items-center justify-center opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100"
onclick={(e) => e.stopPropagation()}
>
<IconButton variant="outline">
<EllipsisVerticalIcon size={16} />
</IconButton>
</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>
@@ -0,0 +1,16 @@
<script lang="ts">
import * as Item from '@cio/ui/base/item';
import Card, { type Props as CardProps } from './card.svelte';
type Props = Omit<CardProps, 'course'> & {
courses: CardProps['course'][];
};
let { courses, ...rest }: Props = $props();
</script>
<Item.Group class="grid! w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{#each courses as course (course.id)}
<Card {course} {...rest} />
{/each}
</Item.Group>
@@ -1,45 +1,37 @@
<script lang="ts">
import { goto } from '$app/navigation';
import type { Component } from 'svelte';
import get from 'lodash/get';
import { resolve } from '$app/paths';
import { Badge } from '@cio/ui/base/badge';
import UserIcon from '@lucide/svelte/icons/user';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import CircleDotIcon from '@lucide/svelte/icons/circle-dot';
import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import ImageRenderer from '$lib/components/Org/ImageRenderer.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { Button } from '@cio/ui/base/button';
import { Separator } from '@cio/ui/base/separator';
import * as Item from '@cio/ui/base/item';
import { Progress } from '@cio/ui/base/progress';
import { COURSE_TYPE } from '$lib/utils/types';
import { Image } from '$features/ui';
import { COURSE_TYPE, type Course } from '$lib/utils/types';
import { t } from '$lib/utils/functions/translations';
import { calcCourseDiscount } from '$lib/utils/functions/course';
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
import { copyCourseModal, deleteCourseModal } from '$lib/features/course/utils/store';
import { calcProgressRate } from '$features/course/utils/functions';
import CardDropdown from './card-dropdown.svelte';
interface Props {
bannerImage: string | undefined;
id?: string;
slug?: string;
title?: string;
description?: string;
isPublished?: boolean;
totalLessons?: number;
totalStudents?: number;
currency?: string;
export interface Props {
course: Course;
isOnLandingPage?: boolean;
isLMS?: boolean;
isExplore?: boolean;
progressRate?: number;
type: COURSE_TYPE;
pricingData?: {
cost: number;
currency?: string;
showDiscount?: boolean;
discount?: number;
};
}
let { course, isOnLandingPage, isLMS, isExplore }: Props = $props();
$inspect('isLMS', isLMS);
let {
bannerImage,
id = '',
@@ -50,45 +42,40 @@
totalLessons = 0,
totalStudents = 0,
currency = 'USD',
isOnLandingPage = false,
isLMS = false,
isExplore = false,
progressRate = 45,
type,
pricingData = {
cost: 0
cost: 0,
discount: 0,
showDiscount: false
}
}: Props = $props();
} = $derived({
id: course.id,
slug: course.slug,
bannerImage: course.logo || '/images/classroomio-course-img-template.jpg',
title: course.title,
type: course.type,
description: course.description,
isPublished: course.is_published,
pricingData: {
cost: course.cost,
discount: course.metadata?.discount || 0,
showDiscount: course.metadata?.showDiscount || false
},
currency: course.currency,
totalLessons: get(course, 'lessons[0].count', 0),
progressRate: calcProgressRate(course.progress_rate, course.total_lessons),
totalStudents: course.total_students
});
let formatter = $derived(getCurrencyFormatter(currency));
function handleCloneCourse() {
$copyCourseModal.open = true;
$copyCourseModal.id = id;
$copyCourseModal.title = title;
$copyCourseModal.description = description;
}
function handleShareCourse() {
goto(`/courses/${id}/settings#share`);
}
function handleInvite() {
goto(`/courses/${id}/people?add=true`);
}
function handleDeleteCourse() {
$deleteCourseModal.open = true;
$deleteCourseModal.id = id;
$deleteCourseModal.title = title;
}
const COURSE_TAG: Record<
string,
{
style: string;
label: string;
icon: any;
icon: Component;
iconStyle?: string;
}
> = {
@@ -102,7 +89,7 @@
style: '',
label: $t('course.navItem.settings.self_paced'),
icon: UserIcon,
iconStyle: 'custom text-primary-700'
iconStyle: 'custom ui:text-primary'
},
SPECIALIZATION: {
style: '',
@@ -118,146 +105,91 @@
);
</script>
<div
role="button"
tabindex="0"
onclick={() => {
goto(courseUrl);
}}
onkeydown={(e) => {
if (e.key === 'Enter') {
goto(courseUrl);
}
}}
class="border-gray group relative w-full max-w-[320px] rounded border text-black dark:border-neutral-600"
>
<div class="p-4">
<div class="relative mb-5">
<Item.Root variant="outline" class="p-3! group relative max-w-[320px]">
{#snippet child({ props })}
<a href={resolve(courseUrl, {})} {...props}>
{#if !isLMS && !isOnLandingPage}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="absolute right-2 top-2 z-40 flex items-center justify-center rounded-full bg-gray-200 p-2 opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100 dark:bg-neutral-800"
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>
<CardDropdown {id} {title} {description} />
{/if}
<ImageRenderer
src={bannerImage}
alt="Banner Image"
className="relative h-[200px] w-full rounded dark:border dark:border-neutral-600"
/>
<Item.Header>
<Item.Media variant="image" class="h-50! w-full! relative">
<Image src={bannerImage} alt="Course banner image" className="w-full h-full rounded-sm object-cover" />
{#if type}
{@const tag = COURSE_TAG[type]}
<span
class="bg-primary-50 absolute bottom-2 left-2 z-10 flex items-center gap-1 rounded-sm p-1 font-mono text-xs capitalize"
>
<tag.icon class={tag.iconStyle} />
{tag.label}
</span>
{/if}
</div>
{#if type}
{@const tag = COURSE_TAG[type]}
<Badge class="rounded-md! absolute bottom-2 left-2 z-10 flex items-center capitalize" variant="secondary">
<tag.icon class={tag.iconStyle} />
{tag.label}
</Badge>
{/if}
</Item.Media>
</Item.Header>
<h3 class="title text-xl dark:text-white">{title}</h3>
<p class="description mt-2 text-sm text-gray-500 dark:text-gray-300">
{description}
</p>
</div>
<Item.Content>
<Item.Title class="text-base! line-clamp-1 min-h-[44px]">
{title}
</Item.Title>
<div
class="border-gray flex justify-between border border-b-0 border-l-0 border-r-0 px-4 py-2 dark:border-neutral-600 {isLMS &&
'items-center'}"
>
<div>
<p class="text-xs {!isLMS && 'pl-2'} dark:text-white">
{totalLessons}
{$t('courses.course_card.lessons_number')}
</p>
<p class="py-2 text-xs">
{#if isOnLandingPage}
<span class="px-2">
{#if !cost}
{$t('course.navItem.landing_page.pricing_section.free')}
{:else if pricingData.showDiscount}
{formatter.format(cost)}
<span class="line-through">
{formatter?.format(pricingData?.cost)}
</span>
{:else}
{formatter.format(cost)}
{/if}
</span>
{:else if isLMS}
{#if !isExplore}
<div class="flex items-center gap-2">
<div class=" relative h-1 w-[50px] bg-[#EAEAEA]">
<div style="width:{progressRate}%" class="bg-primary-700 absolute left-0 top-0 h-full"></div>
</div>
<p class="text-xs text-[#656565] dark:text-white">{progressRate}%</p>
<Item.Description class="min-h-[63px]">{description}</Item.Description>
<Separator class="my-3" />
<div class="flex justify-between {isLMS && 'items-center'} w-full">
<div class="w-[50%]">
<p class="text-xs {!isLMS && 'pl-2'} dark:text-white">
{totalLessons}
{$t('courses.course_card.lessons_number')}
</p>
<div class="py-2 text-xs">
{#if isOnLandingPage}
<span class="px-2">
{#if !cost}
{$t('course.navItem.landing_page.pricing_section.free')}
{:else if pricingData.showDiscount}
{formatter.format(cost)}
<span class="line-through">
{formatter?.format(pricingData?.cost)}
</span>
{:else}
{formatter.format(cost)}
{/if}
</span>
{:else if isLMS}
{#if !isExplore}
<div class="flex w-3/4 items-center gap-2">
<Progress value={50} />
<p class="ui:text-muted-foreground text-xs">{progressRate}%</p>
</div>
{/if}
{:else}
<Badge variant={isPublished ? 'default' : 'outline'}>
{#if isPublished}
{$t('courses.course_card.published')}
{:else}
{$t('courses.course_card.unpublished')}
{/if}
</Badge>
{/if}
</div>
</div>
{#if isLMS}
<Button variant="outline">
{isExplore ? $t('courses.course_card.learn_more') : $t('courses.course_card.continue_course')}
<ArrowRightIcon class="custom" />
</Button>
{:else if !isOnLandingPage}
<div class="flex flex-col justify-between">
<p class="pl-2 text-xs dark:text-white">
{totalStudents}
{$t('courses.course_card.students')}
</p>
</div>
{/if}
{:else}
<Badge variant={isPublished ? 'default' : 'secondary'}>
{#if isPublished}
{$t('courses.course_card.published')}
{:else}
{$t('courses.course_card.unpublished')}
{/if}
</Badge>
{/if}
</p>
</div>
{#if isLMS}
<PrimaryButton
label={isExplore ? $t('courses.course_card.learn_more') : $t('courses.course_card.continue_course')}
variant={VARIANTS.OUTLINED}
className="rounded-none"
/>
{:else if !isOnLandingPage}
<div class="flex flex-col justify-between">
<p class="pl-2 text-xs dark:text-white">
{totalStudents}
{$t('courses.course_card.students')}
</p>
<div></div>
</div>
{/if}
</div>
</div>
<style>
.title,
.description {
height: 42px;
line-height: 20px;
display: -webkit-box;
-webkit-box-orient: vertical;
-moz-box-orient: vertical;
-ms-box-orient: vertical;
box-orient: vertical;
-webkit-line-clamp: 2;
-moz-line-clamp: 2;
-ms-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
</style>
</div>
</Item.Content>
</a>
{/snippet}
</Item.Root>
@@ -1,11 +1,11 @@
<script lang="ts">
import { preventDefault } from '$lib/utils/functions/svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import Modal from '$lib/components/Modal/index.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import { copyCourseModal } from '$lib/features/course/utils/store';
import { courseCloneApi } from '$lib/features/course/api';
import { Button } from '@cio/ui/base/button';
import * as Dialog from '@cio/ui/base/dialog';
import { InputField } from '@cio/ui/custom/input-field';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { copyCourseModal } from '$features/course/utils/store';
import { courseCloneApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
async function createCourse() {
@@ -20,14 +20,15 @@
});
</script>
<Modal
onClose={() => ($copyCourseModal.open = false)}
<Dialog.Root
bind:open={$copyCourseModal.open}
width="w-96"
modalHeading={$t('courses.copy_course.title')}
>
<form onsubmit={preventDefault(createCourse)}>
<TextField
<Dialog.Content class="w-96">
<Dialog.Header>
<Dialog.Title>{$t('courses.copy_course.title')}</Dialog.Title>
</Dialog.Header>
<form onsubmit={preventDefault(createCourse)}>
<InputField
label={$t('courses.copy_course.course_name_label')}
bind:value={$copyCourseModal.title}
autoFocus={true}
@@ -38,7 +39,7 @@
errorMessage={courseCloneApi.errors.title}
/>
<TextArea
<TextareaField
label={$t('courses.copy_course.course_description_label')}
bind:value={$copyCourseModal.description}
placeholder={$t('courses.copy_course.course_description_placeholder')}
@@ -52,12 +53,10 @@
{/if}
<div class="mt-5 flex flex-row-reverse items-center">
<PrimaryButton
className="px-6 py-3"
label={$t('courses.copy_course.create_button')}
type="submit"
isLoading={$copyCourseModal.isSaving}
/>
<Button type="submit" loading={$copyCourseModal.isSaving}>
{$t('courses.copy_course.create_button')}
</Button>
</div>
</form>
</Modal>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,66 @@
<script lang="ts">
import { Separator } from '@cio/ui/base/separator';
import * as Sidebar from '@cio/ui/base/sidebar';
import BellIcon from '@lucide/svelte/icons/bell';
import { Button } from '@cio/ui/base/button';
import * as Popover from '@cio/ui/base/popover';
import Search from '$features/ui/search.svelte';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import * as Empty from '@cio/ui/base/empty';
import { currentOrg } from '$lib/utils/store/org';
import { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
import { course } from '$lib/components/Course/store';
const siteName = $derived($currentOrg.siteName);
$effect(() => {
if (!siteName) return;
setupProgressApi.fetchSetupProgress(siteName);
});
</script>
<header
class="group-has-data-[collapsible=icon]/sidebar-wrapper:h-8 border-border ui:bg-background bg-background sticky top-0 z-50 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear"
>
<div class="flex w-full items-center gap-2 px-4">
<Sidebar.Trigger />
<div class="h-4 w-2">
<Separator orientation="vertical" />
</div>
<p class="max-w-2xs truncate text-sm font-medium">
{$course.title}
</p>
<span class="grow"></span>
<Search />
<Popover.Root>
<Popover.Trigger>
<Button variant="outline" size="icon">
<BellIcon class="custom rounded-full" />
</Button>
</Popover.Trigger>
<Popover.Content>
<Empty.Root class="ui:from-muted/50 ui:to-background ui:h-full ui:bg-gradient-to-b ui:from-30%">
<Empty.Header>
<Empty.Media variant="icon">
<BellIcon />
</Empty.Media>
<Empty.Title>No Notifications</Empty.Title>
<Empty.Description>You're all caught up. New notifications will appear here.</Empty.Description>
</Empty.Header>
<Empty.Content>
<Button variant="outline" size="sm">
<RefreshCcwIcon />
Refresh
</Button>
</Empty.Content>
</Empty.Root>
</Popover.Content>
</Popover.Root>
</div>
</header>
@@ -1,6 +1,8 @@
export { default as CourseCard } from './card.svelte';
export { default as CourseCardList } from './card-list.svelte';
export { default as CourseCardLoader } from './card-loader.svelte';
export { default as CopyCourseModal } from './copy-course-modal.svelte';
export { default as CourseHeader } from './course-header.svelte';
export { default as CreateCourseButton } from './create-course-button.svelte';
export { default as CourseList } from './list.svelte';
export { default as CourseListLoader } from './list-loader.svelte';
@@ -63,7 +63,7 @@
<Table.Cell>{totalLessons}</Table.Cell>
<Table.Cell>{totalStudents}</Table.Cell>
<Table.Cell>
<Badge variant={isPublished ? 'default' : 'secondary'}>
<Badge variant={isPublished ? 'default' : 'outline'}>
{#if isPublished}
{$t('courses.course_card.published')}
{:else}
@@ -3,15 +3,15 @@
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { ComingSoon } from '$lib/features/ui';
import { validateForm } from '../utils/functions';
import { courses, createCourseModal } from '../utils/store';
import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import Modal from '$lib/components/Modal/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { InputField } from '@cio/ui/custom/input-field';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { ROLE } from '@cio/utils/constants';
import * as Field from '@cio/ui/base/field';
import * as RadioGroup from '@cio/ui/base/radio-group';
import { supabase } from '$lib/utils/functions/supabase';
import { t } from '$lib/utils/functions/translations';
import { addDefaultNewsFeed, addGroupMember } from '$lib/utils/services/courses';
@@ -19,7 +19,6 @@
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { COURSE_TYPE, COURSE_VERSION } from '$lib/utils/types';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
let isLoading = $state(false);
let errors = $state({
@@ -30,12 +29,14 @@
const options = [
{
id: 'live-class',
title: $t('new_course_modal.live_class_label'),
subtitle: $t('new_course_modal.live_class_subtitle'),
type: COURSE_TYPE.LIVE_CLASS,
isDisabled: false
},
{
id: 'self-paced',
title: $t('new_course_modal.self_paced_label'),
subtitle: $t('new_course_modal.self_paced_subtitle'),
type: COURSE_TYPE.SELF_PACED,
@@ -131,6 +132,8 @@
isLoading = false;
}
$inspect(type);
let open = $derived(new URLSearchParams(page.url.search).get('create') === 'true');
</script>
@@ -138,97 +141,80 @@
<title>Create a new course</title>
</svelte:head>
<Modal
onClose={() => onClose(page.url.pathname)}
{#snippet course_type_selector()}
<Field.Description>{$t('courses.new_course_modal.type_selector_title')}</Field.Description>
<RadioGroup.Root bind:value={type} class="grid-cols-2">
{#each options as option (option.id)}
<Field.Label for={option.id}>
<Field.Field orientation="horizontal">
<Field.Content>
<Field.Title>{option.title}</Field.Title>
<Field.Description>{option.subtitle}</Field.Description>
</Field.Content>
<RadioGroup.Item value={option.type} id={option.id} aria-label={option.title} />
</Field.Field>
</Field.Label>
{/each}
</RadioGroup.Root>
{/snippet}
<Dialog.Root
bind:open
width="w-4/5 md:w-2/5 md:min-w-[600px]"
containerClass="max-w-2xl mx-auto"
modalHeading={$t('courses.new_course_modal.heading')}
onOpenChange={(isOpen) => {
if (!isOpen) onClose(page.url.pathname);
}}
>
{#if step === 0}
<div>
<h2 class="my-5 text-xl font-medium">
{$t('courses.new_course_modal.type_selector_title')}
</h2>
<Dialog.Content class="mx-auto w-4/5 max-w-2xl md:w-2/5 md:min-w-[600px]">
<Dialog.Header>
<Dialog.Title>{$t('courses.new_course_modal.heading')}</Dialog.Title>
</Dialog.Header>
{#if step === 0}
<div class="my-4 space-y-4">
{@render course_type_selector()}
<div class="my-8 flex flex-col items-center justify-evenly gap-4 md:flex-row">
{#each options as option}
<button
class="w-11/12 rounded-md border-2 p-5 md:h-60 md:w-[261px] dark:bg-neutral-700 {option.type === type
? 'border-primary-400'
: `border-gray-200 dark:border-neutral-600 ${
!option.isDisabled && 'hover:scale-95'
}`} flex flex-col {option.isDisabled && 'cursor-not-allowed opacity-60'} transition-all ease-in-out"
type="button"
onclick={!option.isDisabled ? () => (type = option.type) : undefined}
>
<div class="flex h-[70%] w-full flex-row-reverse">
<CircleCheckIcon size={16} filled={option.type === type} />
</div>
<div>
<p class="flex items-center text-start">
<span class="mr-2 text-sm">{option.title}</span>
{#if option.isDisabled}
<ComingSoon />
{/if}
</p>
<p class="text-start text-xs font-light">{option.subtitle}</p>
</div>
</button>
{/each}
<Dialog.Footer>
<Button onclick={() => (step = 1)} disabled={!type}>
{$t('courses.new_course_modal.next')}
</Button>
</Dialog.Footer>
</div>
{:else}
<form onsubmit={preventDefault(createCourse)}>
<div class="mb-4 flex items-end space-x-2">
<InputField
label={$t('courses.new_course_modal.course_name')}
bind:value={$createCourseModal.title}
placeholder={$t('courses.new_course_modal.course_name_placeholder')}
className="w-full"
isRequired={true}
errorMessage={errors.title}
autoComplete={false}
/>
</div>
<div class="mt-8 flex flex-row-reverse items-center">
<PrimaryButton
className="px-6 py-3"
label={$t('courses.new_course_modal.next')}
onClick={() => (step = 1)}
isDisabled={!type}
/>
</div>
</div>
{:else}
<form onsubmit={preventDefault(createCourse)}>
<div class="mb-4 flex items-end space-x-2">
<TextField
label={$t('courses.new_course_modal.course_name')}
bind:value={$createCourseModal.title}
placeholder={$t('courses.new_course_modal.course_name_placeholder')}
className="w-full "
<TextareaField
label={$t('courses.new_course_modal.short_description')}
bind:value={$createCourseModal.description}
rows={4}
placeholder={$t('courses.new_course_modal.short_description_placeholder')}
className="mb-4"
isRequired={true}
errorMessage={errors.title}
autoComplete={false}
errorMessage={errors.description}
isAIEnabled={true}
initAIPrompt="Write a 30 word description for a course titled: {$createCourseModal.title}"
/>
</div>
<TextArea
label={$t('courses.new_course_modal.short_description')}
bind:value={$createCourseModal.description}
rows={4}
placeholder={$t('courses.new_course_modal.short_description_placeholder')}
className="mb-4"
isRequired={true}
errorMessage={errors.description}
isAIEnabled={true}
initAIPrompt="Write a 30 word description for a course titled: {$createCourseModal.title}"
/>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
label={$t('courses.new_course_modal.back')}
variant={VARIANTS.OUTLINED}
onClick={() => (step = 0)}
/>
<PrimaryButton
className="px-6 py-3"
label={$t('courses.new_course_modal.button')}
type="submit"
isDisabled={isLoading}
{isLoading}
/>
</div>
</form>
{/if}
</Modal>
<Dialog.Footer>
<Button variant="outline" onclick={() => (step = 0)}>
{$t('courses.new_course_modal.back')}
</Button>
<Button type="submit" disabled={isLoading} loading={isLoading}>
{$t('courses.new_course_modal.button')}
</Button>
</Dialog.Footer>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,48 @@
<script lang="ts">
import { flip } from 'svelte/animate';
import { settings } from '../utils/settings-store';
import { dndzone } from 'svelte-dnd-action';
import GripVerticalIcon from '@lucide/svelte/icons/grip-vertical';
import { t } from '$lib/utils/functions/translations';
interface Props {
onchange: () => void;
}
let { onchange }: Props = $props();
const flipDurationMs = 300;
function handleDndConsider(e) {
$settings.tabs = e.detail.items;
}
function handleDndFinalize(e) {
$settings.tabs = e.detail.items;
onchange();
}
</script>
<section
use:dndzone={{
items: $settings.tabs,
flipDurationMs,
dropTargetStyle: {
'border-style': 'dashed'
}
}}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
class="ui:bg-muted ui:text-muted-foreground inline-flex h-12 w-fit items-center justify-start gap-2 rounded-lg px-2 py-1"
>
{#each $settings.tabs as item (item.id)}
<div
animate:flip={{ duration: flipDurationMs }}
class="ui:bg-background ui:dark:text-foreground ui:border-input flex h-[calc(100%-3px)] items-center justify-start gap-2 rounded-md px-3 py-1 text-sm font-medium transition-[color,box-shadow]"
>
<GripVerticalIcon size={16} class="custom text-primary" />
{$t(item.name)}
</div>
{/each}
</section>
@@ -0,0 +1,13 @@
export const NAV_IDS = {
NEWS_FEED: 'NEWS_FEED',
LESSONS: 'LESSONS',
ATTENDANCE: 'ATTENDANCE',
SUBMISSIONS: 'SUBMISSIONS',
MARKS: 'MARKS',
CERTIFICATES: 'CERTIFICATES',
LANDING_PAGE: 'LANDING_PAGE',
PEOPLE: 'PEOPLE',
ANALYTICS: 'ANALYTICS',
SETTINGS: 'SETTINGS',
SECTION: 'SECTION'
};
@@ -0,0 +1,389 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import Plus from '@lucide/svelte/icons/plus';
import * as Sidebar from '@cio/ui/base/sidebar';
import LockIcon from '@lucide/svelte/icons/lock';
import * as Collapsible from '@cio/ui/base/collapsible';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { Button } from '@cio/ui/base/button';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import UsersIcon from '@lucide/svelte/icons/users';
import AwardIcon from '@lucide/svelte/icons/award';
import PanelTop from '@lucide/svelte/icons/panel-top';
import SettingsIcon from '@lucide/svelte/icons/settings';
import MegaphoneIcon from '@lucide/svelte/icons/megaphone';
import ChartLineIcon from '@lucide/svelte/icons/chart-line';
import BookCheckIcon from '@lucide/svelte/icons/book-check';
import ScrollTextIcon from '@lucide/svelte/icons/scroll-text';
import SquareLibrary from '@lucide/svelte/icons/square-library';
import BookOpenCheck from '@lucide/svelte/icons/book-open-check';
import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents';
import { NAV_IDS } from './constants';
import { profile } from '$lib/utils/store/user';
import { course } from '$lib/components/Course/store';
import { t } from '$lib/utils/functions/translations';
import { handleAddLessonWidget } from '$lib/components/Course/components/Lesson/store';
import { getIsLessonComplete } from '$lib/components/Course/components/Lesson/functions';
import { currentOrg, isFreePlan, currentOrgPath } from '$lib/utils/store/org';
import { globalStore } from '$lib/utils/store/app';
import { COURSE_TYPE, COURSE_VERSION } from '$lib/utils/types';
import { lessons, lessonSections } from '$lib/components/Course/components/Lesson/store/lessons';
import { getNavItemRoute, getLessonsRoute, getLectureNo } from '$lib/components/Course/function';
import { Chip } from '@cio/ui/custom/chip';
import { CircleCheckIcon } from '$features/ui/icons';
interface Props {
path: string;
isStudent?: boolean;
}
let { path, isStudent = false }: Props = $props();
const coursesListPath = $derived($globalStore.isOrgSite ? '/lms/mylearning' : `${$currentOrgPath}/courses`);
const navItems = $derived(
[
{
id: NAV_IDS.NEWS_FEED,
title: $t('course.navItems.nav_news_feed'),
url: getNavItemRoute($course.id),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id),
show() {
return isStudent ? $currentOrg.customization?.['course']?.['newsfeed'] : true;
},
icon: getNavIcon(NAV_IDS.NEWS_FEED)
},
{
id: NAV_IDS.LESSONS,
title: $t('course.navItems.nav_content'),
url: getLessonsRoute($course.id),
isActive:
(path || page.url.pathname).includes('/lessons') ||
(path || page.url.pathname) === getLessonsRoute($course.id),
isLesson: true,
icon: getNavIcon(NAV_IDS.LESSONS),
items:
$course.version === COURSE_VERSION.V1
? ($lessons || []).map((lesson, index) => ({
title: lesson.title,
url: getLessonsRoute($course.id, lesson.id),
lesson,
index,
isV1: true
}))
: $lessonSections.flatMap((section) =>
section.lessons.map((lesson) => ({
title: lesson.title,
url: getLessonsRoute($course.id, lesson.id),
lesson,
sectionTitle: section.title,
isV1: false
}))
)
},
{
id: NAV_IDS.ANALYTICS,
title: $t('course.navItems.nav_analytics'),
url: getNavItemRoute($course.id, 'analytics'),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'analytics'),
show() {
return !isStudent;
},
icon: getNavIcon(NAV_IDS.ANALYTICS)
},
{
id: NAV_IDS.ATTENDANCE,
title: $t('course.navItems.nav_attendance'),
url: getNavItemRoute($course.id, 'attendance'),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'attendance'),
show() {
if ($course.type !== COURSE_TYPE.LIVE_CLASS) return false;
return true;
},
icon: getNavIcon(NAV_IDS.ATTENDANCE)
},
{
id: NAV_IDS.SUBMISSIONS,
title: $t('course.navItems.nav_submissions'),
url: getNavItemRoute($course.id, 'submissions'),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'submissions'),
show() {
if (isStudent) return false;
return true;
},
icon: getNavIcon(NAV_IDS.SUBMISSIONS)
},
{
id: NAV_IDS.MARKS,
title: $t('course.navItems.nav_marks'),
url: getNavItemRoute($course.id, 'marks'),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'marks'),
show() {
if ($course.type == COURSE_TYPE.LIVE_CLASS) {
return isStudent ? ($currentOrg.customization?.['course']?.['grading'] ?? false) : true;
}
return false;
},
icon: getNavIcon(NAV_IDS.MARKS)
},
{
id: NAV_IDS.CERTIFICATES,
title: $t('course.navItems.nav_certificates'),
url: getNavItemRoute($course.id, 'certificates'),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'certificates'),
show() {
if (isStudent && $isFreePlan) {
return false;
}
return true;
},
icon: getNavIcon(NAV_IDS.CERTIFICATES)
},
{
id: NAV_IDS.LANDING_PAGE,
title: $t('course.navItems.nav_landing_page'),
url: getNavItemRoute($course.id, 'landingpage'),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'landingpage'),
show() {
return !isStudent;
},
icon: getNavIcon(NAV_IDS.LANDING_PAGE)
},
{
id: NAV_IDS.PEOPLE,
title: $t('course.navItems.nav_people'),
url: getNavItemRoute($course.id, 'people'),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'people'),
show() {
return !isStudent;
},
icon: getNavIcon(NAV_IDS.PEOPLE)
},
{
id: NAV_IDS.SETTINGS,
title: $t('course.navItems.nav_settings'),
url: getNavItemRoute($course.id, 'settings'),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'settings'),
show() {
return !isStudent;
},
icon: getNavIcon(NAV_IDS.SETTINGS)
}
].filter((item) => !item.show || item.show())
);
function addLesson(isSection: boolean, id: string) {
goto('/courses/' + $course.id + '/lessons');
$handleAddLessonWidget.open = true;
if ($course.version === COURSE_VERSION.V2) {
$handleAddLessonWidget.isSection = isSection ? false : true;
if (isSection) {
$handleAddLessonWidget.id = id;
}
}
}
function getNavIcon(id: string) {
if (!id) return null;
if (id === NAV_IDS.SECTION) {
return TableOfContentsIcon;
} else if (id === NAV_IDS.NEWS_FEED) {
return MegaphoneIcon;
} else if (id === NAV_IDS.LESSONS) {
return ScrollTextIcon;
} else if (id === NAV_IDS.ATTENDANCE) {
return BookOpenCheck;
} else if (id === NAV_IDS.SUBMISSIONS) {
return SquareLibrary;
} else if (id === NAV_IDS.MARKS) {
return BookCheckIcon;
} else if (id === NAV_IDS.PEOPLE) {
return UsersIcon;
} else if (id === NAV_IDS.ANALYTICS) {
return ChartLineIcon;
} else if (id === NAV_IDS.LANDING_PAGE) {
return PanelTop;
} else if (id === NAV_IDS.CERTIFICATES) {
return AwardIcon;
} else if (id === NAV_IDS.SETTINGS) {
return SettingsIcon;
}
return null;
}
function toggleSidebarOnMobile() {}
</script>
<Sidebar.Group class="pt-0!">
<Button variant="link" class="justify-start! py-2! px-2! h-fit!" href={coursesListPath}>
<ArrowLeftIcon class="custom" />
<span class="text-xs">Courses</span>
</Button>
<Sidebar.Menu>
{#each navItems as item (item.id)}
<Collapsible.Root open={item.isActive} class="group/collapsible">
{#snippet child({ props })}
<Sidebar.MenuItem {...props}>
{#if item.isLesson && item.items}
<Collapsible.Trigger>
{#snippet child({ props })}
<a href={item.url}>
<Sidebar.MenuButton
{...props}
tooltipContent={item.title}
class="flex w-full items-center gap-4 py-2 pl-1.5"
>
{@const Icon = item.icon}
<Icon size={16} class="nav-icon group-hover:animate-{item.id}" />
{item.title}
<div class="ml-auto flex items-center gap-1">
<Plus
size={20}
class="rounded-full p-1 hover:bg-gray-200"
onclick={() => addLesson(false, item.id)}
/>
<ChevronRightIcon
size={20}
class="rounded-full p-1 transition-transform duration-200 hover:bg-gray-200 group-data-[state=open]/collapsible:rotate-90"
/>
</div>
</Sidebar.MenuButton>
</a>
{/snippet}
</Collapsible.Trigger>
<Collapsible.Content>
<Sidebar.MenuSub>
{#if $course.version === COURSE_VERSION.V1}
{#each item.items as lessonItem}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton isActive={(path || page.url.pathname).includes(lessonItem.lesson.id)}>
{#snippet child({ props })}
<a
href={isStudent && !lessonItem.lesson.is_unlocked ? page.url.pathname : lessonItem.url}
onclick={toggleSidebarOnMobile}
aria-disabled={!lessonItem.lesson.is_unlocked}
title={lessonItem.lesson.title}
class="flex w-full items-center gap-2 {isStudent && !lessonItem.lesson.is_unlocked
? 'cursor-not-allowed opacity-50'
: ''}"
{...props}
>
{#if lessonItem.isV1 && 'index' in lessonItem}
<Chip value={getLectureNo(lessonItem.index + 1)} />
{/if}
<span class="flex-1 truncate">{lessonItem.lesson.title}</span>
{#if !lessonItem.lesson.is_unlocked}
<LockIcon size={16} class="shrink-0" />
{:else if getIsLessonComplete(lessonItem.lesson.lesson_completion, $profile.id)}
<span class="shrink-0">
<CircleCheckIcon size={16} filled />
</span>
{/if}
</a>
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
{:else}
{#each $lessonSections as section}
<Collapsible.Root open={true} class="group/section">
{#snippet child({ props })}
<Sidebar.MenuSubItem {...props}>
<Collapsible.Trigger>
{#snippet child({ props })}
<Sidebar.MenuSubButton {...props} class="flex w-full items-center gap-2 font-medium">
{@const Icon = getNavIcon(NAV_IDS.SECTION)}
<Icon size={14} />
<span class="flex-1 truncate">{section.title}</span>
<div class="ml-auto flex items-center gap-1">
<Plus
size={20}
class="rounded-full p-1 hover:bg-gray-200"
onclick={() => addLesson(true, section.id)}
/>
<ChevronRightIcon
size={20}
class="rounded-full p-1 transition-transform duration-200 hover:bg-gray-200 group-data-[state=open]/collapsible:rotate-90"
/>
</div>
</Sidebar.MenuSubButton>
{/snippet}
</Collapsible.Trigger>
<Collapsible.Content>
<Sidebar.MenuSub class="ml-2">
{#each section.lessons as lesson}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton isActive={(path || page.url.pathname).includes(lesson.id)}>
{#snippet child({ props })}
<a
href={isStudent && !lesson.is_unlocked
? page.url.pathname
: getLessonsRoute($course.id, lesson.id)}
onclick={toggleSidebarOnMobile}
aria-disabled={!lesson.is_unlocked}
title={lesson.title}
class="flex w-full items-center gap-2 {isStudent && !lesson.is_unlocked
? 'cursor-not-allowed opacity-50'
: ''}"
{...props}
>
<span class="flex-1 truncate">{lesson.title}</span>
{#if !lesson.is_unlocked}
<LockIcon size={16} class="shrink-0" />
{:else if getIsLessonComplete(lesson.lesson_completion, $profile.id)}
<span class="shrink-0">
<CircleCheckIcon size={16} filled />
</span>
{/if}
</a>
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
</Sidebar.MenuSubItem>
{/snippet}
</Collapsible.Root>
{/each}
{/if}
</Sidebar.MenuSub>
</Collapsible.Content>
{:else}
<a href={item.url} onclick={toggleSidebarOnMobile}>
<Sidebar.MenuButton
tooltipContent={item.title}
class="flex w-full cursor-pointer items-center gap-4 px-1.5 py-2 {item.isActive
? 'ui:bg-accent ui:text-accent-foreground'
: ''}"
>
{@const Icon = item.icon}
<Icon size={16} class="nav-icon group-hover:animate-{item.id}" />
{item.title}
</Sidebar.MenuButton>
</a>
{/if}
</Sidebar.MenuItem>
{/snippet}
</Collapsible.Root>
{/each}
</Sidebar.Menu>
</Sidebar.Group>
<style>
a {
text-decoration: none;
}
</style>
@@ -0,0 +1,48 @@
<script lang="ts">
import * as Sidebar from '@cio/ui/base/sidebar';
import { profile } from '$lib/utils/store/user';
import { orgs } from '$lib/utils/store/org';
import { globalStore } from '$lib/utils/store/app';
import { isFreePlan } from '$lib/utils/store/org';
import OrgLogo from '$features/ui/sidebar/org-sidebar/org-logo.svelte';
import Navigation from './course-sidebar-navigation.svelte';
import SidebarSkeleton from '$features/ui/sidebar/sidebar-skeleton.svelte';
import UpgradePoweredBy from '$features/ui/upgrade-powered-by.svelte';
import { useSidebar } from '@cio/ui/base/sidebar';
const isOrgLoaded = $derived($orgs.length > 0 && $profile.id);
interface Props {
path: string;
}
let { path }: Props = $props();
const sidebar = useSidebar();
</script>
{#if !isOrgLoaded}
<SidebarSkeleton />
{:else}
<Sidebar.Root collapsible="icon">
<Sidebar.Header>
<OrgLogo />
</Sidebar.Header>
<Sidebar.Content>
<Navigation {path} isStudent={$globalStore.isStudent} />
</Sidebar.Content>
<Sidebar.Rail />
{#if $isFreePlan}
<Sidebar.Footer>
<UpgradePoweredBy
class="static [&_span]:font-normal [&_span]:shadow-none"
showOnlyLogo={!sidebar.open || sidebar.isMobile}
/>
</Sidebar.Footer>
{/if}
</Sidebar.Root>
{/if}
@@ -0,0 +1 @@
export { default as CourseSidebar } from './course-sidebar.svelte';
@@ -0,0 +1,136 @@
<script lang="ts">
import { onMount } from 'svelte';
import ChartBarIcon from '@lucide/svelte/icons/chart-bar';
import { t } from '$lib/utils/functions/translations';
import { getAccessToken } from '$lib/utils/functions/supabase';
import { snackbar } from '$features/ui/snackbar/store';
import type { CourseAnalytics } from '$lib/utils/types/analytics';
import { fade } from 'svelte/transition';
import StudentTable from '$lib/components/Course/components/Analytics/StudentTable.svelte';
import AnalyticsGraph from '$lib/components/Course/components/Analytics/AnalyticsGraph.svelte';
import AnalyticsSkeleton from '$lib/components/Course/components/Analytics/AnalyticsSkeleton.svelte';
import AnalyticsCard from '$lib/components/Course/components/Analytics/AnalyticsCard.svelte';
import StudentsIcon from '$lib/components/Course/components/Analytics/icons/StudentsIcon.svelte';
import LessonsIcon from '$lib/components/Course/components/Analytics/icons/LessonsIcon.svelte';
import ProgressIcon from '$lib/components/Course/components/Analytics/icons/ProgressIcon.svelte';
import ExercisesIcon from '$lib/components/Course/components/Analytics/icons/ExercisesIcon.svelte';
import EmptyState from '$lib/components/Course/components/Analytics/EmptyState.svelte';
interface Props {
courseId: string;
}
let { courseId }: Props = $props();
let courseAnalytics: CourseAnalytics | null = $state(null);
let isLoading = $state(true);
async function fetchCourseAnalytics() {
try {
isLoading = true;
const accessToken = await getAccessToken();
const response = await fetch('/api/analytics/course', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
},
body: JSON.stringify({ courseId })
});
if (response.ok) {
courseAnalytics = (await response.json()) as CourseAnalytics;
} else {
console.error('Failed to fetch course analytics:', response);
snackbar.error('Failed to load analytics data');
courseAnalytics = null;
}
} catch (error) {
console.error('Failed to fetch course analytics:', error);
snackbar.error('Failed to load analytics data');
courseAnalytics = null;
} finally {
isLoading = false;
}
}
onMount(() => {
fetchCourseAnalytics();
});
</script>
{#if isLoading}
<AnalyticsSkeleton />
{:else if courseAnalytics}
<div class="min-h-screen" in:fade>
<!-- Main Content Area -->
<div class="px-4 py-8">
<!-- Course Overview Cards -->
<div class="mb-12">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<!-- Students Card -->
<AnalyticsCard
title={$t('analytics.students')}
value={courseAnalytics?.totalStudents || 0}
subtitle="{$t('analytics.tutors')}: {courseAnalytics?.totalTutors || 0}"
icon={StudentsIcon}
iconColor="blue"
/>
<!-- Lessons Card -->
<AnalyticsCard
title={$t('analytics.lessons')}
value={courseAnalytics?.totalLessons || 0}
subtitle="{$t('analytics.exercises')}: {courseAnalytics?.totalExercises || 0}"
icon={LessonsIcon}
iconColor="green"
/>
<!-- Progress Card -->
<AnalyticsCard
title={$t('analytics.progress')}
value="{courseAnalytics?.lessonCompletionRate || 0}%"
subtitle="{$t('analytics.avg_grade')}: {courseAnalytics?.averageGrade || 0}%"
icon={ProgressIcon}
iconColor="purple"
/>
<!-- Exercise Completion Card -->
<AnalyticsCard
title={$t('analytics.exercises')}
value="{courseAnalytics?.exerciseCompletionRate || 0}%"
subtitle={$t('analytics.completion_rate')}
icon={ExercisesIcon}
iconColor="orange"
/>
</div>
</div>
<!-- Charts Section -->
<div class="mb-12">
<AnalyticsGraph {courseAnalytics} />
</div>
<!-- Students Overview Table -->
<div class="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-neutral-800">
<div class="border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{$t('analytics.student_progress_overview')}
</h3>
</div>
<div class="p-6">
<StudentTable students={courseAnalytics?.students || []} />
</div>
</div>
</div>
</div>
{:else}
<EmptyState
title={$t('analytics.no_analytics_data')}
description={$t('analytics.no_analytics_description')}
icon={ChartBarIcon}
className="h-screen"
/>
{/if}
@@ -0,0 +1,236 @@
<script lang="ts">
import { Input } from '@cio/ui/base/input';
import * as Table from '@cio/ui/base/table';
import Search from '@lucide/svelte/icons/search';
import { Checkbox } from '@cio/ui/base/checkbox';
import * as Pagination from '@cio/ui/base/pagination';
import { profile } from '$lib/utils/store/user';
import { ROLE } from '@cio/utils/constants';
import { globalStore } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import { attendance } from '$lib/utils/store/attendance';
import { snackbar } from '$features/ui/snackbar/store';
import { course, group } from '$lib/components/Course/store';
import { takeAttendance } from '$lib/utils/services/attendance';
import { getLectureNo } from '$lib/components/Course/function.js';
import type { GroupPerson, Lesson } from '$lib/utils/types/index';
import { lessons } from '$lib/components/Course/components/Lesson/store/lessons';
import { Empty } from '@cio/ui/custom/empty';
import UserXIcon from '@lucide/svelte/icons/user-x';
interface Props {
courseId: string;
}
let { courseId }: Props = $props();
let hasFetched = $state(false);
interface CourseData {
attendance: {
student_id: string;
lesson_id: string;
is_present: boolean;
id: number;
}[];
}
const students: GroupPerson[] = $derived(
$globalStore.isStudent
? $group.people.filter((person) => !!person.profile && person.profile.id === $profile.id)
: $group.people.filter((person) => !!person.profile && person.role_id === ROLE.STUDENT)
);
let searchValue = $state('');
let currentPage = $state(1);
let pageSize = $state(10);
const filteredStudents = $derived(searchStudents(searchValue, students));
const totalStudents = $derived(filteredStudents.length);
const paginatedStudents = $derived(filteredStudents.slice((currentPage - 1) * pageSize, currentPage * pageSize));
function setAttendance(courseData: CourseData) {
for (const attendanceItem of courseData.attendance) {
const { student_id, lesson_id, is_present, id } = attendanceItem;
if (!$attendance[student_id]) {
$attendance[student_id] = {
[lesson_id]: {
id,
is_present
}
};
} else {
$attendance[student_id] = {
...$attendance[student_id],
[lesson_id]: {
id,
is_present
}
};
}
}
}
function handleAttendanceChange(e: any, student: GroupPerson, lesson: Lesson) {
if ($globalStore.isStudent) return;
const attendanceItem = $attendance[student.id]
? $attendance[student.id][lesson.id] || { id: undefined }
: { id: undefined };
const _data = {
id: attendanceItem.id,
student_id: student.id,
is_present: e.target.checked,
lesson_id: lesson.id,
course_id: courseId
};
takeAttendance(_data).then((res) => {
if (res.error) {
console.error(`res.error`, res.error);
snackbar.error('snackbar.attendance.error.reload');
} else {
const { id, is_present } = res.data[0];
if ($attendance[student.id]) {
$attendance[student.id][lesson.id] = {
id,
is_present
};
} else {
$attendance[student.id] = {
[lesson.id]: {
id,
is_present
}
};
}
}
});
}
// function for the searchbar
function searchStudents(query: string, _students: GroupPerson[]) {
const lowercaseQuery = query.toLowerCase();
return _students.filter((student) => student.profile?.fullname?.toLowerCase()?.includes(lowercaseQuery));
}
async function firstRender(courseId?: string) {
if (!courseId || hasFetched) return;
hasFetched = true;
if (!Object.keys($attendance).length) {
setAttendance($course);
}
return;
}
$effect(() => {
firstRender($course.id);
});
</script>
<section class="mx-2 my-5 flex items-center lg:mx-9">
<div class="flex w-full flex-col items-start justify-between gap-2 lg:flex-row lg:items-center">
<div class="flex gap-5">
<p class="flex items-center gap-2">
<Checkbox disabled />
<span>{$t('course.navItem.attendance.present')}</span>
</p>
<p class="flex items-center gap-2">
<Checkbox disabled />
<span>{$t('course.navItem.attendance.absent')}</span>
</p>
</div>
<div class="relative w-full">
<Input type="text" placeholder={$t('course.navItem.attendance.search_students')} bind:value={searchValue} />
<Search class="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2" />
</div>
</div>
</section>
<section class="mx-2 my-5 lg:mx-9">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-1/4">{$t('course.navItem.attendance.student')}</Table.Head>
{#each $lessons as _lesson, index}
<Table.Head class="text-center"
>{$t('course.navItem.attendance.lesson')} 0{getLectureNo(index + 1)}</Table.Head
>
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#each paginatedStudents as student}
<Table.Row>
<Table.Cell class="font-semibold">
{student.profile.fullname}
</Table.Cell>
{#each $lessons as lesson}
<Table.Cell class="text-center">
<div class="flex justify-center">
<Checkbox
disabled={$globalStore.isStudent}
checked={$attendance[student.id]
? $attendance[student.id][lesson.id]
? $attendance[student.id][lesson.id].is_present
: false
: false}
onCheckedChange={(checked) => {
const e = { target: { checked } };
handleAttendanceChange(e, student, lesson);
}}
/>
</div>
</Table.Cell>
{/each}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{#if students.length === 0}
<Empty title={$t('course.navItem.attendance.no_student')} icon={UserXIcon} variant="page" />
{/if}
{#if totalStudents > 0}
<div class="mt-4">
<Pagination.Root
count={totalStudents}
perPage={pageSize}
page={currentPage}
onPageChange={(page) => (currentPage = page)}
>
{#snippet children({ pages, currentPage: activePage })}
<Pagination.Content>
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type === 'ellipsis'}
<Pagination.Item>
<Pagination.Ellipsis />
</Pagination.Item>
{:else}
<Pagination.Item>
<Pagination.Link {page} isActive={activePage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
{/if}
</section>
@@ -0,0 +1,15 @@
<script lang="ts">
import IssueCertificateModal from '$lib/components/Course/components/Ceritficate/IssueCertificateModal.svelte';
import { RoleBasedSecurity } from '$features/ui';
import Design from '$lib/components/Course/components/Ceritficate/Design.svelte';
import StudentCertificate from '$lib/components/Course/components/Ceritficate/StudentCertificate/Index.svelte';
</script>
<IssueCertificateModal />
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Design />
</RoleBasedSecurity>
<RoleBasedSecurity onlyStudent allowedRoles={[3]}>
<StudentCertificate />
</RoleBasedSecurity>
@@ -7,22 +7,21 @@
import * as Select from '@cio/ui/base/select';
import GridIcon from '@lucide/svelte/icons/grid-2x2';
import ListIcon from '@lucide/svelte/icons/list';
import { IconButton } from '$lib/components/IconButton';
import { IconButton } from '@cio/ui/custom/icon-button';
import {
CourseCard,
CourseCardList,
CourseCardLoader,
CreateCourseButton,
CourseList,
CopyCourseModal,
NewCourseModal
} from '$lib/features/course/components';
} from '$features/course/components';
import DeleteModal from '$lib/components/Modal/DeleteModal.svelte';
import { DeleteModal } from '$features/ui';
import type { Course } from '$lib/utils/types';
import { globalStore } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import { deleteCourse } from '$lib/utils/services/courses';
import {
deleteCourseModal,
@@ -34,9 +33,10 @@
interface Props {
courses?: Course[];
emptyTitle?: any;
emptyDescription?: any;
emptyTitle?: string;
emptyDescription?: string;
isExplore?: boolean;
isLMS?: boolean;
searchValue?: string;
selectedId?: string;
}
@@ -46,6 +46,7 @@
emptyTitle = $t('courses.course_card.empty_title'),
emptyDescription = $t('courses.course_card.empty_description'),
isExplore = false,
isLMS = false,
searchValue = $bindable(''),
selectedId = $bindable('0')
}: Props = $props();
@@ -67,14 +68,6 @@
}
};
function calcProgressRate(progressRate?: number, totalLessons?: number): number {
if (!progressRate || !totalLessons) {
return 0;
}
return Math.round((progressRate / totalLessons) * 100);
}
async function handleDeleteCourse() {
if (!$deleteCourseModal.id) return;
@@ -118,17 +111,17 @@
<p>{selectedId ? selectedLabel : filterOptions[0].label}</p>
</Select.Trigger>
<Select.Content>
{#each filterOptions as option}
{#each filterOptions as option (option.label)}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if $courseMetaDeta.view === 'list'}
<IconButton onClick={() => setViewPreference('grid')}>
<IconButton onclick={() => setViewPreference('grid')}>
<GridIcon size={16} />
</IconButton>
{:else}
<IconButton onClick={() => setViewPreference('list')}>
<IconButton onclick={() => setViewPreference('list')}>
<ListIcon size={16} />
</IconButton>
{/if}
@@ -143,7 +136,9 @@
</section>
{:else if !courses.length}
<Empty title={emptyTitle} description={emptyDescription} icon={LibraryBigIcon} variant="page">
<CreateCourseButton isResponsive />
{#if !isLMS}
<CreateCourseButton isResponsive />
{/if}
</Empty>
{:else if $courseMetaDeta.view === 'list'}
<div class="w-full overflow-hidden rounded-md border">
@@ -160,7 +155,7 @@
</Table.Row>
</Table.Header>
<Table.Body>
{#each courses as courseData}
{#each courses as courseData (courseData.id)}
<CourseList
id={courseData.id}
title={courseData.title}
@@ -175,26 +170,6 @@
</Table.Root>
</div>
{:else}
<section class={`relative ${$courseMetaDeta.isLoading || courses ? 'cards-container' : ''} `}>
{#each courses as courseData}
{#key courseData.id}
<CourseCard
id={courseData.id}
slug={courseData.slug}
bannerImage={courseData.logo || '/images/classroomio-course-img-template.jpg'}
title={courseData.title}
description={courseData.description}
isPublished={courseData.is_published}
type={courseData.type}
currency={courseData.currency}
totalLessons={courseData.total_lessons}
totalStudents={courseData.total_students}
isLMS={$globalStore.isOrgSite}
{isExplore}
progressRate={calcProgressRate(courseData.progress_rate, courseData.total_lessons)}
/>
{/key}
{/each}
</section>
<CourseCardList {courses} {isExplore} {isLMS} />
{/if}
</div>
@@ -1 +1,10 @@
export { default as CoursesPage } from './courses.svelte';
export { default as CourseSettingsPage } from './settings.svelte';
export { default as NewsFeedPage } from './newsfeed.svelte';
export { default as PeoplePage } from './people.svelte';
export { default as SubmissionsPage } from './submissions.svelte';
export { default as LessonsPage } from './lessons.svelte';
export { default as AnalyticsPage } from './analytics.svelte';
export { default as AttendancePage } from './attendance.svelte';
export { default as MarksPage } from './marks.svelte';
export { default as CertificatesPage } from './certificates.svelte';
@@ -0,0 +1,112 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import ActivateSectionsModal from '$lib/components/Course/components/Lesson/ActivateSectionsModal.svelte';
import { Empty } from '@cio/ui/custom/empty';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import DeleteLessonConfirmation from '$lib/components/Course/components/Lesson/DeleteLessonConfirmation.svelte';
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';
import { handleAddLessonWidget } from '$lib/components/Course/components/Lesson/store';
import { handleDelete, lessons, lessonSections } from '$lib/components/Course/components/Lesson/store/lessons';
import { course } from '$lib/components/Course/store';
import { RoleBasedSecurity } from '$features/ui';
import { t } from '$lib/utils/functions/translations';
import { profile } from '$lib/utils/store/user';
import type { Lesson } from '$lib/utils/types';
import { COURSE_VERSION } from '$lib/utils/types';
interface Props {
courseId: string;
}
let { courseId }: Props = $props();
const query = new URLSearchParams(page.url.search);
const lessonsLength = $derived($course.version === COURSE_VERSION.V1 ? $lessons.length : $lessonSections.length);
let lessonEditing: string | undefined;
let lessonToDelete: Lesson | undefined = $state();
let openDeleteModal: boolean = $state(false);
let isFetching: boolean = $state(false);
let reorder = $state(false);
let activateSections = $state(false);
function hasUserCompletedLesson(completion) {
return completion?.find((c) => c.profile_id === $profile.id);
}
const getLessons = () => {
if ($course.version === COURSE_VERSION.V1) {
return $lessons;
} else {
const _lessons: Lesson[] = [];
$lessonSections.forEach((section) => {
_lessons.push(...section.lessons);
});
return _lessons;
}
};
function findFirstIncompleteLesson() {
return getLessons().find(
(lesson) => !hasUserCompletedLesson(lesson.lesson_completion) && lesson.is_unlocked === true
);
}
function onNextQuery(lessons) {
if (!isFetching && lessons.length > 0) {
const incompleteLesson = findFirstIncompleteLesson();
if (incompleteLesson) {
goto(`/courses/${courseId}/lessons/${incompleteLesson.id}`);
} else {
goto(`/courses/${courseId}/lessons`);
}
}
}
let shouldGoToNextLesson = $derived(query.get('next') === 'true');
$effect(() => {
!isFetching && shouldGoToNextLesson && onNextQuery($lessons);
});
</script>
<NewLessonModal />
<ActivateSectionsModal bind:open={activateSections} />
<DeleteLessonConfirmation bind:openDeleteModal deleteLesson={() => handleDelete(lessonToDelete?.id)} />
{#if shouldGoToNextLesson}
<Empty
title={$t('course.navItem.lessons.no_lesson')}
description={$t('course.navItem.lessons.share_your_knowledge')}
icon={BookOpenIcon}
variant="page"
/>
{:else if lessonsLength > 0}
{#if reorder}
<p class="text-center text-xs italic text-gray-400 dark:text-white">
{$t('course.navItem.lessons.drag')}
</p>
{/if}
{#if $course.version === COURSE_VERSION.V1}
<LessonList {reorder} {lessonEditing} bind:lessonToDelete bind:openDeleteModal />
{:else if $course.version === COURSE_VERSION.V2}
<LessonSectionList {reorder} {lessonEditing} />
{/if}
{:else}
<Empty
title={$t('course.navItem.lessons.body_header')}
description={$t('course.navItem.lessons.body_content')}
icon={BookOpenIcon}
variant="page"
/>
{/if}
@@ -0,0 +1,170 @@
<script lang="ts">
import { Empty } from '@cio/ui/custom/empty';
import UserXIcon from '@lucide/svelte/icons/user-x';
import { course } from '$lib/components/Course/store';
import { t } from '$lib/utils/functions/translations';
import { fetchMarks } from '$lib/utils/services/marks';
import { snackbar } from '$features/ui/snackbar/store';
import { getLectureNo } from '$lib/components/Course/function.js';
import { fetchExercisesByMarks } from '$lib/utils/services/courses';
import { lessons } from '$lib/components/Course/components/Lesson/store/lessons';
import type { GroupPerson } from '$lib/utils/types';
let borderBottomGrey = 'border-r-0 border-t-0 border-b border-l-0 border-gray-300';
let borderleftGrey = 'border-r-0 border-t-0 border-b-0 border-l border-gray-300';
let students: GroupPerson[] = $state([]);
let lessonMapping: Record<string, Record<string, { title: string; points: number }>> = $state({}); // { lessonId: { exerciseId: exerciseTitle, ... }, ... }
let studentMarksByExerciseId: Record<string, Record<string, string>> = $state({}); // { groupMemberId: { exerciseId: `total_gotten/points`, ... }, ... }
let hasFetched = $state(false);
function calculateStudentTotal(studentExerciseData) {
if (!studentExerciseData) return 0;
return Object.values(studentExerciseData).reduce((total: number, point) => (total += parseInt(point as string)), 0);
}
async function firstRender(courseId?: string) {
if (!courseId || hasFetched) return;
hasFetched = true;
const { data: marks, error } = await fetchMarks(courseId);
if (error) {
console.error('Error fetching marks', error);
snackbar.error('snackbar.marks.error');
return;
}
if (!marks || !Array.isArray(marks)) return;
marks.forEach((mark) => {
const { groupmember_id, exercise_id, total_points_gotten } = mark;
if (studentMarksByExerciseId[groupmember_id]) {
studentMarksByExerciseId[groupmember_id] = {
...studentMarksByExerciseId[groupmember_id],
[exercise_id]: total_points_gotten
};
} else {
studentMarksByExerciseId[groupmember_id] = {
[exercise_id]: total_points_gotten
};
}
});
const { data: exercises } = await fetchExercisesByMarks(courseId);
// Map exercises to { lessonId: { exerciseId: exerciseTitle, ... }, ... }
exercises.forEach((exercise) => {
const { lesson_id, exercise_id, exercise_title, points } = exercise;
if (lessonMapping[lesson_id]) {
lessonMapping[lesson_id] = {
...lessonMapping[lesson_id],
[exercise_id]: { title: exercise_title, points }
};
} else {
lessonMapping[lesson_id] = {
[exercise_id]: { title: exercise_title, points }
};
}
});
}
$effect(() => {
firstRender($course.id);
});
</script>
<div id="tableContainer" class="table w-full rounded-md border border-gray-300">
<div class="flex items-center {borderBottomGrey}">
<div class="box flex items-center p-3">
<p class="w-40 dark:text-white">{$t('course.navItem.marks.student')}</p>
</div>
{#each $lessons as lesson, index}
{#if lessonMapping[lesson.id]}
<div class="box flex flex-col items-center {borderleftGrey}">
<p class="col lesson-number dark:text-white" title={lesson.title}>
{getLectureNo(index + 1)}
</p>
<div class="flex h-full items-center border-b-0 border-l-0 border-r-0 border-t border-gray-300">
{#each Object.keys(lessonMapping[lesson.id]) as exerciseId, index}
<p
class="col text-sm dark:text-white {index && borderleftGrey}"
title={lessonMapping[lesson.id][exerciseId].title}
>
{lessonMapping[lesson.id][exerciseId].title}
<span>({lessonMapping[lesson.id][exerciseId].points})</span>
</p>
{/each}
</div>
</div>
{/if}
{/each}
<div class="box flex items-center {borderleftGrey}">
<p class="w-20 text-center dark:text-white">{$t('course.navItem.marks.total')}</p>
</div>
</div>
{#each students as student}
<div class="relative flex cursor-pointer items-center p-3 {borderBottomGrey}">
<div class="flex w-40 items-center">
<img alt="Student avatar" src={student.profile.avatar_url} class="mr-2 h-8 w-8 rounded-full" />
<div class="text-sm">
<p class="font-semibold dark:text-white">
{student.profile.fullname}
</p>
<p class="dark:text-white">
{`${student.assigned_student_id ? '#' + student.assigned_student_id : '-'}`}
</p>
</div>
</div>
{#each $lessons as lesson}
{#if lessonMapping[lesson.id]}
<div class="flex items-center">
{#each Object.keys(lessonMapping[lesson.id]) as exerciseId}
<p class="col dark:text-white">
{studentMarksByExerciseId[student.id] ? studentMarksByExerciseId[student.id][exerciseId] || '-' : '-'}
</p>
{/each}
</div>
{/if}
{/each}
<div class="flex w-20 items-center">
<div class="text-sm">
<p class="col dark:text-white">
{calculateStudentTotal(studentMarksByExerciseId[student.id])}
</p>
</div>
</div>
</div>
{:else}
<Empty title={$t('course.navItem.marks.no_student')} icon={UserXIcon} variant="page" />
{/each}
</div>
<style>
.col {
text-align: center;
padding: 5px;
width: 100px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.br {
border-right: 1px solid rgba(209, 213, 219, var(--tw-border-opacity));
}
.box {
height: 95px;
}
.lesson-number {
height: 30px;
}
</style>
@@ -0,0 +1,289 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import NewFeedModal from '$lib/components/Course/components/NewsFeed/NewFeedModal.svelte';
import NewsFeedCard from '$lib/components/Course/components/NewsFeed/NewsFeedCard.svelte';
import NewsFeedLoader from '$lib/components/Course/components/NewsFeed/NewsFeedLoader.svelte';
import { newsFeed } from '$lib/components/Course/components/NewsFeed/store';
import { group } from '$lib/components/Course/store';
import { RoleBasedSecurity } from '$features/ui';
import { snackbar } from '$features/ui/snackbar/store';
import { supabase } from '$lib/utils/functions/supabase';
import { t } from '$lib/utils/functions/translations';
import {
createComment,
deleteNewsFeed,
deleteNewsFeedComment,
fetchNewsFeedReaction,
fetchNewsFeeds,
handleEditFeed,
toggleFeedIsPinned
} from '$lib/utils/services/newsfeed';
import { NOTIFICATION_NAME, triggerSendEmail } from '$lib/utils/services/notification/notification';
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import type { Feed } from '$lib/utils/types/feed';
import PinIcon from '@lucide/svelte/icons/pin';
import { onMount } from 'svelte';
import type { AccountOrg } from '$features/app/types';
import Empty from '@cio/ui/custom/empty/empty.svelte';
import { BookIcon } from '@lucide/svelte';
interface Props {
courseId: string;
}
let { courseId }: Props = $props();
let createdComment;
let edit = $state(false);
let isLoading = $state(false);
let editFeed: Feed | null = $state(null);
let query = new URLSearchParams(page.url.search);
let feedId = query.get('feedId') || '';
const author = $derived.by(() => {
const groupMember = $group.people.find((person) => person.profile_id === $profile.id);
return {
id: groupMember?.id || '',
username: $profile.username || '',
fullname: $profile.fullname || '',
avatar_url: $profile.avatarUrl || ''
};
});
const onEdit = (id: string, content: string) => {
handleEditFeed(id, content);
$newsFeed = $newsFeed.map((feed) => {
if (feed.id === id) {
return { ...feed, content: content };
}
return feed;
});
};
const deleteComment = (id: string) => {
deleteNewsFeedComment(id);
$newsFeed = $newsFeed.flatMap((feed) => ({
...feed,
comment: feed.comment.filter((comment) => comment.id !== id)
}));
return snackbar.success('snackbar.course.success.comment_deleted');
};
const addNewReaction = async (reactionType: string, feedId: string, authorId: string) => {
const { data } = await fetchNewsFeedReaction(feedId);
if (!data) return;
const reactedFeed = data || $newsFeed.find((feed) => feed.id === feedId);
let reactedAuthorIds: string[] = reactedFeed.reaction[reactionType];
if (reactedAuthorIds.includes(authorId)) {
reactedAuthorIds = reactedAuthorIds.filter((reactionAuthorId) => reactionAuthorId !== authorId);
} else {
reactedAuthorIds = [...reactedAuthorIds, authorId];
}
reactedFeed.reaction = {
...reactedFeed.reaction,
[reactionType]: reactedAuthorIds
};
const response = await supabase
.from('course_newsfeed')
.update({
reaction: reactedFeed.reaction
})
.eq('id', feedId);
if (response.error) {
return snackbar.error('snackbar.course.error.reaction_error');
}
$newsFeed = $newsFeed.map((feed) => {
if (feed.id === feedId) {
feed.reaction = reactedFeed.reaction;
}
return feed;
});
};
const addNewComment = async (comment: string, feedId: string, authorId: string) => {
const { response } = await createComment({
content: comment,
author_id: authorId,
course_newsfeed_id: feedId
});
if (response.error) {
return snackbar.error('snackbar.course.error.commenting_error');
}
if (!response.data) return;
createdComment = response?.data[0];
triggerSendEmail(NOTIFICATION_NAME.NEWSFEED, {
authorId: author.id,
feedId: feedId,
comment
});
$newsFeed = $newsFeed.map((feed) => {
if (feed.id === feedId) {
const newComment = {
id: createdComment.id,
author: {
profile: { ...author }
},
created_at: createdComment.created_at,
content: comment
};
snackbar.success('snackbar.course.success.comment_added');
return {
...feed,
comment: [...feed.comment, { ...newComment }]
};
}
return feed;
});
};
const onPin = async (feedId, isPinned) => {
const newIsPinned = !isPinned;
const { response } = await toggleFeedIsPinned(feedId, newIsPinned);
if (response.error) {
return snackbar.error('snackbar.course.error.toggle_error');
}
$newsFeed = $newsFeed.map((feed) => {
if (feed.id === feedId) {
snackbar.success(
`${
newIsPinned ? 'snackbar.course.success.pinned' : 'snackbar.course.success.unpinned'
} snackbar.course.success.successfully`
);
feed.isPinned = newIsPinned;
return feed;
}
return feed;
});
};
const deleteFeed = (id: string) => {
deleteNewsFeed(id);
const deletedFeed = $newsFeed.filter((feed) => feed.id !== id);
snackbar.success('snackbar.course.success.feed_delete_success');
$newsFeed = deletedFeed;
};
const initNewsFeed = async (courseId: string) => {
if (!courseId) return;
try {
isLoading = true;
const { data, error } = await fetchNewsFeeds(courseId);
if (error) {
snackbar.error('snackbar.course.error.news_feed_fail');
}
if (data) {
$newsFeed = data
.map((feedItem) => ({
...feedItem,
isPinned: feedItem.is_pinned
}))
.sort((a, b) => Number(b.isPinned) - Number(a.isPinned));
}
} catch (error) {
snackbar.error('snackbar.course.error.feed_fetch_error');
} finally {
isLoading = false;
}
};
function getPageRoles(org: AccountOrg) {
const roles: number[] = [1, 2];
if (org.customization.course.newsfeed) {
roles.push(3);
}
return roles;
}
onMount(() => {
initNewsFeed(courseId);
});
</script>
<RoleBasedSecurity
allowedRoles={getPageRoles($currentOrg)}
onDenied={() => {
goto(`/courses/${courseId}/lessons?next=true`);
}}
>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<NewFeedModal
{courseId}
{author}
bind:edit
bind:editFeed
onSave={(newFeed) => {
$newsFeed = [newFeed, ...$newsFeed];
}}
{onEdit}
/>
</RoleBasedSecurity>
{#if isLoading}
<div>
<NewsFeedLoader />
<NewsFeedLoader />
<NewsFeedLoader />
</div>
{:else if !$newsFeed.length}
<Empty
title={$t('course.navItem.news_feed.body_header')}
description={$t('course.navItem.news_feed.body_content')}
icon={BookIcon}
class="h-full"
></Empty>
{:else}
{#each $newsFeed as feed}
{#if feed.isPinned}
<div class="mb-3 flex items-center gap-2">
<PinIcon size={16} class="filled" />
<p class="text-sm">{$t('course.navItem.news_feed.pinned')}</p>
</div>
{/if}
<NewsFeedCard
{feed}
{deleteFeed}
{addNewComment}
{deleteComment}
{addNewReaction}
{onPin}
{author}
bind:edit
bind:editFeed
isActive={feedId === feed.id}
/>
{/each}
{/if}
</RoleBasedSecurity>
@@ -0,0 +1,248 @@
<script lang="ts">
interface Props {
courseId: string;
}
let { courseId }: Props = $props();
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { Input } from '@cio/ui/base/input';
import { Button } from '@cio/ui/base/button';
import * as Table from '@cio/ui/base/table';
import CopyIcon from '@lucide/svelte/icons/copy';
import TrashIcon from '@lucide/svelte/icons/trash';
import CheckIcon from '@lucide/svelte/icons/check';
import SearchIcon from '@lucide/svelte/icons/search';
import { Chip } from '@cio/ui/custom/chip';
import * as Avatar from '@cio/ui/base/avatar';
import { ComingSoon, RoleBasedSecurity } from '$features/ui';
import InvitationModal from '$lib/components/Course/components/People/InvitationModal.svelte';
import DeleteConfirmation from '$lib/components/Course/components/People/DeleteConfirmation.svelte';
import { profile } from '$lib/utils/store/user';
import type { GroupPerson } from '$lib/utils/types';
import { group } from '$lib/components/Course/store';
import { t } from '$lib/utils/functions/translations';
import { shortenName } from '$lib/utils/functions/string';
import * as Select from '@cio/ui/base/select';
import { IconButton } from '@cio/ui/custom/icon-button';
import { ROLE_LABEL, ROLES } from '$lib/utils/constants/roles';
import { deleteGroupMember } from '$lib/utils/services/courses';
import { deleteMemberModal } from '$lib/components/Course/components/People/store';
let member: { id?: string; email?: string; profile?: { email: string } } = $state({});
let filterBy: string = $state(`${ROLES[0].value}`);
let searchValue = $state('');
let copiedEmail = $state<string | null>(null);
const people: Array<GroupPerson> = $derived(sortAndFilterPeople($group.people, filterBy));
function filterPeople(_query, people) {
const query = _query.toLowerCase();
return people.filter((person) => {
const { profile, email } = person;
return profile?.fullname?.toLowerCase()?.includes(query) || email?.includes(query);
});
}
async function deletePerson() {
if (!member.id) return;
$group.people = $group.people.filter((person: { id: string }) => person.id !== member.id);
$group.tutors = $group.tutors.filter((person: GroupPerson) => person.memberId !== member.id);
await deleteGroupMember(member.id);
}
function sortAndFilterPeople(_people: Array<GroupPerson>, filterBy: string) {
return (_people || [])
.filter((person) => {
if (filterBy === 'all') return true;
return person.role_id === Number(filterBy);
})
.sort((a: GroupPerson, b: GroupPerson) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
.sort((a: GroupPerson, b: GroupPerson) => a.role_id - b.role_id);
}
function getEmail(person) {
const { profile, email } = person;
return profile ? profile.email : email;
}
function obscureEmail(email) {
const [username, domain] = email.split('@');
const obscuredUsername =
username.charAt(0) + '*'.repeat(username.length - 2) + username.charAt(username.length - 1);
return `${obscuredUsername}@${domain}`;
}
function gotoPerson(person) {
goto(`${page.url.href}/${person.profile_id}`);
}
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
copiedEmail = text;
setTimeout(() => {
copiedEmail = null;
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
}
const selectOptions = $derived(ROLES.map((role) => ({ label: $t(role.label), value: `${role.value}` })));
</script>
<InvitationModal />
<DeleteConfirmation email={member.email || (member.profile && member.profile.email)} {deletePerson} />
<section class="mx-2 my-5 md:mx-9">
<div class="flex-end mb-7 flex flex-col items-start justify-end gap-2 md:flex-row md:items-center">
<div class="relative max-w-[320px]">
<Input type="text" placeholder={$t('course.navItem.people.search')} bind:value={searchValue} class="w-full" />
<SearchIcon class="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2" />
</div>
<div class="mb-3">
<Select.Root type="single" name="roles" bind:value={filterBy}>
<Select.Trigger class="mt-3 max-w-[80px]">
{$t(ROLE_LABEL[Number(filterBy)])}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each selectOptions as option (option.value)}
<Select.Item value={option.value} label={option.label} disabled={option.value === filterBy}>
{option.label}
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</div>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<p class="hidden w-20 text-lg lg:block dark:text-white"></p>
</RoleBasedSecurity>
</div>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{$t('course.navItem.people.name')}</Table.Head>
<Table.Head>{$t('course.navItem.people.role')}</Table.Head>
<Table.Head>{$t('course.navItem.people.action')}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each filterPeople(searchValue, people) as person}
<Table.Row>
<!-- first column -->
<Table.Cell class="w-4/6 md:w-3/6">
{#if person.profile}
<div class="flex items-start lg:items-center">
<Avatar.Root class="mr-3">
<Avatar.Image
src={person.profile.avatar_url ? person.profile.avatar_url : '/logo-192.png'}
alt={person.profile.fullname ? person.profile.fullname : 'User'}
/>
<Avatar.Fallback>{shortenName(person.profile.fullname) || 'U'}</Avatar.Fallback>
</Avatar.Root>
<div class="flex flex-col items-start lg:flex-row lg:items-center">
<div class="mr-2">
<p class="text-base font-normal dark:text-white">
{person.profile.fullname}
</p>
<p class="ui:text-primary line-clamp-1 text-xs">
{obscureEmail(getEmail(person))}
</p>
</div>
<div class="flex items-center">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={() => copyToClipboard(getEmail(person))}
>
{#if copiedEmail === getEmail(person)}
<CheckIcon size={16} class="text-green-600" />
{:else}
<CopyIcon size={16} />
{/if}
</Button>
</RoleBasedSecurity>
{#if person.profile_id == $profile.id}
<ComingSoon label={$t('course.navItem.people.you')} />
{/if}
</div>
</div>
</div>
{:else}
<div class="flex w-2/4 items-start lg:items-center">
<Chip value={shortenName(person.email)} className="mr-3" />
<a href="mailto:{person.email}" class="text-md ui:text-primary mr-2 dark:text-white">
{person.email}
</a>
<div class="flex items-center justify-between">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={() => copyToClipboard(getEmail(person))}
>
{#if copiedEmail === getEmail(person)}
<CheckIcon size={16} class="text-green-600" />
{:else}
<CopyIcon size={16} />
{/if}
</Button>
</RoleBasedSecurity>
<Chip value={$t('course.navItem.people.pending')} className="bg-yellow-200 text-yellow-700" />
</div>
</div>
{/if}
</Table.Cell>
<!-- second column -->
<Table.Cell class="w-1/4">
<p class=" w-1/4 text-center text-base font-normal dark:text-white">
{$t(ROLE_LABEL[person.role_id])}
</p>
</Table.Cell>
<!-- third column -->
<Table.Cell class="w-1/4">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="hidden space-x-2 sm:flex sm:items-center">
{#if person.profile_id !== $profile.id}
<IconButton
onclick={() => {
member = person;
$deleteMemberModal.open = true;
}}
>
<TrashIcon size={16} />
</IconButton>
<Button variant="outline" onclick={() => gotoPerson(person)}>
{$t('course.navItem.people.view')}
</Button>
{/if}
</div>
</RoleBasedSecurity>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
</section>
@@ -0,0 +1,438 @@
<script lang="ts">
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { Label } from '@cio/ui/base/label';
import { Switch } from '@cio/ui/base/switch';
import * as RadioGroup from '@cio/ui/base/radio-group';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import ReorderMaterialTabs from '$features/course/components/reorder-material-tabs.svelte';
import { IconButton } from '@cio/ui/custom/icon-button';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { InputField } from '@cio/ui/custom/input-field';
import * as Field from '@cio/ui/base/field';
import { UpgradeBanner, UnsavedChanges, UploadWidget } from '$features/ui';
import { Button } from '@cio/ui/base/button';
import { settings } from '$features/course/utils/settings-store';
import Copy from '@lucide/svelte/icons/copy';
import type { Course } from '$lib/utils/types';
import { COURSE_TYPE } from '$lib/utils/types';
import { lessons } from '$lib/components/Course/components/Lesson/store/lessons';
import { course } from '$lib/components/Course/store';
import { t } from '$lib/utils/functions/translations';
import { isObject } from '$lib/utils/functions/isObject';
import { snackbar } from '$features/ui/snackbar/store';
import generateSlug from '$lib/utils/functions/generateSlug';
import { DeleteModal } from '$features/ui';
import { deleteCourse, updateCourse } from '$lib/utils/services/courses';
import { copyToClipboard } from '$lib/utils/functions/formatYoutubeVideo';
import { handleOpenWidget } from '$features/ui/course-landing-page/store';
import { currentOrg, currentOrgDomain, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
let isLoading = $state(false);
let isDeleting = $state(false);
let errors: {
title: string | undefined;
description: string | undefined;
} = $state({
title: undefined,
description: undefined
});
let avatar: string | undefined;
let hasUnsavedChanges = $state(false);
let openDeleteModal = $state(false);
function widgetControl() {
$handleOpenWidget.open = !$handleOpenWidget.open;
}
function getLessonOrder(id: string) {
const index = $lessons.findIndex((lesson) => lesson.id === id);
if (index < 9) {
return '0' + (index + 1);
} else {
return index + 1;
}
}
const downloadCourse = async () => {
alert('Coming soon');
return;
isLoading = true;
try {
const lessonsList = $lessons.map((lesson) => ({
lessonTitle: lesson.title,
lessonNumber: getLessonOrder(lesson.id),
lessonNote: lesson.note,
slideUrl: lesson.slide_url || '',
video: lesson.videos || ''
}));
const response = await fetch('/downloadCourse', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
courseTitle: $course.title,
orgName: $currentOrg.name,
orgTheme: $currentOrg.theme || '',
lessons: lessonsList
})
});
if (!response.ok) {
throw new Error(await response.text());
}
const data = await response.blob();
const file = new Blob([data], { type: 'application/pdf' });
const fileURL = URL.createObjectURL(file);
let a = document.createElement('a');
document.body.append(a);
a.download = $course.title + ' - ' + 'Course ';
a.href = fileURL;
a.click();
a.remove();
snackbar.success('snackbar.course_settings.success.download');
} catch (error) {
snackbar.error('snackbar.course_settings.error.not_right');
}
isLoading = false;
};
const deleteBannerImage = () => {
$settings.logo = '';
hasUnsavedChanges = true;
};
async function handleDeleteCourse() {
isDeleting = true;
try {
await deleteCourse($course.id);
goto($currentOrgPath + '/courses');
} catch (error) {
snackbar.error('snackbar.course_settings.error.went_wrong');
}
isDeleting = false;
}
export async function handleSave() {
if (!$settings.course_title) {
errors.title = $t('snackbar.course_settings.error.title');
return;
}
if (!$settings.course_description) {
errors.description = $t('snackbar.course_settings.error.description');
return;
}
try {
const updatedCourse = {
title: $settings.course_title,
description: $settings.course_description,
type: $settings.type,
logo: $settings.logo,
is_published: $settings.is_published,
metadata: {
...(isObject($course.metadata) ? $course.metadata : {}),
lessonTabsOrder: $settings.tabs,
grading: $settings.grading,
lessonDownload: $settings.lesson_download,
allowNewStudent: $settings.allow_new_students
},
slug: $course.slug
};
await updateCourse($course.id, avatar ? new File([avatar], avatar) : undefined, updatedCourse);
$course = {
...$course,
...updatedCourse
};
snackbar.success('snackbar.course_settings.success.saved');
hasUnsavedChanges = false;
} catch (error) {
snackbar.error();
}
}
const generateNewCourseLink = () => {
$course.slug = generateSlug($course.title);
hasUnsavedChanges = true;
};
async function setDefault(course: Course) {
if (!course || !Object.keys(course).length) return;
untrack(() => {
settings.set({
course_title: course.title,
type: course.type,
course_description: course.description,
logo: course.logo || '',
tabs: course.metadata.lessonTabsOrder || $settings.tabs,
grading: !!course.metadata.grading,
lesson_download: !!course.metadata.lessonDownload,
is_published: !!course.is_published,
allow_new_students: course.metadata.allowNewStudent
});
});
}
$effect(() => {
setDefault($course);
});
let courseLink = $derived(`${$currentOrgDomain}/course/${$course.slug}`);
</script>
<UnsavedChanges bind:hasUnsavedChanges />
<DeleteModal onDelete={handleDeleteCourse} bind:open={openDeleteModal} />
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Legend>{$t('course.navItem.settings.cover_image')}</Field.Legend>
<Field.Description>{$t('course.navItem.settings.optional_image')}</Field.Description>
<Field.Group>
<Field.Field>
<div class="flex items-center gap-2">
<Button onclick={widgetControl}>
{$t('course.navItem.settings.replace')}
</Button>
<Button variant="outline" onclick={deleteBannerImage}>
{$t('ai.reset')}
</Button>
</div>
{#if $handleOpenWidget.open}
<UploadWidget
bind:imageURL={$settings.logo}
onchange={() => {
hasUnsavedChanges = true;
}}
/>
{/if}
</Field.Field>
<Field.Field>
<div class="relative z-20 w-fit">
<img
style="min-width:280px; min-height:200px"
alt="Course cover"
src={$settings.logo ? $settings.logo : '/images/classroomio-course-img-template.jpg'}
class="relative mt-2 h-[200px] w-[280px] rounded-md md:mt-0"
/>
</div>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set id="share">
<Field.Legend>{$t('course.navItem.settings.course_details')}</Field.Legend>
<Field.Group>
<Field.Field>
<InputField
label={$t('course.navItem.settings.course_title')}
placeholder="Write the course title here"
className="w-full"
isRequired
bind:value={$settings.course_title}
errorMessage={errors?.title}
onInputChange={() => {
hasUnsavedChanges = true;
}}
/>
</Field.Field>
<Field.Field>
<TextareaField
label={$t('course.navItem.settings.course_description')}
placeholder={$t('course.navItem.settings.placeholder')}
className="w-full"
isRequired
bind:value={$settings.course_description}
errorMessage={errors?.description}
onchange={() => {
hasUnsavedChanges = true;
}}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('course.navItem.settings.link')}</Field.Label>
<div class="mb-2 flex items-center gap-2">
<IconButton onclick={generateNewCourseLink}>
<RotateCcwIcon size={16} />
</IconButton>
<span class="grow"></span>
<IconButton onclick={() => goto(courseLink)}>
<ArrowUpRightIcon size={16} />
</IconButton>
</div>
<div class="flex items-center justify-between rounded-md border p-3">
{#if $course.slug}
<p class="text-sm">{courseLink}</p>
<IconButton
onclick={() => {
copyToClipboard(courseLink);
}}
>
<Copy size={16} />
</IconButton>
{:else}
<p class="text-sm">Setup landing page to get course link</p>
{/if}
</div>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('course.navItem.settings.order')}</Field.Legend>
<Field.Description>{$t('course.navItem.settings.drag')}</Field.Description>
<Field.Field>
<ReorderMaterialTabs
onchange={() => {
hasUnsavedChanges = true;
}}
/>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('course.navItem.settings.lesson_download')}</Field.Legend>
<Field.Description>{$t('course.navItem.settings.available')}</Field.Description>
<Field.Field>
{#if $isFreePlan}
<UpgradeBanner>{$t('upgrade.download_lessons')}</UpgradeBanner>
{:else}
<div class="flex items-center space-x-2">
<Switch
id="lesson-download"
checked={$settings.lesson_download}
onCheckedChange={(checked) => {
$settings.lesson_download = checked;
hasUnsavedChanges = true;
}}
/>
<Label for="lesson-download" class="text-sm text-gray-500">
{$settings.lesson_download ? $t('course.navItem.settings.enabled') : $t('course.navItem.settings.disabled')}
</Label>
</div>
{/if}
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('course.navItem.settings.course_download')}</Field.Legend>
<Field.Description>{$t('course.navItem.settings.course_avail')}</Field.Description>
<Field.Field>
{#if $isFreePlan}
<UpgradeBanner>{$t('upgrade.download_course')}</UpgradeBanner>
{:else}
<Button variant="outline" onclick={downloadCourse} disabled={isLoading} loading={isLoading}>
{$t('course.navItem.settings.download')}
</Button>
{/if}
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('course.navItem.settings.type')}</Field.Legend>
<Field.Description>{$t('course.navItem.settings.course_type_desc')}</Field.Description>
<Field.Field>
<RadioGroup.Root
value={$settings.type}
onValueChange={(value) => {
$settings.type = value as COURSE_TYPE;
if (hasUnsavedChanges) return;
hasUnsavedChanges = true;
}}
>
<div class="mb-3 flex items-center space-x-2">
<RadioGroup.Item value={COURSE_TYPE.LIVE_CLASS} id="live-class" />
<Label for="live-class">{$t('course.navItem.settings.live_class')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value={COURSE_TYPE.SELF_PACED} id="self-paced" />
<Label for="self-paced">{$t('course.navItem.settings.self_paced')}</Label>
</div>
</RadioGroup.Root>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('course.navItem.settings.allow')}</Field.Legend>
<Field.Description>{$t('course.navItem.settings.access')}</Field.Description>
<Field.Field orientation="horizontal">
<Switch
id="allow-new-students"
checked={$settings.allow_new_students}
onCheckedChange={(checked) => {
$settings.allow_new_students = checked;
hasUnsavedChanges = true;
}}
/>
<Label for="allow-new-students" class="text-sm text-gray-500">
{$settings.allow_new_students ? $t('course.navItem.settings.enabled') : $t('course.navItem.settings.disabled')}
</Label>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('course.navItem.settings.publish')}</Field.Legend>
<Field.Description>{$t('course.navItem.settings.determines')}</Field.Description>
<Field.Field orientation="horizontal">
<Switch
id="is-published"
checked={$settings.is_published}
onCheckedChange={(checked) => {
hasUnsavedChanges = true;
$settings.allow_new_students = checked;
if (!$course.slug) {
generateNewCourseLink();
}
}}
/>
<Label for="is-published" class="text-sm text-gray-500">
{$settings.is_published ? $t('course.navItem.settings.published') : $t('course.navItem.settings.unpublished')}
</Label>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set id="delete">
<Field.Legend>{$t('course.navItem.settings.delete')}</Field.Legend>
<Field.Description>{$t('course.navItem.settings.delete_text')}</Field.Description>
<Field.Field>
<Button variant="destructive" onclick={() => (openDeleteModal = true)} loading={isDeleting} disabled={isDeleting}>
{$t('course.navItem.settings.delete')}
</Button>
</Field.Field>
</Field.Set>
</Field.Group>
@@ -0,0 +1,445 @@
<script lang="ts">
import { page } from '$app/state';
import { flip } from 'svelte/animate';
import { goto } from '$app/navigation';
import { dndzone } from 'svelte-dnd-action';
import {
deleteSubmission,
fetchSubmissions,
updateQuestionAnswer,
updateSubmission
} from '$lib/utils/services/submissions';
import { course } from '$lib/components/Course/store';
import { t } from '$lib/utils/functions/translations';
import formatDate from '$lib/utils/functions/formatDate';
import { snackbar } from '$features/ui/snackbar/store';
import { formatAnswers } from '$lib/components/Course/function.js';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import isSubmissionEarly from '$lib/utils/functions/isSubmissionEarly';
import { NOTIFICATION_NAME, triggerSendEmail } from '$lib/utils/services/notification/notification';
import type { SubmissionIdData, SubmissionItem, SubmissionSection } from '$lib/utils/types/submission';
import { Skeleton } from '@cio/ui/base/skeleton';
import { Chip } from '@cio/ui/custom/chip';
import MarkExerciseModal from '$lib/components/Course/components/Lesson/Exercise/MarkExerciseModal.svelte';
interface Props {
courseId: string;
}
let { courseId }: Props = $props();
const flipDurationMs = 300;
let exerciseDetails: { id: string; title: string };
let lessonDetails: { id: string; title: string };
let totalMark = 0;
let maxMark = 0;
let submissionIdData: { [key: number]: SubmissionIdData } = $state({});
let isGradeWithAI = $state(false);
let fetching = $state(false);
let isSaving = $state(false);
let hasFetched = $state(false);
const submissionId = $derived(new URLSearchParams(page.url.search).get('submissionId') ?? '');
let openExercise = $derived.by(() => {
return !!submissionId && !!submissionIdData[submissionId];
});
const submissionStatus: { [key: number]: string } = {
1: $t('course.navItem.submissions.submission_status.submitted'),
2: $t('course.navItem.submissions.submission_status.in_progress'),
3: $t('course.navItem.submissions.submission_status.graded')
};
let sections: SubmissionSection[] = $state([
{
id: 1,
title: $t('course.navItem.submissions.submission_status.submitted'),
value: 0,
items: []
},
{
id: 2,
title: $t('course.navItem.submissions.submission_status.in_progress'),
value: 0,
items: []
},
{
id: 3,
title: $t('course.navItem.submissions.submission_status.graded'),
value: 10,
items: []
}
]);
function getMaxPoints(questions) {
return (questions || []).reduce((acc, question) => acc + question.points, 0);
}
function calculateTotal(grades: string[]): number {
if (!grades) return 0;
return Object.values(grades).reduce((acc, grade) => acc + parseInt(grade), 0);
}
const notifyStudent = (submissionData) => {
maxMark = getMaxPoints(submissionData?.questions);
totalMark = calculateTotal(submissionData?.questionAnswerByPoint);
const { fullname, email } = submissionData?.student;
const { title, statusId }: { title: string; statusId: number } = submissionData;
const exerciseLink = `${$currentOrgDomain}/courses/${courseId}/lessons/${lessonDetails.id}/exercises/${exerciseDetails.id}`;
const content = `
<p>Hello ${fullname},</p>
<p>The status of your submitted exercise on <strong>${title}</strong> has been updated to ${
submissionStatus[statusId]
}</p>
${
statusId == 3
? `<p>Your score was ${totalMark}/${maxMark}</p>
<a class="button" href="${exerciseLink}">View your Result</a>
`
: `<a class="button" href="${exerciseLink}">Open Exercise</a>`
}
<p>This exercise is for <strong>${
lessonDetails.title
}</strong> in a course you are taking titled <strong>${$course.title}</strong></p>
`;
triggerSendEmail(NOTIFICATION_NAME.SUBMISSION_UPDATE, {
to: email,
content,
orgName: $currentOrg?.name
});
};
function handleItemFinalize(
columnIdx: number,
newItems: { map: (arg0: (item: SubmissionItem) => SubmissionItem) => SubmissionItem[] }
) {
let itemToWithNewStatus: SubmissionItem | undefined;
const { id } = sections[columnIdx];
// Set column in the UI
sections[columnIdx].items = newItems.map((item) => {
if (item.statusId !== id) {
itemToWithNewStatus = item;
item.statusId = id;
}
return item;
});
// Update backend
if (itemToWithNewStatus) {
// Update key mapping for each submission also
submissionIdData[itemToWithNewStatus.id] = {
...submissionIdData[itemToWithNewStatus.id],
statusId: itemToWithNewStatus.statusId
};
notifyStudent(submissionIdData[itemToWithNewStatus.id]);
updateSubmission({
id: itemToWithNewStatus.id,
status_id: itemToWithNewStatus.statusId
}).then((res) => console.log('Updated submission', res));
}
}
function handleDndConsiderCards(columnIdx: number) {
return function (e) {
sections[columnIdx].items = e.detail.items;
};
}
function handleDndFinalizeCards(columnIdx: number) {
return (e) => handleItemFinalize(columnIdx, e.detail.items);
}
function handleModalClose() {
isGradeWithAI = false;
goto(page.url.pathname);
}
// Via dialog
function updateStatus({
submissionId,
prevStatusId,
nextStatusId,
total
}: {
submissionId: string;
prevStatusId: number;
nextStatusId: number;
total: number;
}) {
let itemToWithNewStatus: SubmissionItem | undefined;
// Remove from current column
const { items } = sections[prevStatusId - 1];
sections[prevStatusId - 1].items = items?.filter((item) => {
if (item.id === submissionId) {
itemToWithNewStatus = Object.assign(item);
if (itemToWithNewStatus) {
itemToWithNewStatus.statusId = nextStatusId;
}
return false;
}
return true;
});
// Move to right column
if (itemToWithNewStatus) {
sections[nextStatusId - 1].items = [...sections[nextStatusId - 1].items, itemToWithNewStatus];
}
// If something changed
if (itemToWithNewStatus) {
// Update key mapping for each submission also
submissionIdData[itemToWithNewStatus.id] = {
...submissionIdData[itemToWithNewStatus.id],
statusId: itemToWithNewStatus.statusId
};
notifyStudent(submissionIdData[itemToWithNewStatus.id]);
// Update backend
updateSubmission({
id: itemToWithNewStatus.id,
status_id: itemToWithNewStatus.statusId,
total
}).then((res) => console.log('Updated submission', res));
}
}
async function handleDeleteSubmission(id: string, statusId: number) {
const { items } = sections[statusId - 1];
sections[statusId - 1].items = items?.filter((item) => {
return item.id === id ? false : true;
});
submissionIdData[id] = undefined;
const { error } = await deleteSubmission(id);
if (error) {
console.error('Error deleting submission', error);
snackbar.error('course.navItem.submissions.grading_modal.delete_error');
return;
}
snackbar.success('course.navItem.submissions.grading_modal.delete_success');
handleModalClose();
}
async function handleSave(submission: { questionAnswerByPoint: any; questionAnswers: any; feedback: any }) {
isSaving = true;
const { questionAnswerByPoint, questionAnswers, feedback } = submission;
let totalPoints = 0;
for (const questionId in questionAnswerByPoint) {
if (Object.prototype.hasOwnProperty.call(questionAnswerByPoint, questionId)) {
const questionAnswer = questionAnswers.find(
(answer: { question_id: string }) => answer.question_id == questionId
);
const point = questionAnswerByPoint[questionId];
totalPoints += parseInt(point, 10);
const qaResponse = await updateQuestionAnswer({ point }, { id: questionAnswer?.id });
if (qaResponse.error) {
console.error('Error saving', qaResponse.error);
snackbar.error(`snackbar.something`);
return;
}
}
}
await updateSubmission({
id: submissionId,
total: totalPoints,
feedback: feedback
});
snackbar.success('snackbar.submissions.success.grading');
isSaving = false;
}
async function firstRender(courseId?: string) {
if (!courseId || hasFetched) return;
hasFetched = true;
fetching = true;
const { data: submissions } = await fetchSubmissions(courseId);
const sectionById: { [key: number]: SubmissionSection[] } = {};
if (submissions) {
for (const submission of submissions) {
const { id, created_at, answers, groupmember, feedback, exercise } = submission;
const isEarly = isSubmissionEarly(created_at, exercise.due_by);
const submissionItem = {
id,
statusId: submission.status_id,
isEarly,
feedback,
submittedAt: formatDate(created_at),
exercise: {
id: exercise.id,
title: exercise.title
},
answers,
student: groupmember && groupmember.profile ? groupmember.profile : {},
lesson: {
id: exercise.lesson.id,
title: exercise.lesson.title
}
};
exerciseDetails = { id: submissionItem.exercise.id, title: submissionItem.exercise.title };
lessonDetails = {
id: submissionItem.lesson.id,
title: submissionItem.lesson.title
};
submissionIdData[id] = {
id,
statusId: submissionItem.statusId,
feedback,
isEarly,
title: exercise.title,
student: submissionItem.student,
questions: exercise.questions,
answers: formatAnswers({ questions: exercise.questions, answers }),
questionAnswers: answers,
questionAnswerByPoint: answers.reduce((acc, answer) => {
acc[answer.question_id] = answer.point;
return acc;
}, {})
};
if (Array.isArray(sectionById[submission.status_id])) {
sectionById[submission.status_id].push(submissionItem);
} else {
sectionById[submission.status_id] = [submissionItem];
}
}
}
sections = sections.map((section, index) => ({
...section,
value: Array.isArray(sectionById[index + 1]) ? sectionById[index + 1].length : 0,
items: Array.isArray(sectionById[index + 1]) ? sectionById[index + 1] : []
}));
fetching = false;
}
$effect(() => {
firstRender($course.id);
});
</script>
<MarkExerciseModal
bind:open={openExercise}
onClose={handleModalClose}
data={submissionIdData[submissionId] || {}}
{handleSave}
{updateStatus}
deleteSubmission={handleDeleteSubmission}
bind:isGradeWithAI
{isSaving}
/>
<div class="flex items-center overflow-x-scroll">
{#each sections as { id, title, items }, idx (id)}
<div
class="section ui:bg-muted mr-3 h-80 overflow-hidden rounded-md p-3"
animate:flip={{ duration: flipDurationMs }}
>
<div class="mb-2 flex items-center">
<Chip value={items.length} />
<p class="ml-2 dark:text-white">{title}</p>
</div>
{#if fetching}
<Skeleton class="my-2 h-[170px] w-full rounded-md" />
<Skeleton class="my-2 h-[170px] w-full rounded-md" />
<Skeleton class="my-2 h-[170px] w-full rounded-md" />
{:else}
<div
class="content mb-3 overflow-y-auto pr-2"
use:dndzone={{
items,
flipDurationMs,
dropTargetStyle: { outline: 'blue' }
}}
onconsider={handleDndConsiderCards(idx)}
onfinalize={handleDndFinalizeCards(idx)}
>
{#each items as item (item.id)}
<div
class="{item.isEarly
? 'border-none'
: 'border border-red-700'} mx-0 my-2 w-full rounded-md bg-white px-3 py-3 dark:bg-neutral-800"
animate:flip={{ duration: flipDurationMs }}
>
<a
class="mb-2 flex w-full cursor-pointer items-center text-black"
href={`${page.url.pathname}?submissionId=${item.id}`}
>
<img alt="Student avatar" class="block h-6 w-6 rounded-full" src={item.student.avatar_url} />
<p class="ml-2 text-sm dark:text-white">
{item.student.username}
</p>
</a>
<a class="ui:text-primary text-md" href="{page.url.pathname}?submissionId={item.id}">
{item.exercise.title}
</a>
<a
class="my-2 flex items-center text-black no-underline hover:underline"
href="{page.url?.pathname?.replace('submissions', 'lessons')}/{item.lesson.id}/exercises/{item.exercise
.id}"
>
<p class="text-grey text-sm dark:text-white">
#{item.lesson.title}
</p>
</a>
<p class="text-xs text-gray-500 dark:text-white">
{item.submittedAt}
</p>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
<style>
.section {
max-width: 355px;
min-width: 355px;
height: 75vh;
}
.content {
height: 95%;
}
@media screen and (max-width: 768px) {
.section {
min-width: 250px;
}
}
</style>
@@ -21,3 +21,11 @@ export function validateForm(fields) {
fieldErrors
};
}
export function calcProgressRate(progressRate?: number, totalLessons?: number): number {
if (!progressRate || !totalLessons) {
return 0;
}
return Math.round((progressRate / totalLessons) * 100);
}
@@ -0,0 +1,19 @@
import { COURSE_TYPE } from '$lib/utils/types';
import { writable } from 'svelte/store';
export const settings = writable({
logo: '',
course_title: '',
course_description: '',
grading: false,
type: COURSE_TYPE.LIVE_CLASS,
allow_new_students: false,
tabs: [
{ 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' }
],
lesson_download: false,
is_published: false
});