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
@@ -2,9 +2,7 @@
import { SkeletonPlaceholder, SkeletonText } from 'carbon-components-svelte';
</script>
<div
class="root w-72 border bg-gray-100 dark:bg-black mr-4 mb-4 rounded-md transition ease-in-out relative"
>
<div class="root relative mb-4 mr-4 w-72 rounded-md border bg-gray-100 transition ease-in-out dark:bg-black">
<SkeletonPlaceholder style="width: 100%; height: 10rem;" />
<div class="p-3 pb-0">
<!-- Title -->
@@ -13,7 +11,7 @@
<SkeletonText style="width: 100%; margin-top: 1rem;" />
<SkeletonText style="width: 100%; margin-bottom: 1rem;" />
<div class="flex justify-between mb-3">
<div class="mb-3 flex justify-between">
<SkeletonText style="width: 25%" />
<SkeletonText style="width: 25%" />
</div>
@@ -6,41 +6,56 @@
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
import { t } from '$lib/utils/functions/translations';
import { COURSE_TYPE } from '$lib/utils/types';
import {
ImageLoader,
OverflowMenu,
OverflowMenuItem,
SkeletonPlaceholder,
Tag
} from 'carbon-components-svelte';
import GrowthIcon from 'carbon-icons-svelte/lib/Growth.svelte';
import RadioButtonChecked from 'carbon-icons-svelte/lib/RadioButtonChecked.svelte';
import UserProfileIcon from 'carbon-icons-svelte/lib/UserProfile.svelte';
import { ImageLoader, OverflowMenu, OverflowMenuItem, SkeletonPlaceholder, Tag } from 'carbon-components-svelte';
import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
import CircleDotIcon from '@lucide/svelte/icons/circle-dot';
import UserIcon from '@lucide/svelte/icons/user';
import { copyCourseModal, deleteCourseModal } from '$lib/components/Courses/store';
export let bannerImage: string | undefined;
export let id = '';
export let slug = '';
export let title = '';
export let description = '';
export let isPublished = false;
export let totalLessons = 0;
export let totalStudents = 0;
export let currency = 'USD';
export let isOnLandingPage = false;
export let isLMS = false;
export let isExplore = false;
export let progressRate = 45;
export let type: COURSE_TYPE;
export let pricingData: {
cost: number;
currency?: string;
showDiscount?: boolean;
discount?: number;
} = {
cost: 0
};
$: formatter = getCurrencyFormatter(currency);
interface Props {
bannerImage: string | undefined;
id?: string;
slug?: string;
title?: string;
description?: string;
isPublished?: boolean;
totalLessons?: number;
totalStudents?: number;
currency?: string;
isOnLandingPage?: boolean;
isLMS?: boolean;
isExplore?: boolean;
progressRate?: number;
type: COURSE_TYPE;
pricingData?: {
cost: number;
currency?: string;
showDiscount?: boolean;
discount?: number;
};
}
let {
bannerImage,
id = '',
slug = '',
title = '',
description = '',
isPublished = false,
totalLessons = 0,
totalStudents = 0,
currency = 'USD',
isOnLandingPage = false,
isLMS = false,
isExplore = false,
progressRate = 45,
type,
pricingData = {
cost: 0
}
}: Props = $props();
let formatter = $derived(getCurrencyFormatter(currency));
function handleCloneCourse() {
$copyCourseModal.open = true;
@@ -75,41 +90,36 @@
[COURSE_TYPE.LIVE_CLASS]: {
style: '',
label: $t('course.navItem.settings.live_class'),
icon: RadioButtonChecked,
iconStyle: 'text-red-700'
icon: CircleDotIcon,
iconStyle: 'custom text-red-700'
},
[COURSE_TYPE.SELF_PACED]: {
style: '',
label: $t('course.navItem.settings.self_paced'),
icon: UserProfileIcon,
iconStyle: 'text-primary-700'
icon: UserIcon,
iconStyle: 'custom text-primary-700'
},
SPECIALIZATION: {
style: '',
label: $t('specialization.course_tag'),
icon: GrowthIcon
icon: TrendingUpIcon
}
};
$: cost = calcCourseDiscount(
pricingData.discount,
pricingData.cost ?? 0,
!!pricingData.showDiscount
);
let cost = $derived(calcCourseDiscount(pricingData.discount, pricingData.cost ?? 0, !!pricingData.showDiscount));
$: courseUrl =
isOnLandingPage || isExplore
? `/course/${slug}`
: `/courses/${id}${isLMS ? '/lessons?next=true' : ''}`;
let courseUrl = $derived(
isOnLandingPage || isExplore ? `/course/${slug}` : `/courses/${id}${isLMS ? '/lessons?next=true' : ''}`
);
</script>
<div
role="button"
tabindex="0"
on:click={(e) => {
onclick={() => {
goto(courseUrl);
}}
on:keydown={(e) => {
onkeydown={(e) => {
if (e.key === 'Enter') {
goto(courseUrl);
}
@@ -124,29 +134,10 @@
size="sm"
on:click={(e) => e.stopPropagation()}
>
<OverflowMenuItem
text={$t('courses.course_card.context_menu.clone')}
on:click={(e) => {
e.stopPropagation();
handleCloneCourse();
}}
/>
<OverflowMenuItem
text={$t('courses.course_card.context_menu.share')}
on:click={handleShareCourse}
/>
<OverflowMenuItem
text={$t('courses.course_card.context_menu.invite')}
on:click={handleInvite}
/>
<OverflowMenuItem
danger
text={$t('courses.course_card.context_menu.delete')}
on:click={(e) => {
e.stopPropagation();
handleDeleteCourse();
}}
/>
<OverflowMenuItem text={$t('courses.course_card.context_menu.clone')} on:click={handleCloneCourse} />
<OverflowMenuItem text={$t('courses.course_card.context_menu.share')} on:click={handleShareCourse} />
<OverflowMenuItem text={$t('courses.course_card.context_menu.invite')} on:click={handleInvite} />
<OverflowMenuItem danger text={$t('courses.course_card.context_menu.delete')} on:click={handleDeleteCourse} />
</OverflowMenu>
{/if}
@@ -158,14 +149,16 @@
<svelte:fragment slot="loading">
<SkeletonPlaceholder style="width: 100%; height: 200px;" />
</svelte:fragment>
<svelte:fragment slot="error">{$t('courses.course_card.error_message')}</svelte:fragment>
<svelte:fragment slot="errror">
{$t('courses.course_card.error_message')}
</svelte:fragment>
</ImageLoader>
{#if type}
{@const tag = COURSE_TAG[type]}
<span
class="bg-primary-50 absolute bottom-2 left-2 z-10 flex items-center gap-1 rounded-sm p-1 font-mono text-xs capitalize"
>
<svelte:component this={tag.icon} size={16} class={tag.iconStyle} />
<tag.icon class={tag.iconStyle} />
{tag.label}
</span>
{/if}
@@ -204,10 +197,7 @@
{#if !isExplore}
<div class="flex items-center gap-2">
<div class=" relative h-1 w-[50px] bg-[#EAEAEA]">
<div
style="width:{progressRate}%"
class={`bg-primary-700 absolute left-0 top-0 h-full`}
/>
<div style="width:{progressRate}%" class="bg-primary-700 absolute left-0 top-0 h-full"></div>
</div>
<p class="text-xs text-[#656565] dark:text-white">{progressRate}%</p>
</div>
@@ -226,9 +216,7 @@
{#if isLMS}
<PrimaryButton
label={isExplore
? $t('courses.course_card.learn_more')
: $t('courses.course_card.continue_course')}
label={isExplore ? $t('courses.course_card.learn_more') : $t('courses.course_card.continue_course')}
variant={VARIANTS.OUTLINED}
className="rounded-none"
/>
@@ -1,4 +1,6 @@
<script lang="ts">
import { preventDefault } from '$lib/utils/functions/svelte';
import { goto } from '$app/navigation';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import Modal from '$lib/components/Modal/index.svelte';
@@ -11,12 +13,6 @@
import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
interface CloneCourseResponse {
success: boolean;
course: { id: string };
error?: string;
}
// clone, show spinner and redirect to new course
async function createCourse() {
// Prevent double submission (Option 1: Early Return Guard)
@@ -39,7 +35,7 @@
}
});
const result = (await response.json()) as CloneCourseResponse;
const result = (await response.json());
if (!response.ok) {
throw new Error(result.error || $t('courses.copy_course.error.failed_to_clone'));
@@ -71,7 +67,7 @@
width="w-96"
modalHeading={$t('courses.copy_course.title')}
>
<form on:submit|preventDefault={createCourse}>
<form onsubmit={preventDefault(createCourse)}>
<TextField
label={$t('courses.copy_course.course_name_label')}
bind:value={$copyCourseModal.title}
@@ -96,7 +92,6 @@
label={$t('courses.copy_course.create_button')}
type="submit"
isLoading={$copyCourseModal.isSaving}
isDisabled={$copyCourseModal.isSaving}
/>
</div>
</form>
@@ -1,5 +1,5 @@
<script>
import { SkeletonPlaceholder, SkeletonText } from 'carbon-components-svelte';
import { SkeletonText } from 'carbon-components-svelte';
</script>
<div class="my-2">
@@ -1,22 +1,28 @@
<script lang="ts">
import {
StructuredListRow,
StructuredListCell,
Tag,
OverflowMenuItem,
OverflowMenu
} from 'carbon-components-svelte';
import { StructuredListRow, StructuredListCell, Tag, OverflowMenuItem, OverflowMenu } from 'carbon-components-svelte';
import { isMobile } from '$lib/utils/store/useMobile';
import { goto } from '$app/navigation';
import { t } from '$lib/utils/functions/translations';
export let id = '';
export let title = '';
export let type = '';
export let description = '';
export let isPublished = false;
export let totalLessons = 0;
export let totalStudents = 0;
interface Props {
id?: string;
title?: string;
type?: string;
description?: string;
isPublished?: boolean;
totalLessons?: number;
totalStudents?: number;
}
let {
id = '',
title = '',
type = '',
description = '',
isPublished = false,
totalLessons = 0,
totalStudents = 0
}: Props = $props();
function handleCloneCourse(e) {
e.stopPropagation();
@@ -70,23 +76,10 @@
e.stopPropagation();
}}
>
<OverflowMenuItem
text={$t('courses.course_card.context_menu.clone')}
on:click={handleCloneCourse}
/>
<OverflowMenuItem
text={$t('courses.course_card.context_menu.share')}
on:click={handleShareCourse}
/>
<OverflowMenuItem
text={$t('courses.course_card.context_menu.invite')}
on:click={handleInvite}
/>
<OverflowMenuItem
danger
text={$t('courses.course_card.context_menu.delete')}
on:click={handleDeleteCourse}
/>
<OverflowMenuItem text={$t('courses.course_card.context_menu.clone')} on:click={handleCloneCourse} />
<OverflowMenuItem text={$t('courses.course_card.context_menu.share')} on:click={handleShareCourse} />
<OverflowMenuItem text={$t('courses.course_card.context_menu.invite')} on:click={handleInvite} />
<OverflowMenuItem danger text={$t('courses.course_card.context_menu.delete')} on:click={handleDeleteCourse} />
</OverflowMenu>
</StructuredListCell>
</StructuredListRow>
@@ -1,6 +1,8 @@
<script lang="ts">
import { preventDefault } from '$lib/utils/functions/svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { page } from '$app/state';
import ComingSoon from '$lib/components/ComingSoon/index.svelte';
import { validateForm } from '$lib/components/Courses/functions';
import { courses, createCourseModal } from '$lib/components/Courses/store';
@@ -17,15 +19,14 @@
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { COURSE_TYPE, COURSE_VERSION } from '$lib/utils/types';
import CheckmarkFilledIcon from 'carbon-icons-svelte/lib/CheckmarkFilled.svelte';
import CheckmarkOutlineIcon from 'carbon-icons-svelte/lib/CheckmarkOutline.svelte';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
let isLoading = false;
let errors = {
let isLoading = $state(false);
let errors = $state({
title: '',
description: ''
};
let step = 0;
});
let step = $state(0);
const options = [
{
@@ -43,7 +44,7 @@
isDisabled: false
}
];
let type = options[0].type;
let type = $state(options[0].type);
function onClose(redirectTo) {
goto(redirectTo);
@@ -132,7 +133,7 @@
isLoading = false;
}
$: open = new URLSearchParams($page.url.search).get('create') === 'true';
let open = $derived(new URLSearchParams(page.url.search).get('create') === 'true');
</script>
<svelte:head>
@@ -140,7 +141,7 @@
</svelte:head>
<Modal
onClose={() => onClose($page.url.pathname)}
onClose={() => onClose(page.url.pathname)}
bind:open
width="w-4/5 md:w-2/5 md:min-w-[600px]"
containerClass="max-w-2xl mx-auto"
@@ -155,25 +156,16 @@
<div class="my-8 flex flex-col items-center justify-evenly gap-4 md:flex-row">
{#each options as option}
<button
class="w-11/12 rounded-md border-2 p-5 dark:bg-neutral-700 md:h-[240px] md:w-[261px] {option.type ===
type
class="w-11/12 rounded-md border-2 p-5 md:h-[240px] md:w-[261px] dark:bg-neutral-700 {option.type === type
? 'border-primary-400'
: `border-gray-200 dark:border-neutral-600 ${
!option.isDisabled && 'hover:scale-95'
}`} flex flex-col {option.isDisabled &&
'cursor-not-allowed opacity-60'} transition-all ease-in-out"
}`} flex flex-col {option.isDisabled && 'cursor-not-allowed opacity-60'} transition-all ease-in-out"
type="button"
on:click={!option.isDisabled ? () => (type = option.type) : undefined}
onclick={!option.isDisabled ? () => (type = option.type) : undefined}
>
<div class="flex h-[70%] w-full flex-row-reverse">
{#if option.type === type}
<CheckmarkFilledIcon
size={16}
class="carbon-icon text-primary-600 dark:text-primary-200"
/>
{:else if !option.isDisabled}
<CheckmarkOutlineIcon size={16} class="carbon-icon" />
{/if}
<CircleCheckIcon size={16} filled={option.type === type} />
</div>
<div>
@@ -199,7 +191,7 @@
</div>
</div>
{:else}
<form on:submit|preventDefault={createCourse}>
<form onsubmit={preventDefault(createCourse)}>
<div class="mb-4 flex items-end space-x-2">
<TextField
label={$t('courses.new_course_modal.course_name')}
@@ -1,5 +1,3 @@
import { validateEmail } from '$lib/utils/functions/validateEmail';
export function validateForm(fields) {
let hasError = false;
const { title, description } = fields;
@@ -20,6 +20,20 @@
import { deleteCourse } from '$lib/utils/services/courses';
import { snackbar } from '$lib/components/Snackbar/store';
import { deleteCourseModal, deleteCourseModalInitialState, courses as coursesStore } from './store';
interface Props {
courses?: Course[];
emptyTitle?: any;
emptyDescription?: any;
isExplore?: boolean;
}
let {
courses = [],
emptyTitle = $t('courses.course_card.empty_title'),
emptyDescription = $t('courses.course_card.empty_description'),
isExplore = false
}: Props = $props();
export let courses: Course[] = [];
export let emptyTitle = $t('courses.course_card.empty_title');
@@ -96,7 +110,7 @@
<StructuredListCell head>
{$t('courses.course_card.list_view.published')}
</StructuredListCell>
<StructuredListCell head>{''}</StructuredListCell>
<StructuredListCell head></StructuredListCell>
</StructuredListRow>
</StructuredListHead>
<StructuredListBody>
@@ -139,7 +153,7 @@
</div>
{#if !$courseMetaDeta.isLoading && !courses.length}
<Box className="w-full">
<CoursesEmptyIcon />
<CoursesEmptyIcon size={16} />
<h3 class="my-5 text-2xl dark:text-white">{emptyTitle}</h3>
<p class="w-1/3 text-center dark:text-white">
{emptyDescription}