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 <[email protected]> * 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 <[email protected]>
This commit is contained in:
co-authored by
Abdulmuizz Hamzat
parent
e6a3cf9332
commit
9fb6c7cbaa
@@ -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'
|
||||
};
|
||||
+389
@@ -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
|
||||
});
|
||||
Reference in New Issue
Block a user