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
+14 -17
View File
@@ -1,45 +1,42 @@
<script>
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { page } from '$app/stores';
import { page } from '$app/state';
import { globalStore } from '$lib/utils/store/app';
import ErrorIcon from '$lib/components/Icons/ErrorIcon.svelte';
import ErrorIconDarkMode from '$lib/components/Icons/ErrorIconDarkMode.svelte';
if ($page.status == 404 && browser) {
if (page.status == 404 && browser) {
goto('/404');
}
console.error($page.error?.message);
console.error(page.error?.message);
</script>
<svelte:head>
<title>{$page.status}</title>
<title>{page.status}</title>
</svelte:head>
<div
class="error-container m-auto flex flex-col-reverse lg:flex-row md:flex-col-reverse items-center justify-center h-full py-10 dark:bg-black"
class="error-container m-auto flex h-full flex-col-reverse items-center justify-center py-10 md:flex-col-reverse lg:flex-row dark:bg-black"
>
<div class="md:w-full p-2 lg:w-2/4">
<h2 class="w-full text-lg lg:text-3xl font-medium lg:font-normal">
Something unexpected occured.
</h2>
<p class="dark:text-white w-full text-base font-normal my-6 text-gray-600">
Don't worry, your learning is safe. It isn't your fault, it is ours. We have gotten the error
notification and will push a fix ASAP. In the meantime, take a short break and come back a bit
later.
<div class="p-2 md:w-full lg:w-2/4">
<h2 class="w-full text-lg font-medium lg:text-3xl lg:font-normal">Something unexpected occured.</h2>
<p class="my-6 w-full text-base font-normal text-gray-600 dark:text-white">
Don't worry, your learning is safe. It isn't your fault, it is ours. We have gotten the error notification and
will push a fix ASAP. In the meantime, take a short break and come back a bit later.
</p>
<a
class="bg-primary-700 hover:bg-primary-900 hover:no-underline transition-all py-3 px-9 rounded-xl text-white mt-3"
class="bg-primary-700 hover:bg-primary-900 mt-3 rounded-xl px-9 py-3 text-white transition-all hover:no-underline"
href="/">Go Home</a
>
</div>
<div class="w-72 lg:w-2/4 flex items-center justify-center">
<div class="flex w-72 items-center justify-center lg:w-2/4">
<!-- to check if it's in dark mode so as to render the appropriate svg -->
{#if $globalStore.isDark}
<ErrorIconDarkMode />
<ErrorIcon size="{16}DarkMode" />
{:else}
<ErrorIcon />
<ErrorIcon size={16} />
{/if}
</div>
</div>
+3 -5
View File
@@ -109,7 +109,7 @@ export const load = async ({ url, cookies, request }): Promise<LoadOutput> => {
response.org = (await getCurrentOrg(response.orgSiteName, true)) || null;
if (!response.org && !isDev) {
throw redirect(307, 'https://app.classroomio.com/404?type=org');
redirect(307, 'https://app.classroomio.com/404?type=org');
} else if (!response.org && _orgSiteName) {
cookies.delete('_orgSiteName', { path: '/' });
}
@@ -117,7 +117,7 @@ export const load = async ({ url, cookies, request }): Promise<LoadOutput> => {
response.skipAuth = true;
} else if (!APP_SUBDOMAINS.includes(subdomain) && !isDev) {
// This case is for anything in our blockedSubdomains
throw redirect(307, 'https://app.classroomio.com');
redirect(307, 'https://app.classroomio.com');
}
return response;
@@ -128,9 +128,7 @@ function isURLCustomDomain(url: URL) {
return false;
}
const notCustomDomainHosts = [env.PRIVATE_APP_HOST || '', 'classroomio.com', 'vercel.app'].filter(
Boolean
);
const notCustomDomainHosts = [env.PRIVATE_APP_HOST || '', 'classroomio.com', 'vercel.app'].filter(Boolean);
return !notCustomDomainHosts.some((host) => url.host.endsWith(host));
}
+10 -22
View File
@@ -3,7 +3,6 @@
import { page } from '$app/stores';
import debounce from 'lodash/debounce';
import Apps from '$lib/components/Apps/index.svelte';
import { course } from '$lib/components/Course/store';
import OrgNavigation from '$lib/components/Navigation/app.svelte';
import LandingNavigation from '$lib/components/Navigation/index.svelte';
@@ -11,13 +10,12 @@
import OrgLandingPage from '$lib/components/Org/LandingPage/index.svelte';
import PlayQuiz from '$lib/components/Org/Quiz/Play/index.svelte';
import { PageRestricted } from '$lib/components/Page';
import PageLoadProgressBar from '$lib/components/Progress/PageLoadProgressBar.svelte';
// import PageLoadProgressBar from '$lib/components/Progress/PageLoadProgressBar.svelte';
import Snackbar from '$lib/components/Snackbar/index.svelte';
import UpgradeModal from '$lib/components/Upgrade/Modal.svelte';
import { isCoursesPage, isLMSPage, isOrgPage, toggleBodyByMode } from '$lib/utils/functions/app';
import { getProfile, setupAnalytics } from '$lib/utils/functions/appSetup';
import hideNavByRoute from '$lib/utils/functions/routes/hideNavByRoute';
import showAppsSideBar from '$lib/utils/functions/showAppsSideBar';
import { getSupabase } from '$lib/utils/functions/supabase';
import { setTheme } from '$lib/utils/functions/theme';
import { initOrgAnalytics } from '$lib/utils/services/posthog';
@@ -32,12 +30,12 @@
import '../app.postcss';
export let data;
let { data, children } = $props();
let supabase = getSupabase();
let path = $page.url?.pathname?.replace('/', '');
let path = $derived($page.url?.pathname?.replace('/', ''));
let queryParam = $page.url?.search;
let carbonTheme: CarbonTheme = 'white';
let carbonTheme: CarbonTheme = $derived($globalStore.isDark ? 'g100' : 'white');
function handleResize() {
isMobile.update(() => window.innerWidth <= 760);
@@ -70,7 +68,7 @@
// return goto('/login?redirect=/' + path);
// }
const { data: authListener } = supabase.auth.onAuthStateChange((event, session) => {
const { data: authListener } = supabase.auth.onAuthStateChange((event) => {
// Log key events
console.log(`event`, event);
@@ -113,12 +111,10 @@
};
});
$: path = $page.url?.pathname?.replace('/', '');
$: carbonTheme = $globalStore.isDark ? 'g100' : 'white';
$: metaTags = merge(data.baseMetaTags, $page.data.pageMetaTags);
let metaTags = $derived(merge(data.baseMetaTags, $page.data.pageMetaTags));
</script>
<svelte:window on:resize={handleResize} />
<svelte:window onresize={handleResize} />
<MetaTags {...metaTags} />
@@ -150,15 +146,11 @@
/>
{/if}
<PageLoadProgressBar textColorClass="text-neutral-700" />
<!-- <PageLoadProgressBar textColorClass="text-neutral-700" /> -->
{/if}
<div class={path.includes('home') ? '' : 'flex justify-between'}>
<slot />
{#if showAppsSideBar(path)}
<Apps />
{/if}
{@render children?.()}
</div>
</main>
{/if}
@@ -176,11 +168,7 @@
--main-primary-color: rgba(29, 78, 216, 1);
--border-color: #eaecef;
--app-background-color: #fafbfc;
--app-background: radial-gradient(
circle at 10% 20%,
rgb(239, 246, 249) 0%,
rgb(206, 239, 253) 90%
);
--app-background: radial-gradient(circle at 10% 20%, rgb(239, 246, 249) 0%, rgb(206, 239, 253) 90%);
--dark-app-background: radial-gradient(circle at 10% 20%, rgb(0 0 0) 0%, rgb(27 60 74) 90%);
}
+20 -21
View File
@@ -1,24 +1,25 @@
<script>
import { preventDefault } from '$lib/utils/functions/svelte';
import { onMount } from 'svelte';
import FlowStreamReferenceIcon from 'carbon-icons-svelte/lib/FlowStreamReference.svelte';
import ChartClusterBarIcon from 'carbon-icons-svelte/lib/ChartClusterBar.svelte';
import MachineLearningModelIcon from 'carbon-icons-svelte/lib/MachineLearningModel.svelte';
import DoorOpenIcon from '@lucide/svelte/icons/door-open';
import ChartAreaIcon from '@lucide/svelte/icons/chart-area';
import BotIcon from '@lucide/svelte/icons/bot';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { getSupabase } from '$lib/utils/functions/supabase';
import { validateEmail } from '$lib/utils/functions/validateEmail';
let email = '';
let isAdding = false;
let success = false;
let email = $state('');
let isAdding = $state(false);
let success = $state(false);
const supabase = getSupabase();
const animate = 'transition delay-75 duration-300 ease-in-out';
const areas = [
{
title: 'Access',
description:
'Quickly find any material you need to get your job done. ClassroomIO offers the All-in-on-platform'
description: 'Quickly find any material you need to get your job done. ClassroomIO offers the All-in-on-platform'
},
{
title: 'Analytics',
@@ -56,7 +57,7 @@
<title>ClassroomIO - The classroom software</title>
</svelte:head>
<div class="md:h-[93vh] w-screen flex items-center justify-center flex-col m-2 sm:m-0 font-sans">
<div class="m-2 flex w-screen flex-col items-center justify-center font-sans sm:m-0 md:h-[93vh]">
<!--
<img
src="/logo-192.png"
@@ -66,16 +67,14 @@
/>
-->
<div>
<h3 class="dark:text-white text-4xl text-center">
<h3 class="text-center text-4xl dark:text-white">
Classroom<span class="text-primary-700">IO</span>
</h3>
<p class="dark:text-white text-lg text-center">
The operating system for classroooms of the future 🚀🚀🚀.
</p>
<p class="text-center text-lg dark:text-white">The operating system for classroooms of the future 🚀🚀🚀.</p>
</div>
<form on:submit|preventDefault={handleSubmit} class="my-4 hidden">
<div class="flex items-center flex-col sm:flex-row">
<form onsubmit={preventDefault(handleSubmit)} class="my-4 hidden">
<div class="flex flex-col items-center sm:flex-row">
{#if success}
<p class="dark:text-white">You have been added successfully. Thanks for joining.</p>
{:else}
@@ -92,18 +91,18 @@
</div>
</form>
<div class="flex flex-col md:flex-row mt-4">
<div class="mt-4 flex flex-col md:flex-row">
{#each areas as area, index}
<div
class="max-w-[350px] m-3 bg-white dark:bg-black rounded-md py-3 px-12 active shadow-xl border-2 hover:border-primary-700 {animate}"
class="active hover:border-primary-700 m-3 max-w-[350px] rounded-md border-2 bg-white px-12 py-3 shadow-xl dark:bg-black {animate}"
>
<h3 class="dark:text-white text-3xl">
<h3 class="text-3xl dark:text-white">
{#if index === 0}
<FlowStreamReferenceIcon size={32} class="carbon-icon dark:text-white" />
<DoorOpenIcon size={16} />
{:else if index === 1}
<ChartClusterBarIcon size={32} class="carbon-icon dark:text-white" />
<ChartAreaIcon size={16} />
{:else if index === 2}
<MachineLearningModelIcon size={32} class="carbon-icon dark:text-white" />
<BotIcon size={16} />
{/if}
{area.title}
</h3>
+3 -3
View File
@@ -1,10 +1,10 @@
<script>
import { page } from '$app/stores';
import { page } from '$app/state';
import { PageNotFound } from '$lib/components/Page';
$: query = new URLSearchParams($page.url.search);
$: isOrg = query.get('type') === 'org';
let query = $derived(new URLSearchParams(page.url.search));
let isOrg = $derived(query.get('type') === 'org');
</script>
<PageNotFound {isOrg} />
@@ -79,9 +79,7 @@ async function getCourseAnalytics(courseId: string): Promise<CourseAnalytics> {
// Count tutors and students
const members = (courseData.group as any)?.members || [];
analytics.totalTutors = members.filter(
(member) => member.role_id === 1 || member.role_id === 2
).length;
analytics.totalTutors = members.filter((member) => member.role_id === 1 || member.role_id === 2).length;
analytics.totalStudents = members.filter((member) => member.role_id === 3).length;
// Count lessons and exercises
@@ -91,35 +89,27 @@ async function getCourseAnalytics(courseId: string): Promise<CourseAnalytics> {
// Get student analytics
const studentAnalytics = await Promise.all(
members
.filter((member) => member.role_id === 3)
.map((member) => getStudentOverview(courseId, member))
members.filter((member) => member.role_id === 3).map((member) => getStudentOverview(courseId, member))
);
analytics.students = studentAnalytics.filter(
(student): student is StudentOverview => student !== null
);
analytics.students = studentAnalytics.filter((student): student is StudentOverview => student !== null);
// Calculate aggregated metrics
if (analytics.students.length > 0) {
analytics.lessonCompletionRate = Math.round(
analytics.students.reduce((sum, student) => sum + student.progressPercentage, 0) /
analytics.students.length
analytics.students.reduce((sum, student) => sum + student.progressPercentage, 0) / analytics.students.length
);
analytics.exerciseCompletionRate = Math.round(
analytics.students.reduce((sum, student) => {
const completionRate =
student.totalExercises > 0
? (student.exercisesSubmitted / student.totalExercises) * 100
: 0;
student.totalExercises > 0 ? (student.exercisesSubmitted / student.totalExercises) * 100 : 0;
return sum + completionRate;
}, 0) / analytics.students.length
);
analytics.averageGrade = Math.round(
analytics.students.reduce((sum, student) => sum + student.averageGrade, 0) /
analytics.students.length
analytics.students.reduce((sum, student) => sum + student.averageGrade, 0) / analytics.students.length
);
}
@@ -167,8 +157,7 @@ async function getStudentOverview(courseId: string, member: any): Promise<Studen
const userExercisesStats = await fetchUserExercisesStats(courseId, member.profile_id);
// Calculate exercise completion using the same logic as people page
const completedExercises =
userExercisesStats?.filter((exercise) => exercise.isCompleted)?.length || 0;
const completedExercises = userExercisesStats?.filter((exercise) => exercise.isCompleted)?.length || 0;
const totalExercises = userExercisesStats?.length || 0;
// Get last login using the same approach as analytics/user API
@@ -197,10 +186,8 @@ async function getStudentOverview(courseId: string, member: any): Promise<Studen
const progressPercentage = calcPercentageWithRounding(lessonsCompleted, totalLessons);
// Calculate average grade using the same logic as the people page
const totalEarnedPoints =
userExercisesStats?.reduce((sum, exercise) => sum + exercise.score, 0) || 0;
const totalPoints =
userExercisesStats?.reduce((sum, exercise) => sum + exercise.totalPoints, 0) || 0;
const totalEarnedPoints = userExercisesStats?.reduce((sum, exercise) => sum + exercise.score, 0) || 0;
const totalPoints = userExercisesStats?.reduce((sum, exercise) => sum + exercise.totalPoints, 0) || 0;
const averageGrade = calcPercentageWithRounding(totalEarnedPoints, totalPoints);
return {
@@ -224,10 +211,7 @@ async function getStudentOverview(courseId: string, member: any): Promise<Studen
}
}
async function fetchUserExercisesStats(
courseId: string,
userId: string
): Promise<UserExercisesStats[] | undefined> {
async function fetchUserExercisesStats(courseId: string, userId: string): Promise<UserExercisesStats[] | undefined> {
try {
const { data: courseData, error: queryError } = await supabase
.from('course')
@@ -73,11 +73,7 @@ async function getAudienceData(userId: string, orgId: string): Promise<UserAnaly
overallAverageGrade: 0
};
const userResult = await supabase
.from('profile')
.select('fullname, email, avatar_url')
.eq('id', userId)
.single();
const userResult = await supabase.from('profile').select('fullname, email, avatar_url').eq('id', userId).single();
if (userResult.error) throw new Error('Failed to fetch user profile');
@@ -112,21 +108,12 @@ async function getAudienceData(userId: string, orgId: string): Promise<UserAnaly
});
}
const totalLessons = audienceAnalytics.courses.reduce(
(acc, course) => acc + course.lessons_count,
0
);
const completedLessons = audienceAnalytics.courses.reduce(
(acc, course) => acc + course.lessons_completed,
0
);
const totalLessons = audienceAnalytics.courses.reduce((acc, course) => acc + course.lessons_count, 0);
const completedLessons = audienceAnalytics.courses.reduce((acc, course) => acc + course.lessons_completed, 0);
const overallCourseProgress = calcPercentageWithRounding(completedLessons, totalLessons);
const allGrades = sumArrObject(audienceAnalytics.courses, 'average_grade');
const overallAverageGrade = calcPercentageWithRounding(
allGrades,
audienceAnalytics.courses.length
);
const overallAverageGrade = calcPercentageWithRounding(allGrades, audienceAnalytics.courses.length);
audienceAnalytics.overallCourseProgress = overallCourseProgress;
audienceAnalytics.overallAverageGrade = overallAverageGrade || 0;
@@ -134,10 +121,7 @@ async function getAudienceData(userId: string, orgId: string): Promise<UserAnaly
return audienceAnalytics;
}
async function getStudentAnalyticsData(
userId: string,
courseId: string
): Promise<UserCourseAnalytics> {
async function getStudentAnalyticsData(userId: string, courseId: string): Promise<UserCourseAnalytics> {
const userCourseAnalytics: UserCourseAnalytics = {
user: {
id: userId,
@@ -191,18 +175,12 @@ async function getStudentAnalyticsData(
console.log('completedLessons.length', completedLessons.length);
console.log('lessons.length', lessons.length);
userCourseAnalytics.progressPercentage = calcPercentageWithRounding(
completedLessons.length,
lessons.length
);
userCourseAnalytics.progressPercentage = calcPercentageWithRounding(completedLessons.length, lessons.length);
return userCourseAnalytics;
}
async function fetchUserExercisesStats(
courseId: string,
userId: string
): Promise<UserExercisesStats[] | undefined> {
async function fetchUserExercisesStats(courseId: string, userId: string): Promise<UserExercisesStats[] | undefined> {
try {
const { data: courseData, error: queryError } = await supabase
.from('course')
@@ -21,8 +21,7 @@ export async function POST({ request }) {
messages: [
{
role: 'system',
content:
'You are a teaching assistant, you only spit out JSON and nothing else, dont give any explanation.'
content: 'You are a teaching assistant, you only spit out JSON and nothing else, dont give any explanation.'
},
{
role: 'user',
@@ -1,8 +1,4 @@
import {
addDomainToVercel,
getConfigResponse,
removeDomainFromVercelProject
} from '$lib/utils/services/org/domain';
import { addDomainToVercel, getConfigResponse, removeDomainFromVercelProject } from '$lib/utils/services/org/domain';
import { json } from '@sveltejs/kit';
@@ -15,10 +11,7 @@ export async function POST({ request }) {
}
if (params?.domain?.includes('classroomio.com')) {
return json(
{ success: false, message: 'Domain cannot contain classroomio.com' },
{ status: 400 }
);
return json({ success: false, message: 'Domain cannot contain classroomio.com' }, { status: 400 });
}
try {
@@ -5,12 +5,7 @@ import sendEmail from '$mail/sendEmail';
const supabase = getServerSupabase();
const sendEmailNotification = async (
sFetch: typeof fetch,
feedId: string,
authorId: string,
comment?: string
) => {
const sendEmailNotification = async (sFetch: typeof fetch, feedId: string, authorId: string, comment?: string) => {
const feed = await getFeedForNotification({
supabase,
feedId,
@@ -7,14 +7,7 @@ import sendEmail from '$mail/sendEmail';
export async function POST({ fetch, request }) {
const { to, courseName, teacherEmail, studentFullname, orgName } = await request.json();
// const accessToken = request.headers.get('Authorization') || '';
console.log(
'/POST api/email/course/student_prove_payment',
to,
courseName,
teacherEmail,
studentFullname,
orgName
);
console.log('/POST api/email/course/student_prove_payment', to, courseName, teacherEmail, studentFullname, orgName);
if (!to || !courseName || !teacherEmail || !studentFullname || !orgName) {
return json({ success: false, message: 'Missing required fields' }, { status: 400 });
@@ -3,13 +3,7 @@ import sendEmail from '$mail/sendEmail';
export async function POST({ fetch, request }) {
const { to, courseName, studentEmail, studentFullname } = await request.json();
console.log(
'/POST api/email/course/teacher_student_buycourse',
to,
courseName,
studentEmail,
studentFullname
);
console.log('/POST api/email/course/teacher_student_buycourse', to, courseName, studentEmail, studentFullname);
if (!to || !courseName || !studentEmail || !studentFullname) {
return json({ success: false, message: 'Missing required fields' }, { status: 400 });
@@ -9,10 +9,7 @@ export async function POST({ fetch, request }) {
console.log('/POST api/email/invite', body);
if (!org || !Object.keys(org).length || !email) {
return json(
{ success: false, message: 'Org data and Teacher name are required' },
{ status: 400 }
);
return json({ success: false, message: 'Org data and Teacher name are required' }, { status: 400 });
}
const { id, name, siteName } = org;
@@ -15,9 +15,7 @@ export async function POST({ fetch, request }) {
orgSiteName
});
const verificationLink = `${origin}/api/verify?data=${encodeURIComponent(
btoa(verificationData)
)}`;
const verificationLink = `${origin}/api/verify?data=${encodeURIComponent(btoa(verificationData))}`;
const emailData = [
{
@@ -39,7 +37,6 @@ export async function POST({ fetch, request }) {
await sendEmail(fetch)(emailData);
return json({
success: true,
message: `Email sent ${verificationLink}`
success: true
});
}
@@ -1,9 +1,10 @@
import { env } from '$env/dynamic/private';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
import { cancelOrgPlan, createOrgPlan, updateOrgPlan } from '$lib/utils/services/org';
import { PLAN } from '@cio/utils/plans';
import type { PolarWebhookPayload } from '$lib/utils/types/polar';
import { Webhooks } from '@polar-sh/sveltekit';
import { PLAN } from 'shared/src/plans/constants';
import { env } from '$env/dynamic/private';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
export const POST = Webhooks({
webhookSecret: env.POLAR_WEBHOOK_SECRET!,
@@ -28,9 +28,6 @@ export async function POST({ request }) {
} catch (error) {
console.error(error);
return json(
{ success: false, message: 'Error fetching photos from Unsplash' },
{ status: 500 }
);
return json({ success: false, message: 'Error fetching photos from Unsplash' }, { status: 500 });
}
}
@@ -22,8 +22,8 @@ export const GET = async ({ url }) => {
if (error) {
console.log('profile not found', error);
throw redirect(307, '/404');
redirect(307, '/404');
}
throw redirect(307, `/org/${orgSiteName}?welcomePopup=true`);
redirect(307, `/org/${orgSiteName}?welcomePopup=true`);
};
@@ -6,17 +6,11 @@
course: Course;
}
export let data: Data;
let courseData: Course;
function setCourseData(course: Course | null) {
if (!course) return;
courseData = { ...course };
interface Props {
data: Data;
}
$: setCourseData(data.course);
let { data }: Props = $props();
</script>
<CourseLandingPage {courseData} />
<CourseLandingPage courseData={data.course} />
@@ -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}
+16 -21
View File
@@ -11,11 +11,11 @@
import EmailSentIcon from '$lib/components/Icons/EmailSentIcon.svelte';
let supabase = getSupabase();
let fields = Object.assign({}, FORGOT_PASSWORD_FIELDS);
let submitError;
let loading = false;
let success = false;
let errors = {};
let fields = $state(Object.assign({}, FORGOT_PASSWORD_FIELDS));
let submitError = $state();
let loading = $state(false);
let success = $state(false);
let errors = $state({});
async function handleSubmit() {
errors = forgotValidation(fields);
@@ -48,27 +48,22 @@
<AuthUI {supabase} {handleSubmit} showOnlyContent={true} showLogo={!success}>
{#if success}
<div class="mt-4 w-full flex items-center justify-center flex-col">
<EmailSentIcon />
<h3 class="dark:text-white text-xl font-semibold my-3">Email Sent!</h3>
<p class="dark:text-white text-md mb-6 text-center">
We have sent a confirmation email to <span class="text-primary-700">{fields.email}</span>.
Kindly check your inbox to reset password or spam to reset your password.
<div class="mt-4 flex w-full flex-col items-center justify-center">
<EmailSentIcon size={16} />
<h3 class="my-3 text-xl font-semibold dark:text-white">Email Sent!</h3>
<p class="text-md mb-6 text-center dark:text-white">
We have sent a confirmation email to <span class="text-primary-700">{fields.email}</span>. Kindly check your
inbox to reset password or spam to reset your password.
</p>
</div>
<div class="my-4 w-full flex justify-end items-center flex-col">
<PrimaryButton
label="Okay"
type="button"
className="sm:w-full w-full mb-4"
onClick={() => goto(ROUTE.LOGIN)}
/>
<div class="my-4 flex w-full flex-col items-center justify-end">
<PrimaryButton label="Okay" type="button" className="sm:w-full w-full mb-4" onClick={() => goto(ROUTE.LOGIN)} />
</div>
{:else}
<div class="mt-4 w-full">
<h3 class="dark:text-white text-xl font-semibold my-3">Forgot Password</h3>
<p class="dark:text-white text-sm mb-6">We will send you a reset link to your email</p>
<h3 class="my-3 text-xl font-semibold dark:text-white">Forgot Password</h3>
<p class="mb-6 text-sm dark:text-white">We will send you a reset link to your email</p>
<TextField
label="Your email"
bind:value={fields.email}
@@ -85,7 +80,7 @@
{/if}
</div>
<div class="my-4 w-full flex justify-end items-center flex-col">
<div class="my-4 flex w-full flex-col items-center justify-end">
<PrimaryButton
label="Reset Password"
type="submit"
@@ -26,6 +26,6 @@ export const load = async ({ params = { hash: '' } }) => {
};
} catch (error) {
console.error('Error decoding course invite params.hash', error);
throw redirect(307, '/404');
redirect(307, '/404');
}
};
@@ -10,21 +10,18 @@
import type { CurrentOrg } from '$lib/utils/types/org.js';
import { ROLE } from '$lib/utils/constants/roles';
import { profile } from '$lib/utils/store/user';
import {
triggerSendEmail,
NOTIFICATION_NAME
} from '$lib/utils/services/notification/notification';
import { triggerSendEmail, NOTIFICATION_NAME } from '$lib/utils/services/notification/notification';
import { snackbar } from '$lib/components/Snackbar/store.js';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { page } from '$app/stores';
export let data;
let { data } = $props();
let supabase = getSupabase();
let loading = false;
let loading = $state(false);
let disableSubmit = false;
let formRef: HTMLFormElement;
let formRef: HTMLFormElement = $state();
async function handleSubmit() {
loading = true;
@@ -34,11 +31,7 @@
return goto(`/signup?redirect=${$page.url?.pathname || ''}`);
}
const { data: courseData, error } = await supabase
.from('course')
.select('group_id')
.eq('id', data.id)
.single();
const { data: courseData, error } = await supabase.from('course').select('group_id').eq('id', data.id).single();
console.log({ courseData });
if (!courseData?.group_id) {
@@ -118,9 +111,8 @@
onMount(async () => {
setTheme(data.currentOrg?.theme || '');
setCurOrg(data.currentOrg as CurrentOrg);
});
$: setCurOrg(data.currentOrg as CurrentOrg);
</script>
<svelte:head>
@@ -31,6 +31,6 @@ export const load = async ({ params = { hash: '' } }) => {
};
} catch (error) {
console.error('Error decoding invite params.hash', error);
throw redirect(307, '/404');
redirect(307, '/404');
}
};
@@ -1,6 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { page } from '$app/state';
import AuthUI from '$lib/components/AuthUI/index.svelte';
import type { Profile } from '$lib/components/Course/components/People/types';
import TextField from '$lib/components/Form/TextField.svelte';
@@ -12,34 +11,31 @@
import { setTheme } from '$lib/utils/functions/theme';
import { t } from '$lib/utils/functions/translations';
import { profile, user } from '$lib/utils/store/user';
import {
authValidation,
getConfirmPasswordError,
getDisableSubmit
} from '$lib/utils/functions/validator';
import { authValidation, getConfirmPasswordError, getDisableSubmit } from '$lib/utils/functions/validator';
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import type { CurrentOrg } from '$lib/utils/types/org';
import { onMount } from 'svelte';
import { onMount, untrack } from 'svelte';
import { snackbar } from '$lib/components/Snackbar/store.js';
export let data;
let { data } = $props();
let supabase = getSupabase();
let fields = Object.assign({}, SIGNUP_FIELDS);
let loading = false;
let isLoggingOut = false;
let shouldLogout = false;
let fields = $state(Object.assign({}, SIGNUP_FIELDS));
let loading = $state(false);
let isLoggingOut = $state(false);
let shouldLogout = $state(false);
let errors: {
name?: string;
email?: string;
password?: string;
confirmPassword?: string;
} = {};
} = $state({});
let submitError: string;
let disableSubmit = false;
let formRef: HTMLFormElement;
let submitError: string = $state('');
let formRef: HTMLFormElement | undefined = $state();
const confirmPasswordError = $derived(getConfirmPasswordError(fields));
const disableSubmit = $derived(getDisableSubmit(fields));
async function joinOrg(profileId: string, email: string) {
if (!profileId || !email || !data.invite.currentOrg?.id) return;
@@ -163,23 +159,20 @@
setCurOrg(data.invite.currentOrg as CurrentOrg);
});
$: errors.confirmPassword = getConfirmPasswordError(fields);
$: disableSubmit = getDisableSubmit(fields);
const isLoading = $derived(loading || $user.fetchingUser);
$: autoLogout($profile?.email);
function autoLogout(email?: string) {
$effect(() => {
const email = $profile?.email;
if (!email) return;
if (email !== data.invite.email) {
console.log('logout');
snackbar.error('You are logged in with a different email');
shouldLogout = true;
untrack(() => {
shouldLogout = true;
});
}
}
$: isLoading = loading || $user.fetchingUser;
$: console.log('$profile', $profile);
$: console.log('data.invite', data.invite);
});
</script>
<svelte:head>
@@ -188,7 +181,7 @@
<AuthUI
{supabase}
redirectPathname={$page.url.pathname}
redirectPathname={page.url.pathname}
isLogin={false}
{handleSubmit}
{isLoading}
@@ -248,7 +241,7 @@
className="mb-6"
inputClassName="w-full"
isDisabled={isLoading}
errorMessage={errors.confirmPassword}
errorMessage={confirmPasswordError}
isRequired
/>
{/if}
+9 -4
View File
@@ -1,10 +1,15 @@
<script>
<script lang="ts">
import LMSSideBar from '$lib/components/LMS/SideBar.svelte';
interface Props {
children?: import('svelte').Snippet;
}
let { children }: Props = $props();
</script>
<div class="org-root w-full flex items-center justify-between">
<div class="org-root flex w-full items-center justify-between">
<LMSSideBar />
<div class="org-slot bg-white dark:bg-black w-full">
<slot />
<div class="org-slot w-full bg-white dark:bg-black">
{@render children?.()}
</div>
</div>
+25 -29
View File
@@ -10,41 +10,39 @@
import { fetchCourses } from '$lib/utils/services/courses';
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import type { Course } from '$lib/utils/types';
import { untrack } from 'svelte';
let hasFetched = false;
let progressPercentage = 0;
let totalLessons = 0;
let totalCompleted = 0;
async function getCourses(userId: string | undefined, orgId: string) {
let totalCompleted = $derived($courses.reduce((acc, cur) => acc + (cur.progress_rate || 0), 0));
let totalLessons = $derived($courses.reduce((acc, cur) => acc + (cur.total_lessons || 0), 0));
let progressPercentage = $derived(Math.round((totalCompleted / totalLessons) * 100) || 0);
function getCourses(userId?: string, orgId?: string) {
if (hasFetched || !userId || !orgId) {
return;
}
// only show is loading when fetching for the first time
if (!$courses.length) {
$courseMetaDeta.isLoading = true;
}
const coursesResult = await fetchCourses(userId, orgId);
console.log(`coursesResult`, coursesResult);
untrack(async () => {
// only show is loading when fetching for the first time
if (!$courses.length) {
$courseMetaDeta.isLoading = true;
}
$courseMetaDeta.isLoading = false;
if (!coursesResult) return;
const coursesResult = await fetchCourses(userId, orgId);
console.log(`coursesResult`, coursesResult);
courses.set(coursesResult.allCourses);
hasFetched = true;
$courseMetaDeta.isLoading = false;
if (!coursesResult) return;
courses.set(coursesResult.allCourses);
hasFetched = true;
});
}
function calcTotalProgress(courses: Course[]) {
totalCompleted = courses.reduce((acc, cur) => acc + (cur.progress_rate || 0), 0);
totalLessons = courses.reduce((acc, cur) => acc + (cur.total_lessons || 0), 0);
progressPercentage = Math.round((totalCompleted / totalLessons) * 100) || 0;
}
$: getCourses($profile.id, $currentOrg.id);
$: calcTotalProgress($courses);
$effect(() => {
getCourses($profile.id, $currentOrg.id);
});
</script>
<svelte:head>
@@ -54,7 +52,7 @@
<section class="mx-auto max-w-6xl gap-5">
<div class="m-5">
<div class="mb-10 flex items-center justify-between">
<h1 class="text-2xl font-bold dark:text-white md:text-3xl">
<h1 class="text-2xl font-bold md:text-3xl dark:text-white">
{$t(getGreeting())}
{$profile.fullname}!
</h1>
@@ -94,7 +92,7 @@
{$t('dashboard.your_progress')}
</p>
<div
class="flex h-fit items-center justify-center gap-2 rounded border border-[#EAEAEA] p-3 dark:bg-neutral-800 lg:h-[40vh] lg:overflow-y-auto"
class="flex h-fit items-center justify-center gap-2 rounded border border-[#EAEAEA] p-3 lg:h-[40vh] lg:overflow-y-auto dark:bg-neutral-800"
>
<div
class="flex h-full w-full flex-col items-center justify-between gap-5 sm:flex-row lg:items-center lg:justify-around xl:flex-col xl:items-start"
@@ -117,9 +115,7 @@
</p>
{/if}
</span>
<h1
class="my-0 whitespace-nowrap text-5xl font-bold text-[#262626] dark:text-white lg:text-6xl"
>
<h1 class="my-0 whitespace-nowrap text-5xl font-bold text-[#262626] lg:text-6xl dark:text-white">
{progressPercentage} %
</h1>
</div>
@@ -13,10 +13,10 @@
<title>Community - 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">
<h1 class="dark:text-white text-3xl font-bold">{$t('community.title')}</h1>
<section class="mx-auto w-full 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('community.title')}</h1>
<PrimaryButton label={$t('community.ask_button')} onClick={askCommunity} />
</div>
@@ -1,34 +1,31 @@
<script lang="ts">
import { goto } from '$app/navigation';
import pluralize from 'pluralize';
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte';
import TrashIcon from '@lucide/svelte/icons/trash';
import { Dropdown, SkeletonPlaceholder, SkeletonText } from 'carbon-components-svelte';
import ArrowLeftIcon from 'carbon-icons-svelte/lib/ArrowLeft.svelte';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import Vote from '$lib/components/Vote/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import Avatar from '$lib/components/Avatar/index.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import CheckmarkOutlineIcon from 'carbon-icons-svelte/lib/CheckmarkOutline.svelte';
import { IconButton } from '$lib/components/IconButton';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
import { currentOrg, isOrgAdmin } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { supabase } from '$lib/utils/functions/supabase';
import {
askCommunityValidation,
commentInCommunityValidation
} from '$lib/utils/functions/validator';
import { askCommunityValidation, commentInCommunityValidation } from '$lib/utils/functions/validator';
import { snackbar } from '$lib/components/Snackbar/store';
import TextField from '$lib/components/Form/TextField.svelte';
import DeleteModal from '$lib/components/Org/Community/DeleteModal.svelte';
import TextEditor from '$lib/components/TextEditor/index.svelte';
import { calDateDiff } from '$lib/utils/functions/date';
import { browser } from '$app/environment';
import { untrack } from 'svelte';
import { fetchCourses } from '$lib/utils/services/courses';
import { t } from '$lib/utils/functions/translations';
import { courses } from '$lib/components/Courses/store';
import type { Course } from '$lib/utils/types';
export let data;
let { data } = $props();
const { slug } = data;
interface Comment {
@@ -56,39 +53,38 @@
courseId: string;
}
let question: Question;
let comment = '';
let question: Question | undefined = $state();
let comment = $state('');
let errors: {
title?: string;
courseId?: string;
} = {};
} = $state({});
let isValidAnswer = false; // V2 allow admin mark an answer as accepted
let resetInput = 1;
let voted: {
question: boolean;
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 {
@@ -161,11 +157,15 @@
return goto(`/lms`);
}
question = mapResToQuestion(data);
question.totalComments = question.comments.length;
untrack(() => {
question = mapResToQuestion(data);
question.totalComments = question.comments.length;
});
}
async function submitComment() {
if (!question) return;
errors = commentInCommunityValidation({ comment });
console.log('submitComment errors', errors);
@@ -209,11 +209,12 @@
// Reset input
comment = '';
resetInput = new Date().getTime();
}
}
async function upvoteQuestion(type: string, commentId?: string) {
if (!question) return;
const isQuestion = type === 'question';
if (isQuestion && voted.question) return;
@@ -249,6 +250,8 @@
}
async function handleQuestionEdit() {
if (!question) return;
if (isEditMode) {
errors = askCommunityValidation(editContent);
console.log('handleQuestionEdit errors', errors);
@@ -296,13 +299,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;
@@ -351,12 +353,14 @@
deleteQuestion.isDeleting = false;
}
$: browser && fetchCommunityQuestion(slug);
$: {
$effect(() => {
fetchCommunityQuestion(slug);
});
$effect(() => {
if ($profile.id && $currentOrg.id) {
getCourses($profile.id, $currentOrg.id);
}
}
});
</script>
<svelte:head>
@@ -365,7 +369,7 @@
<DeleteModal
bind:open={deleteQuestion.shouldDelete}
bind:isDeleting={deleteQuestion.isDeleting}
isDeleting={deleteQuestion.isDeleting}
onCancel={() => {
deleteQuestion.shouldDelete = false;
deleteQuestion.questionId = '';
@@ -383,28 +387,24 @@
}}
onDelete={() => handleDelete(false)}
/>
<section class="max-w-3xl mx-auto md:mx-10 lg:mb-20">
<section class="mx-auto max-w-3xl md:mx-10 lg:mb-20">
{#if !question}
<div class="py-10 px-5 mb-3">
<div class="mb-3 px-5 py-10">
<SkeletonText style="width: 25%;" />
<SkeletonText style="width: 100%; margin-bottom: 2rem" />
<SkeletonPlaceholder style="width: 100%; height: 20rem;" />
</div>
{:else}
<div class="py-10 px-5">
<a class="text-gray-500 dark:text-white text-md flex items-center" href={`/lms/community`}>
<ArrowLeftIcon size={24} class="carbon-icon dark:text-white" />
<div class="px-5 py-10">
<a class="text-md flex items-center text-gray-500 dark:text-white" href="/lms/community">
<ArrowLeftIcon size={16} />
{$t('community.ask.go_back')}
</a>
<div class="my-5 flex justify-between items-center">
<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="w-[25%] h-full"
class="h-full w-[25%]"
size="xl"
label="Select Course"
items={fetchedCourses.map((course) => ({ id: course.id, text: course.title }))}
@@ -412,11 +412,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}
@@ -439,17 +435,12 @@
{/if}
{/if}
</div>
<div class="my-1 px-1 rounded-lg border border-1 border-gray">
<header class="flex items-center justify-between leading-none p-2">
<div class="flex items-center no-underline hover:underline text-black">
<Avatar
src={question.author.avatar}
name={question.author.name}
width="w-7"
height="h-7"
/>
<p class="dark:text-white ml-2 text-sm">{question.author.name}</p>
<p class="dark:text-white ml-2 text-sm text-gray-500">
<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" />
<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}
</p>
</div>
@@ -457,11 +448,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>
@@ -485,24 +478,24 @@
</div>
{#each question.comments as comment}
<div class="my-5 px-1 flex items-start">
<div class="my-5 flex items-start px-1">
<Vote
value={comment.votes}
upVote={() => upvoteQuestion('comment', comment.id)}
disabled={voted.comment[comment.id]}
/>
<div class="w-full rounded-lg border border-1 border-gray">
<header class="flex items-center justify-between leading-none p-2">
<div class="border-1 border-gray w-full rounded-lg border">
<header class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black">
<Avatar src={comment.avatar} name={comment.name} width="w-7" height="h-7" />
<p class="dark:text-white ml-2 text-sm">{comment.name}</p>
<p class="dark:text-white ml-2 text-sm text-gray-500">
<p class="ml-2 text-sm dark:text-white">{comment.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white">
{comment.createdAt}
</p>
</div>
{#if isValidAnswer}
<CheckmarkOutlineIcon size={20} />
<CircleCheckIcon size={16} />
{/if}
{#if comment.authorId === $profile.id || $isOrgAdmin}
@@ -513,7 +506,7 @@
deleteComment.commentId = comment.id;
}}
>
<TrashCanIcon size={16} class="carbon-icon dark:text-white" />
<TrashIcon size={16} />
</IconButton>
{/if}
</header>
@@ -528,14 +521,10 @@
<div>
{#if !editorInstance}
<TextEditor
bind:value={comment}
placeholder="Give an answer"
onChange={(html) => (comment = html)}
/>
<TextEditor bind:value={comment} placeholder="Give an answer" onChange={(html) => (comment = html)} />
{/if}
<div class="flex justify-end mr-2">
<div class="mr-2 flex justify-end">
<PrimaryButton label="Comment" onClick={submitComment} />
</div>
</div>
@@ -1,6 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import ArrowLeftIcon from 'carbon-icons-svelte/lib/ArrowLeft.svelte';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import { Dropdown } from 'carbon-components-svelte';
import { currentOrg } from '$lib/utils/store/org';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
@@ -14,20 +14,27 @@
import { t } from '$lib/utils/functions/translations';
import { courses } from '$lib/components/Courses/store';
import type { Course } from '$lib/utils/types';
import { fetchCourses } from '$lib/utils/services/courses';
let errors: {
title?: string;
courseId?: string;
} = {};
let fields = {
} = $state({});
let fields = $state({
title: '',
body: '',
courseId: ''
};
});
let fetchedCourses: Course[] = [];
let fetchedCourses: Course[] = $state([]);
let hasFetched = $state(false);
async function getCourses(userId: string | null, orgId: string) {
async function getCourses(userId?: string, orgId?: string) {
if (!userId || !orgId || hasFetched) {
return;
}
hasFetched = true;
if ($courses.length) {
fetchedCourses = [...$courses];
return;
@@ -70,30 +77,28 @@
}
}
$: {
if ($profile.id && $currentOrg.id) {
getCourses($profile.id, $currentOrg.id);
}
}
$effect(() => {
getCourses($profile.id, $currentOrg.id);
});
</script>
<svelte:head>
<title>Ask the Community - ClassroomIO</title>
</svelte:head>
<section class="w-full max-w-3xl mx-auto">
<section class="mx-auto w-full max-w-3xl">
<div class="p-5">
<a class="text-gray-500 dark:text-white text-md flex items-center" href={`/lms/community`}>
<ArrowLeftIcon size={24} class="carbon-icon dark:text-white" />
<a class="text-md flex items-center text-gray-500 dark:text-white" href="/lms/community">
<ArrowLeftIcon size={16} />
{$t('community.ask.go_back')}
</a>
<div class="flex items-center justify-between">
<h1 class="dark:text-white text-3xl font-bold">{$t('community.ask.ask_the')}</h1>
<h1 class="text-3xl font-bold dark:text-white">{$t('community.ask.ask_the')}</h1>
<PrimaryButton label={$t('community.ask.publish')} onClick={handleSave} />
</div>
</div>
<div class="mb-3 p-2 flex gap-x-5 justify-between">
<div class="mb-3 flex justify-between gap-x-5 p-2">
<TextField
bind:value={fields.title}
placeholder={$t('community.ask.title')}
@@ -101,7 +106,7 @@
className="w-[75%]"
/>
<Dropdown
class="w-[25%] h-full"
class="h-full w-[25%]"
size="xl"
label={$t('community.ask.select_course')}
invalid={!!errors.courseId}
@@ -2,7 +2,6 @@
import cloneDeep from 'lodash/cloneDeep';
import Chip from '$lib/components/Chip/index.svelte';
import { profile } from '$lib/utils/store/user';
import { browser } from '$app/environment';
import { currentOrg } from '$lib/utils/store/org';
import { snackbar } from '$lib/components/Snackbar/store';
import { fetchLMSExercises } from '$lib/utils/services/lms/exercises';
@@ -37,7 +36,9 @@
className: 'text-green-700 bg-green-200'
}
];
let sections: Section[] = cloneDeep(defaultSections);
let sections: Section[] = $state(cloneDeep(defaultSections));
let hasFetched = $state(false);
interface Section {
id: number;
title: string;
@@ -75,10 +76,7 @@
const lessonURL = `${courseURL}/lessons/${lesson.id}`;
const exerciseURL = `${lessonURL}/exercises/${id}`;
const grade = `${submissionItem.total}/${questions.reduce(
(acc, cur) => (acc += cur.points),
0
)}`;
const grade = `${submissionItem.total}/${questions.reduce((acc, cur) => (acc += cur.points), 0)}`;
const item: ExerciseItem = {
exerciseId: id,
@@ -100,7 +98,14 @@
return _sections;
}
async function fetchData(profileId: string, orgId: string) {
async function fetchData(profileId?: string, orgId?: string) {
if (hasFetched || !profileId || !orgId) {
return;
}
hasFetched = true;
// Don't rerun this function if any state is updated in this function.
const { exercises, error } = await fetchLMSExercises(profileId, orgId);
console.log('exercises', exercises);
console.log('error', error);
@@ -116,51 +121,45 @@
console.log('sections', sections);
}
$: if (browser && $profile.id && $currentOrg.id) {
$effect(() => {
fetchData($profile.id, $currentOrg.id);
}
});
</script>
<section class="w-full max-w-6xl mx-auto">
<section class="mx-auto w-full max-w-6xl">
<div class="p-5">
<div class="flex items-center justify-between mb-10">
<h1 class="dark:text-white text-3xl font-bold">{$t('exercises.heading')}</h1>
<div class="mb-10 flex items-center justify-between">
<h1 class="text-3xl font-bold dark:text-white">{$t('exercises.heading')}</h1>
</div>
<div>
<div class="flex items-center w-full">
<div class="flex w-full items-center">
{#each sections as { title, items, className, id }}
<div
class="min-w-[355px] max-w-[355px] h-[70vh] rounded-md bg-gray-100 dark:bg-black border border-gray-50 dark:border-neutral-700 p-3 mr-3 overflow-hidden"
class="mr-3 h-[70vh] min-w-[355px] max-w-[355px] overflow-hidden rounded-md border border-gray-50 bg-gray-100 p-3 dark:border-neutral-700 dark:bg-black"
>
<div class="flex items-center mb-2 gap-2">
<p class="dark:text-white ml-2 font-bold">{title}</p>
<div class="mb-2 flex items-center gap-2">
<p class="ml-2 font-bold dark:text-white">{title}</p>
<Chip value={items.length} {className} />
</div>
<div class="pr-2 h-full overflow-y-auto pb-3">
<div class="h-full overflow-y-auto pb-3 pr-2">
{#each items as item}
<div class=" w-full my-2 mx-0 rounded-md bg-white dark:bg-neutral-800 py-3 px-3">
<a
class="flex w-full items-center cursor-pointer text-primary-600 mb-2"
href={item.courseURL}
>
<div class=" mx-0 my-2 w-full rounded-md bg-white px-3 py-3 dark:bg-neutral-800">
<a class="text-primary-600 mb-2 flex w-full cursor-pointer items-center" href={item.courseURL}>
<p class="text-xs">{item.courseTitle}</p>
</a>
<a class="text-black dark:text-white text-md font-bold" href={item.exerciseURL}>
<a class="text-md font-bold text-black dark:text-white" href={item.exerciseURL}>
{#if id === 3}
({item.grade}) -
{/if}
{item.exerciseTitle}
</a>
<a
class="flex items-center no-underline hover:underline text-black my-2 w-fit"
href={item.lessonURL}
>
<p class="dark:text-white text-grey text-sm">
<a class="my-2 flex w-fit items-center text-black no-underline hover:underline" href={item.lessonURL}>
<p class="text-grey text-sm dark:text-white">
{$t('exercises.lesson')} <span class="italic">{item.lessonTitle}</span>
</p>
</a>
<p class="dark:text-white text-gray-500 text-xs">
<p class="text-xs text-gray-500 dark:text-white">
{item.submissionUpdatedAt}
</p>
</div>
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, untrack } from 'svelte';
import { Search, Dropdown } from 'carbon-components-svelte';
import { profile } from '$lib/utils/store/user';
import { fetchExploreCourses } from '$lib/utils/services/courses';
@@ -8,46 +8,45 @@
import { courseMetaDeta } from '$lib/components/Courses/store';
import { currentOrg } 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 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';
let searchValue = '';
let selectedId: string;
let filteredExploreCourses: Course[];
let searchValue = $state('');
let selectedId: string | undefined = $state();
let hasFetched = false;
let exploreCourseList: Course[] = [];
let exploreCourseList: Course[] = $state([]);
async function getCourses(userId: string | null, orgId: string) {
const filteredExploreCourses: Course[] = $derived(filterCourses(searchValue, selectedId || '', exploreCourseList));
function getCourses(userId?: string, orgId?: string) {
if (hasFetched || !userId || !orgId) {
return;
}
$courseMetaDeta.isLoading = true;
// don't rerun this function if any state is updated in this function.
untrack(async () => {
$courseMetaDeta.isLoading = true;
const coursesResult = await fetchExploreCourses(userId, orgId);
const coursesResult = await fetchExploreCourses(userId, orgId);
$courseMetaDeta.isLoading = false;
$courseMetaDeta.isLoading = false;
if (!coursesResult) return;
if (!coursesResult) return;
exploreCourseList = coursesResult.allCourses;
hasFetched = true;
exploreCourseList = coursesResult.allCourses;
hasFetched = true;
});
}
function filterCourses(searchValue: string, _selectedId: string, courses: Course[]) {
if (browser) {
if (!selectedId) {
selectedId = localStorage.getItem('classroomio_filter_course_key') || '0';
} else {
localStorage.setItem('classroomio_filter_course_key', _selectedId);
}
function filterCourses(searchValue: string, _selectedId: string | null, courses: Course[]) {
if (_selectedId) {
localStorage.setItem('classroomio_filter_course_key', _selectedId);
}
filteredExploreCourses = courses.filter((course) => {
const coursesFiltered = courses.filter((course) => {
if (!searchValue || course.title.toLowerCase().includes(searchValue.toLowerCase())) {
return true;
}
@@ -56,14 +55,12 @@
});
if (_selectedId === '0') {
filteredExploreCourses = filteredExploreCourses.sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
return coursesFiltered.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
} else if (_selectedId === '1') {
filteredExploreCourses = filteredExploreCourses.sort(
(a, b) => b.total_lessons - a.total_lessons
);
return coursesFiltered.sort((a, b) => (b.total_lessons ?? 0) - (a.total_lessons ?? 0));
}
return coursesFiltered;
}
const setViewPreference = (preference: 'grid' | 'list') => {
@@ -77,18 +74,18 @@
if (courseView) {
$courseMetaDeta.view = courseView;
}
selectedId = localStorage.getItem('classroomio_filter_course_key') || '0';
});
$: filterCourses(searchValue, selectedId, exploreCourseList);
$: if (browser && $profile.id && $currentOrg.id) {
$effect(() => {
getCourses($profile.id, $currentOrg.id);
}
});
</script>
<section class="w-full md:max-w-6xl md:mx-auto">
<div class="py-2 md:py-10 px-2 md:px-5">
<h1 class="dark:text-white text-3xl font-bold">{$t('explore.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">
<h1 class="text-3xl font-bold dark:text-white">{$t('explore.heading')}</h1>
<div class="flex flex-row-reverse">
<div class="filter-container flex items-end justify-start">
<Search
@@ -109,17 +106,17 @@
{#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>
<Courses
bind:courses={filteredExploreCourses}
courses={filteredExploreCourses}
emptyTitle={$t('explore.empty_heading')}
emptyDescription={$t('explore.empty_description')}
isExplore={true}
@@ -6,13 +6,8 @@
import { fetchCourses } from '$lib/utils/services/courses';
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
import {
courses,
courseMetaDeta,
coursesComplete,
coursesInProgress
} from '$lib/components/Courses/store';
import { browser } from '$app/environment';
import { courses, courseMetaDeta, coursesComplete, coursesInProgress } from '$lib/components/Courses/store';
import { untrack } from 'svelte';
import { t } from '$lib/utils/functions/translations';
let hasFetched = false;
@@ -21,32 +16,36 @@
return () => (currentTab = tab);
}
async function getCourses(userId: string | null, orgId: string) {
function getCourses(userId?: string, orgId?: string) {
if (hasFetched || !userId || !orgId) {
return;
}
hasFetched = true;
// only show is loading when fetching for the first time
if (!$courses.length) {
$courseMetaDeta.isLoading = true;
}
// don't rerun this function if any state is updated in this function.
untrack(async () => {
hasFetched = true;
const coursesResult = await fetchCourses(userId, orgId);
console.log(`get courses result`, coursesResult);
// only show is loading when fetching for the first time
if (!$courses.length) {
$courseMetaDeta.isLoading = true;
}
$courseMetaDeta.isLoading = false;
if (!coursesResult) return;
const coursesResult = await fetchCourses(userId, orgId);
console.log(`get courses result`, coursesResult);
courses.set(coursesResult.allCourses);
hasFetched = true;
$courseMetaDeta.isLoading = false;
if (!coursesResult) return;
courses.set(coursesResult.allCourses);
hasFetched = true;
});
}
$: if (browser && $profile.id && $currentOrg.id) {
$effect(() => {
getCourses($profile.id, $currentOrg.id);
}
});
$: tabs = [
let tabs = $derived([
{
label: `${$t('my_learning.progress')} (${$coursesInProgress.length})`,
value: '1'
@@ -55,33 +54,35 @@
label: `${$t('my_learning.complete')} (${$coursesComplete.length})`,
value: '2'
}
];
$: currentTab = tabs[0].value;
]);
let currentTab = $derived(tabs[0].value);
</script>
<section class="max-w-6xl mx-auto">
<section class="mx-auto max-w-6xl">
<div class="m-2 md:m-5">
<div role="searchbox" class=" bg-gray-100 w-full md:w-[60%] lg:w-[30%]">
<div role="searchbox" class=" w-full bg-gray-100 md:w-[60%] lg:w-[30%]">
<Search placeholder={$t('my_learning.search')} class="dark:text-black" />
</div>
<h1 class="text-3xl font-semibold my-4">{$t('my_learning.heading')}</h1>
<h1 class="my-4 text-3xl font-semibold">{$t('my_learning.heading')}</h1>
<Tabs {tabs} {currentTab} {onChange}>
<slot:fragment slot="content">
<TabContent value={tabs[0].value} index={currentTab}>
<Courses
courses={$coursesInProgress}
emptyTitle={$t('my_learning.not_in_progress')}
emptyDescription={$t('my_learning.any_progress')}
/>
</TabContent>
<TabContent value={tabs[1].value} index={currentTab}>
<Courses
courses={$coursesComplete}
emptyTitle={$t('my_learning.not_completed')}
emptyDescription={$t('my_learning.any_course')}
/>
</TabContent>
</slot:fragment>
{#snippet content()}
<slot:fragment>
<TabContent value={tabs[0].value} index={currentTab}>
<Courses
courses={$coursesInProgress}
emptyTitle={$t('my_learning.not_in_progress')}
emptyDescription={$t('my_learning.any_progress')}
/>
</TabContent>
<TabContent value={tabs[1].value} index={currentTab}>
<Courses
courses={$coursesComplete}
emptyTitle={$t('my_learning.not_completed')}
emptyDescription={$t('my_learning.any_course')}
/>
</TabContent>
</slot:fragment>
{/snippet}
</Tabs>
</div>
</section>
@@ -1,33 +1,32 @@
<script>
import { page } from '$app/stores';
import { page } from '$app/state';
import { Tabs, Tab, TabContent } from 'carbon-components-svelte';
import Profile from '$lib/components/LMS/components/Profile.svelte';
import Integrations from '$lib/components/Org/Settings/Integrations.svelte';
import { t } from '$lib/utils/functions/translations';
let selected = 0;
let tabs = [];
$: tabs = [
let selected = $state(0);
const tabs = $derived([
{
key: 0,
label: $t('settings.tabs.profile_tab'),
tabKey: '',
href: $page.url.pathname
href: page.url.pathname
},
{
key: 1,
label: $t('settings.tabs.integrations_tab'),
tabKey: 'integrations',
href: `${$page.url.pathname}?tab=integrations`,
href: `${page.url.pathname}?tab=integrations`,
disabled: false
}
];
]);
</script>
<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-3xl font-bold">{$t('settings.heading')}</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-3xl font-bold dark:text-white">{$t('settings.heading')}</h1>
</div>
<div class="">
+5 -5
View File
@@ -10,12 +10,12 @@
import { globalStore } from '$lib/utils/store/app';
import { currentOrg } from '$lib/utils/store/org';
let formRef: HTMLFormElement;
let formRef: HTMLFormElement = $state();
let supabase = getSupabase();
let fields = Object.assign({}, LOGIN_FIELDS);
let submitError: string;
let loading = false;
let errors = Object.assign({}, LOGIN_FIELDS);
let fields = $state(Object.assign({}, LOGIN_FIELDS));
let submitError: string = $state();
let loading = $state(false);
let errors = $state(Object.assign({}, LOGIN_FIELDS));
async function handleSubmit() {
const validationRes = authValidation(fields);
@@ -7,6 +7,6 @@
});
</script>
<div class="app-background w-full min-h-screen flex items-center justify-center">
<div class="app-background flex min-h-screen w-full items-center justify-center">
<div>Logging you out...</div>
</div>
@@ -12,12 +12,10 @@
import { blockedSubdomain } from '$lib/utils/constants/app';
import { getOrganizations } from '$lib/utils/services/org';
import { generateSitename } from '$lib/utils/functions/org';
import {
triggerSendEmail,
NOTIFICATION_NAME
} from '$lib/utils/services/notification/notification';
import { triggerSendEmail, NOTIFICATION_NAME } from '$lib/utils/services/notification/notification';
import { handleLocaleChange, t } from '$lib/utils/functions/translations';
import { LOCALE } from '$lib/utils/types';
import { untrack } from 'svelte';
interface OnboardingField {
fullname?: string;
@@ -35,17 +33,16 @@
}
const maxSteps = 2;
let fields: OnboardingField = {
let fields: OnboardingField = $state({
fullname: '',
orgName: '',
siteName: '',
locale: LOCALE.EN
};
let errors: OnboardingField = {};
let progress = 50;
let step = 1;
let loading = false;
let isSiteNameTouched = false;
});
let errors: OnboardingField = $state({});
let step = $state(1);
let loading = $state(false);
let isSiteNameTouched = $state(false);
let selectedId = 'en';
let dropdownItems = [
@@ -61,6 +58,8 @@
{ id: 'da', text: 'Danish' }
];
const progress = $derived(Math.round((step / maxSteps) * 100));
const educatorGoals: Goal[] = [
{
label: $t('onboarding.sell'),
@@ -116,15 +115,6 @@
fields.metadata = payload;
}
function setOrgSiteName(orgName: string | undefined, isTouched: boolean) {
if (!orgName || isTouched) return;
fields.siteName = orgName
?.toLowerCase()
?.replace(/\s+/g, '-')
?.replace(/[^a-zA-Z0-9-]/g, '');
}
const handleSubmit = async () => {
loading = true;
// Run validation
@@ -233,9 +223,30 @@
loading = false;
};
$: progress = Math.round((step / maxSteps) * 100);
$: fields.siteName = generateSitename(fields.siteName || '');
$: setOrgSiteName(fields.orgName, isSiteNameTouched);
function updateSiteName(sname?: string) {
if (!sname) return;
untrack(() => {
fields.siteName = generateSitename(sname);
});
}
$effect(() => {
updateSiteName(fields.siteName);
});
function setOrgSiteName(orgName: string | undefined, isTouched: boolean) {
if (!orgName || isTouched) return;
untrack(() => {
fields.siteName = orgName
?.toLowerCase()
?.replace(/\s+/g, '-')
?.replace(/[^a-zA-Z0-9-]/g, '');
});
}
$effect(() => {
setOrgSiteName(fields.orgName, isSiteNameTouched);
});
</script>
{#if $profile.id}
@@ -252,7 +263,7 @@
<div
class="mb-6 flex w-64 items-center justify-center rounded-2xl border border-gray-300 bg-gray-100 py-6 dark:bg-neutral-800"
>
<UserProfileIcon />
<UserProfileIcon size={16} />
<p class="ml-2 text-sm dark:text-white">{$profile.email}</p>
</div>
</div>
@@ -317,13 +328,7 @@
<!-- Loop through Goals -->
{#each educatorGoals as goal}
<label class="mb-1 inline-flex w-full items-center font-light dark:text-white">
<input
type="radio"
bind:group={fields.goal}
name="goal"
value={goal.value}
class="mr-2"
/>
<input type="radio" bind:group={fields.goal} name="goal" value={goal.value} class="mr-2" />
{goal.label}
</label>
{/each}
@@ -344,13 +349,7 @@
<!-- Loop through Goals -->
{#each sources as source}
<label class="mb-1 inline-flex w-full items-center font-light dark:text-white">
<input
type="radio"
bind:group={fields.source}
name="source"
value={source.value}
class="mr-2"
/>
<input type="radio" bind:group={fields.source} name="source" value={source.value} class="mr-2" />
{source.label}
</label>
{/each}
@@ -365,12 +364,7 @@
<!-- Language Picker -->
<div class="mt-10">
<span>{$t('content.toggle_label')}: </span>
<Dropdown
items={dropdownItems}
{selectedId}
on:select={handleSelect}
class="w-full"
/>
<Dropdown items={dropdownItems} {selectedId} on:select={handleSelect} class="w-full" />
</div>
</div>
</div>
@@ -380,10 +374,7 @@
<!-- Footer -->
<div class="mt-8 flex w-full items-center justify-between">
<div class="relative h-2 w-24 bg-gray-300">
<span
class="progress bg-primary-700 absolute left-0 top-0 h-full"
style="width: {progress}%;"
/>
<span class="progress bg-primary-700 absolute left-0 top-0 h-full" style="width: {progress}%;"></span>
</div>
<div class="flex">
@@ -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);
@@ -1,5 +0,0 @@
export const load = ({ params = { id: '' } }) => {
return {
profileId: params.id
};
};
@@ -1,214 +0,0 @@
<script>
import UserAvatarIcon from 'carbon-icons-svelte/lib/UserAvatar.svelte';
import BookIcon from 'carbon-icons-svelte/lib/Book.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import { supabase } from '$lib/utils/functions/supabase';
import { user, profile } from '$lib/utils/store/user';
export let data;
const { profileId } = data;
let avatar;
let loading = false;
let currentProfile = {};
let isOwner = false;
let initialValueOfUserName, initialValueOfFullName, initialValueOfAvatar;
async function getProfile(profileId) {
loading = true;
// Get user profile
// Check if user has profile
let {
data: profileData,
error,
status
} = await supabase.from('profile').select(`*`).eq('id', profileId).single();
if (error && !profileData && status === 406 && $user.currentSession) {
// Not found
} else if (profileData) {
// Profile exists, go to profile page
currentProfile = profileData;
initialValueOfUserName = currentProfile.username;
initialValueOfFullName = currentProfile.fullname;
}
loading = false;
}
async function updateProfile() {
try {
loading = true;
const updates = {
fullname: currentProfile.fullname,
username: currentProfile.username
};
if (avatar) {
const filename = `user/${currentProfile.username + Date.now()}.webp`;
const { data } = await supabase.storage.from('avatars').upload(filename, avatar, {
cacheControl: '3600',
upsert: false
});
if (data) {
const { data: response } = supabase.storage.from('avatars').getPublicUrl(filename);
updates.avatar_url = response.publicUrl;
currentProfile.avatar_url = response.publicUrl;
}
avatar = undefined;
}
let { error } = await supabase.from('profile').update(updates).match({ id: profileId });
if (isOwner) {
profile.update((_profile) => ({
..._profile,
...updates
}));
}
if (error) throw error;
} catch (error) {
alert(error.message);
loading = false;
} finally {
loading = false;
initialValueOfUserName = currentProfile.username;
initialValueOfFullName = currentProfile.fullname;
}
}
$: isDirty =
avatar ||
initialValueOfUserName !== currentProfile.username ||
initialValueOfFullName !== currentProfile.fullname;
$: isOwner = $profile.id === profileId;
$: getProfile(profileId);
$: if (avatar) {
updateProfile();
}
</script>
<svelte:head>
<title>Profile of {currentProfile.username}</title>
</svelte:head>
<section class="root w-11/12 mt-3 m-auto flex items-start justify-between flex-col lg:flex-row">
{#if currentProfile.id}
<div class="w-full lg:w-1/4 flex items-center flex-col">
<!-- <img
alt={currentProfile.username}
src={currentProfile.avatar_url}
height="260"
width="260"
class="rounded-full bg-primary-100 mb-4"
/> -->
<UploadImage bind:avatar src={currentProfile.avatar_url} widthHeight="w-60 h-60" />
{#if isOwner}
<TextField
label="Full name"
bind:value={currentProfile.fullname}
className="w-60 mb-4"
inputClassName="rounded-md"
/>
<TextField
label="Username"
bind:value={currentProfile.username}
className="w-60 mb-4"
inputClassName="rounded-md"
/>
<TextField
label="Email"
bind:value={currentProfile.email}
className="w-60 mb-4"
inputClassName="rounded-md"
isDisabled={true}
/>
{#if isDirty}
<PrimaryButton
label={loading ? 'Updating...' : 'Update profile'}
onClick={updateProfile}
isDisabled={loading}
/>
{/if}
{:else}
<h3 class="dark:text-white text-xl">{currentProfile.username}</h3>
{/if}
</div>
<div class="flex-grow" />
<div class="flex-grow flex flex-wrap mx-3 w-3/5">
<div class="mb-3 w-full">
<h3 class="dark:text-white font-bold text-md m-0">Organizations</h3>
<p class="dark:text-white text-gray-500 text-sm">This are the organizations you work at</p>
<div class="hidden my-3 py-3 px-4 border shadow-sm rounded-md">
<h4 class="dark:text-white m-0 font-normal">
<a class="text-primary-700 font-bold" href="/courses"> CitiBim </a>
</h4>
<p class="dark:text-white text-gray-500">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Non aliquam tenetur magnam.
Alias cumque quaerat expedita dolores placeat laboriosam culpa, suscipit veritatis
adipisci eius magni obcaecati. Sequi doloremque blanditiis earum.
</p>
<div class="mt-2 flex items-center">
<span class="flex mr-2 items-center">
<UserAvatarIcon size={16} class="mr-2" /> 5
</span>
<span class="flex mr-2 items-center">
<BookIcon size={16} class="mr-2" /> 5
</span>
</div>
</div>
<div class="hidden my-2 py-3 px-4 border shadow-sm rounded-md">
<h4 class="dark:text-white m-0 font-normal">
<a class="text-primary-700 font-bold" href="/courses"> Climate Change Group </a>
</h4>
<p class="dark:text-white text-gray-500">
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Illo quasi autem quo possimus
illum consequuntur repudiandae nobis error, porro
</p>
<div class="mt-2 flex items-center">
<span class="flex mr-2 items-center">
<UserAvatarIcon size={16} class="mr-2" /> 30
</span>
<span class="flex mr-2 items-center">
<BookIcon size={16} class="mr-2" /> 3
</span>
</div>
</div>
</div>
</div>
<!-- {:else if loading}
<Box>
<Chasing size="60" color="#ff3e00" unit="px" duration="1s" />
</Box>
{:else}
<Box>
<div>
<Search32 />
</div>
<h3 class="dark:text-white text-xxl">Profile not found</h3>
</Box> -->
{/if}
</section>
<style>
.root {
max-width: 1204px;
}
</style>
+28 -20
View File
@@ -1,26 +1,27 @@
<script>
<script lang="ts">
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { getSupabase } from '$lib/utils/functions/supabase';
import {
resetValidation,
getConfirmPasswordError,
getDisableSubmit
} from '$lib/utils/functions/validator';
import { resetValidation, getConfirmPasswordError, getDisableSubmit } from '$lib/utils/functions/validator';
import { RESET_FIELDS } from '$lib/utils/constants/authentication';
import AuthUI from '$lib/components/AuthUI/index.svelte';
let supabase = getSupabase();
let fields = Object.assign({}, RESET_FIELDS);
let loading = false;
let success = false;
let errors = {};
let submitError;
let disableSubmit = false;
let formRef;
let fields = $state(Object.assign({}, RESET_FIELDS));
let loading = $state(false);
let errors: {
email?: string;
password?: string;
confirmPassword?: string;
} = $state({});
let submitError = $state();
let formRef: HTMLFormElement | undefined = $state();
async function handleSubmit(e) {
const disableSubmit = $derived(getDisableSubmit(fields));
async function handleSubmit() {
errors = resetValidation(fields);
console.log('errors', errors);
@@ -40,14 +41,21 @@
return goto('/login');
} catch (error) {
submitError = error.error_description || error.message;
submitError = error instanceof Error ? error.message : String(error);
} finally {
loading = false;
}
}
$: errors.confirmPassword = getConfirmPasswordError(fields);
$: disableSubmit = getDisableSubmit(fields);
function setConfirmPasswordError(fields) {
untrack(() => {
errors.confirmPassword = getConfirmPasswordError(fields);
});
}
$effect(() => {
setConfirmPasswordError(fields);
});
</script>
<svelte:head>
@@ -64,8 +72,8 @@
bind:formRef
>
<div class="mt-4 w-full">
<h3 class="dark:text-white text-xl font-semibold my-3">New Password</h3>
<p class="dark:text-white text-sm mb-6">Enter your new password details</p>
<h3 class="my-3 text-xl font-semibold dark:text-white">New Password</h3>
<p class="mb-6 text-sm dark:text-white">Enter your new password details</p>
<TextField
label="Your Password"
bind:value={fields.password}
@@ -94,7 +102,7 @@
{/if}
</div>
<div class="my-4 w-full flex justify-end items-center">
<div class="my-4 flex w-full items-center justify-end">
<PrimaryButton
label="Reset Password"
type="submit"
+20 -15
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { page } from '$app/state';
import AuthUI from '$lib/components/AuthUI/index.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
@@ -8,32 +9,29 @@
import { SIGNUP_FIELDS } from '$lib/utils/constants/authentication';
import { getSupabase } from '$lib/utils/functions/supabase';
import { t } from '$lib/utils/functions/translations';
import {
authValidation,
getConfirmPasswordError,
getDisableSubmit
} from '$lib/utils/functions/validator';
import { authValidation, getConfirmPasswordError, getDisableSubmit } from '$lib/utils/functions/validator';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { globalStore } from '$lib/utils/store/app';
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
let supabase = getSupabase();
let fields = Object.assign({}, SIGNUP_FIELDS);
let loading = false;
let fields = $state(Object.assign({}, SIGNUP_FIELDS));
let loading = $state(false);
let success = false;
let errors: {
email?: string;
password?: string;
confirmPassword?: string;
} = {};
let submitError: string;
let disableSubmit = false;
let formRef: HTMLFormElement;
} = $state({});
let submitError: string = $state('');
let formRef: HTMLFormElement | undefined = $state();
let query = new URLSearchParams($page.url.search);
let query = new URLSearchParams(page.url.search);
let redirect = query.get('redirect');
const disableSubmit = $derived(getDisableSubmit(fields));
async function handleSubmit() {
const validationRes = authValidation(fields);
console.log('validationRes', validationRes);
@@ -119,8 +117,15 @@
}
}
$: errors.confirmPassword = getConfirmPasswordError(fields);
$: disableSubmit = getDisableSubmit(fields);
function setConfirmPasswordError(fields: typeof SIGNUP_FIELDS) {
untrack(() => {
errors.confirmPassword = getConfirmPasswordError(fields);
});
}
$effect(() => {
setConfirmPasswordError(fields);
});
</script>
<svelte:head>