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:
Rotimi Best
2025-12-22 02:36:48 +01:00
committed by GitHub
co-authored by Abdulmuizz Hamzat
parent e6a3cf9332
commit 9fb6c7cbaa
401 changed files with 10489 additions and 11679 deletions
@@ -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>