feat: setup drizzle (#594)

* chore: migration to SvelteKit 2

* stage 1: migrate to svelte 5 and fix rerendering issues

* stage 2: fix more $: reactivity across dashboard

* fix merge conflicts

* chore: migrate code in main to svelte 5

* chore: update preventDefault

* chore: simplify tsconfig and remove unused mobileMenuRef in MobileMenu.svelte

* chore: enforce .ts across dashboard

* rename from classroomio-com to website

* stage-3: Replace carbon icon svelte with lucide icon

* update lock file

* chore: finish icon replacement

* fix: settings and setup page multiple rerender

* stage 3: More icon and rerendering fixes

* feat: setup drizzle and better auth

* fix: onboarding validation error showing translation key #586 (#593)

* fix: onboarding validation error showing translation key #586

* fix: remove debug log for profile update errors

* chore: add a clean command

* fix: remove message from email verification response
This commit is contained in:
Rotimi Best
2025-11-01 15:49:29 +01:00
committed by GitHub
parent 97fb446df5
commit b96d91cd7c
801 changed files with 22279 additions and 10834 deletions
@@ -1,34 +1,19 @@
<script lang="ts">
import { onMount } from 'svelte';
import { sideBar } from '$lib/components/Org/store';
import { apps } from '$lib/components/Apps/store';
import { globalStore } from '$lib/utils/store/app';
import APPS_CONSTANTS from '$lib/components/Apps/constants';
import hotkeys from 'hotkeys-js';
function handleClose() {
$apps.selectedApp = undefined;
if ($globalStore.isStudent) {
$apps.open = false;
}
interface Props {
children?: import('svelte').Snippet;
}
function handleAppClick(appName?: string) {
if (appName === $apps.selectedApp) {
handleClose();
} else {
$apps.selectedApp = appName;
}
$apps.open = true;
$apps.dropdown = !$apps.dropdown;
}
let { children }: Props = $props();
function toggleSidebar() {
$sideBar.hidden = !$sideBar.hidden;
}
onMount(() => {
hotkeys('ctrl+b,command+b,ctrl+1,command+1,ctrl+2,command+2', function (event, handler) {
hotkeys('ctrl+b,command+b', function (event, handler) {
// Prevent default behavior
event.preventDefault();
@@ -39,17 +24,9 @@
case 'command+b':
toggleSidebar();
break;
case 'ctrl+1':
case 'command+1':
handleAppClick(APPS_CONSTANTS.APPS.COMMENTS);
break;
case 'ctrl+2':
case 'command+2':
handleAppClick(APPS_CONSTANTS.APPS.POLL);
break;
}
});
});
</script>
<slot />
{@render children?.()}
@@ -1,13 +1,13 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { page } from '$app/state';
import Box from '$lib/components/Box/index.svelte';
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 { isNewFeedModal, newsFeed } from '$lib/components/Course/components/NewsFeed/store';
import { group } from '$lib/components/Course/store';
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
import { CourseContainer } from '$lib/components/CourseContainer';
import { PageBody, PageNav } from '$lib/components/Page';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
@@ -23,32 +23,35 @@
handleEditFeed,
toggleFeedIsPinned
} from '$lib/utils/services/newsfeed';
import {
NOTIFICATION_NAME,
triggerSendEmail
} from '$lib/utils/services/notification/notification';
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 type { CurrentOrg } from '$lib/utils/types/org';
import PinFilled from 'carbon-icons-svelte/lib/PinFilled.svelte';
import PinIcon from '@lucide/svelte/icons/pin';
import { onMount } from 'svelte';
export let data;
let { data = $bindable() } = $props();
let createdComment;
let edit = false;
let isLoading = false;
let editFeed: Feed;
let author = {
id: '',
username: '',
fullname: '',
avatar_url: ''
};
let edit = $state(false);
let isLoading = $state(false);
let editFeed: Feed | null = $state(null);
let query = new URLSearchParams($page.url.search);
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.avatar_url || ''
};
});
const onEdit = (id: string, content: string) => {
handleEditFeed(id, content);
@@ -81,9 +84,7 @@
let reactedAuthorIds: string[] = reactedFeed.reaction[reactionType];
if (reactedAuthorIds.includes(authorId)) {
reactedAuthorIds = reactedAuthorIds.filter(
(reactionAuthorId) => reactionAuthorId !== authorId
);
reactedAuthorIds = reactedAuthorIds.filter((reactionAuthorId) => reactionAuthorId !== authorId);
} else {
reactedAuthorIds = [...reactedAuthorIds, authorId];
}
@@ -199,10 +200,12 @@
snackbar.error('snackbar.course.error.news_feed_fail');
}
if (data) {
$newsFeed = data.map((feedItem) => ({
...feedItem,
isPinned: feedItem.is_pinned
}));
$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');
@@ -211,17 +214,6 @@
}
};
function setAuthor(groups, profileId) {
const currentGroupMember = groups.people.find((person) => person.profile_id === profileId);
author = {
id: currentGroupMember?.id || '',
username: $profile.username || '',
fullname: $profile.fullname || '',
avatar_url: $profile.avatar_url || ''
};
}
function getPageRoles(org: CurrentOrg) {
const roles: number[] = [1, 2];
@@ -232,13 +224,12 @@
return roles;
}
$: initNewsFeed(data.courseId);
$: setAuthor($group, $profile.id);
$: $newsFeed = $newsFeed.sort((a, b) => Number(b.isPinned) - Number(a.isPinned));
onMount(() => {
initNewsFeed(data.courseId);
});
</script>
<CourseContainer bind:courseId={data.courseId}>
<CourseContainer courseId={data.courseId}>
<RoleBasedSecurity
allowedRoles={getPageRoles($currentOrg)}
onDenied={() => {
@@ -246,7 +237,7 @@
}}
>
<PageNav title={$t('course.navItem.news_feed.heading')} disableSticky={true}>
<slot:fragment slot="widget">
{#snippet widget()}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<PrimaryButton
className="mr-2"
@@ -254,7 +245,7 @@
onClick={() => ($isNewFeedModal.open = true)}
/>
</RoleBasedSecurity>
</slot:fragment>
{/snippet}
</PageNav>
<PageBody width="max-w-4xl px-3">
@@ -290,7 +281,7 @@
{#each $newsFeed as feed}
{#if feed.isPinned}
<div class="mb-3 flex items-center gap-2">
<PinFilled size={16} />
<PinIcon size={16} class="filled" />
<p class="text-sm">{$t('course.navItem.news_feed.pinned')}</p>
</div>
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
import ChartBarIcon from '@lucide/svelte/icons/chart-bar';
import { CourseContainer } from '$lib/components/CourseContainer';
import { PageBody, PageNav } from '$lib/components/Page';
import { t } from '$lib/utils/functions/translations';
import { getAccessToken } from '$lib/utils/functions/supabase';
@@ -11,17 +12,16 @@
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 { ChartBar } from 'carbon-icons-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';
export let data;
let { data = $bindable() } = $props();
let courseAnalytics: CourseAnalytics | null = null;
let isLoading = true;
let courseAnalytics: CourseAnalytics | null = $state(null);
let isLoading = $state(true);
async function fetchCourseAnalytics() {
try {
@@ -58,7 +58,7 @@
});
</script>
<CourseContainer bind:courseId={data.courseId}>
<CourseContainer courseId={data.courseId}>
<PageNav title={$t('course.navItem.analytics.title')} />
<PageBody width="w-full max-w-6xl md:w-11/12">
{#if isLoading}
@@ -114,9 +114,7 @@
</div>
<!-- Students Overview Table -->
<div
class="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-neutral-800"
>
<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')}
@@ -132,7 +130,7 @@
<EmptyState
title={$t('analytics.no_analytics_data')}
description={$t('analytics.no_analytics_description')}
icon={ChartBar}
icon={ChartBarIcon}
className="h-screen"
/>
{/if}
@@ -1,10 +1,9 @@
<script lang="ts">
import { browser } from '$app/environment';
import Box from '$lib/components/Box/index.svelte';
import { lessons } from '$lib/components/Course/components/Lesson/store/lessons';
import { getLectureNo } from '$lib/components/Course/function.js';
import { course, group } from '$lib/components/Course/store';
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
import { CourseContainer } from '$lib/components/CourseContainer';
import { PageBody, PageNav } from '$lib/components/Page';
import { snackbar } from '$lib/components/Snackbar/store';
import { ROLE } from '$lib/utils/constants/roles';
@@ -24,9 +23,9 @@
StructuredListHead,
StructuredListRow
} from 'carbon-components-svelte';
import AudioConsoleIcon from 'carbon-icons-svelte/lib/AudioConsole.svelte';
import BadgeXIcon from '@lucide/svelte/icons/badge-x';
export let data;
let { data = $bindable() } = $props();
interface CourseData {
attendance: {
@@ -37,8 +36,14 @@
}[];
}
let students: GroupPerson[] = [];
let searchValue = '';
let hasFetched = $state(false);
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('');
function setAttendance(courseData: CourseData) {
for (const attendanceItem of courseData.attendance) {
@@ -115,34 +120,30 @@
// function for the searchbar
function searchStudents(query: string, _students: GroupPerson[]) {
const lowercaseQuery = query.toLowerCase();
return _students.filter((student) =>
student.profile?.fullname?.toLowerCase()?.includes(lowercaseQuery)
);
return _students.filter((student) => student.profile?.fullname?.toLowerCase()?.includes(lowercaseQuery));
}
async function firstRender(courseId: string) {
if (courseId) {
if (!Object.keys($attendance).length) {
setAttendance($course);
}
return;
async function firstRender(courseId?: string) {
if (!courseId || hasFetched) return;
hasFetched = true;
if (!Object.keys($attendance).length) {
setAttendance($course);
}
return;
}
$: students = $globalStore.isStudent
? $group.people.filter((person) => !!person.profile && person.profile.id === $profile.id)
: $group.people.filter((person) => !!person.profile && person.role_id === ROLE.STUDENT);
$: browser && $course.id && firstRender($course.id);
$effect(() => {
firstRender($course.id);
});
</script>
<CourseContainer bind:courseId={data.courseId}>
<CourseContainer courseId={data.courseId}>
<PageNav title={$t('course.navItem.attendance.title')} />
<PageBody width="w-full max-w-6xl md:w-11/12">
<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 w-full flex-col items-start justify-between gap-2 lg:flex-row lg:items-center">
<div class="flex">
<p class="mr-5 flex items-center">
<Checkbox checked disabled />
@@ -171,11 +172,9 @@
<StructuredListCell head class="text-primary-600 py-3"
>{$t('course.navItem.attendance.student')}</StructuredListCell
>
{#each $lessons as lesson, index}
{#each $lessons as _lesson, index}
<StructuredListCell head class="text-primary-600 py-3"
>{$t('course.navItem.attendance.lesson')} 0{getLectureNo(
index + 1
)}</StructuredListCell
>{$t('course.navItem.attendance.lesson')} 0{getLectureNo(index + 1)}</StructuredListCell
>
{/each}
</StructuredListRow>
@@ -211,7 +210,7 @@
</StructuredList>
{#if students.length === 0}
<Box className="h-[300px] w-full">
<AudioConsoleIcon size={32} class="carbon-icon w-80" />
<BadgeXIcon size={48} />
<h3 class="text-center text-3xl text-gray-500 dark:text-white">
{$t('course.navItem.attendance.no_student')}
</h3>
@@ -1,6 +1,6 @@
<script lang="ts">
import IssueCertificateModal from '$lib/components/Course/components/Ceritficate/IssueCertificateModal.svelte';
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
import { CourseContainer } from '$lib/components/CourseContainer';
import { PageBody, PageNav } from '$lib/components/Page';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
// import DeleteConfirmation from '$lib/components/Course/components/People/DeleteConfirmation.svelte';
@@ -12,23 +12,25 @@
import StudentCertificate from '$lib/components/Course/components/Ceritficate/StudentCertificate/Index.svelte';
import { t } from '$lib/utils/functions/translations';
export let data;
let { data = $bindable() } = $props();
</script>
<IssueCertificateModal />
<CourseContainer bind:courseId={data.courseId}>
<CourseContainer courseId={data.courseId}>
<PageNav title={$t('course.navItem.certificates.title')} disableSticky={true}>
<slot:fragment slot="widget">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<!-- Hide Functionality For V1 -->
<!-- <PrimaryButton
className="mr-2 rounded-md text-sm font-medium"
label="Issue Cert"
onClick={() => ($issueCertificateModal.open = true)}
/> -->
</RoleBasedSecurity>
</slot:fragment>
{#snippet widget()}
<slot:fragment>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<!-- Hide Functionality For V1 -->
<!-- <PrimaryButton
className="mr-2 rounded-md text-sm font-medium"
label="Issue Cert"
onClick={() => ($issueCertificateModal.open = true)}
/> -->
</RoleBasedSecurity>
</slot:fragment>
{/snippet}
</PageNav>
<PageBody className="w-full mx-0" padding="px-3 pb-10">
@@ -1,41 +1,40 @@
<script lang="ts">
import { dev } from '$app/environment';
import { fly } from 'svelte/transition';
import { course } from '$lib/components/Course/store';
import type { Course, Lesson, LessonSection } from '$lib/utils/types';
import type { Course } from '$lib/utils/types';
import { lessons, lessonSections } from '$lib/components/Course/components/Lesson/store/lessons';
import { CourseContainer } from '$lib/components/CourseContainer';
import CourseLandingPage from '$lib/components/CourseLandingPage/index.svelte';
import Editor from '$lib/components/CourseLandingPage/components/Editor/index.svelte';
export let data;
let { data } = $props();
const { courseId } = data;
let courseData: Course = $course;
function setCourseData(course: Course, lessons: Lesson[], lesson_section: LessonSection[]) {
courseData = { ...course, lessons, lesson_section };
}
let courseData: Course = $derived({
...$course,
$lessons,
$lessonSections
});
function syncCourseStore(_courseData: Course) {
$course = _courseData;
}
$: setCourseData($course, $lessons, $lessonSections);
$: dev && console.log('courseData changed', courseData);
</script>
<div
class="absolute flex inset-0 z-50 bg-white"
in:fly={{ y: 500, duration: 500 }}
out:fly={{ y: 500, duration: 500 }}
>
<Editor {courseId} bind:course={courseData} {syncCourseStore} />
<div class="rightBar">
<CourseLandingPage bind:courseData editMode={true} />
<CourseContainer {courseId} renderOnlyChildren={true}>
<div
class="absolute inset-0 z-50 flex bg-white"
in:fly={{ y: 500, duration: 500 }}
out:fly={{ y: 500, duration: 500 }}
>
<Editor {courseId} bind:course={courseData} {syncCourseStore} />
<div class="rightBar">
<CourseLandingPage bind:courseData editMode={true} />
</div>
</div>
</div>
</CourseContainer>
<style>
.rightBar {
@@ -1,6 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { page } from '$app/state';
import Box from '$lib/components/Box/index.svelte';
import ActivateSectionsModal from '$lib/components/Course/components/Lesson/ActivateSectionsModal.svelte';
import DeleteLessonConfirmation from '$lib/components/Course/components/Lesson/DeleteLessonConfirmation.svelte';
@@ -8,13 +8,9 @@
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 { handleDelete, lessons, lessonSections } from '$lib/components/Course/components/Lesson/store/lessons';
import { course } from '$lib/components/Course/store';
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
import { CourseContainer } from '$lib/components/CourseContainer';
import { PageBody, PageNav } from '$lib/components/Page';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
@@ -24,16 +20,18 @@
import type { Lesson } from '$lib/utils/types';
import { COURSE_VERSION } from '$lib/utils/types';
export let data;
let { data } = $props();
const query = new URLSearchParams($page.url.search);
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;
let openDeleteModal: boolean = false;
let isFetching: boolean = false;
let reorder = false;
let activateSections = false;
let lessonToDelete: Lesson | undefined = $state();
let openDeleteModal: boolean = $state(false);
let isFetching: boolean = $state(false);
let reorder = $state(false);
let activateSections = $state(false);
function addLesson() {
$handleAddLessonWidget.open = true;
@@ -77,48 +75,50 @@
}
}
$: shouldGoToNextLesson = query.get('next') === 'true';
$: !isFetching && shouldGoToNextLesson && onNextQuery($lessons);
$: lessonsLength =
$course.version === COURSE_VERSION.V1 ? $lessons.length : $lessonSections.length;
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)}
/>
<DeleteLessonConfirmation bind:openDeleteModal deleteLesson={() => handleDelete(lessonToDelete?.id)} />
<CourseContainer bind:isFetching bind:courseId={data.courseId}>
<CourseContainer
onFetchingChange={(fetching) => {
isFetching = fetching;
}}
courseId={data.courseId}
>
<PageNav title={$t('course.navItem.lessons.heading_v2')}>
<div slot="widget" class="flex w-full justify-end gap-2">
<RoleBasedSecurity allowedRoles={[1, 2]}>
{#if $course.version === COURSE_VERSION.V1}
{#snippet widget()}
<div class="flex w-full justify-end gap-2">
<RoleBasedSecurity allowedRoles={[1, 2]}>
{#if $course.version === COURSE_VERSION.V1}
<PrimaryButton
label={$t(`course.navItem.lessons.section_prompt.cta`)}
variant={VARIANTS.OUTLINED}
onClick={() => (activateSections = !activateSections)}
isDisabled={!!lessonEditing}
/>
{/if}
<PrimaryButton
label={$t(`course.navItem.lessons.section_prompt.cta`)}
label={$t(`course.navItem.lessons.add_lesson.${reorder ? 'end_reorder' : 'start_reorder'}`)}
variant={VARIANTS.OUTLINED}
onClick={() => (activateSections = !activateSections)}
onClick={() => (reorder = !reorder)}
isDisabled={!!lessonEditing}
/>
{/if}
<PrimaryButton
label={$t(
`course.navItem.lessons.add_lesson.${reorder ? 'end_reorder' : 'start_reorder'}`
)}
variant={VARIANTS.OUTLINED}
onClick={() => (reorder = !reorder)}
isDisabled={!!lessonEditing}
/>
<PrimaryButton
label={$t('course.navItem.lessons.add_lesson.button_title')}
onClick={addLesson}
isDisabled={!!lessonEditing}
/>
</RoleBasedSecurity>
</div>
<PrimaryButton
label={$t('course.navItem.lessons.add_lesson.button_title')}
onClick={addLesson}
isDisabled={!!lessonEditing}
/>
</RoleBasedSecurity>
</div>
{/snippet}
</PageNav>
<PageBody width="max-w-6xl" padding="p-0">
@@ -1,4 +1,4 @@
export const load = ({ params = { id: '' } }) => {
export const load = ({ params }) => {
const { id: courseId, lessonParams = '' } = params;
const splitparams = lessonParams.split('/');
@@ -1,95 +1,83 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
import {
checkExercisesComplete,
fetchLesson,
updateLessonCompletion
} from '$lib/utils/services/courses';
import { page } from '$app/state';
import { CourseContainer } from '$lib/components/CourseContainer';
import { checkExercisesComplete, fetchLesson, updateLessonCompletion } from '$lib/utils/services/courses';
import { globalStore } from '$lib/utils/store/app';
import CheckmarkFilledIcon from 'carbon-icons-svelte/lib/CheckmarkFilled.svelte';
import CheckmarkOutlineIcon from 'carbon-icons-svelte/lib/CheckmarkOutline.svelte';
import ListChecked from 'carbon-icons-svelte/lib/ListChecked.svelte';
import ListChecksIcon from '@lucide/svelte/icons/list-checks';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
import PencilIcon from '@lucide/svelte/icons/pencil';
import SaveIcon from '@lucide/svelte/icons/save';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import HistoryIcon from '@lucide/svelte/icons/history';
import { browser } from '$app/environment';
import { apps } from '$lib/components/Apps/store';
import Exercises from '$lib/components/Course/components/Lesson/Exercises/index.svelte';
import { getIsLessonComplete } from '$lib/components/Course/components/Lesson/functions';
import LessonVersionHistory from '$lib/components/Course/components/Lesson/LessonVersionHistory.svelte';
import Materials from '$lib/components/Course/components/Lesson/Materials/index.svelte';
import {
lesson,
lessonByTranslation,
lessons,
lessonSections
} from '$lib/components/Course/components/Lesson/store/lessons';
import { lesson, setLesson, lessons, lessonSections } from '$lib/components/Course/components/Lesson/store/lessons';
import { getGroupMemberId } from '$lib/components/Course/function';
import { course, group } from '$lib/components/Course/store';
import IconButton from '$lib/components/IconButton/index.svelte';
import CourseIcon from '$lib/components/Icons/CourseIcon.svelte';
import { IconButton } from '$lib/components/IconButton';
import { PageBody, PageNav } from '$lib/components/Page';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { snackbar } from '$lib/components/Snackbar/store';
import MODES from '$lib/utils/constants/mode';
import { LANGUAGES } from '$lib/utils/constants/translation';
import { t } from '$lib/utils/functions/translations';
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { COURSE_VERSION, type Lesson, type LessonCompletion } from '$lib/utils/types';
import { Dropdown } from 'carbon-components-svelte';
import { ChevronLeft, ChevronRight, Edit, Save } from 'carbon-icons-svelte';
import OverflowMenuVertical from 'carbon-icons-svelte/lib/OverflowMenuVertical.svelte';
import ResultOld from 'carbon-icons-svelte/lib/ResultOld.svelte';
import { classroomio } from '$lib/utils/services/api';
export let data;
let { data = $bindable() } = $props();
let path = '';
let mode = MODES.view;
let prevMode = '';
let isMarkingComplete = false;
let isLoading = false;
let isSaving = false;
let isLessonComplete = false;
let isVersionDrawerOpen = false;
let mode = $state(MODES.view);
let prevMode = $state('');
let isMarkingComplete = $state(false);
let isSaving = $state(false);
let isVersionDrawerOpen = $state(false);
let hasFetched = $state('');
function getLessonOrder(id: string) {
const index = $lessons.findIndex((lesson) => lesson.id === id);
if (index < 10) {
return '0' + (index + 1);
} else {
return index + 1;
}
}
const path = $derived(page.url?.pathname?.replace(/\/exercises[\/ 0-9 a-z -]*/, ''));
const isLessonComplete = $derived(getIsLessonComplete($lesson.lesson_completion, $profile.id));
async function fetchReqData(lessonId = '', isMaterialsTabActive: boolean) {
const timeout = setTimeout(() => {
$lesson.isFetching = true;
}, 500);
if (lessonId === $lesson.id) return;
if (!lessonId || hasFetched === lessonId) return;
hasFetched = lessonId;
$lesson.isFetching = true;
console.log('fetching', $lesson.isFetching);
let lessonData;
if (isMaterialsTabActive) {
const lesson = await fetchLesson(lessonId);
lessonData = lesson.data;
clearTimeout(timeout);
}
console.log({ lessonData });
const totalExercises = lessonData?.totalExercises?.[0]?.count || 0;
const totalComments = lessonData?.totalComments?.[0]?.count || 0;
setLesson(lessonData, totalExercises, totalComments);
setLesson({
id: lessonId,
lessonData,
totalExercises,
totalComments,
locale: $profile.locale
});
$lesson.isFetching = false;
}
async function markLessonComplete(lessonId: string) {
const groupMemberId = getGroupMemberId($group.people, $profile.id);
const { data: areExercisesComplete, error } = await checkExercisesComplete(
lessonId,
groupMemberId
);
const { data: areExercisesComplete, error } = await checkExercisesComplete(lessonId, groupMemberId);
if (error) {
snackbar.error('snackbar.lessons.error.try_later');
@@ -147,115 +135,11 @@
isMarkingComplete = false;
}
const downloadLesson = async () => {
const currentLesson = $lessons.find((l) => l.id === $lesson?.id);
if (!currentLesson) {
return;
}
isLoading = true;
try {
const lessonVideo = $lesson.materials.videos.map((video) => video.link);
const lessonNumber = getLessonOrder(currentLesson.id);
const slideUrl = $lesson.materials.slide_url || '';
const response = await classroomio.course.lesson.download.pdf.$post({
json: {
title: currentLesson.title,
number: lessonNumber,
orgName: $currentOrg.name,
note: $lesson.materials.note,
slideUrl: slideUrl,
video: lessonVideo,
courseTitle: $course.title
}
});
const blob = await response.blob();
const file = new Blob([blob], { type: 'application/pdf' });
const fileURL = URL.createObjectURL(file);
let a = document.createElement('a');
document.body.append(a);
a.download = $course.title + ' - ' + 'Lesson ' + lessonNumber;
a.href = fileURL;
a.click();
a.remove();
snackbar.success('snackbar.lessons.success.complete_download');
} catch (error) {
console.log('error downloading lesson', error);
snackbar.error('snackbar.lessons.error.try_later');
}
isLoading = false;
};
function setLesson(lessonData, totalExercises: number, totalComments: number) {
if (!lessonData) return;
let lesson_completion: LessonCompletion[] = [];
if (Array.isArray(lessonData.lesson_completion)) {
lesson_completion = [...lessonData.lesson_completion];
}
lesson.update((l) => ({
...l,
id: data.lessonId,
title: lessonData.title,
totalExercises,
totalComments: totalComments,
materials: {
videos: lessonData.videos,
note: lessonData.note,
slide_url: lessonData.slide_url,
documents: lessonData.documents || null
},
lesson_completion,
exercises: [],
locale: $profile.locale
}));
if (Array.isArray(lessonData.lesson_language)) {
lessonByTranslation.update((lessLocales) => {
return {
...lessLocales,
[data.lessonId]: lessonData.lesson_language.reduce(
(acc, cur) => {
acc[cur.locale] = cur.content;
return acc;
},
{
en: ''
}
)
};
});
}
}
const toggleApps = () => {
$apps.open = !$apps.open;
};
function toggleMode() {
prevMode = mode;
mode = mode === MODES.edit ? MODES.view : MODES.edit;
}
function handleAppClick(appName: string) {
if ($apps.selectedApp === appName) {
$apps.selectedApp = undefined;
$apps.open = false;
return;
}
$apps.selectedApp = appName;
$apps.open = true;
}
const getLessons = () => {
if ($course.version === COURSE_VERSION.V1) {
return $lessons;
@@ -299,31 +183,31 @@
isVersionDrawerOpen = false;
if (data.courseId && browser) {
mode = MODES.view;
hasFetched = '';
fetchReqData(data.lessonId, data.isMaterialsTabActive);
}
snackbar.success('snackbar.lessons.success.version_restored');
};
$: path = $page.url?.pathname?.replace(/\/exercises[\/ 0-9 a-z -]*/, '');
$effect(() => {
if (data.courseId && $profile.id) {
fetchReqData(data.lessonId, data.isMaterialsTabActive);
}
});
$: if (data.courseId && $profile.id && browser) {
mode = MODES.view;
fetchReqData(data.lessonId, data.isMaterialsTabActive);
}
$: isLessonComplete = getIsLessonComplete($lesson.lesson_completion, $profile.id);
$: isPrevDisabled = isNextOrPrevDisabled(data.lessonId, true);
$: isNextDisabled = isNextOrPrevDisabled(data.lessonId, false);
const isPrevDisabled = $derived(isNextOrPrevDisabled(data.lessonId, true));
const isNextDisabled = $derived(isNextOrPrevDisabled(data.lessonId, false));
</script>
<CourseContainer
{path}
isExercisePage={!data.isMaterialsTabActive && !!data.exerciseId}
bind:courseId={data.courseId}
containerClass="relative"
courseId={data.courseId}
isExercisePage={!data.isMaterialsTabActive && !!data.exerciseId}
>
<PageNav
bind:hideOnMobile={$globalStore.isStudent}
hideOnMobile={$globalStore.isStudent}
navItems={[
{
label: $t('course.navItem.lessons.lesson_nav.materials'),
@@ -338,40 +222,28 @@
}
]}
>
<svelte:fragment slot="widget">
{#snippet widget()}
<div class="flex items-center gap-1">
<RoleBasedSecurity allowedRoles={[1, 2]}>
{#if data.isMaterialsTabActive}
<!-- Version control -->
{#if mode === MODES.edit && window.innerWidth >= 1024}
<IconButton onClick={() => (isVersionDrawerOpen = true)}>
<ResultOld size={24} />
<HistoryIcon size={20} />
</IconButton>
{/if}
<div class="tab">
<IconButton onClick={toggleApps} buttonClassName="">
<OverflowMenuVertical size={24} />
</IconButton>
</div>
<div
class={`flex-row ${
$apps.dropdown && $apps.open
? 'absolute right-14 top-[85px] z-40 rounded-md bg-gray-100 p-3 lg:relative lg:right-0 lg:top-0 lg:p-0 dark:bg-neutral-800'
: 'hidden'
} items-center lg:flex`}
>
<div class="hidden flex-row items-center lg:flex">
<IconButton
onClick={() => {
$apps.dropdown = false;
toggleMode();
}}
disabled={isSaving}
>
{#if mode === MODES.edit}
<Save size={24} />
<SaveIcon size={20} />
{:else}
<Edit size={24} />
<PencilIcon size={20} />
{/if}
</IconButton>
</div>
@@ -380,17 +252,13 @@
{/if}
</RoleBasedSecurity>
</div>
</svelte:fragment>
{/snippet}
</PageNav>
{#if !data.isMaterialsTabActive}
<Exercises lessonId={data.lessonId} exerciseId={data.exerciseId} path={`${path}/exercises`} />
{:else if !!data.lessonId}
<PageBody
bind:isPageNavHidden={$globalStore.isStudent}
width="lg:w-full xl:w-11/12"
className="overflow-x-hidden"
>
<PageBody isPageNavHidden={$globalStore.isStudent} width="lg:w-full xl:w-11/12" className="overflow-x-hidden">
<!-- Shows Lesson Note / Slides / Video -->
<Materials
lessonId={data.lessonId}
@@ -405,54 +273,48 @@
<!-- Bottom Lesson Widget -->
<div class="absolute bottom-5 flex w-full items-center justify-center">
<div
class="flex w-fit items-center gap-2 rounded-full bg-gray-100 px-5 py-1 shadow-xl dark:bg-neutral-700"
>
<div class="flex w-fit items-center gap-2 rounded-full bg-gray-100 px-5 py-1 shadow-xl dark:bg-neutral-700">
<button
disabled={isPrevDisabled}
class={`my-2 flex items-center border border-b-0 border-l-0 border-t-0 border-gray-300 px-2 pr-4 ${
isPrevDisabled && 'cursor-not-allowed opacity-25'
}`}
on:click={() => goToNextOrPrevLesson(data.lessonId, true)}
onclick={() => goToNextOrPrevLesson(data.lessonId, true)}
>
<ChevronLeft size={24} />
<ChevronLeftIcon size={16} />
<span class="hidden md:block">{$t('course.navItem.lessons.prev')}</span>
</button>
{#if data.isMaterialsTabActive}
<button
class="my-2 flex items-center border border-b-0 border-l-0 border-t-0 border-gray-300 px-2 pr-4"
on:click={() => goto(`${path}/exercises`)}
onclick={() => goto(`${path}/exercises`)}
>
<ListChecked size={24} class="carbon-icon" />
<ListChecksIcon size={16} />
<span class="ml-1">{$lesson.totalExercises}</span>
</button>
{:else}
<button
class="my-2 flex items-center border border-b-0 border-l-0 border-t-0 border-gray-300 px-2 pr-4"
on:click={() => goto(path)}
onclick={() => goto(path)}
>
<CourseIcon />
<LibraryBigIcon size={16} />
</button>
{/if}
<button
class="my-2 flex items-center border border-b-0 border-l-0 border-t-0 border-gray-300 px-2 pr-4"
on:click={() => markLessonComplete(data.lessonId)}
onclick={() => markLessonComplete(data.lessonId)}
disabled={isMarkingComplete}
>
{#if isLessonComplete}
<CheckmarkFilledIcon size={24} class="carbon-icon text-primary-600" />
{:else}
<CheckmarkOutlineIcon size={24} class="carbon-icon" />
{/if}
<CircleCheckIcon filled={isLessonComplete} />
</button>
<button
disabled={isNextDisabled}
class={`my-2 flex items-center px-2 ${isNextDisabled && 'cursor-not-allowed opacity-25'}`}
on:click={() => goToNextOrPrevLesson(data.lessonId, false)}
onclick={() => goToNextOrPrevLesson(data.lessonId, false)}
>
<span class="hidden md:block">{$t('course.navItem.lessons.next')}</span>
<ChevronRight size={24} />
<ChevronRightIcon size={16} />
</button>
</div>
</div>
@@ -1,49 +1,47 @@
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import Box from '$lib/components/Box/index.svelte';
import { lessons } from '$lib/components/Course/components/Lesson/store/lessons';
import { getLectureNo } from '$lib/components/Course/function.js';
import { course, group } from '$lib/components/Course/store';
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
import { course } from '$lib/components/Course/store';
import { CourseContainer } from '$lib/components/CourseContainer';
import { PageBody, PageNav } from '$lib/components/Page';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { snackbar } from '$lib/components/Snackbar/store';
import { ROLE } from '$lib/utils/constants/roles';
import { t } from '$lib/utils/functions/translations';
import { fetchExercisesByMarks } from '$lib/utils/services/courses';
import { fetchMarks } from '$lib/utils/services/marks';
import { globalStore } from '$lib/utils/store/app';
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import type { GroupPerson } from '$lib/utils/types';
import type { CurrentOrg } from '$lib/utils/types/org';
import { Loading, OverflowMenu, OverflowMenuItem } from 'carbon-components-svelte';
import AudioConsoleIcon from 'carbon-icons-svelte/lib/AudioConsole.svelte';
import Download from 'carbon-icons-svelte/lib/Download.svelte';
import BadgeXIcon from '@lucide/svelte/icons/badge-x';
import DownloadIcon from '@lucide/svelte/icons/download';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import Papa from 'papaparse';
export let data;
let { data } = $props();
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[] = [];
let lessonMapping = {}; // { lessonId: { exerciseId: exerciseTitle, ... }, ... }
let studentMarksByExerciseId = {}; // { groupMemberId: { exerciseId: `total_gotten/points`, ... }, ... }
let isDownloading = false;
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 isDownloading = $state(false);
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
);
return Object.values(studentExerciseData).reduce((total: number, point) => (total += parseInt(point as string)), 0);
}
async function firstRender(courseId: string) {
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);
@@ -204,14 +202,12 @@
}
}
$: students = $globalStore.isStudent
? $group.people.filter((person) => !!person.profile && person.profile.id === $profile.id)
: $group.people.filter((person) => !!person.profile && person.role_id === ROLE.STUDENT);
$: browser && $course.id && firstRender($course.id);
$effect(() => {
firstRender($course.id);
});
</script>
<CourseContainer bind:courseId={data.courseId}>
<CourseContainer courseId={data.courseId}>
<RoleBasedSecurity
allowedRoles={getPageRoles($currentOrg)}
onDenied={() => {
@@ -219,22 +215,24 @@
}}
>
<PageNav title={$t('course.navItem.marks.title')}>
<slot:fragment slot="widget">
{#snippet widget()}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<OverflowMenu flipped style="border-radius: 50px" class="bg-gray-100 dark:bg-neutral-800">
<div slot="menu" style="">
{#if isDownloading}
<Loading withOverlay={false} small />
{:else}
<Download />
{/if}
<div style="">
{#if isDownloading}
<Loading withOverlay={false} small />
{:else}
<DownloadIcon size={16} />
{/if}
</div>
</div>
<OverflowMenuItem text={$t('course.navItem.marks.export.csv')} on:click={downloadCSV} />
<OverflowMenuItem text={$t('course.navItem.marks.export.pdf')} on:click={downloadPDF} />
</OverflowMenu>
</RoleBasedSecurity>
</slot:fragment>
{/snippet}
</PageNav>
<PageBody width="w-full max-w-6xl md:w-11/12">
@@ -249,9 +247,7 @@
<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"
>
<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}"
@@ -273,11 +269,7 @@
{#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"
/>
<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}
@@ -311,7 +303,7 @@
</div>
{:else}
<Box>
<AudioConsoleIcon size={32} class="carbon-icon w-80" />
<BadgeXIcon size={16} />
<h3 class="text-3xl text-gray-500 dark:text-white">
{$t('course.navItem.marks.no_student')}
</h3>
@@ -1,21 +1,21 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import { page } from '$app/state';
import { IconButton } from '$lib/components/IconButton';
import { PageBody, PageNav } from '$lib/components/Page';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { t } from '$lib/utils/functions/translations';
import ArrowLeft from 'carbon-icons-svelte/lib/ArrowLeft.svelte';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import { CourseContainer } from '$lib/components/CourseContainer';
export let data;
let { data = $bindable(), children } = $props();
// Get back URL from query parameters
$: backUrl = $page.url.searchParams.get('back');
let backUrl = $derived(page.url.searchParams.get('back'));
const handleClick = () => {
const url = $page.url.href + '?add=true';
const url = page.url.href + '?add=true';
goto(url);
};
@@ -28,30 +28,26 @@
};
</script>
<CourseContainer bind:courseId={data.courseId}>
<CourseContainer courseId={data.courseId}>
<PageNav title={$t('course.navItem.people.title')} disableSticky={true}>
<slot:fragment slot="image">
{#snippet image()}
{#if data.personId}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<IconButton size="large" onClick={handleBackNavigation}>
<ArrowLeft size={16} class="carbon-icon dark:text-white " />
<ArrowLeftIcon size={16} />
</IconButton>
</RoleBasedSecurity>
{/if}
</slot:fragment>
<slot:fragment slot="widget">
{/snippet}
{#snippet widget()}
{#if !data.personId}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<PrimaryButton
className="mr-2"
label={$t('course.navItem.people.add')}
onClick={handleClick}
/>
<PrimaryButton className="mr-2" label={$t('course.navItem.people.add')} onClick={handleClick} />
</RoleBasedSecurity>
{/if}
</slot:fragment>
{/snippet}
</PageNav>
<PageBody width="w-full max-w-6xl md:w-11/12">
<slot />
{@render children?.()}
</PageBody>
</CourseContainer>
@@ -1,6 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { page } from '$app/state';
import Avatar from '$lib/components/Avatar/index.svelte';
import TextChip from '$lib/components/Chip/Text.svelte';
import ComingSoon from '$lib/components/ComingSoon/index.svelte';
@@ -10,7 +10,7 @@
import type { ProfileRole } from '$lib/components/Course/components/People/types';
import { group } from '$lib/components/Course/store';
import Select from '$lib/components/Form/Select.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import { IconButton } from '$lib/components/IconButton';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
@@ -28,12 +28,13 @@
StructuredListHead,
StructuredListRow
} from 'carbon-components-svelte';
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte';
import TrashIcon from '@lucide/svelte/icons/trash';
let people: Array<GroupPerson> = [];
let member: { id?: string; email?: string; profile?: { email: string } } = {};
let filterBy: ProfileRole = ROLES[0];
let searchValue = '';
let member: { id?: string; email?: string; profile?: { email: string } } = $state({});
let filterBy: ProfileRole = $state(ROLES[0]);
let searchValue = $state('');
const people: Array<GroupPerson> = $derived(sortAndFilterPeople($group.people, filterBy));
function filterPeople(_query, people) {
const query = _query.toLowerCase();
@@ -52,16 +53,13 @@
}
function sortAndFilterPeople(_people: Array<GroupPerson>, filterBy: ProfileRole) {
people = (_people || [])
return (_people || [])
.filter((person) => {
if (filterBy.value === 'all') return true;
return person.role_id === filterBy.value;
})
.sort(
(a: GroupPerson, b: GroupPerson) =>
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
)
.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);
}
@@ -80,23 +78,16 @@
}
function gotoPerson(person) {
goto(`${$page.url.href}/${person.profile_id}`);
goto(`${page.url.href}/${person.profile_id}`);
}
$: sortAndFilterPeople($group.people, filterBy);
</script>
<InvitationModal />
<DeleteConfirmation
email={member.email || (member.profile && member.profile.email)}
{deletePerson}
/>
<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="flex-end mb-7 flex flex-col items-start justify-end gap-2 md:flex-row md:items-center">
<div class="max-w-[320px]">
<Search
class="w-full border-0 bg-zinc-100 dark:text-slate-950"
@@ -117,14 +108,12 @@
</select> -->
</div>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<p class="hidden w-20 text-lg dark:text-white lg:block" />
<p class="hidden w-20 text-lg lg:block dark:text-white"></p>
</RoleBasedSecurity>
</div>
<StructuredList class="m-0">
<StructuredListHead
class="bg-slate-100 dark:border-2 dark:border-neutral-800 dark:bg-neutral-800"
>
<StructuredListHead class="bg-slate-100 dark:border-2 dark:border-neutral-800 dark:bg-neutral-800">
<StructuredListRow head class="mx-7">
<StructuredListCell head class="text-primary-700 py-3 dark:text-white"
>{$t('course.navItem.people.name')}</StructuredListCell
@@ -136,7 +125,7 @@
>{$t('course.navItem.people.action')}</StructuredListCell
>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<p class="hidden w-20 text-lg dark:text-white lg:block" />
<p class="hidden w-20 text-lg lg:block dark:text-white"></p>
</RoleBasedSecurity>
</StructuredListRow>
</StructuredListHead>
@@ -181,18 +170,12 @@
className="bg-primary-200 text-black font-semibold text-xs mr-3"
shape="rounded-full"
/>
<a
href="mailto:{person.email}"
class="text-md text-primary-600 mr-2 dark:text-white"
>
<a href="mailto:{person.email}" class="text-md text-primary-600 mr-2 dark:text-white">
{person.email}
</a>
<div class="flex items-center justify-between">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<CopyButton
text={getEmail(person)}
feedback={$t('course.navItem.people.feedback')}
/>
<CopyButton text={getEmail(person)} feedback={$t('course.navItem.people.feedback')} />
</RoleBasedSecurity>
<TextChip
@@ -223,12 +206,12 @@
$deleteMemberModal.open = true;
}}
>
<TrashCanIcon size={16} class="carbon-icon dark:text-white" />
<TrashIcon size={16} />
</IconButton>
<!-- <IconButton
onClick={() => gotoPerson(person)}
>
<TrashCanIcon size={16} class="carbon-icon dark:text-white" />
<TrashIcon size={16} />
</IconButton> -->
<PrimaryButton
@@ -6,15 +6,15 @@
import { t } from '$lib/utils/functions/translations';
import type { UserCourseAnalytics } from '$lib/utils/types/analytics';
import { Grid, Tag } from 'carbon-components-svelte';
import Notebook from 'carbon-icons-svelte/lib/Notebook.svelte';
import Report from 'carbon-icons-svelte/lib/Report.svelte';
import RowExpand from 'carbon-icons-svelte/lib/RowExpand.svelte';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import ChartLineIcon from '@lucide/svelte/icons/chart-line';
import UnfoldVerticalIcon from '@lucide/svelte/icons/unfold-vertical';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
export let data;
let { data } = $props();
let userCourseAnalytics: UserCourseAnalytics;
let userCourseAnalytics: UserCourseAnalytics | undefined = $state();
function getPercentage(a: number, b: number): number {
if (b === 0) {
@@ -23,7 +23,7 @@
return Math.round((a / b) * 100);
}
let exerciseFilter: 'all' | 'completed' | 'incomplete' = 'all';
let exerciseFilter: 'all' | 'completed' | 'incomplete' = $state('all');
function toggleExerciseFilter(filter: 'completed' | 'incomplete') {
if (exerciseFilter === filter) {
exerciseFilter = 'all';
@@ -55,42 +55,44 @@
fetchUserCourseAnalytics();
});
$: learningActivities = [
let learningActivities = $derived([
{
description: $t('analytics.overall_course_progress_user_description'),
icon: Notebook,
icon: BookOpenIcon,
percentage: userCourseAnalytics?.progressPercentage,
title: $t('analytics.overall_course_progress')
},
{
description: $t('analytics.assignment_completion_description'),
icon: Report,
icon: ChartLineIcon,
percentage: getPercentage(
userCourseAnalytics?.userExercisesStats?.filter((exercise) => exercise.isCompleted)?.length,
userCourseAnalytics?.userExercisesStats?.length
userCourseAnalytics?.userExercisesStats?.filter((exercise) => exercise.isCompleted)?.length || 0,
userCourseAnalytics?.userExercisesStats?.length || 0
),
title: $t('analytics.assignment_completion')
},
{
description: $t('analytics.average_grade_description'),
icon: RowExpand,
icon: UnfoldVerticalIcon,
percentage: userCourseAnalytics?.averageGrade,
title: $t('analytics.average_grade')
}
];
]);
$: filteredExercises = userCourseAnalytics?.userExercisesStats?.filter((exercise) => {
if (exerciseFilter === 'all') {
return true;
}
return exercise.isCompleted === (exerciseFilter === 'completed');
});
$: completedExercises = userCourseAnalytics?.userExercisesStats?.filter(
(exercise) => exercise.isCompleted
)?.length;
$: incompleteExercises = userCourseAnalytics?.userExercisesStats?.filter(
(exercise) => !exercise.isCompleted
)?.length;
let filteredExercises = $derived(
userCourseAnalytics?.userExercisesStats?.filter((exercise) => {
if (exerciseFilter === 'all') {
return true;
}
return exercise.isCompleted === (exerciseFilter === 'completed');
})
);
let completedExercises = $derived(
userCourseAnalytics?.userExercisesStats?.filter((exercise) => exercise.isCompleted)?.length || 0
);
let incompleteExercises = $derived(
userCourseAnalytics?.userExercisesStats?.filter((exercise) => !exercise.isCompleted)?.length || 0
);
</script>
{#if userCourseAnalytics}
@@ -122,9 +124,7 @@
})}
</p>
</div>
<Progress
value={getPercentage(completedExercises, userCourseAnalytics?.userExercisesStats?.length)}
/>
<Progress value={getPercentage(completedExercises, userCourseAnalytics?.userExercisesStats?.length)} />
<div class="flex items-center justify-between">
<Tag
interactive
@@ -160,7 +160,7 @@
transition:fade={{ duration: 300 }}
>
<div class="flex w-2/3 items-center gap-4">
<svelte:component this={Notebook} size={24} class="text-black" />
<BookOpenIcon size={16} />
<div>
<div class="mb-2">
<a
@@ -189,13 +189,7 @@
</div>
</div>
<Tag
class={`${
exercise.isCompleted
? 'bg-green-200 text-green-700'
: 'bg-yellow-200 text-yellow-700'
}`}
>
<Tag class={`${exercise.isCompleted ? 'bg-green-200 text-green-700' : 'bg-yellow-200 text-yellow-700'}`}>
{exercise.isCompleted ? $t('analytics.completed') : $t('analytics.incomplete')}
</Tag>
</div>
@@ -1,13 +1,13 @@
<script>
<script lang="ts">
import Settings from '$lib/components/Course/components/Settings/index.svelte';
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
import { PageBody, PageNav } from '$lib/components/Page';
import { t } from '$lib/utils/functions/translations';
import { CourseContainer } from '$lib/components/CourseContainer';
export let data;
let { data } = $props();
</script>
<CourseContainer bind:courseId={data.courseId}>
<CourseContainer courseId={data.courseId}>
<PageNav title={$t('course.navItem.settings.heading')} />
<PageBody>
<Settings />
@@ -1,22 +1,18 @@
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { page } from '$app/state';
import Chip from '$lib/components/Chip/index.svelte';
import MarkExerciseModal from '$lib/components/Course/components/Lesson/Exercise/MarkExerciseModal.svelte';
import { formatAnswers } from '$lib/components/Course/function.js';
import { course } from '$lib/components/Course/store';
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
import { CourseContainer } from '$lib/components/CourseContainer';
import { PageBody, PageNav } from '$lib/components/Page';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { snackbar } from '$lib/components/Snackbar/store';
import formatDate from '$lib/utils/functions/formatDate';
import isSubmissionEarly from '$lib/utils/functions/isSubmissionEarly';
import { t } from '$lib/utils/functions/translations';
import {
NOTIFICATION_NAME,
triggerSendEmail
} from '$lib/utils/services/notification/notification';
import { NOTIFICATION_NAME, triggerSendEmail } from '$lib/utils/services/notification/notification';
import {
deleteSubmission,
fetchSubmissions,
@@ -24,16 +20,12 @@
updateSubmission
} from '$lib/utils/services/submissions';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import type {
SubmissionIdData,
SubmissionItem,
SubmissionSection
} from '$lib/utils/types/submission';
import type { SubmissionIdData, SubmissionItem, SubmissionSection } from '$lib/utils/types/submission';
import { SkeletonPlaceholder } from 'carbon-components-svelte';
import { dndzone } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
export let data;
let { data = $bindable() } = $props();
const { courseId } = data;
const flipDurationMs = 300;
@@ -41,12 +33,16 @@
let lessonDetails: { id: string; title: string };
let totalMark = 0;
let maxMark = 0;
let submissionIdData: { [key: number]: SubmissionIdData } = {};
let submissionId: string;
let openExercise = false;
let isGradeWithAI = false;
let fetching = false;
let isSaving = false;
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'),
@@ -54,7 +50,7 @@
3: $t('course.navItem.submissions.submission_status.graded')
};
let sections: SubmissionSection[] = [
let sections: SubmissionSection[] = $state([
{
id: 1,
title: $t('course.navItem.submissions.submission_status.submitted'),
@@ -73,7 +69,7 @@
value: 10,
items: []
}
];
]);
function getMaxPoints(questions) {
return (questions || []).reduce((acc, question) => acc + question.points, 0);
@@ -163,7 +159,7 @@
function handleModalClose() {
isGradeWithAI = false;
goto($page.url.pathname);
goto(page.url.pathname);
}
// Via dialog
@@ -238,11 +234,7 @@
handleModalClose();
}
async function handleSave(submission: {
questionAnswerByPoint: any;
questionAnswers: any;
feedback: any;
}) {
async function handleSave(submission: { questionAnswerByPoint: any; questionAnswers: any; feedback: any }) {
isSaving = true;
const { questionAnswerByPoint, questionAnswers, feedback } = submission;
@@ -279,7 +271,11 @@
isSaving = false;
}
async function firstRender(courseId: string) {
async function firstRender(courseId?: string) {
if (!courseId || hasFetched) return;
hasFetched = true;
fetching = true;
const { data: submissions } = await fetchSubmissions(courseId);
const sectionById: { [key: number]: SubmissionSection[] } = {};
@@ -345,15 +341,13 @@
value: Array.isArray(sectionById[index + 1]) ? sectionById[index + 1].length : 0,
items: Array.isArray(sectionById[index + 1]) ? sectionById[index + 1] : []
}));
fetching = false;
}
$: browser && $course.id && firstRender($course.id);
$: {
const query = new URLSearchParams($page.url.search);
submissionId = query.get('submissionId') ?? '';
openExercise = !!submissionId && submissionIdData[submissionId];
}
$effect(() => {
firstRender($course.id);
});
</script>
<MarkExerciseModal
@@ -367,7 +361,7 @@
{isSaving}
/>
<CourseContainer bind:courseId={data.courseId}>
<CourseContainer courseId={data.courseId}>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<PageNav title={$t('course.navItem.submissions.title')} />
@@ -394,8 +388,8 @@
flipDurationMs,
dropTargetStyle: { outline: 'blue' }
}}
on:consider={handleDndConsiderCards(idx)}
on:finalize={handleDndFinalizeCards(idx)}
onconsider={handleDndConsiderCards(idx)}
onfinalize={handleDndFinalizeCards(idx)}
>
{#each items as item (item.id)}
<div
@@ -406,27 +400,20 @@
>
<a
class="mb-2 flex w-full cursor-pointer items-center text-black"
href={`${$page.url.pathname}?submissionId=${item.id}`}
href={`${page.url.pathname}?submissionId=${item.id}`}
>
<img
alt="Student avatar"
class="block h-6 w-6 rounded-full"
src={item.student.avatar_url}
/>
<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="text-primary-700 text-md font-bold"
href="{$page.url.pathname}?submissionId={item.id}"
>
<a class="text-primary-700 text-md font-bold" 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}"
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}