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
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { courses } from '$features/course/utils/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
|
||||
const gotoCourse = (id: string | undefined) => {
|
||||
if (!id) return;
|
||||
goto(`/courses/${id}/lessons?next=true`);
|
||||
};
|
||||
|
||||
let last3Courses = $derived($courses.length > 0 ? $courses.slice(0, 3) : []);
|
||||
</script>
|
||||
|
||||
<section class="h-full">
|
||||
<p class="pb-3 text-base font-semibold text-[#040F2D] dark:text-white">
|
||||
{$t('dashboard.current_lesson')}
|
||||
</p>
|
||||
<div
|
||||
class="flex w-full flex-col items-center gap-2 rounded border border-[#EAEAEA] p-3 lg:h-[40vh] dark:bg-neutral-800"
|
||||
>
|
||||
{#if last3Courses.length > 0}
|
||||
<div class="flex h-full w-full flex-col justify-start overflow-y-auto">
|
||||
{#each last3Courses as course}
|
||||
<div class="p-5">
|
||||
<span class="flex flex-col items-start gap-3 pb-5 lg:flex-row">
|
||||
<img
|
||||
src={course.logo || '/images/classroomio-course-img-template.jpg'}
|
||||
alt="course"
|
||||
class="hidden lg:block lg:h-[60px] lg:w-[60px]"
|
||||
/>
|
||||
<div class="w-full">
|
||||
<p class="text-base font-semibold dark:text-white">{course.title}</p>
|
||||
<p class="line-clamp-2 text-xs font-normal text-[#656565] dark:text-white">
|
||||
{course.description}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => gotoCourse(course.id)}>
|
||||
{$t('dashboard.continue')}
|
||||
</Button>
|
||||
</span>
|
||||
<div class="relative h-1 w-full bg-[#EAEAEA]">
|
||||
<div
|
||||
style="width: {Math.round(((course?.progress_rate ?? 0) / (course?.total_lessons ?? 0)) * 100) || 0}%"
|
||||
class="bg-primary-700 absolute left-0 top-0 h-full"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<Empty title={$t('dashboard.no_courses')} description={$t('dashboard.start_course')} icon={BookOpenIcon} />
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as Learning } from './components/learning.svelte';
|
||||
export * from './pages';
|
||||
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { courseMetaDeta, courses } from '$features/course/utils/store';
|
||||
import { Learning } from '$features/lms';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { fetchCourses } from '$lib/utils/services/courses';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
let hasFetched = false;
|
||||
|
||||
let totalCompleted = $derived($courses.reduce((acc, cur) => acc + (cur.progress_rate || 0), 0));
|
||||
let totalLessons = $derived($courses.reduce((acc, cur) => acc + (cur.total_lessons || 0), 0));
|
||||
let progressPercentage = $derived(Math.round((totalCompleted / totalLessons) * 100) || 0);
|
||||
|
||||
function getCourses(userId?: string, orgId?: string) {
|
||||
if (hasFetched || !userId || !orgId) {
|
||||
return;
|
||||
}
|
||||
|
||||
untrack(async () => {
|
||||
if (!$courses.length) {
|
||||
$courseMetaDeta.isLoading = true;
|
||||
}
|
||||
|
||||
const coursesResult = await fetchCourses(userId, orgId);
|
||||
console.log(`coursesResult`, coursesResult);
|
||||
|
||||
$courseMetaDeta.isLoading = false;
|
||||
if (!coursesResult) return;
|
||||
|
||||
courses.set(coursesResult.allCourses);
|
||||
hasFetched = true;
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
getCourses($profile.id, $currentOrg.id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-fit w-full flex-col-reverse justify-between rounded-md border px-4 py-2 md:flex-row md:items-center">
|
||||
<div class="w-full md:w-[75%] lg:w-[80%]">
|
||||
<p class="mb-5 w-4/6 text-xs font-normal lg:text-xl">
|
||||
{$currentOrg.customization.dashboard.bannerText
|
||||
? $currentOrg.customization.dashboard.bannerText
|
||||
: $t('dashboard.lms_dashboard_hero')}
|
||||
</p>
|
||||
<Button onclick={() => goto('/lms/mylearning')}>
|
||||
{$t('dashboard.continue_learning')}
|
||||
</Button>
|
||||
</div>
|
||||
<img
|
||||
src={$currentOrg.customization.dashboard.bannerImage
|
||||
? $currentOrg.customization.dashboard.bannerImage
|
||||
: '/images/student-learning.svg'}
|
||||
alt="student Learning Pictogram"
|
||||
class="mb-3 aspect-square size-48 md:mb-0 md:block"
|
||||
/>
|
||||
</div>
|
||||
<section class="flex w-full flex-col md:gap-4 lg:flex-row">
|
||||
<div class="mt-10 w-full lg:w-[50%] xl:mt-2">
|
||||
<Learning />
|
||||
</div>
|
||||
<div class="mt-10 w-full lg:w-[50%] xl:mt-2">
|
||||
<p class="pb-3 text-base font-semibold text-[#040F2D] dark:text-white">
|
||||
{$t('dashboard.your_progress')}
|
||||
</p>
|
||||
<div
|
||||
class="flex h-fit items-center justify-center gap-2 rounded border border-[#EAEAEA] p-3 lg:h-[40vh] lg:overflow-y-auto dark:bg-neutral-800"
|
||||
>
|
||||
<div
|
||||
class="flex h-full w-full flex-col items-center justify-between gap-5 sm:flex-row lg:items-center lg:justify-around"
|
||||
>
|
||||
<div>
|
||||
<img src="/images/target.svg" alt="student Learning score" />
|
||||
</div>
|
||||
<span class="text-center xl:text-start">
|
||||
<p class="py-2 text-base font-semibold text-[#040F2D] dark:text-white">
|
||||
{$t('dashboard.your_progress')}
|
||||
</p>
|
||||
{#if totalLessons > 0}
|
||||
<p class="text-xs font-normal text-[#656565] dark:text-white">
|
||||
{totalCompleted}/{totalLessons}
|
||||
{$t('dashboard.lessons_completed')}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-xs font-normal text-[#656565] dark:text-white">
|
||||
{$t('dashboard.no_courses_started')}
|
||||
</p>
|
||||
{/if}
|
||||
</span>
|
||||
<h1 class="my-0 whitespace-nowrap text-5xl text-[#262626] lg:text-6xl dark:text-white">
|
||||
{progressPercentage} %
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { Chip } from '@cio/ui/custom/chip';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { fetchLMSExercises } from '$lib/utils/services/lms/exercises';
|
||||
import type { LMSExercise } from '$lib/utils/services/lms/exercises';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
const defaultSections = [
|
||||
{
|
||||
id: 0,
|
||||
title: $t('exercises.not_submitted'),
|
||||
items: [],
|
||||
className: 'text-[#E35353] bg-[#FDDFE4]'
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: $t('exercises.submitted'),
|
||||
items: [],
|
||||
className: 'text-orange-700 bg-orange-200'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: $t('exercises.in_progress'),
|
||||
items: [],
|
||||
className: 'text-yellow-700 bg-yellow-200'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: $t('exercises.graded'),
|
||||
value: 0,
|
||||
items: [],
|
||||
className: 'text-green-700 bg-green-200'
|
||||
}
|
||||
];
|
||||
let sections: Section[] = $state(cloneDeep(defaultSections));
|
||||
let hasFetched = $state(false);
|
||||
|
||||
interface Section {
|
||||
id: number;
|
||||
title: string;
|
||||
className: string;
|
||||
items: ExerciseItem[];
|
||||
}
|
||||
|
||||
interface ExerciseItem {
|
||||
courseTitle: string;
|
||||
courseURL: string;
|
||||
exerciseId: string;
|
||||
exerciseTitle: string;
|
||||
exerciseURL: string;
|
||||
grade: string;
|
||||
lessonNo: string;
|
||||
lessonTitle: string;
|
||||
lessonURL: string;
|
||||
submissionStatus: number;
|
||||
submissionUpdatedAt: string;
|
||||
}
|
||||
|
||||
function generateSections(exercises: LMSExercise[]): Section[] {
|
||||
const _sections: Section[] = cloneDeep(defaultSections);
|
||||
|
||||
for (const exercise of exercises) {
|
||||
const { id, title, updated_at, submission, lesson, questions } = exercise;
|
||||
|
||||
const submissionItem = submission[0] || {
|
||||
status_id: 0,
|
||||
updated_at,
|
||||
total: 0
|
||||
};
|
||||
|
||||
const courseURL = `/courses/${lesson.course.id}`;
|
||||
const lessonURL = `${courseURL}/lessons/${lesson.id}`;
|
||||
const exerciseURL = `${lessonURL}/exercises/${id}`;
|
||||
|
||||
const grade = `${submissionItem.total}/${questions.reduce((acc, cur) => (acc += cur.points), 0)}`;
|
||||
|
||||
const item: ExerciseItem = {
|
||||
exerciseId: id,
|
||||
courseTitle: lesson.course.title,
|
||||
courseURL,
|
||||
exerciseTitle: title,
|
||||
exerciseURL,
|
||||
lessonTitle: lesson.title,
|
||||
lessonNo: lesson.order < 9 ? '0' + (lesson.order + 1) : `${lesson.order}`,
|
||||
lessonURL,
|
||||
submissionStatus: submissionItem.status_id,
|
||||
submissionUpdatedAt: calDateDiff(submissionItem.updated_at),
|
||||
grade
|
||||
};
|
||||
|
||||
_sections[submissionItem.status_id].items.push(item);
|
||||
}
|
||||
|
||||
return _sections;
|
||||
}
|
||||
|
||||
async function fetchData(profileId?: string, orgId?: string) {
|
||||
if (hasFetched || !profileId || !orgId) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasFetched = true;
|
||||
|
||||
const { exercises, error } = await fetchLMSExercises(profileId, orgId);
|
||||
console.log('exercises', exercises);
|
||||
console.log('error', error);
|
||||
|
||||
if (error) {
|
||||
snackbar.error('snackbar.exercise.error_fetching');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!exercises) return;
|
||||
|
||||
sections = generateSections(exercises);
|
||||
console.log('sections', sections);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
fetchData($profile.id, $currentOrg.id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex w-full items-center overflow-x-auto">
|
||||
{#each sections as { title, items, className, id }}
|
||||
<div
|
||||
class="mr-3 h-[70vh] min-w-[355px] max-w-[355px] overflow-hidden rounded-md border border-gray-50 bg-gray-100 p-3 dark:border-neutral-700 dark:bg-black"
|
||||
>
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<p class="ml-2 dark:text-white">{title}</p>
|
||||
<Chip value={items.length} {className} />
|
||||
</div>
|
||||
<div class="h-full overflow-y-auto pb-3 pr-2">
|
||||
{#each items as item}
|
||||
<div class=" mx-0 my-2 w-full rounded-md bg-white px-3 py-3 dark:bg-neutral-800">
|
||||
<a class="ui:text-primary mb-2 flex w-full cursor-pointer items-center" href={item.courseURL}>
|
||||
<p class="text-xs">{item.courseTitle}</p>
|
||||
</a>
|
||||
<a class="text-md text-black dark:text-white" href={item.exerciseURL}>
|
||||
{#if id === 3}
|
||||
({item.grade}) -
|
||||
{/if}
|
||||
{item.exerciseTitle}
|
||||
</a>
|
||||
<a class="my-2 flex w-fit items-center text-black no-underline hover:underline" href={item.lessonURL}>
|
||||
<p class="text-grey text-sm dark:text-white">
|
||||
{$t('exercises.lesson')} <span class="italic">{item.lessonTitle}</span>
|
||||
</p>
|
||||
</a>
|
||||
<p class="text-xs text-gray-500 dark:text-white">
|
||||
{item.submissionUpdatedAt}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import type { Course } from '$lib/utils/types';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { CoursesPage } from '$features/course/pages';
|
||||
import { courseMetaDeta } from '$features/course/utils/store';
|
||||
import { fetchExploreCourses } from '$lib/utils/services/courses';
|
||||
|
||||
let searchValue = $state('');
|
||||
let selectedId: string | undefined = $state('');
|
||||
let hasFetched = false;
|
||||
let exploreCourseList: Course[] = $state([]);
|
||||
|
||||
const filteredExploreCourses: Course[] = $derived(filterCourses(searchValue, selectedId || '', exploreCourseList));
|
||||
|
||||
function getCourses(userId?: string, orgId?: string) {
|
||||
if (hasFetched || !userId || !orgId) {
|
||||
return;
|
||||
}
|
||||
|
||||
untrack(async () => {
|
||||
$courseMetaDeta.isLoading = true;
|
||||
|
||||
const coursesResult = await fetchExploreCourses(userId, orgId);
|
||||
|
||||
$courseMetaDeta.isLoading = false;
|
||||
|
||||
if (!coursesResult) return;
|
||||
|
||||
exploreCourseList = coursesResult.allCourses;
|
||||
hasFetched = true;
|
||||
});
|
||||
}
|
||||
|
||||
function filterCourses(searchValue: string, _selectedId: string | null, courses: Course[]) {
|
||||
if (_selectedId) {
|
||||
localStorage.setItem('classroomio_filter_course_key', _selectedId);
|
||||
}
|
||||
|
||||
const coursesFiltered = courses.filter((course) => {
|
||||
if (!searchValue || course.title.toLowerCase().includes(searchValue.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (_selectedId === '0') {
|
||||
return coursesFiltered.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
||||
} else if (_selectedId === '1') {
|
||||
return coursesFiltered.sort((a, b) => (b.total_lessons ?? 0) - (a.total_lessons ?? 0));
|
||||
}
|
||||
|
||||
return coursesFiltered;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const courseView = localStorage.getItem('courseView') as 'grid' | 'list' | null;
|
||||
|
||||
if (courseView) {
|
||||
$courseMetaDeta.view = courseView;
|
||||
}
|
||||
|
||||
selectedId = localStorage.getItem('classroomio_filter_course_key') || '0';
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
getCourses($profile.id, $currentOrg.id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<CoursesPage
|
||||
courses={filteredExploreCourses}
|
||||
emptyTitle={$t('explore.empty_heading')}
|
||||
emptyDescription={$t('explore.empty_description')}
|
||||
isExplore={true}
|
||||
isLMS={true}
|
||||
bind:searchValue
|
||||
bind:selectedId
|
||||
/>
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as DashboardPage } from './dashboard.svelte';
|
||||
export { default as ExplorePage } from './explore.svelte';
|
||||
export { default as MyLearningPage } from './mylearning.svelte';
|
||||
export { default as ExercisesPage } from './exercises.svelte';
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
|
||||
import { CoursesPage } from '$features/course/pages';
|
||||
import { fetchCourses } from '$lib/utils/services/courses';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { courses, courseMetaDeta, coursesComplete, coursesInProgress } from '$features/course/utils/store';
|
||||
|
||||
let hasFetched = false;
|
||||
let searchValue = $state('');
|
||||
|
||||
function getCourses(userId?: string, orgId?: string) {
|
||||
if (hasFetched || !userId || !orgId) {
|
||||
return;
|
||||
}
|
||||
|
||||
untrack(async () => {
|
||||
hasFetched = true;
|
||||
|
||||
if (!$courses.length) {
|
||||
$courseMetaDeta.isLoading = true;
|
||||
}
|
||||
|
||||
const coursesResult = await fetchCourses(userId, orgId);
|
||||
console.log(`get courses result`, coursesResult);
|
||||
|
||||
$courseMetaDeta.isLoading = false;
|
||||
if (!coursesResult) return;
|
||||
|
||||
courses.set(coursesResult.allCourses);
|
||||
hasFetched = true;
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
getCourses($profile.id, $currentOrg.id);
|
||||
});
|
||||
|
||||
let tabs = $derived([
|
||||
{
|
||||
label: `${$t('my_learning.progress')} (${$coursesInProgress.length})`,
|
||||
value: '1'
|
||||
},
|
||||
{
|
||||
label: `${$t('my_learning.complete')} (${$coursesComplete.length})`,
|
||||
value: '2'
|
||||
}
|
||||
]);
|
||||
let currentTab = $state('1');
|
||||
</script>
|
||||
|
||||
<UnderlineTabs.Root bind:value={currentTab}>
|
||||
<UnderlineTabs.List>
|
||||
{#each tabs as tab}
|
||||
<UnderlineTabs.Trigger value={tab.value}>
|
||||
{$t(tab.label)}
|
||||
</UnderlineTabs.Trigger>
|
||||
{/each}
|
||||
</UnderlineTabs.List>
|
||||
<UnderlineTabs.Content value={tabs[0].value}>
|
||||
<CoursesPage
|
||||
courses={$coursesInProgress}
|
||||
emptyTitle={$t('my_learning.not_in_progress')}
|
||||
emptyDescription={$t('my_learning.any_progress')}
|
||||
bind:searchValue
|
||||
isLMS={true}
|
||||
/>
|
||||
</UnderlineTabs.Content>
|
||||
<UnderlineTabs.Content value={tabs[1].value}>
|
||||
<CoursesPage
|
||||
courses={$coursesComplete}
|
||||
emptyTitle={$t('my_learning.not_completed')}
|
||||
emptyDescription={$t('my_learning.any_course')}
|
||||
bind:searchValue
|
||||
isLMS={true}
|
||||
/>
|
||||
</UnderlineTabs.Content>
|
||||
</UnderlineTabs.Root>
|
||||
Reference in New Issue
Block a user