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,5 +1,5 @@
<script>
import { page } from '$app/stores';
<script lang="ts">
import { page } from '$app/state';
import AddOrgModal from '$lib/components/Org/AddOrgModal/AddOrgModal.svelte';
import { isQuizPage } from '$lib/utils/functions/app';
import OrgSideBar from '$lib/components/Org/SideBar.svelte';
@@ -8,29 +8,31 @@
import { currentOrg } from '$lib/utils/store/org';
import { goto } from '$app/navigation';
export let data;
let { data, children } = $props();
let ref = null;
$: if ($currentOrg.id && data.orgName === '*') {
const newUrl = $page.url.pathname.replace('*', $currentOrg.siteName);
goto(newUrl + $page.url.search);
function redirect(siteName: string) {
const newUrl = page.url.pathname.replace('*', siteName);
goto(newUrl + page.url.search);
}
$effect(() => {
data.orgName === '*' && redirect($currentOrg.siteName);
});
</script>
<AddOrgModal />
<VerifyEmailModal />
<div class="org-root w-full flex items-center justify-between">
{#if !isQuizPage($page.url?.pathname)}
<div class="org-root flex w-full items-center justify-between">
{#if !isQuizPage(page.url?.pathname)}
<OrgSideBar />
{/if}
<div class="org-slot bg-white dark:bg-black w-full">
<div class="org-slot w-full bg-white dark:bg-black">
{#if data.orgName === '*'}
<Box>Taking you to your organization...</Box>
{:else}
<slot />
{@render children?.()}
{/if}
</div>
</div>
@@ -1,9 +1,9 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ActivityCard } from '$lib/components/Analytics';
import CourseIcon from '$lib/components/Icons/CourseIcon.svelte';
import Progress from '$lib/components/Progress/index.svelte';
import UserProfile from 'carbon-icons-svelte/lib/UserProfile.svelte';
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
import UserIcon from '@lucide/svelte/icons/user';
import { snackbar } from '$lib/components/Snackbar/store';
import { calDateDiff } from '$lib/utils/functions/date';
@@ -11,10 +11,11 @@
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import type { OrganisationAnalytics } from '$lib/utils/types/analytics';
import Add from 'carbon-icons-svelte/lib/Add.svelte';
import Book from 'carbon-icons-svelte/lib/Book.svelte';
import CurrencyDollar from 'carbon-icons-svelte/lib/CurrencyDollar.svelte';
import UserMultiple from 'carbon-icons-svelte/lib/UserMultiple.svelte';
import PlusIcon from '@lucide/svelte/icons/plus';
import BookIcon from '@lucide/svelte/icons/book';
import DollarSignIcon from '@lucide/svelte/icons/dollar-sign';
import UsersIcon from '@lucide/svelte/icons/users';
import Avatar from '$lib/components/Avatar/index.svelte';
import VisitOrgSiteButton from '$lib/components/Buttons/VisitOrgSite.svelte';
@@ -27,16 +28,17 @@
import { isMobile } from '$lib/utils/store/useMobile';
import { Grid, Link, SkeletonPlaceholder } from 'carbon-components-svelte';
// export let data;
let dashAnalytics: OrganisationAnalytics;
let dashAnalytics: OrganisationAnalytics | undefined = $state();
let hasFetched = $state(false);
function createCourse() {
goto(`${$currentOrgPath}/courses?create=true`);
}
async function fetchDashAnalytics(orgId: string) {
if (!orgId) return;
if (!orgId || hasFetched) return;
hasFetched = true;
const accessToken = await getAccessToken();
@@ -61,31 +63,33 @@
}
}
$: fetchDashAnalytics($currentOrg.id);
$effect(() => {
fetchDashAnalytics($currentOrg.id);
});
$: cards = [
let cards = $derived([
{
icon: CurrencyDollar,
icon: DollarSignIcon,
title: `${$t('dashboard.revenue')} ($)`,
percentage: dashAnalytics?.revenue ?? 0,
description: $t('dashboard.revenue_description'),
hidePercentage: true
},
{
icon: Book,
icon: BookIcon,
title: $t('dashboard.no_of_courses'),
percentage: dashAnalytics?.numberOfCourses ?? 0,
description: $t('dashboard.no_courses_description'),
hidePercentage: true
},
{
icon: UserMultiple,
icon: UsersIcon,
title: $t('dashboard.total_students'),
percentage: dashAnalytics?.totalStudents ?? 0,
description: $t('dashboard.total_students_description'),
hidePercentage: true
}
];
]);
</script>
<svelte:head>
@@ -96,7 +100,7 @@
<div class="w-full max-w-5xl px-5 py-10 md:mx-auto">
<div class="mb-5 flex items-center justify-between">
<h1 class="mb-3 text-2xl font-bold dark:text-white md:text-3xl">
<h1 class="mb-3 text-2xl dark:text-white">
{$t(getGreeting())}
{$profile.fullname}!
</h1>
@@ -108,7 +112,7 @@
className="min-h-[36px]"
>
{#if $isMobile}
<Add size={24} />
<PlusIcon size={16} />
{:else}
{$t('dashboard.create_course')}
{/if}
@@ -123,10 +127,7 @@
<div class="mt-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each cards as card}
{#if !dashAnalytics}
<SkeletonPlaceholder
style="width: 100%; min-width: 300px; height: 10rem;"
class="rounded-md"
/>
<SkeletonPlaceholder style="width: 100%; min-width: 300px; height: 10rem;" class="rounded-md" />
{:else}
<ActivityCard activity={card} />
{/if}
@@ -136,9 +137,7 @@
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div
class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 dark:border-neutral-600 md:p-5"
>
<div class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:border-neutral-600">
<h3 class="mt-0 text-2xl font-bold">
{$t('dashboard.top_courses')}
</h3>
@@ -173,7 +172,7 @@
{:else}
<div class="flex flex-col h-full items-center justify-center p-3">
<div class="bg-primary-200 w-fit rounded-full p-4 text-black">
<CourseIcon width="30" height="30" />
<LibraryBigIcon size={30} />
</div>
<div class="my-4 text-center">
<p class=" text-xl font-semibold">
@@ -183,20 +182,14 @@
{$t('dashboard.create_first_course_description')}
</p>
</div>
<PrimaryButton
variant={VARIANTS.OUTLINED}
onClick={createCourse}
label={$t('dashboard.create_course')}
/>
<PrimaryButton variant={VARIANTS.OUTLINED} onClick={createCourse} label={$t('dashboard.create_course')} />
</div>
{/each}
{/if}
</div>
</div>
<div
class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 dark:border-neutral-600 md:p-5"
>
<div class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:border-neutral-600">
<h3 class="mt-0 text-2xl font-bold">
{$t('dashboard.recent_enrollments')}
</h3>
@@ -210,12 +203,7 @@
{#each dashAnalytics.enrollments as enrollment}
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Avatar
src={enrollment.avatarUrl}
name={enrollment.name}
width="w-6"
height="h-6"
/>
<Avatar src={enrollment.avatarUrl} name={enrollment.name} width="w-6" height="h-6" />
<div class="min-h-[45px] space-y-1">
<p class="text-sm font-medium capitalize leading-none">{enrollment.name}</p>
@@ -238,7 +226,7 @@
{:else}
<div class="flex flex-col h-full items-center justify-center p-3">
<div class="bg-primary-200 w-fit rounded-full p-4 text-black">
<UserProfile size={24} />
<UserIcon size={16} />
</div>
<div class="my-4 text-center">
<p class=" text-xl font-semibold">
@@ -3,10 +3,10 @@
import Audience from '$lib/components/Org/Audience/index.svelte';
import { t } from '$lib/utils/functions/translations';
import { orgAudience, currentOrgPlan, currentOrgMaxAudience } from '$lib/utils/store/org';
import { PLAN } from 'shared/src/plans/constants';
import { PLAN } from '@cio/utils/plans';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
let isLoading = false;
let isLoading = $state(false);
function exportAudience() {
isLoading = true;
@@ -19,23 +19,18 @@
<title>Audience - ClassroomIO</title>
</svelte:head>
<section class="w-full max-w-4xl mx-auto">
<div class="py-10 px-5">
<div class="flex items-center justify-between mb-10">
<section class="mx-auto w-full max-w-4xl">
<div class="px-5 py-10">
<div class="mb-10 flex items-center justify-between">
<div class="flex items-end">
<h1 class="dark:text-white text-2xl md:text-3xl font-bold m-0">{$t('audience.title')}</h1>
<h1 class="m-0 text-2xl font-bold md:text-3xl dark:text-white">{$t('audience.title')}</h1>
{#if $currentOrgPlan?.plan_name !== PLAN.ENTERPRISE}
<span class="ml-2">
({$orgAudience.length} / {$currentOrgMaxAudience})
</span>
{/if}
</div>
<PrimaryButton
label={$t('audience.export')}
onClick={exportAudience}
isDisabled={isLoading}
{isLoading}
/>
<PrimaryButton label={$t('audience.export')} onClick={exportAudience} isDisabled={isLoading} {isLoading} />
</div>
{#if $orgAudience.length >= $currentOrgMaxAudience}
@@ -7,18 +7,18 @@
import { currentOrgPath } from '$lib/utils/store/org';
import type { UserAnalytics } from '$lib/utils/types/analytics';
import { Grid, Tag } from 'carbon-components-svelte';
import ArrowLeftIcon from 'carbon-icons-svelte/lib/ArrowLeft.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 ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
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 userAnalytics: UserAnalytics;
let userAnalytics: UserAnalytics | undefined = $state();
let courseFilter = 'all';
let courseFilter = $state('all');
function toggleCourseFilter(filter: 'completed' | 'incomplete') {
if (courseFilter === filter) {
courseFilter = 'all';
@@ -50,51 +50,50 @@
fetchUserAnalytics();
});
$: userMetrics = [
let userMetrics = $derived([
{
icon: Notebook,
icon: BookOpenIcon,
title: $t('analytics.enrolled_courses'),
description: $t('analytics.enrolled_courses_description'),
percentage: userAnalytics?.courses?.length,
hidePercentage: true
},
{
icon: Report,
icon: ChartLineIcon,
title: $t('analytics.overall_course_progress'),
description: $t('analytics.overall_course_progress_description'),
percentage: userAnalytics?.overallCourseProgress
},
{
icon: RowExpand,
icon: UnfoldVerticalIcon,
title: $t('analytics.total_average_grade'),
description: $t('analytics.total_average_grade_description'),
percentage: userAnalytics?.overallAverageGrade
}
];
]);
$: completedCourses = userAnalytics?.courses?.filter(
(course) => course.lessons_count === course.lessons_completed
)?.length;
$: incompleteCourses = userAnalytics?.courses?.filter(
(course) => course.lessons_count !== course.lessons_completed
)?.length;
let completedCourses = $derived(
userAnalytics?.courses?.filter((course) => course.lessons_count === course.lessons_completed)?.length
);
let incompleteCourses = $derived(
userAnalytics?.courses?.filter((course) => course.lessons_count !== course.lessons_completed)?.length
);
$: filteredCourses = userAnalytics?.courses?.filter((course) => {
if (courseFilter === 'all') {
return true;
}
return (course.lessons_count === course.lessons_completed) === (courseFilter === 'completed');
});
let filteredCourses = $derived(
userAnalytics?.courses?.filter((course) => {
if (courseFilter === 'all') {
return true;
}
return (course.lessons_count === course.lessons_completed) === (courseFilter === 'completed');
})
);
</script>
{#if userAnalytics}
<section class="w-full md:mx-auto md:max-w-5xl">
<div class="p-5">
<a
class="text-md flex items-center text-gray-500 dark:text-white"
href={`${$currentOrgPath}/audience`}
>
<ArrowLeftIcon size={24} class="carbon-icon dark:text-white" />
<a class="text-md flex items-center text-gray-500 dark:text-white" href={`${$currentOrgPath}/audience`}>
<ArrowLeftIcon size={16} />
{$t('community.ask.go_back')}
</a>
</div>
@@ -5,7 +5,7 @@
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { currentOrgPath } from '$lib/utils/store/org';
import { isMobile } from '$lib/utils/store/useMobile';
import Add from 'carbon-icons-svelte/lib/Add.svelte';
import PlusIcon from '@lucide/svelte/icons/plus';
import { t } from '$lib/utils/functions/translations';
function askCommunity() {
@@ -17,20 +17,16 @@
<title>Community - ClassroomIO</title>
</svelte:head>
<section class="w-full md:max-w-4xl md:mx-auto">
<div class="w-full py-10 px-5">
<div class="flex items-center justify-between mb-10 w-full">
<h1 class="dark:text-white text-2xl md:text-3xl font-bold">{$t('community.title')}</h1>
<section class="w-full md:mx-auto md:max-w-4xl">
<div class="w-full px-5 py-10">
<div class="mb-10 flex w-full items-center justify-between">
<h1 class="text-2xl font-bold md:text-3xl dark:text-white">{$t('community.title')}</h1>
{#if $isMobile}
<PrimaryButton onClick={askCommunity}>
<Add size={24} />
<PlusIcon size={16} />
</PrimaryButton>
{:else}
<PrimaryButton
label={$t('community.ask_button')}
variant={VARIANTS.CONTAINED_DARK}
onClick={askCommunity}
/>
<PrimaryButton label={$t('community.ask_button')} variant={VARIANTS.CONTAINED_DARK} onClick={askCommunity} />
{/if}
</div>
<Community />
@@ -1,10 +1,10 @@
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import Avatar from '$lib/components/Avatar/index.svelte';
import { courses } from '$lib/components/Courses/store';
import TextField from '$lib/components/Form/TextField.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import { IconButton } from '$lib/components/IconButton';
import DeleteModal from '$lib/components/Org/Community/DeleteModal.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
@@ -14,21 +14,18 @@
import { calDateDiff } from '$lib/utils/functions/date';
import { supabase } from '$lib/utils/functions/supabase';
import { t } from '$lib/utils/functions/translations';
import {
askCommunityValidation,
commentInCommunityValidation
} from '$lib/utils/functions/validator';
import { askCommunityValidation, commentInCommunityValidation } from '$lib/utils/functions/validator';
import { fetchCourses } from '$lib/utils/services/courses';
import { currentOrg, currentOrgPath, isOrgAdmin } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import type { Course } from '$lib/utils/types';
import { Dropdown, SkeletonPlaceholder, SkeletonText } from 'carbon-components-svelte';
import ArrowLeftIcon from 'carbon-icons-svelte/lib/ArrowLeft.svelte';
import CheckmarkOutlineIcon from 'carbon-icons-svelte/lib/CheckmarkOutline.svelte';
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
import TrashIcon from '@lucide/svelte/icons/trash';
import pluralize from 'pluralize';
export let data;
let { data } = $props();
const { slug } = data;
interface Comment {
@@ -56,11 +53,11 @@
courseId: string;
}
let question: Question;
let comment = '';
let question: Question | undefined = $state();
let comment = $state('');
let errors: {
title?: string;
} = {};
} = $state({});
let isValidAnswer = false; // V2 allow admin mark an answer as accepted
let resetInput = 1;
let voted: {
@@ -68,26 +65,26 @@
comment: {
[key: string]: boolean;
};
} = { question: false, comment: {} };
let isEditMode = false;
let deleteComment = {
} = $state({ question: false, comment: {} });
let isEditMode = $state(false);
let deleteComment = $state({
shouldDelete: false,
commentId: '',
isDeleting: false
};
let deleteQuestion = {
});
let deleteQuestion = $state({
shouldDelete: false,
questionId: '',
isDeleting: false
};
let editContent = {
});
let editContent = $state({
title: '',
body: '',
courseId: ''
};
});
let editorInstance = false;
let fetchedCourses: Course[] = [];
let editorInstance = $state(false);
let fetchedCourses: Course[] = $state([]);
function mapResToQuestion(data): Question {
return {
@@ -115,14 +112,22 @@
};
}
async function getCourses(userId: string | null, orgId: string) {
if ($courses.length) {
fetchedCourses = [...$courses];
return;
}
let hasFetched = $state(false);
function getCourses(userId: string | null, orgId: string) {
if (hasFetched) return;
const coursesResults = await fetchCourses(userId, orgId);
fetchedCourses = coursesResults?.allCourses || [];
untrack(async () => {
if ($courses.length) {
fetchedCourses = [...$courses];
hasFetched = true;
return;
}
const coursesResults = await fetchCourses(userId, orgId);
fetchedCourses = coursesResults?.allCourses || [];
hasFetched = true;
});
}
async function fetchCommunityQuestion(slug: string) {
@@ -157,18 +162,20 @@
if (error) {
console.error('[ORG] Error loading community', error);
return goto(`${currentOrgPath}`);
return goto(`${$currentOrgPath}`);
}
question = mapResToQuestion(data);
question.totalComments = question.comments.length;
untrack(() => {
question = mapResToQuestion(data);
question.totalComments = question.comments.length;
});
}
async function submitComment() {
errors = commentInCommunityValidation({ comment });
console.log('submitComment errors', errors);
if (Object.keys(errors).length) {
if (Object.keys(errors).length || !question) {
return;
}
@@ -213,6 +220,8 @@
}
async function upvoteQuestion(type: string, commentId?: string) {
if (!question) return;
const isQuestion = type === 'question';
if (isQuestion && voted.question) return;
@@ -248,6 +257,8 @@
}
async function handleQuestionEdit() {
if (!question) return;
if (isEditMode) {
errors = askCommunityValidation(editContent);
console.log('handleQuestionEdit errors', errors);
@@ -295,13 +306,12 @@
}
async function handleDelete(isQuestion: boolean) {
if (!question) return;
if (!isQuestion) {
deleteComment.isDeleting = true;
const { error } = await supabase
.from('community_answer')
.delete()
.match({ id: deleteComment.commentId });
const { error } = await supabase.from('community_answer').delete().match({ id: deleteComment.commentId });
deleteComment.isDeleting = false;
@@ -350,12 +360,14 @@
deleteQuestion.isDeleting = false;
}
$: browser && fetchCommunityQuestion(slug);
$: {
$effect(() => {
fetchCommunityQuestion(slug);
});
$effect(() => {
if ($profile.id && $currentOrg.id) {
getCourses($profile.id, $currentOrg.id);
}
}
});
</script>
<svelte:head>
@@ -364,7 +376,7 @@
<DeleteModal
bind:open={deleteQuestion.shouldDelete}
bind:isDeleting={deleteQuestion.isDeleting}
isDeleting={deleteQuestion.isDeleting}
onCancel={() => {
deleteQuestion.shouldDelete = false;
deleteQuestion.questionId = '';
@@ -391,20 +403,13 @@
</div>
{:else}
<div class="px-5 py-10">
<a
class="text-md flex items-center text-gray-500 dark:text-white"
href={`${$currentOrgPath}/community`}
>
<ArrowLeftIcon size={24} class="carbon-icon dark:text-white" />
<a class="text-md flex items-center text-gray-500 dark:text-white" href={`${$currentOrgPath}/community`}>
<ArrowLeftIcon size={16} />
{$t('community.ask.go_back')}
</a>
<div class="my-5 flex items-center justify-between">
{#if isEditMode}
<TextField
bind:value={editContent.title}
className="w-full mr-2"
errorMessage={errors.title}
/>
<TextField bind:value={editContent.title} className="w-full mr-2" errorMessage={errors.title} />
<Dropdown
class="h-full w-[25%]"
size="xl"
@@ -414,11 +419,7 @@
/>
{:else}
<div class="flex items-center">
<Vote
value={question.votes}
upVote={() => upvoteQuestion('question')}
disabled={voted.question}
/>
<Vote value={question.votes} upVote={() => upvoteQuestion('question')} disabled={voted.question} />
<h2 class="text-3xl">{question.title}</h2>
</div>
{/if}
@@ -444,12 +445,7 @@
<div class="border-1 border-gray my-1 rounded-lg border px-1">
<header class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black no-underline hover:underline">
<Avatar
src={question.author.avatar}
name={question.author.name}
width="w-7"
height="h-7"
/>
<Avatar src={question.author.avatar} name={question.author.name} width="w-7" height="h-7" />
<p class="ml-2 text-sm dark:text-white">{question.author.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white">
{question.createdAt}
@@ -459,11 +455,13 @@
<IconButton
value="delete-question"
onClick={() => {
if (!question) return;
deleteQuestion.shouldDelete = true;
deleteQuestion.questionId = question.id;
}}
>
<TrashCanIcon size={16} class="carbon-icon dark:text-white" />
<TrashIcon size={16} />
</IconButton>
{/if}
</header>
@@ -504,7 +502,7 @@
</div>
{#if isValidAnswer}
<CheckmarkOutlineIcon size={20} />
<CircleCheckIcon size={16} />
{/if}
{#if comment.authorId === $profile.id || $isOrgAdmin}
@@ -515,7 +513,7 @@
deleteComment.commentId = comment.id;
}}
>
<TrashCanIcon size={16} class="carbon-icon dark:text-white" />
<TrashIcon size={16} />
</IconButton>
{/if}
</header>
@@ -15,24 +15,28 @@
import { profile } from '$lib/utils/store/user';
import type { Course } from '$lib/utils/types';
import { Dropdown } from 'carbon-components-svelte';
import ArrowLeftIcon from 'carbon-icons-svelte/lib/ArrowLeft.svelte';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
let errors: {
title?: string;
courseId?: string;
body?: string;
} = {};
let fields = {
} = $state({});
let fields = $state({
title: '',
body: '',
courseId: ''
};
});
let fetchedCourses: Course[] = [];
let hasFetched = $state(false);
let fetchedCourses: Course[] = $state([]);
async function getCourses(userId: string | null, orgId: string) {
if (hasFetched) return;
if ($courses.length) {
fetchedCourses = [...$courses];
hasFetched = true;
return;
}
@@ -40,6 +44,7 @@
if (!coursesResults) return;
fetchedCourses = coursesResults.allCourses;
hasFetched = true;
}
async function handleSave() {
@@ -75,11 +80,11 @@
}
}
$: {
$effect(() => {
if ($profile.id && $currentOrg.id) {
getCourses($profile.id, $currentOrg.id);
}
}
});
</script>
<svelte:head>
@@ -88,20 +93,13 @@
<section class="w-full md:mx-auto md:max-w-3xl">
<div class="p-5">
<a
class="text-md flex items-center text-gray-500 dark:text-white"
href={`${$currentOrgPath}/community`}
>
<ArrowLeftIcon size={24} class="carbon-icon dark:text-white" />
<a class="text-md flex items-center text-gray-500 dark:text-white" href={`${$currentOrgPath}/community`}>
<ArrowLeftIcon size={16} />
{$t('community.ask.go_back')}
</a>
<div class="flex items-center justify-between gap-12">
<h1 class="text-2xl font-bold dark:text-white md:text-3xl">{$t('community.ask.ask_the')}</h1>
<PrimaryButton
label={$t('community.ask.publish')}
variant={VARIANTS.CONTAINED_DARK}
onClick={handleSave}
/>
<h1 class="text-2xl font-bold md:text-3xl dark:text-white">{$t('community.ask.ask_the')}</h1>
<PrimaryButton label={$t('community.ask.publish')} variant={VARIANTS.CONTAINED_DARK} onClick={handleSave} />
</div>
</div>
@@ -7,27 +7,28 @@
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { courses, courseMetaDeta } from '$lib/components/Courses/store';
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import { Add } from 'carbon-icons-svelte';
import PlusIcon from '@lucide/svelte/icons/plus';
import { isMobile } from '$lib/utils/store/useMobile';
import { isOrgAdmin } from '$lib/utils/store/org';
import type { Course } from '$lib/utils/types';
import { browser } from '$app/environment';
import { t } from '$lib/utils/functions/translations';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import IconButton from '$lib/components/IconButton/index.svelte';
import Grid from 'carbon-icons-svelte/lib/Grid.svelte';
import List from 'carbon-icons-svelte/lib/List.svelte';
import { IconButton } from '$lib/components/IconButton';
import GridIcon from '@lucide/svelte/icons/grid-2x2';
import ListIcon from '@lucide/svelte/icons/list';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
export let data;
let { data } = $props();
let { cantFetch } = data;
let searchValue = '';
let selectedId: string;
let filteredCourses: Course[];
let searchValue = $state('');
let selectedId: string = $state('0');
let hasFetched = false;
const filteredCourses: Course[] = $derived(filterCourses(searchValue, selectedId, $courses));
async function getCourses(userId: string | undefined, orgId: string) {
if (cantFetch && typeof cantFetch === 'boolean' && orgId && !hasFetched) {
// only show is loading when fetching for the first time
@@ -56,7 +57,7 @@
}
}
filteredCourses = courses.filter((course) => {
const filteredCourses = courses.filter((course) => {
if (!searchValue || course.title.toLowerCase().includes(searchValue.toLowerCase())) {
return true;
}
@@ -65,14 +66,14 @@
});
if (_selectedId === '0') {
filteredCourses = filteredCourses.sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
return filteredCourses.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
} else if (_selectedId === '1') {
filteredCourses = filteredCourses.sort((a, b) => b.is_published - a.is_published);
return filteredCourses.sort((a, b) => (b.is_published ? 0 : 1) - (a.is_published ? 0 : 1));
} else if (_selectedId === '2') {
filteredCourses = filteredCourses.sort((a, b) => b.total_lessons - a.total_lessons);
return filteredCourses.sort((a, b) => (b.total_lessons ?? 0) - (a.total_lessons ?? 0));
}
return filteredCourses;
}
const setViewPreference = (preference: 'grid' | 'list') => {
@@ -92,21 +93,22 @@
}
});
$: filterCourses(searchValue, selectedId, $courses);
$: getCourses($profile.id, $currentOrg.id);
$effect(() => {
getCourses($profile.id, $currentOrg.id);
});
</script>
<svelte:head>
<title>Courses - ClassroomIO</title>
</svelte:head>
<section class="w-full md:max-w-6xl md:mx-auto">
<div class="py-2 md:py-10 px-2 md:px-5">
<div class="flex items-center justify-between mb-5">
<h1 class="dark:text-white text-2xl md:text-3xl font-bold">{$t('courses.heading')}</h1>
<section class="w-full md:mx-auto md:max-w-6xl">
<div class="px-2 py-2 md:px-5 md:py-10">
<div class="mb-5 flex items-center justify-between">
<h1 class="text-2xl font-bold md:text-3xl dark:text-white">{$t('courses.heading')}</h1>
{#if $isMobile}
<PrimaryButton isDisabled={!$isOrgAdmin} onClick={openNewCourseModal}>
<Add size={24} />
<PlusIcon size={16} />
</PrimaryButton>
{:else}
<PrimaryButton
@@ -117,7 +119,7 @@
/>
{/if}
</div>
<div class="flex flex-row-reverse mb-5">
<div class="mb-5 flex flex-row-reverse">
<div class="filter-containter flex items-end justify-start">
<Search
placeholder={$t('courses.search_placeholder')}
@@ -136,18 +138,18 @@
/>
{#if $courseMetaDeta.view === 'list'}
<IconButton onClick={() => setViewPreference('grid')}>
<Grid size={24} />
<GridIcon size={16} />
</IconButton>
{:else}
<IconButton onClick={() => setViewPreference('list')}>
<List size={24} />
<ListIcon size={16} />
</IconButton>
{/if}
</div>
</div>
<NewCourseModal />
<Courses bind:courses={filteredCourses} />
<Courses courses={filteredCourses} />
</div>
</section>
@@ -5,20 +5,20 @@
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { createQuizModal } from '$lib/utils/store/org';
import { isMobile } from '$lib/utils/store/useMobile';
import { Add } from 'carbon-icons-svelte';
import PlusIcon from '@lucide/svelte/icons/plus';
</script>
<svelte:head>
<title>Interactive Quizzes - ClassroomIO</title>
</svelte:head>
<section class="w-full max-w-6xl mx-auto">
<div class="py-10 px-5">
<div class="flex items-center justify-between mb-10">
<h1 class="dark:text-white text-2xl md:text-3xl font-bold">Interactive Quizzes</h1>
<section class="mx-auto w-full max-w-6xl">
<div class="px-5 py-10">
<div class="mb-10 flex items-center justify-between">
<h1 class="text-2xl font-bold md:text-3xl dark:text-white">Interactive Quizzes</h1>
{#if $isMobile}
<PrimaryButton onClick={() => ($createQuizModal.open = true)}>
<Add size={24} />
<PlusIcon size={16} />
</PrimaryButton>
{:else}
<PrimaryButton label="Create Quiz" onClick={() => ($createQuizModal.open = true)} />
@@ -1,8 +1,8 @@
<script lang="ts">
import { goto } from '$app/navigation';
import ArrowLeftIcon from 'carbon-icons-svelte/lib/ArrowLeft.svelte';
import CheckmarkFilledIcon from 'carbon-icons-svelte/lib/CheckmarkFilled.svelte';
import WarningFilledIcon from 'carbon-icons-svelte/lib/WarningFilled.svelte';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
import CircleAlertIcon from '@lucide/svelte/icons/circle-alert';
import cloneDeep from 'lodash/cloneDeep';
import isBoolean from 'lodash/isBoolean';
import { onMount } from 'svelte';
@@ -32,34 +32,36 @@
options: QuizOption[];
}
export let data;
let { data } = $props();
const { quizId } = data;
// Questionnaire State
let currentQuestion: QuizQuestion = $quizStore.questions[0] || {
id: 0,
label: '',
type: 'multichoice',
options: []
};
let currentQuestion: QuizQuestion = $state(
$quizStore.questions[0] || {
id: 0,
label: '',
type: 'multichoice',
options: []
}
);
// Behavioural State
let openPreview = false;
let type = 'multichoice';
let openPreview = $state(false);
let type = $state('multichoice');
let errors: Array<{
isLabelEmpty: boolean;
hasOneAnswer: boolean;
id: number;
options: Array<{ id: number; error: boolean }>;
}> = [];
}> = $state([]);
let currentError: {
isLabelEmpty?: boolean;
hasOneAnswer?: boolean;
id?: number;
options?: Array<{ id: number; error: boolean }>;
} = {};
let isFocused = false;
let selectEl: Select | null = null;
} = $state({});
let isFocused = $state(false);
let selectEl: Select | null = $state(null);
function activeClass(q, cq) {
if (q.id === cq.id) {
@@ -200,9 +202,7 @@
function qHasError(qId, _errs) {
return _errs.some((qe) => {
return (
(qId ? qe.id === qId : true) && (qe.isLabelEmpty || !qe.hasOneAnswer || !!qe.options.length)
);
return (qId ? qe.id === qId : true) && (qe.isLabelEmpty || !qe.hasOneAnswer || !!qe.options.length);
});
}
@@ -239,11 +239,8 @@
<!-- Questions list -->
<aside class="root h-full w-1/5 bg-gray-100 p-4 dark:bg-neutral-800">
<div class="flex h-full flex-col">
<a
class="text-md flex items-center text-gray-500 dark:text-white"
href={`${$currentOrgPath}/quiz`}
>
<ArrowLeftIcon size={24} class="carbon-icon dark:text-white" /> Back to Quizzes
<a class="text-md flex items-center text-gray-500 dark:text-white" href={`${$currentOrgPath}/quiz`}>
<ArrowLeftIcon size={16} /> Back to Quizzes
</a>
<h3 class="my-3">Quiz</h3>
@@ -255,11 +252,11 @@
question,
currentQuestion
)}"
on:click={() => {
onclick={() => {
currentQuestion = question;
type = question.type;
}}
on:keydown={(e) => {
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
currentQuestion = question;
type = question.type;
@@ -269,7 +266,7 @@
Question {i + 1}
{#if qHasError(question.id, errors)}
<WarningFilledIcon size={20} class="carbon-icon error" />
<CircleAlertIcon size={16} class="filled" />
{/if}
</button>
{/each}
@@ -325,18 +322,8 @@
<aside class="settings h-full w-1/5 bg-gray-100 p-4 dark:bg-neutral-800">
<div class="py-5">
<h5>Quiz settings</h5>
<PrimaryButton
label="Save Changes"
variant={VARIANTS.CONTAINED}
onClick={saveQuiz}
className="my-3"
/>
<PrimaryButton
label="Preview Quiz"
variant={VARIANTS.OUTLINED}
onClick={previewQuiz}
className="my-3"
/>
<PrimaryButton label="Save Changes" variant={VARIANTS.CONTAINED} onClick={saveQuiz} className="my-3" />
<PrimaryButton label="Preview Quiz" variant={VARIANTS.OUTLINED} onClick={previewQuiz} className="my-3" />
<PrimaryButton
label="Delete question"
variant={VARIANTS.TEXT}
@@ -377,11 +364,7 @@
</Select>
<!-- -->
<Select
labelText="Time limit"
bind:selected={$quizStore.timelimit}
class="mb-3 flex items-center"
>
<Select labelText="Time limit" bind:selected={$quizStore.timelimit} class="mb-3 flex items-center">
<SelectItem value="10 seconds" text="10s" />
<SelectItem value="20 seconds" text="20s" />
<SelectItem value="30 seconds" text="30s" />
@@ -397,23 +380,19 @@
{#each allThemes as _theme}
<div
class="theme relative mb-5 w-full cursor-pointer rounded-md border {$quizStore.theme ===
_theme.id && 'border-primary-700'}"
class="theme relative mb-5 w-full cursor-pointer rounded-md border {$quizStore.theme === _theme.id &&
'border-primary-700'}"
>
{#if $quizStore.theme === _theme.id}
<CheckmarkFilledIcon
size={24}
class="carbon-icon absolute right-4 top-4"
style="fill:white;"
/>
<CircleCheckIcon size={16} filled />
{/if}
<div
role="button"
tabindex="0"
class="flex h-full w-full flex-col-reverse rounded-md border"
style="background: url({themeImages[_theme.id]?.card});"
on:click={() => ($quizStore.theme = _theme.id)}
on:keydown={(e) => {
onclick={() => ($quizStore.theme = _theme.id)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
$quizStore.theme = _theme.id;
}
@@ -8,10 +8,10 @@
<title>Settings - ClassroomIO</title>
</svelte:head>
<section class="w-full md:max-w-4xl mx-auto">
<div class="py-10 px-5">
<div class="flex items-center justify-between mb-10">
<h1 class="dark:text-white text-3xl font-bold">{$t('settings.heading')}</h1>
<section class="mx-auto w-full md:max-w-4xl">
<div class="px-5 py-10">
<div class="mb-10 flex items-center justify-between">
<h1 class="text-3xl font-bold dark:text-white">{$t('settings.heading')}</h1>
<ViewSiteBtn />
</div>
@@ -8,13 +8,13 @@
<title>Customize LMS - ClassroomIO</title>
</svelte:head>
<section class="w-full md:max-w-4xl mx-auto">
<div class="py-10 px-3 md:px-5">
<a class="text-gray-500 dark:text-white text-md" href={`${$currentOrgPath}/settings?tab=org`}
<section class="mx-auto w-full md:max-w-4xl">
<div class="px-3 py-10 md:px-5">
<a class="text-md text-gray-500 dark:text-white" href={`${$currentOrgPath}/settings?tab=org`}
>{$t('upgrade.back')}</a
>
<div class="flex items-center justify-between mb-10">
<h1 class="dark:text-white text-3xl font-bold">
<div class="mb-10 flex items-center justify-between">
<h1 class="text-3xl font-bold dark:text-white">
{$t('components.settings.customize_lms.title')}
</h1>
</div>
@@ -8,13 +8,13 @@
<title>Custom domain - ClassroomIO</title>
</svelte:head>
<section class="w-full md:max-w-4xl mx-auto">
<div class="py-10 px-3 md:px-5">
<a class="text-gray-500 dark:text-white text-md" href={`${$currentOrgPath}/settings?tab=org`}
<section class="mx-auto w-full md:max-w-4xl">
<div class="px-3 py-10 md:px-5">
<a class="text-md text-gray-500 dark:text-white" href={`${$currentOrgPath}/settings?tab=org`}
>{$t('upgrade.back')}</a
>
<div class="flex items-center justify-between mb-10">
<h1 class="dark:text-white text-3xl font-bold">{$t('components.settings.domains.title')}</h1>
<div class="mb-10 flex items-center justify-between">
<h1 class="text-3xl font-bold dark:text-white">{$t('components.settings.domains.title')}</h1>
</div>
<Domains />
@@ -9,13 +9,13 @@
<title>Teams - ClassroomIO</title>
</svelte:head>
<section class="w-full md:max-w-4xl mx-auto">
<div class="py-10 px-3 md:px-5">
<a class="text-gray-500 dark:text-white text-md" href={`${$currentOrgPath}/settings?tab=org`}
<section class="mx-auto w-full md:max-w-4xl">
<div class="px-3 py-10 md:px-5">
<a class="text-md text-gray-500 dark:text-white" href={`${$currentOrgPath}/settings?tab=org`}
>{$t('upgrade.back')}</a
>
<div class="flex items-center justify-between mb-10">
<h1 class="dark:text-white text-3xl font-bold">{$t('upgrade.team_members')}</h1>
<div class="mb-10 flex items-center justify-between">
<h1 class="text-3xl font-bold dark:text-white">{$t('upgrade.team_members')}</h1>
</div>
<UpgradeBanner>{$t('upgrade.team')}</UpgradeBanner>
@@ -1,8 +1,8 @@
<script>
<script lang="ts">
import Chip from '$lib/components/Chip/Text.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import CheckmarkOutline from 'carbon-icons-svelte/lib/CheckmarkOutline.svelte';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
import { currentOrg } from '$lib/utils/store/org';
import { goto } from '$app/navigation';
@@ -10,9 +10,17 @@
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
export let data;
let setupList = data.setup || [];
let completed = 0;
let { data } = $props();
const setupList = $derived(
(data.setup || []).map((item) => {
if (item.id === 'profile') {
item.is_completed = !$profile.avatar_url.includes('avatars/avatar.png');
}
return item;
})
);
const completed = $derived(setupList.filter((list) => list.is_completed).length);
const StepsEnum = {
UPDATE_PROFILE: 'profile',
@@ -70,21 +78,12 @@
break;
}
};
$: setupList = setupList.map((item) => {
if (item.id === 'profile') {
item.is_completed = !$profile.avatar_url.includes('avatars/avatar.png');
}
return item;
});
$: completed = setupList.filter((list) => list.is_completed).length;
</script>
<section class="w-full md:max-w-4xl mx-auto">
<div class="py-2 md:py-10 px-2 md:px-5">
<section class="mx-auto w-full md:max-w-4xl">
<div class="px-2 py-2 md:px-5 md:py-10">
<div class="flex items-center gap-2">
<h1 class="dark:text-white text-2xl md:text-3xl font-bold">{$t('setup.get_started')}</h1>
<h1 class="text-2xl font-bold md:text-3xl dark:text-white">{$t('setup.get_started')}</h1>
<Chip
value={`${completed}/${setupList.length}`}
className="text-[10px] font-semibold px-3 !py-1"
@@ -95,18 +94,14 @@
<section class="px-4">
{#each setupList as list, i}
<div
class="flex flex-col lg:flex-row lg:items-center justify-between gap-2 w-full py-8 border-b border-gray-300"
class="flex w-full flex-col justify-between gap-2 border-b border-gray-300 py-8 lg:flex-row lg:items-center"
>
<div class={`space-y-1 flex-1 ${list.is_completed ? 'opacity-50' : ''} lg:max-w-[50%]`}>
<div class={`flex-1 space-y-1 ${list.is_completed ? 'opacity-50' : ''} lg:max-w-[50%]`}>
<div class="flex items-center gap-3">
<Chip
value={i + 1}
className={`text-[10px] font-semibold !py-1 `}
shape="rounded-full"
/>
<p class="font-medium text-lg">{$t(list.title)}</p>
<Chip value={i + 1} className={`text-[10px] font-semibold !py-1 `} shape="rounded-full" />
<p class="text-lg font-medium">{$t(list.title)}</p>
</div>
<p class={`text-sm`}>
<p class="text-sm">
{$t(list.desc)}
</p>
</div>
@@ -118,7 +113,7 @@
isDisabled={list.is_completed}
>
{#if list.is_completed}
<CheckmarkOutline />
<CircleCheckIcon size={16} />
{/if}
{$t(list.button_label)}
</PrimaryButton>
@@ -1,5 +1,5 @@
import { getSupabase } from '$lib/utils/functions/supabase';
import get from 'lodash/get';
import { getSupabase } from '$lib/utils/functions/supabase';
const supabase = getSupabase();
@@ -53,9 +53,7 @@ async function getGenericData(orgSlug: string): Promise<{
};
}
async function getIsLessonCreated(
orgSlug: string
): Promise<{ isLessonCreated: boolean; lessonData: any }> {
async function getIsLessonCreated(orgSlug: string): Promise<{ isLessonCreated: boolean; lessonData: any }> {
const { data } = await supabase
.from('lesson')
.select(
@@ -104,9 +102,7 @@ async function getIsExerciseCreated(orgSlug: string): Promise<boolean> {
}
export const load = async ({ params = { slug: '' } }) => {
const { isCourseCreated, isCoursePublished, orgHasAvatarUrl, courseData } = await getGenericData(
params.slug
);
const { isCourseCreated, isCoursePublished, orgHasAvatarUrl, courseData } = await getGenericData(params.slug);
const { isLessonCreated, lessonData } = await getIsLessonCreated(params.slug);
const isExerciseCreated = await getIsExerciseCreated(params.slug);