feat: 🚀 😃 convert repo into a monorepo 🚀 😃 (#51)

* Merge main into turbo-monrepo

* feat: rename apps

* feat: remove supbase videos and serve all from project

* feat: move supabase to root

* feat: update README

* docs: update link
This commit is contained in:
Rotimi Best
2023-11-25 21:56:02 +00:00
committed by GitHub
parent 03ca379094
commit 41e29014ef
806 changed files with 85853 additions and 5746 deletions
+51
View File
@@ -0,0 +1,51 @@
<script>
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { page } from '$app/stores';
import { appStore } 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) {
goto('/404');
}
console.error($page.error?.message);
</script>
<svelte:head>
<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"
>
<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.
</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"
href="/">Go Home</a
>
</div>
<div class="w-72 lg:w-2/4 flex items-center justify-center">
<!-- to check if it's in dark mode so as to render the appropriate svg -->
{#if $appStore.isDark}
<ErrorIconDarkMode />
{:else}
<ErrorIcon />
{/if}
</div>
</div>
<style>
.error-container {
max-width: 964px;
}
</style>
@@ -0,0 +1,51 @@
// import { redirect } from '@sveltejs/kit';
import { blockedSubdomain } from '$lib/utils/constants/app';
import { getCurrentOrg } from '$lib/utils/services/org';
import { getSupabase, supabase } from '$lib/utils/functions/supabase';
import type { CurrentOrg } from '$lib/utils/types/org';
if (!supabase) {
getSupabase();
}
interface LoadOutput {
orgSiteName: string;
isOrgSite: boolean;
skipAuth: boolean;
org: CurrentOrg;
}
export const load = async ({ url, cookies }): Promise<LoadOutput> => {
// if (url.hostname === 'classroomio.com') {
// throw redirect(301, 'https://about.classroomio.com');
// }
let response: LoadOutput = {
orgSiteName: '',
isOrgSite: false,
skipAuth: false,
org: {
theme: ''
}
};
const _orgSiteName = cookies.get('_orgSiteName');
const debugPlay = cookies.get('debugPlay');
const debugMode = _orgSiteName && _orgSiteName !== 'false';
console.log('_orgSiteName', _orgSiteName);
const matches = url.host.match(/([a-z 0-9 \-]+).*classroomio[.]com/);
const subdomain = matches?.[1] ?? '';
if (!blockedSubdomain.includes(subdomain)) {
const answer = Array.isArray(matches) ? !!subdomain && subdomain !== 'www' : false;
response.isOrgSite = debugMode || answer;
response.orgSiteName = debugMode ? _orgSiteName : subdomain;
response.org = await getCurrentOrg(response.orgSiteName, true);
} else if (subdomain === 'play' || debugPlay === 'true') {
response.skipAuth = true;
}
return response;
};
+444
View File
@@ -0,0 +1,444 @@
<script lang="ts">
import { onMount } from 'svelte';
import { PUBLIC_SUPABASE_PROJECT_REF } from '$env/static/public';
import { fly } from 'svelte/transition';
import { derived } from 'svelte/store';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { page, navigating } from '$app/stores';
import isEmpty from 'lodash/isEmpty';
import { Theme, ToastNotification, Loading } from 'carbon-components-svelte';
import type { CarbonTheme } from 'carbon-components-svelte/types/Theme/Theme.svelte';
import LandingNavigation from '$lib/components/Navigation/index.svelte';
import OrgNavigation from '$lib/components/Navigation/app.svelte';
import LMSNavigation from '$lib/components/Navigation/lms.svelte';
import OrgLandingPage from '$lib/components/Org/LandingPage/index.svelte';
import Snackbar from '$lib/components/Snackbar/index.svelte';
import Backdrop from '$lib/components/Backdrop/index.svelte';
import OrgSideBar from '$lib/components/Org/SideBar.svelte';
import LMSSideBar from '$lib/components/LMS/SideBar.svelte';
import Apps from '$lib/components/Apps/index.svelte';
import PlayQuiz from '$lib/components/Org/Quiz/Play/index.svelte';
import { course } from '$lib/components/Course/store';
import { isCoursesPage, isOrgPage, isLMSPage, isQuizPage } from '$lib/utils/functions/app';
import showAppsSideBar from '$lib/utils/functions/showAppsSideBar';
import isPublicRoute from '$lib/utils/functions/routes/isPublicRoute';
import { user, profile } from '$lib/utils/store/user';
import { getSupabase } from '$lib/utils/functions/supabase';
import { isMobile } from '$lib/utils/store/useMobile';
import { ROUTE } from '$lib/utils/constants/routes';
import { getOrganizations } from '$lib/utils/services/org';
import { toggleBodyByMode } from '$lib/utils/functions/app';
import { appStore } from '$lib/utils/store/app';
import { currentOrg } from '$lib/utils/store/org';
import { setTheme } from '$lib/utils/functions/theme';
import hideNavByRoute from '$lib/utils/functions/routes/hideNavByRoute';
import shouldRedirectOnAuth from '$lib/utils/functions/routes/shouldRedirectOnAuth';
import AddOrgModal from '$lib/components/Org/AddOrgModal/AddOrgModal.svelte';
import { identifyPosthogUser, initPosthog } from '$lib/utils/services/posthog';
import { initSentry, setSentryUser } from '$lib/utils/services/sentry';
import '../app.postcss';
export let data;
let supabase = getSupabase();
let path = $page.url?.pathname?.replace('/', '');
let carbonTheme: CarbonTheme = 'white';
const delayedPreloading = derived(navigating, (currentPreloading, set) => {
setTimeout(() => set(currentPreloading), 250);
});
function setupAnalytics() {
// Set up sentry
initSentry();
// Set up posthog
initPosthog();
}
function setAnalyticsUser() {
if ($profile.id) {
setSentryUser({
id: $profile.id,
username: $profile.username,
email: $profile.email,
fullname: $profile.fullname
});
identifyPosthogUser($profile.id, { email: $profile.email, name: $profile.fullname });
}
}
async function getProfile() {
const params = new URLSearchParams(window.location.search);
// Get user profile
const {
data: { session }
} = await supabase.auth.getSession();
const { user: authUser } = session || {};
console.log('Get user', authUser);
if (!authUser) {
goto('/login?redirect=/' + path);
}
// Check if user has profile
let {
data: profileData,
error,
status
} = await supabase.from('profile').select(`*`).eq('id', authUser?.id).single();
console.log('Get user', authUser);
console.log('Get profile', profileData);
if (error && !profileData && status === 406 && authUser) {
// User wasn't found, create profile
console.log(`User wasn't found, create profile`);
const [regexUsernameMatch] = [...(authUser.email?.matchAll(/(.*)@/g) || [])];
const { data: newProfileData, error } = await supabase
.from('profile')
.insert({
id: authUser.id,
username: regexUsernameMatch[1] + `${new Date().getTime()}`,
fullname: regexUsernameMatch[1],
email: authUser.email
})
.select();
// Profile created, go to onboarding or lms
if (!error && newProfileData) {
$user.fetchingUser = false;
$user.isLoggedIn = true;
$user.currentSession = authUser;
profile.set(newProfileData[0]);
setAnalyticsUser();
if (data.isOrgSite) {
const { data, error } = await supabase
.from('organizationmember')
.insert({
organization_id: $currentOrg.id,
profile_id: $profile.id,
role_id: 3
})
.select();
if (error) {
console.error('Error adding user to organisation', error);
} else {
console.log('Success adding user to organisation', data);
const memberId = data?.[0]?.id || '';
$currentOrg.memberId = memberId;
}
if (params.get('redirect')) {
goto(params.get('redirect') || '');
} else {
goto('/lms');
}
return;
}
// On invite page, don't go to onboarding
if (!path.includes('invite')) {
goto(ROUTE.ONBOARDING);
}
}
$user.fetchingUser = false;
} else if (profileData) {
// Profile exists, go to profile page
$user.fetchingUser = false;
$user.isLoggedIn = true;
$user.currentSession = authUser;
profile.set(profileData);
// Set user in sentry
setAnalyticsUser();
const orgRes = await getOrganizations(profileData.id);
// student redirect
if (data.isOrgSite) {
if (params.has('redirect')) {
goto(params.get('redirect') || '');
} else if (shouldRedirectOnAuth(path)) {
goto('/lms');
}
} else {
// Not on invite page or no org, go to onboarding
if (isEmpty(orgRes.orgs) && !path.includes('invite')) {
goto(ROUTE.ONBOARDING);
} else if (params.has('redirect')) {
goto(params.get('redirect') || '');
} else if (shouldRedirectOnAuth(path)) {
// By default redirect to first organization
goto(`/org/${orgRes.currentOrg.siteName}`);
}
}
if (typeof orgRes?.currentOrg?.theme === 'string') {
setTheme(orgRes.currentOrg.theme);
}
}
if (!profileData) {
goto('/login?redirect=/' + path);
}
}
function handleResize() {
isMobile.update(() => window.innerWidth <= 760);
}
onMount(() => {
console.log(
'Welcome to ClassroomIO, we are grateful you chose us.',
$page.url.host,
`\nIs student domain: ${data.isOrgSite}`
);
if (browser) {
// Update theme - dark or light mode
$appStore.isDark = localStorage.getItem('mode') === 'dark';
toggleBodyByMode($appStore.isDark);
const theme = localStorage.getItem('theme');
if (data.isOrgSite && data.org?.theme) {
setTheme(data.org?.theme);
} else if (theme) {
setTheme(theme);
}
}
setupAnalytics();
handleResize();
if (
!localStorage.getItem(`sb-${PUBLIC_SUPABASE_PROJECT_REF}-auth-token`) &&
!isPublicRoute($page.url.pathname)
) {
console.log('No auth token and is not a public route, redirect to login', path);
return goto('/login?redirect=/' + path);
}
const { data: authListener } = supabase.auth.onAuthStateChange((event, session) => {
// Log key events
console.log(`event`, event);
if (event == 'PASSWORD_RECOVERY') {
console.log('PASSWORD RESET');
}
if (path.includes('reset')) {
console.log('Dont change auth when on reset page');
return;
}
// Skip Authentication
if (data.skipAuth) return;
// Authentication Steps
if (event === 'SIGNED_IN') {
$user.fetchingUser = true;
getProfile();
} else if (!['INITIAL_SESSION', 'TOKEN_REFRESHED'].includes(event)) {
console.log('not logged in, go to login');
return goto('/login');
}
});
if (data.isOrgSite) {
if (!data.org) {
goto('/404');
} else {
$appStore.orgSiteName = data.orgSiteName;
$appStore.isOrgSite = data.isOrgSite;
currentOrg.set(data.org);
}
}
return () => {
console.log('unsubscribed');
authListener.subscription.unsubscribe();
};
});
$: path = $page.url?.pathname?.replace('/', '');
$: carbonTheme = $appStore.isDark ? 'g100' : 'white';
</script>
<svelte:window on:resize={handleResize} />
<svelte:head>
<link href="/css/carbon.css" rel="stylesheet" />
</svelte:head>
<Theme bind:theme={carbonTheme} />
<Snackbar />
{#if data.skipAuth}
<PlayQuiz />
{:else if data.isOrgSite && !path}
<OrgLandingPage orgSiteName={data.orgSiteName} org={data.org} />
{:else}
<main class="dark:bg-black">
{#if $navigating && $delayedPreloading}
<Backdrop disableCenteredContent={true} className="opacity-90">
<div class="h-full w-full relative" transition:fly={{ x: -200, duration: 500 }}>
<ToastNotification kind="info-square" class="absolute bottom-5 left-5">
<span slot="title" class="flex items-center">
<span class="mr-2">Redirecting</span>
<Loading withOverlay={false} small />
</span>
<span slot="caption">Taking you to the next page, please wait.</span>
</ToastNotification>
</div>
</Backdrop>
{/if}
{#if !hideNavByRoute($page.url.pathname)}
{#if isOrgPage($page.url.pathname) || $page.url.pathname.includes('profile') || isCoursesPage(path)}
<OrgNavigation bind:title={$course.title} isCoursePage={isCoursesPage(path)} />
{:else if isLMSPage($page.url.pathname)}
<LMSNavigation />
{:else}
<LandingNavigation
isOrgSite={data.isOrgSite}
logo={data.isOrgSite ? $currentOrg.avatar_url : undefined}
orgName={data.isOrgSite ? $currentOrg.name : undefined}
disableSignup={false}
/>
{/if}
{/if}
<div class="flex justify-between">
{#if isOrgPage($page.url.pathname)}
<AddOrgModal />
<div class="org-root w-full flex items-center justify-between">
{#if !isQuizPage($page.url.pathname)}
<OrgSideBar />
{/if}
<div class="org-slot bg-white dark:bg-black w-full">
<slot />
</div>
</div>
{:else if isLMSPage($page.url.pathname)}
<div class="org-root w-full flex items-center justify-between">
<LMSSideBar />
<div class="org-slot bg-white dark:bg-black w-full">
<slot />
</div>
</div>
{:else}
<slot />
{/if}
{#if showAppsSideBar(path)}
<Apps />
{/if}
</div>
</main>
{/if}
<style>
main {
background-color: white;
box-sizing: border-box;
}
.org-root {
height: calc(100vh - 48px);
}
.org-slot {
min-width: calc(100vw - 250px);
height: calc(100vh - 48px);
overflow-y: auto;
}
:global(a:hover) {
text-decoration: underline;
}
:global(:root) {
--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%
);
--dark-app-background: radial-gradient(circle at 10% 20%, rgb(0 0 0) 0%, rgb(27 60 74) 90%);
}
:global(.app-background) {
background: var(--app-background);
}
:global(.dark .app-background) {
background: var(--dark-app-background);
}
:global(.bx--data-table-container) {
width: 100%;
}
:global(.dark svg.dark) {
fill: #fff;
}
:global(.border-c) {
border: 1px solid var(--border-color);
}
:global(.border-bottom-c) {
border-bottom: 1px solid var(--border-color);
}
:global(.bx--search-close > svg) {
fill: black;
}
:global(.dark .bx--search-close:hover > svg) {
fill: #fff;
}
:global(.plyr__controls) {
background: url(/logo-192.png) 99% 70% no-repeat,
linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.5)) !important;
background-size: 50px auto, auto !important;
}
:global(.cards-container) {
width: 90%;
margin: 0 auto;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
column-gap: 12px;
row-gap: 12px;
}
@media screen and (max-width: 768px) {
:global(.cards-container) {
width: 95%;
margin: 0 auto;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
column-gap: 12px;
row-gap: 12px;
}
}
@media screen and (max-width: 457px) {
.org-slot {
min-width: 100%;
max-height: calc(100vh - 48px);
overflow-y: auto;
}
}
</style>
+121
View File
@@ -0,0 +1,121 @@
<script>
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 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;
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'
},
{
title: 'Analytics',
description:
'Avoid assumptions, better understand the needs of your classrooms and give students a personlized experience'
},
{
title: 'Automation',
description:
'Lesson reminders, deadline reminders and many other automated alerts to help everyone in the learning process be proactive'
}
];
async function handleSubmit() {
if (!email || !validateEmail(email)) return;
isAdding = true;
await supabase.from('waitinglist').insert([{ email }]);
success = true;
setTimeout(() => {
isAdding = false;
success = false;
email = '';
}, 5000);
}
</script>
<svelte:head>
<title>ClassroomIO - The classroom software</title>
</svelte:head>
<div class="root w-screen flex items-center justify-center flex-col m-2 sm:m-0">
<!--
<img
src="/logo-192.png"
alt="ClassroomIO logo"
class="rounded inline-block mx-auto w-20 h-20 sm:w-auto sm:h-auto"
data-atf="1"
/>
-->
<div>
<h3 class="dark:text-white text-4xl text-center">
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>
</div>
<form on:submit|preventDefault={handleSubmit} class="my-4">
<div class="flex items-center flex-col sm:flex-row">
{#if success}
<p class="dark:text-white">You have been added successfully. Thanks for joining.</p>
{:else}
<TextField
bind:value={email}
type="email"
placeholder="Enter your email.."
className="mr-3 my-2"
isRequired={true}
isDisabled={isAdding}
/>
<PrimaryButton type="submit" isLoading={isAdding}>Join waiting list</PrimaryButton>
{/if}
</div>
</form>
<div class="flex flex-col md:flex-row">
{#each areas as area, index}
<div
class="box m-3 bg-white dark:bg-black rounded-md py-3 px-12 active shadow-xl border-2 hover:border-primary-700 {animate}"
>
<h3 class="dark:text-white text-3xl">
{#if index === 0}
<FlowStreamReferenceIcon size={32} class="carbon-icon dark:text-white" />
{:else if index === 1}
<ChartClusterBarIcon size={32} class="carbon-icon dark:text-white" />
{:else if index === 2}
<MachineLearningModelIcon size={32} class="carbon-icon dark:text-white" />
{/if}
{area.title}
</h3>
<p class="dark:text-white">{area.description}</p>
</div>
{/each}
</div>
</div>
<style>
.box {
max-width: 350px;
}
@media (min-width: 640px) {
.root {
height: 93vh;
}
}
</style>
@@ -0,0 +1,53 @@
<script>
import { goto } from '$app/navigation';
import { appStore } from '$lib/utils/store/app';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
const src = './bg-404.png';
</script>
<div
style={$appStore.isDark ? `background-image:url(${src})` : ''}
class={`error bg-gray-200 bg-no-repeat bg-cover flex items-center justify-center w-screen gap-20`}
>
<div class=" dark:text-white gap-3 w-4/5 lg:w-2/5">
<p class="text-3xl dark:text-white font-semibold mb-5">
Oops😕,where did the page go?
</p>
<p class="text-base text-gray-600 dark:text-white mb-5">
We're sorry, but something went wrong and we can't show you the page
you're looking for. In the meantime, here's a joke to make you laugh:<br
/>
What do you call a fish with no eyes? Fsh!
</p>
<PrimaryButton
label="Go to Home"
className="rounded-md"
onClick={() => goto('/')}
/>
</div>
<img
src="/classroom-404.png"
alt="classroomio_error_image"
class="errorImg"
/>
</div>
<style>
.error {
height: calc(100vh - 57px);
}
@media (max-width: 920px) {
.error {
flex-direction: column-reverse;
gap: 20px;
}
.errorImg {
width: 160px;
height: 160px;
}
}
</style>
@@ -0,0 +1,43 @@
import { Configuration, OpenAIApi } from 'openai-edge';
import { OpenAIStream, StreamingTextResponse } from 'ai';
import { OPENAI_API_KEY } from '$env/static/private';
// Create an OpenAI API client (that's edge friendly!)
const openAIConfig = new Configuration({
apiKey: OPENAI_API_KEY
});
const openai = new OpenAIApi(openAIConfig);
// Set the runtime to edge for best performance
export const config = {
runtime: 'edge'
};
const instruction = {
outline: 'a structured lesson outline',
note: 'a sample lesson note',
activities: 'few interactive lesson activities to my lecture'
};
export async function POST({ request }) {
const { prompt } = await request.json();
const { courseTitle, lessonTitle, type } = JSON.parse(prompt);
const response = await openai.createChatCompletion({
model: 'gpt-3.5-turbo',
messages: [
{
role: 'system',
content: 'Act like an assistant of a lecturer'
},
{
role: 'user',
content: `Generate ${instruction[type]} given the course title is "${courseTitle}" and the title of the lesson is "${lessonTitle}". Format in HTML without any styling. MOST IMPORTANT DON'T include the title of the course and don't include the lesson title: "${lessonTitle}" in your output. Please make sure the content is well detailed.`
}
],
stream: true
});
// Convert the response into a friendly text-stream
const stream = OpenAIStream(response);
// Respond with the stream
return new StreamingTextResponse(stream);
}
@@ -0,0 +1,37 @@
import { Configuration, OpenAIApi } from 'openai-edge';
import { OpenAIStream, StreamingTextResponse } from 'ai';
import { OPENAI_API_KEY } from '$env/static/private';
// Create an OpenAI API client (that's edge friendly!)
const openAIConfig = new Configuration({
apiKey: OPENAI_API_KEY
});
const openai = new OpenAIApi(openAIConfig);
// Set the runtime to edge for best performance
export const config = {
runtime: 'edge'
};
export async function POST({ request }) {
const { prompt } = await request.json();
const response = await openai.createChatCompletion({
model: 'gpt-3.5-turbo',
messages: [
{
role: 'system',
content: 'Act like an assistant of a lecturer or an educator'
},
{
role: 'user',
content: `${prompt}. DONT ADD ANY CSS STYLE TO THE HTML FORMATTING AND DON'T USE CODE HTML TAG`
}
],
stream: true
});
// Convert the response into a friendly text-stream
const stream = OpenAIStream(response);
// Respond with the stream
return new StreamingTextResponse(stream);
}
@@ -0,0 +1,81 @@
import { Configuration, OpenAIApi } from 'openai-edge';
import { OpenAIStream, StreamingTextResponse } from 'ai';
import { OPENAI_API_KEY } from '$env/static/private';
// Create an OpenAI API client (that's edge friendly!)
const openAIConfig = new Configuration({
apiKey: OPENAI_API_KEY
});
const openai = new OpenAIApi(openAIConfig);
// Set the runtime to edge for best performance
export const config = {
runtime: 'edge'
};
export async function POST({ request }) {
const { prompt } = await request.json();
const { questionNumber, optionNumber, lessonNote } = JSON.parse(prompt);
const refinedPrompt = `
Please note this typescript interface:
interface Question_type {
id: number;
label: any;
}
const QuestionTypes: Question_type[] = [
{
id: 1,
label: 'RADIO'
},
{
id: 2,
label: 'CHECKBOX'
},
{
id: 3,
label: 'TEXTAREA'
}
];
interface ExerciseTemplate {
title: string;
description: string;
questionnaire: {
questions: {
title: string;
name: string;
points: number; // a number out of 10 to score the student if student gets the correct answer
order: number; // a unique number from 0 to n+1, that orders the question in ascending order
question_type: Question_type;
options: {
label: string;
is_correct: boolean;
};
}[]
};
}
I am a teacher and I need your help evaluating my students after I have taught a lesson. Please generate a test with ${questionNumber} questions and ${optionNumber} options. The sourse of this test should come from my lesson note in thripple quotes: """${lessonNote}""". Format the quiz to match the ExerciseTemplate interface above and VERY IMPORTANTANTLY: ONLY RETURN JSON FORMAT AND DO NOT EXPLAIN ANTHING.
NB: Ask a mixture of ${optionNumber} options and ${questionNumber} DIFFERENT Question Types(RADIO, CHECKBOX AND TEXTAREA), mark the correct answer.`;
const response = await openai.createChatCompletion({
model: 'gpt-3.5-turbo',
messages: [
{
role: 'system',
content: 'You only spit out JSON and nothing else, dont give any explanation.'
},
{
role: 'user',
content: refinedPrompt
}
],
stream: true
});
// Convert the response into a friendly text-stream
const stream = OpenAIStream(response);
// Respond with the stream
return new StreamingTextResponse(stream);
}
@@ -0,0 +1,38 @@
import { json } from '@sveltejs/kit';
import { getSupabase } from '$lib/utils/functions/supabase';
import { sendEmail } from '$lib/utils/services/notification/send';
const supabase = getSupabase();
export async function POST({ request }) {
const { to, content, orgName, exerciseTitle } = await request.json();
const accessToken = request.headers.get('Authorization') || '';
if (!to || !content || !orgName) {
return json({ success: false, message: 'Missing required fields' }, { status: 400 });
}
let user;
try {
const { data } = await supabase.auth.getUser(accessToken);
user = data.user;
} catch (error) {
console.error(error);
}
if (!user) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
}
await sendEmail({
from: `"${orgName} (via ClassroomIO.com)" <help@classroomio.com>`,
to,
subject: `[Submitted]: ${exerciseTitle}`,
content
});
return json({
success: true,
message: 'Email sent'
});
}
@@ -0,0 +1,53 @@
import { json } from '@sveltejs/kit';
import { getSupabase } from '$lib/utils/functions/supabase';
import { sendEmail } from '$lib/utils/services/notification/send';
const supabase = getSupabase();
export async function POST({ 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
);
if (!to || !courseName || !teacherEmail || !studentFullname || !orgName) {
return json({ success: false, message: 'Missing required fields' }, { status: 400 });
}
let user;
try {
const { data } = await supabase.auth.getUser(accessToken);
user = data.user;
} catch (error) {
console.error(error);
}
if (!user) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
}
await sendEmail({
from: `"${orgName} - ClassroomIO" <help@classroomio.com>`,
to,
replyTo: teacherEmail,
subject: `[${courseName}] One more step left`,
content: `
<p>Hi ${studentFullname},</p>
<p>You are one step closer to joining: ${courseName}</p>
<p>Please send your proof of payment to: ${teacherEmail}, in order to join the course.</p>
<p>Talk to you soon and see you in class.</p>
<p>${orgName}</p>
`
});
return json({
success: true,
message: 'Email sent'
});
}
@@ -0,0 +1,47 @@
import { json } from '@sveltejs/kit';
import { getSupabase } from '$lib/utils/functions/supabase';
import { sendEmail } from '$lib/utils/services/notification/send';
const supabase = getSupabase();
// Sends email to student when they successfully join a course
export async function POST({ request }) {
const { to, orgName, courseName } = await request.json();
const accessToken = request.headers.get('Authorization') || '';
console.log('/POST api/email/course/student_welcome', to, orgName);
if (!to || !orgName || !courseName) {
return json({ success: false, message: 'Missing required fields' }, { status: 400 });
}
let user;
try {
const { data } = await supabase.auth.getUser(accessToken);
user = data.user;
} catch (error) {
console.error(error);
}
if (!user) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
}
await sendEmail({
from: `"${orgName} (via ClassroomIO.com)" <help@classroomio.com>`,
to,
subject: `${orgName} - Welcome to Class 🎉`,
content: `
<p>Hi there,</p>
<p>Congratulations 🎉, you’ve successfully joined: <strong>${courseName}</strong></p>
<p>Everything has been set up for you to have an amazing learning experience.</p>
<p>If you run into any issues, don’t fail to reach out to your instructor(s).</p>
<p>Cheers,</p>
<p>${orgName}</p>
`
});
return json({
success: true,
message: 'Email sent'
});
}
@@ -0,0 +1,38 @@
import { json } from '@sveltejs/kit';
import { getSupabase } from '$lib/utils/functions/supabase';
import { sendEmail } from '$lib/utils/services/notification/send';
const supabase = getSupabase();
export async function POST({ request }) {
const { to, content, orgName } = await request.json();
const accessToken = request.headers.get('Authorization') || '';
if (!to || !content || !orgName) {
return json({ success: false, message: 'Missing required fields' }, { status: 400 });
}
let user;
try {
const { data } = await supabase.auth.getUser(accessToken);
user = data.user;
} catch (error) {
console.error(error);
}
if (!user) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
}
await sendEmail({
from: `"${orgName} (via ClassroomIO.com)" <help@classroomio.com>`,
to,
subject: 'Submission Update',
content
});
return json({
success: true,
message: 'Email sent'
});
}
@@ -0,0 +1,53 @@
import { json } from '@sveltejs/kit';
import { getSupabase } from '$lib/utils/functions/supabase';
import { sendEmail } from '$lib/utils/services/notification/send';
const supabase = getSupabase();
export async function POST({ request }) {
const { to, courseName, studentEmail, studentFullname } = await request.json();
const accessToken = request.headers.get('Authorization') || '';
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 });
}
let user;
try {
const { data } = await supabase.auth.getUser(accessToken);
user = data.user;
} catch (error) {
console.error(error);
}
if (!user) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
}
await sendEmail({
from: `ClassroomIO" <help@classroomio.com>`,
to,
subject: `[${courseName}] Request to Join Course!`,
content: `
<p>Hi amazing tutor,</p>
<p> A new student has requested to join a course you are teaching: “${courseName}"</p>
<p style="font: bold;">Student details</p>
<p>
Name: ${studentFullname}<br />
Email: ${studentEmail}
</p>
`
});
return json({
success: true,
message: 'Email sent'
});
}
@@ -0,0 +1,45 @@
import { json } from '@sveltejs/kit';
import { getSupabase } from '$lib/utils/functions/supabase';
import { sendEmail } from '$lib/utils/services/notification/send';
const supabase = getSupabase();
export async function POST({ request }) {
const { to, courseName } = await request.json();
const accessToken = request.headers.get('Authorization') || '';
console.log('/POST api/email/course/teacher_student_joined', to, courseName);
if (!to || !courseName) {
return json({ success: false, message: 'Missing required fields' }, { status: 400 });
}
let user;
try {
const { data } = await supabase.auth.getUser(accessToken);
user = data.user;
} catch (error) {
console.error(error);
}
if (!user) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
}
await sendEmail({
from: `"ClassroomIO" <help@classroomio.com>`,
to,
subject: `[${courseName}] You've got a new student 🎉!`,
content: `
<p>Hi amazing tutor,</p>
<p>Congratulations 🎉, a new student has joined a course you are teaching: ${courseName}</p>
<p>We hope they have a great experience learning from the best (YOU).</p>
<p>If you run into any issues, please don’t fail to reach out to us, we’d love to make your teaching
experience as easy as possible.</p>
`
});
return json({
success: true,
message: 'Email sent'
});
}
@@ -0,0 +1,49 @@
import { json } from '@sveltejs/kit';
import { getSupabase } from '$lib/utils/functions/supabase';
import { sendEmail } from '$lib/utils/services/notification/send';
const supabase = getSupabase();
export async function POST({ request }) {
const { to, name, orgName, courseName, orgSiteName } = await request.json();
const accessToken = request.headers.get('Authorization') || '';
console.log('/POST api/email/course/teacher_welcome', to, name, orgName);
if (!to || !name || !orgName || !courseName || !orgSiteName) {
return json({ success: false, message: 'Missing required fields' }, { status: 400 });
}
let user;
try {
const { data } = await supabase.auth.getUser(accessToken);
user = data.user;
} catch (error) {
console.error(error);
}
if (!user) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
}
const origin = request.headers.get('origin');
const inviteLink = `${origin}/org/${orgSiteName}/courses`;
await sendEmail({
from: `"${orgName} (via ClassroomIO.com)" <help@classroomio.com>`,
to,
subject: `You have been invited to a course in ${orgName}!`,
content: `
<p>Hey ${name},</p>
<p> You have been given access to teach a course by ${orgName}</p>
<p>The course is titled: ${courseName}</p>
<div>
<a class="button" href="${inviteLink}">Open Dashboard</a>
</div>
`
});
return json({
success: true,
message: 'Email sent'
});
}
@@ -0,0 +1,62 @@
import { json } from '@sveltejs/kit';
import { getSupabase } from '$lib/utils/functions/supabase';
import { sendEmail } from '$lib/utils/services/notification/send';
const supabase = getSupabase();
// API to send invite to teacher
export async function POST({ request }) {
const body = await request.json();
const { org, email } = body;
const accessToken = request.headers.get('Authorization');
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 }
);
}
let user;
try {
const { data } = await supabase.auth.getUser(accessToken);
user = data.user;
} catch (error) {
console.error(error);
}
if (!user) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
}
const { id, name, siteName } = org;
const origin = request.headers.get('origin');
const inviteData = JSON.stringify({
email,
orgId: id,
orgSiteName: siteName
});
const inviteLink = `${origin}/invite/t/${encodeURIComponent(btoa(inviteData))}`;
console.log('inviteData', inviteData);
await sendEmail({
to: email,
subject: `Join ${name} on ClassroomIO 😃`,
content: `
<p>Hey there,</p>
<p> You have been invited to join ${name} on ClassroomIO 🎉🎉🎉.</p>
<div>
<a class="button" href="${inviteLink}">Accept Invitation</a>
</div>
`,
isPersonalEmail: true
});
return json({
success: true,
message: 'Email sent'
});
}
@@ -0,0 +1,57 @@
import { json } from '@sveltejs/kit';
import { getSupabase } from '$lib/utils/functions/supabase';
import { sendEmail } from '$lib/utils/services/notification/send';
const supabase = getSupabase();
export async function POST({ request }) {
const { to, name } = await request.json();
const accessToken = request.headers.get('Authorization');
console.log('/POST api/email/welcome', to, name);
if (!to || !name) {
return json({ success: false, message: 'Name and To are required fields' }, { status: 400 });
}
let user;
try {
const { data } = await supabase.auth.getUser(accessToken);
user = data.user;
} catch (error) {
console.error(error);
}
if (!user) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
}
await sendEmail({
to,
subject: 'Thank you so so so much for choosing ClassroomIO!',
content: `
<p>Dear ${name},</p>
<p>My name is Best the CEO of ClassroomIO and I will personally like to welcome to the vibrant community of ClassroomIO!</p>
<p>
We're thrilled to have you join us in shaping the future of education. Your decision to embark on
this journey with us is deeply appreciated, and we're genuinely excited about the possibilities
that lie ahead.
</p>
<p>
Once again, welcome to the ClassroomIO family. We're here to help, to listen, and to celebrate
your successes. Get ready to unlock a world of possibilities in education!
</p>
<p>
If you have any question or face any issues just send me an email best@classroomio.com 😃
</p>
<div>
<a href="https://app.classroomio.com">Go to Dashboard</a>
</div>
`,
isPersonalEmail: true
});
return json({
success: true,
message: 'Email sent'
});
}
@@ -0,0 +1,24 @@
import { json } from '@sveltejs/kit';
const downloadTranscript = async () => {};
const uploadTranscriptToS3 = async () => {};
export async function GET({ url }) {
const museFid = url.searchParams.get('museFid');
console.log('museFid');
// check the transcript table and see if we find a record
// if we find one
// check if downloaded = true
// if true
// return the transcript
// if false
// download the transcript from muse
// upload it to S3
// return the transcript
// if we dont
return json({ success: true, museFid });
}
@@ -0,0 +1,36 @@
import { json } from '@sveltejs/kit';
import { UNSPLASH_API_KEY } from '$env/static/private';
const UNSPLASH_API_URL = `https://api.unsplash.com/search/photos?page=2&per_page=15&auto=format&fit=crop&w=2970&q=80&client_id=${UNSPLASH_API_KEY}`;
// API to fetch photos from Unsplash
export async function POST({ request }) {
const body = await request.json();
const { searchQuery } = body;
const query = searchQuery || 'rocks';
try {
const response = await fetch(`${UNSPLASH_API_URL}&query=${encodeURIComponent(query)}`, {
method: 'GET'
});
if (!response.ok) {
throw new Error('Unable to fetch photos from Unsplash API');
}
const data = await response.json();
return json({
success: true,
photos: data.results
});
} catch (error) {
console.error(error);
return json(
{ success: false, message: 'Error fetching photos from Unsplash' },
{ status: 500 }
);
}
}
@@ -0,0 +1,15 @@
import { fetchCourse } from '$lib/utils/services/courses';
import { supabase, getSupabase } from '$lib/utils/functions/supabase';
if (!supabase) {
getSupabase();
}
export const load = async ({ params = { slug: '' } }) => {
const { data } = await fetchCourse(undefined, params.slug);
return {
slug: params.slug,
course: data
};
};
@@ -0,0 +1,22 @@
<script lang="ts">
import CourseLandingPage from '$lib/components/CourseLandingPage/index.svelte';
import type { Course } from '$lib/utils/types';
interface Data {
course: Course;
}
export let data: Data;
let courseData: Course;
function setCourseData(course: Course | null) {
if (!course) return;
courseData = { ...course };
}
$: setCourseData(data.course);
</script>
<CourseLandingPage {courseData} />
@@ -0,0 +1,54 @@
<script lang="ts">
import { onMount } from 'svelte';
import { sideBar } from '$lib/components/Org/store';
import { apps } from '$lib/components/Apps/store';
import APPS_CONSTANTS from '$lib/components/Apps/constants';
import hotkeys from 'hotkeys-js';
function handleClose() {
$apps.selectedApp = undefined;
if ($apps.isStudent) {
$apps.open = false;
}
}
function handleAppClick(appName?: string) {
if (appName === $apps.selectedApp) {
handleClose();
} else {
$apps.selectedApp = appName;
}
$apps.open = true;
$apps.dropdown = !$apps.dropdown;
}
function toggleSidebar() {
$sideBar.hidden = !$sideBar.hidden;
}
onMount(() => {
hotkeys('ctrl+b,command+b,ctrl+1,command+1,ctrl+2,command+2', function (event, handler) {
// Prevent default behavior
event.preventDefault();
console.log({ handlerKey: handler.key });
switch (handler.key) {
case 'ctrl+b':
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 />
@@ -0,0 +1,5 @@
export const load = ({ params = { id: '' } }) => {
return {
courseId: params.id
};
};
@@ -0,0 +1,10 @@
<script>
import Overview from '$lib/components/Course/index.svelte';
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
export let data;
</script>
<CourseContainer bind:courseId={data.courseId}>
<Overview />
</CourseContainer>
@@ -0,0 +1,5 @@
export const load = ({ params = { id: '' } }) => {
return {
courseId: params.id
};
};
@@ -0,0 +1,218 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
Search,
Checkbox,
StructuredList,
StructuredListBody,
StructuredListCell,
StructuredListHead,
StructuredListRow,
Pagination
} from 'carbon-components-svelte';
import AudioConsoleIcon from 'carbon-icons-svelte/lib/AudioConsole.svelte';
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
import PageNav from '$lib/components/PageNav/index.svelte';
import PageBody from '$lib/components/PageBody/index.svelte';
import Box from '$lib/components/Box/index.svelte';
import { course, group } from '$lib/components/Course/store';
import { getLectureNo } from '$lib/components/Course/function';
import { lessons } from '$lib/components/Course/components/Lesson/store/lessons';
import { ROLE } from '$lib/utils/constants/roles';
import { takeAttendance } from '$lib/utils/services/attendance';
import { snackbar } from '$lib/components/Snackbar/store';
import { attendance } from '$lib/utils/store/attendance.js';
import { profile } from '$lib/utils/store/user';
import type { GroupPerson, Lesson } from '$lib/utils/types/index';
import { browser } from '$app/environment';
export let data;
interface CourseData {
attendance: {
student_id: string;
lesson_id: string;
is_present: boolean;
id: number;
}[];
}
let students: GroupPerson[] = [];
let isStudent: boolean = false;
let searchValue = '';
function setAttendance(courseData: CourseData) {
for (const attendanceItem of courseData.attendance) {
const { student_id, lesson_id, is_present, id } = attendanceItem;
if (!$attendance[student_id]) {
$attendance[student_id] = {
[lesson_id]: {
id,
is_present
}
};
} else {
$attendance[student_id] = {
...$attendance[student_id],
[lesson_id]: {
id,
is_present
}
};
}
}
}
function handleAttendanceChange(e: any, student: GroupPerson, lesson: Lesson) {
if (isStudent) return;
const attendanceItem = $attendance[student.id]
? $attendance[student.id][lesson.id] || { id: undefined }
: { id: undefined };
/*
This validation is useless. We shouldn't assume we already have an attendance item id
because it might be a new attendance.
if (!attendanceItem.id) {
snackbar.error("Something isn't right. Please reload and take attendance again");
console.error(`Attendance Id Missing`, courseId, student.id, lesson.id);
return;
}
*/
const _data = {
id: attendanceItem.id,
student_id: student.id,
is_present: e.target.checked,
lesson_id: lesson.id,
course_id: data.courseId
};
takeAttendance(_data).then((res) => {
if (res.error) {
console.error(`res.error`, res.error);
snackbar.error("Something isn't right. Please reload and take attendance again");
} else {
// console.log('res', JSON.stringify(res));
const { id, is_present } = res.data[0];
if ($attendance[student.id]) {
$attendance[student.id][lesson.id] = {
id,
is_present
};
} else {
$attendance[student.id] = {
[lesson.id]: {
id,
is_present
}
};
}
}
});
}
// function for the searchbar
function searchStudents(query: string, _students: GroupPerson[]) {
const lowercaseQuery = query.toLowerCase();
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;
}
}
$: students = 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);
</script>
<CourseContainer bind:isStudent bind:courseId={data.courseId}>
<PageNav title="Attendance" />
<PageBody width="w-full max-w-6xl md:w-11/12">
<section class="flex items-center mx-2 lg:mx-9 my-5">
<div
class="flex flex-col lg:flex-row items-start gap-2 lg:items-center justify-between w-full"
>
<div class="flex">
<p class="flex items-center mr-5">
<Checkbox checked disabled /> Present
</p>
<p class="flex items-center">
<Checkbox disabled /> Absent
</p>
</div>
<div>
<Search
class="dark:text-slate-950"
placeholder="Search students"
bind:value={searchValue}
/>
</div>
</div>
</section>
<section class="my-5 mx-2 lg:mx-9">
<StructuredList class="m-0 relative">
<!-- Moved the lesson headers outside the students loop -->
<StructuredListHead class="bg-primary-100">
<StructuredListRow head class="mx-7">
<StructuredListCell head class="text-primary-600 py-3">Student</StructuredListCell>
{#each $lessons as lesson, index}
<StructuredListCell head class="text-primary-600 py-3"
>Lesson 0{getLectureNo(index + 1)}</StructuredListCell
>
{/each}
</StructuredListRow>
</StructuredListHead>
{#each searchStudents(searchValue, students) as student}
<StructuredListBody>
<StructuredListRow>
<StructuredListCell>
<div class="w-1/4 flex items-center">
<p class="dark:text-white font-semibold">
{student.profile.fullname}
</p>
</div>
</StructuredListCell>
{#each $lessons as lesson}
<StructuredListCell class="">
<Checkbox
class={isStudent ? 'cursor-not-allowed' : ''}
disabled={isStudent}
checked={$attendance[student.id]
? $attendance[student.id][lesson.id]
? $attendance[student.id][lesson.id].is_present
: false
: false}
on:change={(e) => handleAttendanceChange(e, student, lesson)}
/>
</StructuredListCell>
{/each}
</StructuredListRow>
</StructuredListBody>
{/each}
</StructuredList>
{#if students.length === 0}
<Box className="h-[300px] w-full">
<AudioConsoleIcon size={32} class="carbon-icon w-80" />
<h3 class="text-3xl text-gray-500 dark:text-white text-center">No Student Added</h3>
</Box>
{/if}
{#if students.length !== 0}
<Pagination totalItems={10} pageSizes={[10, 15, 20]} />
{/if}
</section>
</PageBody>
</CourseContainer>
@@ -0,0 +1,5 @@
export const load = ({ params = { id: '' } }) => {
return {
courseId: params.id
};
};
@@ -0,0 +1,59 @@
<script lang="ts">
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
import PageNav from '$lib/components/PageNav/index.svelte';
import PageBody from '$lib/components/PageBody/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { setCourse, course } from '$lib/components/Course/store';
import IssueCertificateModal from '$lib/components/Course/components/Ceritficate/IssueCertificateModal.svelte';
// import DeleteConfirmation from '$lib/components/Course/components/People/DeleteConfirmation.svelte';
import Design from '$lib/components/Course/components/Ceritficate/Design.svelte';
// import Reports from '$lib/components/Course/components/Ceritficate/Reports.svelte';
// import { issueCertificateModal } from '$lib/components/Course/components/Ceritficate/store';
// import { Tabs, Tab } from 'carbon-components-svelte';
// import TabContent from 'carbon-components-svelte/src/Tabs/TabContent.svelte';
import StudentCertificate from '$lib/components/Course/components/Ceritficate/StudentCertificate/Index.svelte';
export let courseId: string = '';
let isStudent = false;
</script>
<IssueCertificateModal />
<CourseContainer bind:isStudent bind:courseId>
<PageNav title="Certificates" 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>
</PageNav>
<PageBody className="w-full mx-0" padding="px-3">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Design />
</RoleBasedSecurity>
<RoleBasedSecurity allowedRoles={[3]}>
<StudentCertificate />
</RoleBasedSecurity>
<!-- <Tabs autoWidth class="border-b border-gray-200">
<Tab label="Design" />
<Tab label="Reports" />
<svelte:fragment slot="content">
<TabContent>
<Design />
</TabContent>
Temp disable Reports tab for V2
<TabContent>
<Reports />
</TabContent>
</svelte:fragment>
</Tabs> -->
</PageBody>
</CourseContainer>
@@ -0,0 +1,5 @@
export const load = ({ params = { id: '' } }) => {
return {
courseId: params.id
};
};
@@ -0,0 +1,42 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import CourseLandingPage from '$lib/components/CourseLandingPage/index.svelte';
import Editor from '$lib/components/CourseLandingPage/components/Editor/index.svelte';
import { course } from '$lib/components/Course/store';
import { lessons } from '$lib/components/Course/components/Lesson/store/lessons';
import type { Course, Lesson } from '$lib/utils/types';
export let data;
const { courseId } = data;
let courseData: Course;
function setCourseData(course: Course, lessons: Lesson[]) {
courseData = { ...course, lessons };
}
$: setCourseData($course, $lessons);
$: 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} />
<div class="rightBar">
<CourseLandingPage bind:courseData editMode={true} />
</div>
</div>
<style>
.rightBar {
flex-grow: 1;
width: 100%;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
}
</style>
@@ -0,0 +1,5 @@
export const load = ({ params = { id: '' } }) => {
return {
courseId: params.id
};
};
@@ -0,0 +1,344 @@
<script>
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { dndzone } from 'svelte-dnd-action';
import UnlockedIcon from 'carbon-icons-svelte/lib/Unlocked.svelte';
import LockedIcon from 'carbon-icons-svelte/lib/Locked.svelte';
import SaveIcon from 'carbon-icons-svelte/lib/Save.svelte';
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte';
import Calendar from 'carbon-icons-svelte/lib/Calendar.svelte';
import Video from 'carbon-icons-svelte/lib/Video.svelte';
import WatsonHealthTextAnnotationToggle from 'carbon-icons-svelte/lib/WatsonHealthTextAnnotationToggle.svelte';
import { OverflowMenu, OverflowMenuItem } from 'carbon-components-svelte';
import PageNav from '$lib/components/PageNav/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import Box from '$lib/components/Box/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import Select from '$lib/components/Form/Select.svelte';
import { updateLesson } from '$lib/utils/services/courses';
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
import { profile } from '$lib/utils/store/user';
import {
lessons,
handleSaveLesson,
handleDelete
} from '$lib/components/Course/components/Lesson/store/lessons';
import { course, group } from '$lib/components/Course/store';
import PageBody from '$lib/components/PageBody/index.svelte';
import TextChip from '$lib/components/Chip/Text.svelte';
import Avatar from '$lib/components/Apps/components/Poll/components/Avatar.svelte';
import AddLessonModal from '$lib/components/Course/components/Lesson/AddLessonModal.svelte';
import DateField from '$lib/components/Form/Date.svelte';
export let data;
let lessonEditing;
let isStudent = true;
let openModal = false;
const flipDurationMs = 300;
function formatDate(date) {
const d = new Date(date);
const ye = new Intl.DateTimeFormat('en', { year: 'numeric' }).format(d);
const mo = new Intl.DateTimeFormat('en', { month: '2-digit' }).format(d);
const da = new Intl.DateTimeFormat('en', { day: '2-digit' }).format(d);
return `${ye}-${mo}-${da}`;
}
function addLesson() {
openModal = true;
}
function handleDndConsider(e) {
$lessons = e.detail.items;
}
function handleDndFinalize(e) {
const prevLessonsByOrder = $lessons.reduce((acc, l) => ({ ...acc, [l.id]: l.order }), {});
$lessons = e.detail.items;
// Only update the lesson order that changed
e.detail.items.forEach((item, index) => {
const order = index + 1;
if (order !== prevLessonsByOrder[item.id]) {
$lessons[index].order = order;
updateLesson({ order }, item.id).then((update) =>
console.log(`updated lesson order`, update)
);
}
});
}
function getLessonOrder(id) {
const index = $lessons.findIndex((lesson) => lesson.id === id);
if (index < 9) {
return '0' + (index + 1);
} else {
return index + 1;
}
}
$: {
const user = $group.people.find((person) => person.profile_id === $profile.id);
if (user) {
isStudent = user.role_id === 3;
}
}
</script>
<AddLessonModal {isStudent} bind:openModal />
<CourseContainer bind:isStudent bind:courseId={data.courseId}>
<PageNav title="Lessons">
<div slot="widget">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<PrimaryButton label="Add" onClick={addLesson} isDisabled={!!lessonEditing} />
</RoleBasedSecurity>
</div>
</PageNav>
<PageBody width="max-w-6xl" padding="p-0">
{#if $lessons.length}
<section
class="w-full lg:w-11/12 p-3 lg:px-4 m-auto"
use:dndzone={{
items: $lessons,
flipDurationMs,
dragDisabled: isStudent,
dropTargetStyle: {
border: '2px #1d4ed8 solid',
'border-style': 'dashed'
}
}}
on:consider={handleDndConsider}
on:finalize={handleDndFinalize}
>
{#each $lessons as lesson (lesson.id)}
<div
class="sm:min-h-[245px] md:min-h-[100px] lg:min-h-[190px] relative m-auto rounded-md border-2 border-gray-200 py-3 px-5 mb-4 flex items-center dark:bg-neutral-800"
>
<div class="mr-5">
<TextChip
value={getLessonOrder(lesson.id)}
size="sm"
shape="rounded-full"
className="bg-primary-200 text-primary-600 text-xs"
/>
</div>
<div class="w-4/5">
{#if lessonEditing === lesson.id}
<TextField bind:value={lesson.title} autofocus={true} className="max-w-lg" />
{:else}
<h3 class="dark:text-white text-xl m-0 flex items-center">
<a
href={isStudent && !lesson.is_unlocked
? $page.url.pathname
: '/courses/' + $course.id + '/lessons/' + lesson.id}
class="dark:text-white no-underline hover:underline font-semibold text-black {isStudent &&
!lesson.is_unlocked
? 'cursor-not-allowed'
: ''}"
>
{lesson.title}
</a>
</h3>
{/if}
<!-- <div class="flex items-center mb-2">
<div class="flex items-center mr-2">
{resources.map((r) => `${r.value} ${r.label}`).join(', ')}
</div>
</div> -->
<div
class="flex items-start justify-between flex-col lg:flex-row lg:items-center mt-2 w-4/5"
>
<div class="lg:mb-0">
{#if lessonEditing === lesson.id}
<Select
bind:value={lesson.profile}
options={$group.tutors}
labelKey="fullname"
className="sm:my-1 w-[100%]"
/>
{:else if !lesson.profile}
<p class="dark:text-white ml-2 text-sm mb-3">No tutor added</p>
{:else}
<a href="." class="flex items-center hover:underline mb-2">
<Avatar
className="w-6 h-6"
src={lesson.profile.avatar_url}
name="Avatar placeholder"
/>
<p class="dark:text-white ml-2 text-sm">
{lesson.profile.fullname}
</p>
</a>
{/if}
</div>
<div class="flex items-center lg:mb-0">
{#if lessonEditing === lesson.id}
<DateField
value={formatDate(lesson.lesson_at)}
className="p-2 my-2 rounded-md sm:w-[179px] dark:bg-neutral-800 dark:text-white"
onChange={(e) => (lesson.lesson_at = e.target.value)}
/>
{:else}
<div class="flex mb-2">
<Calendar size={20} class="carbon-icon text-gray-400 dark:text-white" />
<p class="dark:text-white text-sm ml-2">
{new Date(lesson.lesson_at).toDateString()}
</p>
</div>
{/if}
</div>
<div class="flex items-center mb-3 lg:mb-0">
{#if lessonEditing === lesson.id}
<TextField
bind:value={lesson.call_url}
autofocus={true}
className="w-[179px]"
placeholder="https://meet.google.com/mga-dsjs-fmb"
/>
{:else}
<div class="flex">
<Video size={20} class="carbon-icon text-gray-400 dark:text-white ml-0.5" />
<a
class="text-sm ml-2 text-primary-600"
href={lesson.call_url || '#'}
target="_blank"
>
{lesson.call_url ? 'Join lesson' : 'No link'}
</a>
</div>
{/if}
</div>
</div>
<div class="flex flex-row absolute sm:bottom-20 bottom-10 right-10">
<div class="success hidden md:block">
<IconButton
disabled={isStudent}
onClick={() => {
lesson.is_unlocked = !lesson.is_unlocked;
handleSaveLesson(lesson, $course.id);
}}
toolTipProps={isStudent
? {}
: {
title: `Click to ${lesson.is_unlocked ? 'lock' : 'unlock'}`,
direction: 'right'
}}
>
{#if lesson.is_unlocked}
<UnlockedIcon size={24} class="carbon-icon dark:text-white" />
{:else}
<LockedIcon size={24} class="carbon-icon dark:text-white" />
{/if}
</IconButton>
</div>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="hidden md:block">
{#if lessonEditing === lesson.id}
<IconButton
onClick={() => {
lessonEditing = null;
handleSaveLesson(lesson, $course.id);
}}
>
<SaveIcon size={24} class="carbon-icon dark:text-white" />
</IconButton>
{:else}
<IconButton onClick={() => (lessonEditing = lesson.id)}>
<WatsonHealthTextAnnotationToggle
size={32}
class="carbon-icon dark:text-white"
/>
</IconButton>
{/if}
</div>
</RoleBasedSecurity>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="hidden md:block">
<IconButton onClick={handleDelete(lesson.id)}>
<TrashCanIcon size={24} class="carbon-icon dark:text-white" />
</IconButton>
</div>
</RoleBasedSecurity>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="overflowmenu">
<OverflowMenu size="xl" class="block absolute right-10 bottom-4 pl-4">
<OverflowMenuItem
disabled={isStudent}
text={lesson.is_unlocked ? 'Lock' : 'Unlock'}
on:click={() => {
lesson.is_unlocked = !lesson.is_unlocked;
handleSaveLesson(lesson, $course.id);
}}
/>
<OverflowMenuItem
text={lessonEditing === lesson.id ? 'Save' : 'Edit'}
on:click={() => {
if (lessonEditing === lesson.id) {
lessonEditing = null;
handleSaveLesson(lesson, $course.id);
} else {
lessonEditing = lesson.id;
}
}}
/>
<OverflowMenuItem danger text="Delete" on:click={handleDelete(lesson.id)} />
</OverflowMenu>
</div>
</RoleBasedSecurity>
</div>
</div>
</div>
{:else}
<Box>
<div class="flex justify-between flex-col items-center w-[90%] md:w-96">
<img src="/images/empty-lesson-icon.svg" alt="Lesson" class="my-2.5 mx-auto" />
<h2 class="text-xl my-1.5 font-normal">No lessons yet</h2>
<p class="text-sm text-center text-slate-500">
Share your knowledge with the world by creating engaging lessons. Start by clicking
on the Add button.
</p>
</div>
</Box>
{/each}
</section>
{:else}
<Box className="w-full lg:w-11/12 lg:px-4 m-auto">
<div class="flex justify-between flex-col items-center">
<img src="/images/empty-lesson-icon.svg" alt="Lesson" class="my-2.5 mx-auto" />
<h2 class="text-xl my-1.5 font-normal">No lessons yet</h2>
<p class="text-sm text-center text-slate-500">
Share your knowledge with the world by creating engaging lessons. Start by clicking on
the Add button.
</p>
</div>
</Box>
{/if}
</PageBody>
</CourseContainer>
<style>
@media (min-width: 760px) {
.overflowmenu {
display: none !important;
}
}
</style>
@@ -0,0 +1,14 @@
export const load = ({ params = { id: '' } }) => {
const { id: courseId, lessonParams = '' } = params;
const splitparams = lessonParams.split('/');
const [lessonId, exerciseRouteName, exerciseId] = splitparams;
return {
courseId,
lessonId,
exerciseRouteName,
exerciseId,
isMaterialsTabActive: !exerciseRouteName
};
};
@@ -0,0 +1,369 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
import { PUBLIC_SERVER_URL } from '$env/static/public';
import { fetchLesson, updateLessonCompletion } from '$lib/utils/services/courses';
import CheckmarkOutlineIcon from 'carbon-icons-svelte/lib/CheckmarkOutline.svelte';
import CheckmarkFilledIcon from 'carbon-icons-svelte/lib/CheckmarkFilled.svelte';
import ListChecked from 'carbon-icons-svelte/lib/ListChecked.svelte';
import SendAlt from 'carbon-icons-svelte/lib/SendAlt.svelte';
import CourseIcon from '$lib/components/Icons/CourseIcon.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { Loading } from 'carbon-components-svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import PageNav from '$lib/components/PageNav/index.svelte';
import PageBody from '$lib/components/PageBody/index.svelte';
import Materials from '$lib/components/Course/components/Lesson/Materials/index.svelte';
import Exercises from '$lib/components/Course/components/Lesson/Exercises/index.svelte';
import MODES from '$lib/utils/constants/mode';
import { course } from '$lib/components/Course/store';
import Download from 'carbon-icons-svelte/lib/Download.svelte';
import OverflowMenuVertical from 'carbon-icons-svelte/lib/OverflowMenuVertical.svelte';
import { apps } from '$lib/components/Apps/store';
import APPS_CONSTANTS from '$lib/components/Apps/constants';
import IconButton from '$lib/components/IconButton/index.svelte';
import { lesson, lessons } from '$lib/components/Course/components/Lesson/store/lessons';
import { browser } from '$app/environment';
import { currentOrg } from '$lib/utils/store/org';
import { snackbar } from '$lib/components/Snackbar/store';
import type { LessonCompletion } from '$lib/utils/types';
import { profile } from '$lib/utils/store/user';
import { getIsLessonComplete } from '$lib/components/Course/components/Lesson/functions';
export let data;
let path = '';
let mode = MODES.view;
let prevMode = '';
let prevLessonId = '';
let isFetching = false;
let isMarkingComplete = false;
let isLoading = false;
let isSaving = false;
let isStudent = true;
let isLessonComplete = false;
function getLessonOrder(id: string) {
const index = $lessons.findIndex((lesson) => lesson.id === id);
if (index < 10) {
return '0' + (index + 1);
} else {
return index + 1;
}
}
async function fetchReqData(lessonId = '', isMaterialsTabActive: boolean) {
isFetching = true;
let lessonData;
if (isMaterialsTabActive) {
const lesson = await fetchLesson(lessonId);
lessonData = lesson.data;
}
console.log('lessonData', lessonData);
prevLessonId = lessonId;
const totalExercises = lessonData?.totalExercises?.[0] && lessonData.totalExercises[0].count;
const totalComments = lessonData?.totalComments?.[0] && lessonData.totalComments[0].count;
setLesson(lessonData, totalExercises || 0, totalComments || 0);
isFetching = false;
}
async function markLessonComplete(lessonId: string) {
let found = false;
isMarkingComplete = true;
let completion: LessonCompletion = {
is_complete: true,
profile_id: $profile.id || '',
lesson_id: lessonId,
created_at: new Date().toDateString(),
updated_at: new Date().toDateString()
};
const completions: LessonCompletion[] = $lesson.lesson_completion.map((_completion) => {
if (_completion.profile_id === $profile.id) {
found = true;
completion = {
...completion,
is_complete: _completion.is_complete
};
}
return _completion;
});
if (!found) {
completions.push(completion);
}
$lesson.lesson_completion = completions;
$lessons = $lessons.map((l) => {
if (l.id === $lesson.id) {
l.lesson_completion = completions;
}
return l;
});
await updateLessonCompletion(completion);
snackbar.success('Marked as complete');
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 fetch(PUBLIC_SERVER_URL + '/downloadLesson', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: currentLesson.title,
number: lessonNumber,
orgName: $currentOrg.name,
note: $lesson.materials.note,
slideUrl: slideUrl,
video: lessonVideo,
courseTitle: $course.title
})
});
if (!response.ok) {
throw new Error(await response.text());
}
const data = await response.blob();
console.log(data);
const file = new Blob([data], { 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('Download Complete');
} catch (error) {
console.log('error downloading lesson', error);
snackbar.error("Something's not right - Please 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,
totalExercises,
totalComments: totalComments,
materials: {
videos: lessonData.videos,
note: lessonData.note,
slide_url: lessonData.slide_url
},
lesson_completion,
exercises: []
}));
}
const toggleApps = () => {
$apps.open = !$apps.open;
};
function toggleMode() {
prevMode = mode;
mode = mode === MODES.edit ? MODES.view : MODES.edit;
}
function handleAppClick(appName: string) {
$apps.selectedApp = appName;
$apps.open = true;
}
$: path = $page.url?.pathname?.replace(/\/exercises[\/ 0-9 a-z -]*/, '');
$: if (data.courseId && browser) {
mode = MODES.view;
fetchReqData(data.lessonId, data.isMaterialsTabActive);
}
$: isLessonComplete = getIsLessonComplete($lesson.lesson_completion, $profile.id);
</script>
<CourseContainer
bind:isStudent
{path}
isExercisePage={!data.isMaterialsTabActive && data.exerciseId}
bind:courseId={data.courseId}
>
<PageNav
bind:hideOnMobile={isStudent}
navItems={[
{
label: 'Materials',
isActive: data.isMaterialsTabActive,
href: path
},
{
label: 'Exercises',
badgeValue: data.isMaterialsTabActive ? $lesson.totalExercises : $lesson.exercises.length,
isActive: !data.isMaterialsTabActive,
href: `${path}/exercises`
}
]}
>
<svelte:fragment slot="widget">
<RoleBasedSecurity allowedRoles={[1, 2]}>
{#if data.isMaterialsTabActive}
<div class="tab">
<IconButton onClick={toggleApps} buttonClassName="">
<OverflowMenuVertical size={24} />
</IconButton>
</div>
<div
class={`flex-row ${
$apps.dropdown && $apps.open
? 'absolute lg:relative top-[85px] lg:top-0 right-14 lg:right-0 z-40 dark:bg-neutral-800 p-3 lg:p-0'
: 'hidden'
} lg:flex items-center`}
>
{#if $course.metadata.lessonDownload && !!PUBLIC_SERVER_URL}
<PrimaryButton
className="mb-2 lg:mb-0 mr-2"
variant={VARIANTS.OUTLINED}
onClick={downloadLesson}
{isLoading}
>
<Download size={16} class="mr-2" />
Download PDF
</PrimaryButton>
{/if}
<PrimaryButton className="mr-2" variant={VARIANTS.OUTLINED} onClick={toggleMode}>
{#if isSaving}
<Loading withOverlay={false} small />
<span class="text-sm ml-2 italic">Autosaving...</span>
{:else}
{mode === MODES.edit ? 'Done' : 'Edit'}
{/if}
</PrimaryButton>
</div>
{/if}
</RoleBasedSecurity>
</svelte:fragment>
</PageNav>
{#if !data.isMaterialsTabActive}
<Exercises
bind:isStudent
lessonId={data.lessonId}
exerciseId={data.exerciseId}
path={`${path}/exercises`}
/>
{:else if !!data.lessonId}
<PageBody
bind:isPageNavHidden={isStudent}
onClick={() => {
$apps.open = false;
$apps.dropdown = false;
}}
width="lg:w-full xl:w-11/12"
className="overflow-x-hidden"
>
<Materials lessonId={data.lessonId} {mode} {prevMode} {toggleMode} bind:isSaving />
<div class="w-full hidden lg:flex flex-row-reverse mt-10">
<PrimaryButton
onClick={() => markLessonComplete(data.lessonId)}
isLoading={isMarkingComplete}
isDisabled={isMarkingComplete}
variant={VARIANTS.OUTLINED}
className="mt-10"
>
{#if isLessonComplete}
<CheckmarkFilledIcon size={24} class="carbon-icon text-primary-600 mr-2" />
{:else}
<CheckmarkOutlineIcon size={24} class="carbon-icon mr-2" />
{/if}
Mark as {isLessonComplete ? 'Incomplete' : 'Complete'}
</PrimaryButton>
</div>
</PageBody>
{/if}
<!-- Mobile Navigation -->
<div class="fixed bottom-5 w-[90%] flex items-center justify-center lg:hidden">
<div
class="flex items-center gap-2 w-fit rounded-full shadow-xl bg-gray-100 dark:bg-neutral-700 px-5 py-1"
>
{#if data.isMaterialsTabActive}
<button
class="px-2 my-2 pr-4 border-t-0 border-b-0 border-l-0 border border-gray-300 flex items-center"
on:click={() => goto(`${path}/exercises`)}
>
<ListChecked size={24} class="carbon-icon" />
<span class="ml-1">{$lesson.totalExercises}</span>
</button>
{:else}
<button
class="px-2 my-2 pr-4 border-t-0 border-b-0 border-l-0 border border-gray-300 flex items-center"
on:click={() => goto(path)}
>
<CourseIcon />
</button>
{/if}
<button
class="px-2 my-2 pr-4 border-t-0 border-b-0 border-l-0 border border-gray-300 flex items-center"
on:click={() => handleAppClick(APPS_CONSTANTS.APPS.COMMENTS)}
>
<SendAlt size={24} class="carbon-icon" />
<span class="ml-1">{$lesson.totalComments}</span>
</button>
<button
class="px-2 my-2"
on:click={() => markLessonComplete(data.lessonId)}
disabled={isMarkingComplete}
>
{#if isLessonComplete}
<CheckmarkFilledIcon size={24} class="carbon-icon text-primary-600" />
{:else}
<CheckmarkOutlineIcon size={24} class="carbon-icon" />
{/if}
</button>
</div>
</div>
</CourseContainer>
<style>
@media screen and (min-width: 1023px) {
.tab {
display: none;
}
}
</style>
@@ -0,0 +1,5 @@
export const load = ({ params = { id: '' } }) => {
return {
courseId: params.id
};
};
@@ -0,0 +1,189 @@
<script lang="ts">
import AudioConsoleIcon from 'carbon-icons-svelte/lib/AudioConsole.svelte';
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
import PageNav from '$lib/components/PageNav/index.svelte';
import PageBody from '$lib/components/PageBody/index.svelte';
import Box from '$lib/components/Box/index.svelte';
import { ROLE } from '$lib/utils/constants/roles';
import { group } from '$lib/components/Course/store';
import { lessons } from '$lib/components/Course/components/Lesson/store/lessons';
import { fetchExercisesByMarks } from '$lib/utils/services/courses';
import { getLectureNo } from '$lib/components/Course/function';
import { fetchMarks } from '$lib/utils/services/marks';
import { profile } from '$lib/utils/store/user';
import { browser } from '$app/environment';
import { course } from '$lib/components/Course/store';
export let data;
const { courseId } = data;
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 = [];
let isStudent = false;
let lessonMapping = {}; // { lessonId: { exerciseId: exerciseTitle, ... }, ... }
let studentMarksByExerciseId = {}; // { groupMemberId: { exerciseId: `total_gotten/points`, ... }, ... }
function calculateStudentTotal(studentExerciseData) {
if (!studentExerciseData) return 0;
return Object.values(studentExerciseData).reduce(
(total, point) => (total += parseInt(point)),
0
);
}
async function firstRender(courseId: string) {
const { data: marks } = await fetchMarks(courseId);
marks.forEach((mark) => {
const { groupmember_id, exercise_id, total_points_gotten } = mark;
if (studentMarksByExerciseId[groupmember_id]) {
studentMarksByExerciseId[groupmember_id] = {
...studentMarksByExerciseId[groupmember_id],
[exercise_id]: total_points_gotten
};
} else {
studentMarksByExerciseId[groupmember_id] = {
[exercise_id]: total_points_gotten
};
}
});
const { data: exercises } = await fetchExercisesByMarks(courseId);
// Map exercises to { lessonId: { exerciseId: exerciseTitle, ... }, ... }
exercises.forEach((exercise) => {
const { lesson_id, exercise_id, exercise_title, points } = exercise;
if (lessonMapping[lesson_id]) {
lessonMapping[lesson_id] = {
...lessonMapping[lesson_id],
[exercise_id]: { title: exercise_title, points }
};
} else {
lessonMapping[lesson_id] = {
[exercise_id]: { title: exercise_title, points }
};
}
});
}
$: students = 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);
</script>
<CourseContainer bind:isStudent bind:courseId={data.courseId}>
<PageNav title="Marks" />
<PageBody width="w-full max-w-6xl md:w-11/12">
<div class="table rounded-md border border-gray-300 w-full">
<div class="flex items-center {borderBottomGrey}">
<div class="box flex items-center p-3">
<p class="dark:text-white w-40">Student</p>
</div>
{#each $lessons as lesson, index}
{#if lessonMapping[lesson.id]}
<div class="box flex flex-col items-center {borderleftGrey}">
<p class="dark:text-white col lesson-number" title={lesson.title}>
{getLectureNo(index + 1)}
</p>
<div
class="flex h-full items-center border-r-0 border-t border-b-0 border-l-0 border-gray-300"
>
{#each Object.keys(lessonMapping[lesson.id]) as exerciseId, index}
<p
class="col text-sm dark:text-white {index && borderleftGrey}"
title={lessonMapping[lesson.id][exerciseId].title}
>
{lessonMapping[lesson.id][exerciseId].title}
<span>({lessonMapping[lesson.id][exerciseId].points})</span>
</p>
{/each}
</div>
</div>
{/if}
{/each}
<div class="box flex items-center {borderleftGrey}">
<p class="dark:text-white w-20 text-center">Total</p>
</div>
</div>
{#each students as student}
<div class="flex relative items-center p-3 cursor-pointer {borderBottomGrey}">
<div class="w-40 flex items-center">
<img
alt="Student avatar"
src={student.profile.avatar_url}
class="w-8 h-8 rounded-full mr-2"
/>
<div class="text-sm">
<p class="dark:text-white font-semibold">
{student.profile.fullname}
</p>
<p class="dark:text-white">
{`${student.assigned_student_id ? '#' + student.assigned_student_id : '-'}`}
</p>
</div>
</div>
{#each $lessons as lesson}
{#if lessonMapping[lesson.id]}
<div class="flex items-center">
{#each Object.keys(lessonMapping[lesson.id]) as exerciseId}
<p class="dark:text-white col">
{studentMarksByExerciseId[student.id]
? studentMarksByExerciseId[student.id][exerciseId] || '-'
: '-'}
</p>
{/each}
</div>
{/if}
{/each}
<div class="w-20 flex items-center">
<div class="text-sm">
<p class="dark:text-white col">
{calculateStudentTotal(studentMarksByExerciseId[student.id])}
</p>
</div>
</div>
</div>
{:else}
<Box>
<AudioConsoleIcon size={32} class="carbon-icon w-80" />
<h3 class="text-3xl text-gray-500 dark:text-white">No Student Added</h3>
</Box>
{/each}
</div>
</PageBody>
</CourseContainer>
<style>
.col {
text-align: center;
padding: 5px;
width: 100px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.br {
border-right: 1px solid rgba(209, 213, 219, var(--tw-border-opacity));
}
.box {
height: 95px;
}
.lesson-number {
height: 30px;
}
</style>
@@ -0,0 +1,5 @@
export const load = ({ params = { id: '' } }) => {
return {
courseId: params.id
};
};
@@ -0,0 +1,272 @@
<script lang="ts">
import TextChip from '$lib/components/Chip/Text.svelte';
import ComingSoon from '$lib/components/ComingSoon/index.svelte';
import copy from 'copy-to-clipboard';
import {
CopyButton,
Search,
StructuredList,
StructuredListBody,
StructuredListCell,
StructuredListHead,
StructuredListRow
} from 'carbon-components-svelte';
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
import { deleteGroupMember, updatedGroupMember } from '$lib/utils/services/courses';
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte';
import PageNav from '$lib/components/PageNav/index.svelte';
import PageBody from '$lib/components/PageBody/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import Select from '$lib/components/Form/Select.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { group } from '$lib/components/Course/store';
import InviationModal from '$lib/components/Course/components/People/InviationModal.svelte';
import DeleteConfirmation from '$lib/components/Course/components/People/DeleteConfirmation.svelte';
import {
invitationModal,
deleteMemberModal
} from '$lib/components/Course/components/People/store';
import type { Person, ProfileRole } from '$lib/components/Course/components/People/types';
import { ROLE_LABEL, ROLES } from '$lib/utils/constants/roles';
import { profile } from '$lib/utils/store/user';
import { snackbar } from '$lib/components/Snackbar/store';
import Avatar from '$lib/components/Avatar/index.svelte';
import type { GroupPerson } from '$lib/utils/types';
export let data;
let people: Array<Person> = [];
let member: { id?: string; email?: string; profile?: { email: string } } = {};
let shouldEditMemberId: string | null = null;
let filterBy: ProfileRole = ROLES[0];
let isStudent = false;
let searchValue = '';
// function setPeople(people: Array<Person>) {
// if (!Array.isArray(people)) return;
// people = (people || []).sort((a, b) => a.role_id - b.role_id);
// console.log(`people changed`, people);
// }
// function for the searchbar
function filterPeople(_query, people) {
const query = _query.toLowerCase();
return people.filter((person) => {
const { profile, email } = person;
return profile?.fullname?.toLowerCase()?.includes(query) || email?.includes(query);
});
}
async function deletePerson() {
$group.people = $group.people.filter((person: { id: string }) => person.id !== member.id);
$group.tutors = $group.tutors.filter((person: GroupPerson) => person.memberId !== member.id);
await deleteGroupMember(member.id);
}
async function editMemberId(person: Person) {
await updatedGroupMember(
{ assigned_student_id: person.assigned_student_id },
{ id: person.id }
);
shouldEditMemberId = null;
}
function sortAndFilterPeople(_people: Array<Person>, filterBy: ProfileRole) {
people = (_people || [])
.filter((person) => {
if (filterBy.value === 'all') return true;
return person.role_id === filterBy.value;
})
.sort((a: Person, b: Person) => new Date(a.created_at) - new Date(b.created_at))
.sort((a: Person, b: Person) => a.role_id - b.role_id);
}
function getEmail(person) {
const { profile, email } = person;
return profile ? profile.email : email;
}
function handleEditStudentIdMode(personId: string) {
shouldEditMemberId = personId;
}
function copyToClipboard(studentAssignedId: string | null) {
snackbar.success(`Copied to clipboard`);
if (studentAssignedId) {
copy(studentAssignedId);
}
}
$: sortAndFilterPeople($group.people, filterBy);
</script>
<InviationModal />
<DeleteConfirmation
email={member.email || (member.profile && member.profile.email)}
{deletePerson}
/>
<CourseContainer bind:isStudent bind:courseId={data.courseId}>
<PageNav title="People" disableSticky={true}>
<slot:fragment slot="widget">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<PrimaryButton
className="mr-2"
label="Add"
onClick={() => ($invitationModal.open = true)}
/>
</RoleBasedSecurity>
</slot:fragment>
</PageNav>
<PageBody width="w-full max-w-6xl md:w-11/12">
<section class="my-5 mx-2 md:mx-9">
<div
class="flex flex-col md:flex-row flex-end gap-2 justify-end items-start md:items-center mb-7"
>
<div class="max-w-[320px]">
<Search
class="dark:text-slate-950 border-0 bg-zinc-100 w-full"
placeholder="Search people"
bind:value={searchValue}
/>
</div>
<div class="mb-3">
<Select
bind:value={filterBy}
options={ROLES}
className="dark:text-black mt-3 max-w-[80px]"
/>
<!-- <select bind:value={filterBy} class="mt-3">
{#each ROLES as option}
<option value={option.value}>{option.label}</option>
{/each}
</select> -->
</div>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<p class="dark:text-white hidden lg:block text-lg w-20" />
</RoleBasedSecurity>
</div>
<StructuredList class="m-0">
<StructuredListHead
class="bg-slate-100 dark:bg-neutral-800 dark:border-2 dark:border-neutral-800"
>
<StructuredListRow head class="mx-7">
<StructuredListCell head class="text-primary-700 py-3 dark:text-white"
>Name</StructuredListCell
>
<StructuredListCell head class="text-primary-700 py-3 dark:text-white"
>Role</StructuredListCell
>
<StructuredListCell head class="text-primary-700 py-3 dark:text-white"
>Action</StructuredListCell
>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<p class="dark:text-white hidden lg:block text-lg w-20" />
</RoleBasedSecurity>
</StructuredListRow>
</StructuredListHead>
{#each filterPeople(searchValue, people) as person}
<StructuredListBody>
<StructuredListRow class="relative">
<!-- first column -->
<StructuredListCell class="w-4/6 md:w-3/6">
{#if person.profile}
<div class="flex items-start lg:items-center">
<Avatar
src={person.profile.avatar_url}
name={person.profile.fullname}
width="w-8"
height="h-8"
className="mr-3"
/>
<div class="flex flex-col lg:flex-row items-start lg:items-center">
<div class="mr-2">
<p class="dark:text-white text-base font-normal">
{person.profile.fullname}
</p>
<p class="text-xs text-primary-600 line-clamp-1">
{getEmail(person)}
</p>
</div>
<div class="flex items-center">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<CopyButton
text={getEmail(person)}
feedback="Copied Email to clipboard"
/>
</RoleBasedSecurity>
{#if person.profile_id == $profile.id}
<ComingSoon label="You" />
{/if}
</div>
</div>
</div>
{:else}
<div class="flex items-start lg:items-center w-2/4">
<TextChip
value={person.email.substring(0, 2).toUpperCase()}
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"
>
{person.email}
</a>
<div class="flex items-center justify-between">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<CopyButton text={getEmail(person)} feedback="Copied Email to clipboard" />
</RoleBasedSecurity>
<TextChip
value="Pending"
className="text-xs bg-yellow-200 text-yellow-700 h-fit"
size="sm"
/>
</div>
</div>
{/if}
</StructuredListCell>
<!-- second column -->
<StructuredListCell class="w-1/4 px-3">
<p class=" dark:text-white font-normal text-center text-base w-1/4">
{ROLE_LABEL[person.role_id]}
</p>
</StructuredListCell>
<!-- third column -->
<StructuredListCell class="p-0 w-1/4">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="hidden lg:flex lg:justify-between lg:items-center w-20">
{#if person.profile_id !== $profile.id}
<IconButton
onClick={() => {
member = person;
$deleteMemberModal.open = true;
}}
>
<TrashCanIcon size={16} class="carbon-icon dark:text-white" />
</IconButton>
{/if}
</div>
</RoleBasedSecurity>
</StructuredListCell>
</StructuredListRow>
</StructuredListBody>
{/each}
</StructuredList>
<!-- <Pagination totalItems={10} pageSizes={[10, 15, 20]} /> -->
</section>
</PageBody>
</CourseContainer>
@@ -0,0 +1,5 @@
export const load = ({ params = { id: '' } }) => {
return {
courseId: params.id
};
};
@@ -0,0 +1,16 @@
<script>
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
import PageNav from '$lib/components/PageNav/index.svelte';
import PageBody from '$lib/components/PageBody/index.svelte';
import Settings from '$lib/components/Course/components/Settings/index.svelte';
export let data;
let isStudent = false;
</script>
<CourseContainer bind:isStudent bind:courseId={data.courseId}>
<PageNav title="Settings" />
<PageBody>
<Settings />
</PageBody>
</CourseContainer>
@@ -0,0 +1,5 @@
export const load = ({ params = { id: '' } }) => {
return {
courseId: params.id
};
};
@@ -0,0 +1,433 @@
<script lang="ts">
import { flip } from 'svelte/animate';
import { dndzone } from 'svelte-dnd-action';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import PageNav from '$lib/components/PageNav/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import MarkExerciseModal from '$lib/components/Course/components/Lesson/Exercise/MarkExerciseModal.svelte';
import Chip from '$lib/components/Chip/index.svelte';
import PageBody from '$lib/components/PageBody/index.svelte';
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
import { course } from '$lib/components/Course/store';
import {
fetchSubmissions,
updateSubmission,
updateQuestionAnswer
} from '$lib/utils/services/submissions/index';
import { formatAnswers } from '$lib/components/Course/function';
import { snackbar } from '$lib/components/Snackbar/store';
import isSubmissionEarly from '$lib/utils/functions/isSubmissionEarly';
import formatDate from '$lib/utils/functions/formatDate';
import {
triggerSendEmail,
NOTIFICATION_NAME
} from '$lib/utils/services/notification/notification';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { browser } from '$app/environment';
type items = {
id: number;
statusId: number;
isEarly: boolean;
submittedAt: string;
exercise: {
id: string;
title: string;
};
answers: string;
student: any;
lesson: {
id: string | any;
title: string | any;
};
};
type sectionType = {
id: number;
title: string;
value: number;
items: items[] | [];
};
export let data;
const { courseId } = data;
const flipDurationMs = 300;
let exerciseDetails: { id: string; title: string };
let lessonDetails: { id: string; title: string };
let totalMark = 0;
let maxMark = 0;
let submissionIdData: { [key: number]: any } = {};
let submissionId: string | number | null;
let openExercise = false;
const submissionStatus: { [key: number]: string } = {
1: 'Submitted',
2: 'In Progress',
3: 'Graded'
};
let sections: sectionType[] = [
{
id: 1,
title: 'Submitted',
value: 0,
items: []
},
{
id: 2,
title: 'In Progress',
value: 0,
items: []
},
{
id: 3,
title: 'Graded',
value: 10,
items: []
}
];
function getMaxPoints(questions) {
return (questions || []).reduce((acc, question) => acc + question.points, 0);
}
function calculateTotal(grades: string[]): number {
if (!grades) return 0;
return Object.values(grades).reduce((acc, grade) => acc + parseInt(grade), 0);
}
const sendEmail = (submissionData) => {
maxMark = getMaxPoints(submissionData?.questions);
totalMark = calculateTotal(submissionData?.questionAnswerByPoint);
const { fullname, email } = submissionData?.student;
const { title, status_id }: { title: string; status_id: number } = submissionData;
const exerciseLink = `${$currentOrgDomain}/courses/${courseId}/lessons/${lessonDetails.id}/exercises/${exerciseDetails.id}`;
const content = `
<p>Hello ${fullname},</p>
<p>The status of your submitted exercise on <strong>${title}</strong> has been updated to ${
submissionStatus[status_id]
}</p>
${
status_id == 3
? `<p>Your score was ${totalMark}/${maxMark}</p>
<a class="button" href="${exerciseLink}">View your Result</a>
`
: `<a class="button" href="${exerciseLink}">Open Exercise</a>`
}
<p>This exercise is for <strong>${
lessonDetails.title
}</strong> in a course you are taking titled <strong>${$course.title}</strong></p>
`;
triggerSendEmail(NOTIFICATION_NAME.SUBMISSION_UPDATE, {
to: email,
content,
orgName: $currentOrg?.name
});
};
function handleItemFinalize(
columnIdx: number,
newItems: { map: (arg0: (item: items) => items) => items[] }
) {
let itemToWithNewStatus: items | undefined;
const { id } = sections[columnIdx];
// Set column in the UI
sections[columnIdx].items = newItems.map((item) => {
if (item.statusId !== id) {
itemToWithNewStatus = item;
item.statusId = id;
}
return item;
});
// Update backend
if (itemToWithNewStatus) {
// Update key mapping for each submission also
submissionIdData[itemToWithNewStatus.id] = {
...submissionIdData[itemToWithNewStatus.id],
status_id: itemToWithNewStatus.statusId
};
sendEmail(submissionIdData[itemToWithNewStatus.id]);
updateSubmission({
id: itemToWithNewStatus.id,
status_id: itemToWithNewStatus.statusId
}).then((res) => console.log('Updated submission', res));
}
}
function handleDndConsiderCards(columnIdx: number) {
return function (e) {
sections[columnIdx].items = e.detail.items;
};
}
function handleDndFinalizeCards(columnIdx: number) {
return (e) => handleItemFinalize(columnIdx, e.detail.items);
}
function handleModalClose() {
goto($page.url.pathname);
}
// Via dialog
function updateStatus({
submissionId,
prevStatusId,
nextStatusId,
total
}: {
submissionId: number;
prevStatusId: number;
nextStatusId: number;
total: number;
}) {
let itemToWithNewStatus: items | undefined;
// Remove from current column
const { items } = sections[prevStatusId - 1];
sections[prevStatusId - 1].items = items?.filter((item) => {
if (item.id === submissionId) {
itemToWithNewStatus = Object.assign(item);
if (itemToWithNewStatus) {
itemToWithNewStatus.statusId = nextStatusId;
}
return false;
}
return true;
});
console.log(`itemToWithNewStatus`, itemToWithNewStatus);
// Move to right column
if (itemToWithNewStatus) {
sections[nextStatusId - 1].items = [...sections[nextStatusId - 1].items, itemToWithNewStatus];
}
// If something changed
if (itemToWithNewStatus) {
// Update key mapping for each submission also
submissionIdData[itemToWithNewStatus.id] = {
...submissionIdData[itemToWithNewStatus.id],
status_id: itemToWithNewStatus.statusId
};
sendEmail(submissionIdData[itemToWithNewStatus.id]);
// Update backend
updateSubmission({
id: itemToWithNewStatus.id,
status_id: itemToWithNewStatus.statusId,
total
}).then((res) => console.log('Updated submission', res));
}
}
async function handleSave(submission: { questionAnswerByPoint: any; questionAnswers: any }) {
const { questionAnswerByPoint, questionAnswers } = submission;
let totalPoints = 0;
const updates = Object.keys(questionAnswerByPoint).map((questionId) => {
const questionAnswer = questionAnswers.find(
(answer: { question_id: string }) => answer.question_id == questionId
);
const point = questionAnswerByPoint[questionId];
totalPoints += parseInt(point, 10);
return updateQuestionAnswer({ point }, { id: questionAnswer?.id });
});
updateSubmission({
id: submissionId,
total: totalPoints
}).then((res) => console.log('Updated submission', res));
snackbar.success('Grading saved and student notified');
await Promise.all(updates);
}
$: {
const query = new URLSearchParams($page.url.search);
submissionId = query.get('submissionId');
openExercise = !!submissionId && submissionIdData[submissionId];
}
async function firstRender(courseId: string) {
const { data: submissions } = await fetchSubmissions(courseId);
const sectionById: { [key: number]: sectionType[] } = {};
if (submissions) {
for (const submission of submissions) {
const { id, created_at, exercise, course, answers, groupmember, status_id } = submission;
const isEarly = isSubmissionEarly(created_at, exercise.due_by);
const submissionItem = {
id,
statusId: status_id,
isEarly,
submittedAt: formatDate(created_at),
exercise: {
id: exercise.id,
title: exercise.title
},
answers,
student: groupmember && groupmember.profile ? groupmember.profile : {},
lesson: {
id: exercise.lesson.id,
title: exercise.lesson.title
}
};
exerciseDetails = { id: submissionItem.exercise.id, title: submissionItem.exercise.title };
lessonDetails = {
id: submissionItem.lesson.id,
title: submissionItem.lesson.title
};
submissionIdData[id] = {
id,
status_id,
isEarly,
title: exercise.title,
student: submissionItem.student,
questions: exercise.questions,
answers: formatAnswers({ questions: exercise.questions, answers }),
questionAnswers: answers,
questionAnswerByPoint: answers.reduce((acc, answer) => {
acc[answer.question_id] = answer.point;
return acc;
}, {})
};
if (Array.isArray(sectionById[status_id])) {
sectionById[status_id].push(submissionItem);
} else {
sectionById[status_id] = [submissionItem];
}
}
}
sections = sections.map((section, index) => ({
...section,
value: Array.isArray(sectionById[index + 1]) ? sectionById[index + 1].length : 0,
items: Array.isArray(sectionById[index + 1]) ? sectionById[index + 1] : []
}));
}
$: browser && $course.id && firstRender($course.id);
</script>
<MarkExerciseModal
bind:open={openExercise}
onClose={handleModalClose}
data={submissionIdData[submissionId] || {}}
{handleSave}
{updateStatus}
{submissionId}
/>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<CourseContainer bind:courseId={data.courseId}>
<PageNav title="Submitted Exercises" />
<PageBody width="w-full max-w-6xl md:w-11/12 overflow-x-auto">
<div class="flex items-center w-full">
{#each sections as { id, title, items }, idx (id)}
<div
class="section rounded-md bg-gray-100 dark:bg-black border border-gray-50 dark:border-neutral-600 p-3 h-80 mr-3 overflow-hidden"
animate:flip={{ duration: flipDurationMs }}
>
<div class="flex items-center mb-2">
<Chip value={items.length} className="bg-set dark:bg-neutral-800" />
<p class="dark:text-white ml-2 font-bold">{title}</p>
</div>
<div
class="content pr-2 overflow-y-auto mb-3"
use:dndzone={{
items,
flipDurationMs,
dropTargetStyle: { outline: 'blue' }
}}
on:consider={handleDndConsiderCards(idx)}
on:finalize={handleDndFinalizeCards(idx)}
>
{#each items as item (item.id)}
<div
class="{item.isEarly
? 'border-none'
: 'border border-red-700'} w-full my-2 mx-0 rounded-md bg-white dark:bg-neutral-800 py-3 px-3"
animate:flip={{ duration: flipDurationMs }}
>
<a
class="flex w-full items-center cursor-pointer text-black mb-2"
href={`${$page.url.pathname}?submissionId=${item.id}`}
>
<img
alt="Student avatar"
class="block rounded-full h-6 w-6"
src={item.student.avatar_url}
/>
<p class="dark:text-white ml-2 text-sm">
{item.student.username}
</p>
</a>
<a
class="text-primary-700 text-md font-bold"
href="{$page.url.pathname}?submissionId={item.id}"
>
{item.exercise.title}
</a>
<a
class="flex items-center no-underline hover:underline text-black my-2"
href="{$page.url?.pathname?.replace('submissions', 'lessons')}/{item.lesson
.id}/exercises/{item.exercise.id}"
>
<p class="dark:text-white text-grey text-sm">
#{item.lesson.title}
{`${item.tutor ? 'created by' + item.tutor.name : ''}`}
</p>
</a>
<p class="dark:text-white text-gray-500 text-xs">
{item.submittedAt}
</p>
<!-- <div class="badge rounded-md px-2 bg-green-500 text-white">
early
</div>
<div class="badge rounded-md px-2 bg-red-600 text-white">
late
</div> -->
</div>
{/each}
</div>
</div>
{/each}
</div>
</PageBody>
</CourseContainer>
</RoleBasedSecurity>
<style>
.section {
max-width: 355px;
min-width: 355px;
height: 75vh;
}
.content {
height: 95%;
}
@media screen and (max-width: 768px) {
.section {
min-width: 250px;
}
}
</style>
@@ -0,0 +1,105 @@
<script>
import { goto } from '$app/navigation';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { getSupabase } from '$lib/utils/functions/supabase';
import { forgotValidation } from '$lib/utils/functions/validator';
import { ROUTE } from '$lib/utils/constants/routes';
import { FORGOT_PASSWORD_FIELDS } from '$lib/utils/constants/authentication';
import AuthUI from '$lib/components/AuthUI/index.svelte';
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 = {};
async function handleSubmit() {
errors = forgotValidation(fields);
console.log('errors', errors);
if (Object.keys(errors).length) {
return;
}
try {
loading = true;
const { data, error } = await supabase.auth.resetPasswordForEmail(fields.email, {
redirectTo: `${window.location.origin}/reset`
});
console.log('data', data);
if (error) throw error;
success = true;
} catch (error) {
submitError = error.error_description || error.message;
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>Reset Password - ClassroomIO</title>
</svelte:head>
<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.
</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>
{: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>
<TextField
label="Your email"
bind:value={fields.email}
type="email"
autoFocus={true}
placeholder="you@domain.com"
className="mb-6"
inputClassName="w-full"
isDisabled={loading}
errorMessage={errors.email}
/>
{#if submitError}
<p class="text-sm text-red-500">{submitError}</p>
{/if}
</div>
<div class="my-4 w-full flex justify-end items-center flex-col">
<PrimaryButton
label="Reset Password"
type="submit"
className="sm:w-full w-full mb-4"
isDisabled={loading}
isLoading={loading}
/>
<PrimaryButton
label="Cancel"
type="button"
className="sm:w-full w-full text-primary-700"
variant={VARIANTS.NONE}
onClick={() => goto(ROUTE.LOGIN)}
/>
</div>
{/if}
</AuthUI>
@@ -0,0 +1,31 @@
import { redirect } from '@sveltejs/kit';
import { getSupabase, supabase } from '$lib/utils/functions/supabase';
import { getCurrentOrg } from '$lib/utils/services/org';
if (!supabase) {
getSupabase();
}
export const load = async ({ params = { hash: '' } }) => {
try {
const courseHashData = atob(decodeURIComponent(params.hash));
console.log('courseHashData', courseHashData);
const { groupId, name, description, orgSiteName } = JSON.parse(courseHashData);
if (!groupId || !name || !description || !orgSiteName) {
throw 'Validation failed';
}
const currentOrg = await getCurrentOrg(orgSiteName, true);
return {
groupId,
name,
description,
currentOrg
};
} catch (error) {
console.error('Error decoding course invite params.hash', error);
throw redirect(301, '/404');
}
};
@@ -0,0 +1,145 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { getSupabase } from '$lib/utils/functions/supabase';
import AuthUI from '$lib/components/AuthUI/index.svelte';
import { currentOrg } from '$lib/utils/store/org';
import { setTheme } from '$lib/utils/functions/theme';
import { addGroupMember } from '$lib/utils/services/courses';
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 { snackbar } from '$lib/components/Snackbar/store.js';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
export let data;
let supabase = getSupabase();
let loading = false;
let refresh = false;
let disableSubmit = false;
let formRef: HTMLFormElement;
async function handleSubmit() {
loading = true;
if (refresh) {
window.location.reload();
refresh = false;
return;
}
if (!$profile.id || !$profile.email) {
console.log('Profile not found', $profile);
refresh = true;
return;
}
const member = {
profile_id: $profile.id,
group_id: data.groupId,
role_id: ROLE.STUDENT
};
const teacherMembers = await supabase
.from('groupmember')
.select('id, profile(email)')
.eq('group_id', data.groupId)
.eq('role_id', ROLE.TUTOR)
.returns<
{
id: string;
profile: {
email: string;
};
}[]
>();
const teachers: Array<string> =
teacherMembers.data?.map((teacher) => {
return teacher.profile?.email || '';
}) || [];
addGroupMember(member).then((addedMember) => {
loading = false;
if (addedMember.error) {
console.error('Error adding student to group', data.groupId, addedMember.error);
snackbar.error('Joining failed, please contact your admin');
return;
}
capturePosthogEvent('student_joined_course', {
course_name: data.name,
student_id: $profile.id,
student_email: $profile.email
});
// Send email welcoming student to the course
triggerSendEmail(NOTIFICATION_NAME.STUDENT_COURSE_WELCOME, {
to: $profile.email,
orgName: data.currentOrg?.name,
courseName: data.name
});
// Send notification to teacher(s) that a student has joined the course.
triggerSendEmail(NOTIFICATION_NAME.TEACHER_STUDENT_JOINED, {
to: teachers[0],
courseName: data.name
});
// go to lms
return goto('/lms');
});
}
function setCurOrg(cOrg: CurrentOrg) {
if (!cOrg) return;
currentOrg.set(cOrg);
}
onMount(async () => {
setTheme(data.currentOrg?.theme || '');
});
$: setCurOrg(data.currentOrg as CurrentOrg);
</script>
<svelte:head>
<title>Join {data.name} on ClassroomIO</title>
</svelte:head>
<AuthUI
{supabase}
isLogin={false}
{handleSubmit}
isLoading={loading}
showOnlyContent={true}
showLogo={true}
bind:formRef
>
<div class="mt-0 w-full">
{#if refresh}
<p class="dark:text-white text-sm font-light text-center">
Something went wrong, please try again.
</p>
{:else}
<h3 class="dark:text-white text-lg font-medium mt-0 mb-4 text-center">{data.name}</h3>
<p class="dark:text-white text-sm font-light text-center">{data.description}</p>
{/if}
</div>
<div class="my-4 w-full flex justify-center items-center">
<PrimaryButton
label={refresh ? 'Try again' : 'Join Course'}
type="submit"
isDisabled={disableSubmit || loading}
isLoading={loading}
/>
</div>
</AuthUI>
@@ -0,0 +1,34 @@
import { redirect } from '@sveltejs/kit';
import { getCurrentOrg } from '$lib/utils/services/org';
import { getSupabase, supabase } from '$lib/utils/functions/supabase';
import { getProfile } from '$lib/utils/functions/user';
if (!supabase) {
getSupabase();
}
// we need to know if the email exists or not.
// with this we can only ask the user to accept
export const load = async ({ params = { hash: '' } }) => {
try {
const hashData = atob(decodeURIComponent(params.hash));
console.log('hashData', hashData);
const { orgId, email, orgSiteName } = JSON.parse(hashData);
const _currentOrg = await getCurrentOrg(orgSiteName, true);
const profile = await getProfile(email);
console.log('profile data', profile);
return {
orgId,
email,
currentOrg: _currentOrg,
profile
};
} catch (error) {
console.error('Error decoding invite params.hash', error);
throw redirect(301, '/404');
}
};
@@ -0,0 +1,215 @@
<script lang="ts">
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 {
authValidation,
getConfirmPasswordError,
getDisableSubmit
} from '$lib/utils/functions/validator';
import { SIGNUP_FIELDS } from '$lib/utils/constants/authentication';
import AuthUI from '$lib/components/AuthUI/index.svelte';
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import { setTheme } from '$lib/utils/functions/theme';
import type { CurrentOrg } from '$lib/utils/types/org.js';
import { onMount } from 'svelte';
import type { Profile } from '$lib/components/Course/components/People/types';
export let data;
let supabase = getSupabase();
let fields = Object.assign({}, SIGNUP_FIELDS);
let loading = false;
let errors: {
name?: string;
email?: string;
password?: string;
confirmPassword?: string;
} = {};
let submitError: string;
let disableSubmit = false;
let formRef: HTMLFormElement;
async function handleSubmit() {
const validationRes = authValidation({
...fields,
email: 'test@gmail.com' // validation for this ema
});
console.log('validationRes', validationRes);
if (Object.keys(validationRes).length) {
errors = Object.assign(errors, validationRes);
return;
}
try {
loading = true;
let profile: Profile | null = data.profile;
if (!data.profile) {
// Signup
const { data: signupData, error } = await supabase.auth.signUp({
email: data.email,
password: fields.password
});
console.log('Signup', signupData);
if (error) throw error;
if (!signupData.user) {
throw 'User cannot be created ';
}
// Insert profile
const userId = signupData.user.id;
const profileRes = await supabase
.from('profile')
.insert({
id: userId,
username: fields.name.toLowerCase().replace(' ', '-') + new Date().getTime(),
fullname: fields.name,
email: data.email
})
.select();
console.log('Insert profile', profileRes.data);
if (profileRes.error) {
throw profileRes.error;
}
profile = profileRes.data[0] || {};
}
if (!profile?.id) {
throw 'Unable to create profile';
}
// Update member response
const updateMemberRes = await supabase
.from('organizationmember')
.update({
verified: true,
profile_id: profile.id
})
.match({ email: data.email, organization_id: data.currentOrg?.id });
console.log('Update member response', updateMemberRes);
const res = await supabase.auth.signInWithPassword({
email: data.email,
password: fields.password
});
if (res.error) {
throw res.error;
}
formRef?.reset();
return goto($currentOrgPath);
} catch (error) {
if (error instanceof Error) {
submitError = error.message;
} else {
submitError = error?.toString() || '';
}
} finally {
loading = false;
}
}
function setCurOrg(cOrg: CurrentOrg) {
if (!cOrg) return;
currentOrg.set(cOrg);
}
onMount(() => {
setTheme(data.currentOrg?.theme || '');
});
$: errors.confirmPassword = getConfirmPasswordError(fields);
$: disableSubmit = getDisableSubmit(fields);
$: setCurOrg(data.currentOrg as CurrentOrg);
$: console.log('data.profile', data.profile);
</script>
<svelte:head>
<title>Join ClassroomIO</title>
</svelte:head>
<AuthUI {supabase} isLogin={false} {handleSubmit} isLoading={loading} showLogo={true} bind:formRef>
<div class="mt-4 w-full">
<p class="dark:text-white text-lg font-semibold mb-6">
{#if data.profile}
Log in to join
{:else}
Create a free account to join
{/if}
</p>
<TextField
label="Your Email"
value={data.email}
type="email"
placeholder="you@domain.com"
className="mb-6"
inputClassName="w-full"
isDisabled={true}
/>
{#if !data.profile}
<TextField
label="Full Name"
bind:value={fields.name}
type="text"
autoFocus={true}
placeholder="e.g Joke Silva"
className="mb-6"
inputClassName="w-full"
isDisabled={loading}
errorMessage={errors.name}
isRequired
/>
{/if}
<TextField
label="Your Password"
bind:value={fields.password}
type="password"
placeholder="************"
className="mb-6"
inputClassName="w-full"
isDisabled={loading}
errorMessage={errors.password}
helperMessage="Password must be more than 6 characters"
isRequired
/>
{#if !data.profile}
<TextField
label="Confirm Password"
bind:value={fields.confirmPassword}
type="password"
placeholder="************"
className="mb-6"
inputClassName="w-full"
isDisabled={loading}
errorMessage={errors.confirmPassword}
isRequired
/>
{/if}
{#if submitError}
<p class="text-sm text-red-500">{submitError}</p>
{/if}
</div>
<div class="my-4 w-full flex justify-end items-center">
<PrimaryButton
label="Accept Invite"
type="submit"
className="sm:w-full w-full"
isDisabled={disableSubmit || loading}
isLoading={loading}
/>
</div>
</AuthUI>
+112
View File
@@ -0,0 +1,112 @@
<script lang="ts">
import Learning from '$lib/components/LMS/components/Learning.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { goto } from '$app/navigation';
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
import { fetchCourses } from '$lib/components/Courses/api';
import { courses, courseMetaDeta } from '$lib/components/Courses/store';
import type { Course } from '$lib/utils/types';
import { getGreeting } from '$lib/utils/functions/date';
let hasFetched = false;
let progressPercentage = 0;
let totalLessons = 0;
let totalComPleted = 0;
async function getCourses(userId: string | undefined, orgId: string) {
if (hasFetched || !userId || !orgId) {
return;
}
$courseMetaDeta.isLoading = true;
const coursesResult = await fetchCourses(userId, orgId);
console.log(`coursesResult`, coursesResult);
$courseMetaDeta.isLoading = false;
if (!coursesResult) return;
courses.set(coursesResult.allCourses);
hasFetched = true;
}
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);
}
$: getCourses($profile.id, $currentOrg.id);
$: calcTotalProgress($courses);
</script>
<svelte:head>
<title>Student Dashboard - ClassroomIO</title>
</svelte:head>
<section class="max-w-6xl gap-5 mx-auto">
<div class="m-5">
<h1 class="dark:text-white text-2xl md:text-3xl font-bold mb-3">
{getGreeting()}
{$profile.fullname}!
</h1>
<div
class="w-full h-fit lg:h-[265px] flex md:items-center justify-between flex-col-reverse md:flex-row p-5 lg:p-10 rounded-md bg-primary-900 my-2"
>
<span>
<p class="w-full md:w-[75%] lg:w-[80%] text-white text-xs lg:text-xl font-normal mb-5">
Today is another day to bring you closer to your learning goals. Don’t give up, the more
you learn the better you get.
</p>
<PrimaryButton
label="Don't give up"
variant={VARIANTS.CONTAINED_WHITE}
onClick={() => goto('/lms/mylearning')}
/>
</span>
<img
src="/images/student-learning.svg"
alt="student Learning Pictogram"
class="w-28 md:block md:w-1/3 lg:w-[275px] lg:h-[205px] mb-3 md:mb-0"
/>
</div>
<section class="flex w-full flex-col lg:flex-row md:gap-5">
<div class="w-full lg:w-[50%] mt-10 xl:mt-2">
<Learning />
</div>
<div class="mt-10 xl:mt-2 w-full lg:w-[50%]">
<p class="text-base font-semibold text-[#040F2D] pb-3 dark:text-white">Your Progress</p>
<div
class="flex items-center justify-center border border-[#EAEAEA] dark:bg-neutral-800 gap-2 rounded h-fit lg:h-[40vh] lg:overflow-y-auto p-3"
>
<div
class="w-full h-full flex flex-col sm:flex-row xl:flex-col items-center justify-between lg:items-center xl:items-start gap-5 lg:justify-around"
>
<div>
<img src="/images/target.svg" alt="student Learning score" />
</div>
<span class="text-center xl:text-start">
<p class="text-base font-semibold py-2 text-[#040F2D] dark:text-white">
Your Progress
</p>
{#if totalLessons > 0}
<p class="text-xs font-normal text-[#656565] dark:text-white">
{totalComPleted}/{totalLessons} lessons completed
</p>
{:else}
<p class="text-xs font-normal text-[#656565] dark:text-white">No courses started</p>
{/if}
</span>
<h1
class="text-5xl lg:text-6xl font-bold text-[#262626] dark:text-white my-0 whitespace-nowrap"
>
{progressPercentage} %
</h1>
</div>
</div>
</div>
</section>
</div>
</section>
@@ -0,0 +1,25 @@
<script>
import { goto } from '$app/navigation';
import Community from '$lib/components/Org/Community/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { currentOrgPath } from '$lib/utils/store/org';
function askCommunity() {
goto(`${$currentOrgPath}/community/ask`);
}
</script>
<svelte:head>
<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">Community</h1>
<PrimaryButton label="Ask Community" onClick={askCommunity} />
</div>
<Community isLMS={true} />
</div>
</section>
@@ -0,0 +1,5 @@
export const load = ({ params = { slug: '' } }) => {
return {
slug: params.slug
};
};
@@ -0,0 +1,402 @@
<script>
import { goto } from '$app/navigation';
import pluralize from 'pluralize';
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte';
import { SkeletonPlaceholder, SkeletonText } from 'carbon-components-svelte';
import ArrowLeftIcon from 'carbon-icons-svelte/lib/ArrowLeft.svelte';
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 { currentOrg } 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 { snackbar } from '$lib/components/Snackbar/store';
import TextField from '$lib/components/Form/TextField.svelte';
import DeleteCommentModal from '$lib/components/Org/Community/DeleteCommentModal.svelte';
import TextEditor from '$lib/components/TextEditor/index.svelte';
import { calDateDiff } from '$lib/utils/functions/date';
export let data;
const { slug } = data;
let question;
let comment = '';
let errors = {};
let isValidAnswer = false; // V2 allow admin mark an answer as accepted
let resetInput = 1;
let voted = { question: false, comment: {} };
let isEditMode = false;
let deleteComment = {
shouldDelete: false,
commentId: null
};
let editContent = {
title: '',
body: ''
};
let editorInstance = false;
function mapResToQuestion(data) {
return {
id: data.id,
title: data.title,
votes: data.votes,
author: {
id: data?.author?.profile?.id || '',
name: data?.author?.profile?.fullname || '',
avatar: data?.author?.profile?.avatar_url || ''
},
body: data.body,
createdAt: calDateDiff(data.created_at),
comments: data.comments.map((c) => ({
id: c.id,
authorId: c.author?.profile?.id || '',
name: c.author?.profile?.fullname || '',
avatar: c.author?.profile?.avatar_url || '',
votes: c.votes,
comment: c.body,
createdAt: calDateDiff(c.created_at)
})),
totalComments: 0
};
}
async function fetchCommunityQuestion(slug) {
if (!slug) return;
const { data, error } = await supabase
.from('community_question')
.select(
`
id,
title,
body,
votes,
created_at,
comments:community_answer(
id,
body,
votes,
created_at,
author:organizationmember!community_answer_author_id_fkey!inner(
profile!inner(id, fullname, avatar_url)
)
),
author:organizationmember!community_question_author_id_fkey!inner(
profile!inner(id, fullname, avatar_url)
)
`
)
.eq('slug', slug)
.single();
if (error) {
console.error('Error loading community', error);
return goto('/lms/community');
}
question = mapResToQuestion(data);
question.totalComments = question.comments.length;
}
async function submitComment() {
errors = commentInCommunityValidation({ comment });
console.log('submitComment errors', errors);
if (Object.keys(errors).length) {
return;
}
const { data, error } = await supabase
.from('community_answer')
.insert({
id: undefined,
body: comment,
question_id: question.id,
author_id: $currentOrg.memberId,
votes: 0
})
.select();
if (error) {
console.error('Error: commenting', error);
snackbar.error('Error - Please try again later');
} else {
console.log('Success: commenting', data);
snackbar.success();
// Add to comment
const _c = data?.[0];
question.comments = [
...question.comments,
{
id: _c.id,
name: $profile?.fullname || '',
avatar: $profile?.avatar_url || '',
votes: 0,
comment: _c.body,
createdAt: calDateDiff(_c.created_at)
}
];
// Reset input
comment = '';
resetInput = new Date().getTime();
}
}
async function upvoteQuestion(type, commentId) {
const isQuestion = type === 'question';
if (isQuestion && voted.question) return;
if (!isQuestion && voted.comment[commentId]) return;
const table = isQuestion ? 'community_question' : 'community_answer';
const matchId = isQuestion ? question.id : commentId;
let votes = 0;
if (isQuestion) {
question.votes = question.votes + 1;
votes = question.votes;
} else {
question.comments = question.comments.map((c) => {
if (c.id === commentId) {
c.votes = c.votes + 1;
votes = c.votes;
}
return c;
});
}
const { error } = await supabase.from(table).update({ votes }).match({ id: matchId });
if (error) {
console.error('Error: upvoteQuestion', error);
snackbar.error();
} else {
if (isQuestion) {
voted.question = true;
} else {
voted.comment[commentId] = true;
}
}
}
async function handleQuestionEdit() {
if (isEditMode) {
const fields = { title: editContent.title, body: editContent.body };
errors = askCommunityValidation(fields);
console.log('handleQuestionEdit errors', errors);
if (Object.keys(errors).length) {
return;
}
}
isEditMode = !isEditMode;
editorInstance = !editorInstance;
if (!isEditMode) {
const fields = { title: editContent.title, body: editContent.body };
errors = askCommunityValidation(fields);
console.log('handleQuestionEdit errors', errors);
if (Object.keys(errors).length) {
return;
}
const { error } = await supabase
.from('community_question')
.update(fields)
.match({ id: question.id });
if (error) {
console.error('Error: handleQuestionEdit', error);
snackbar.error();
} else {
question.title = fields.title;
question.body = fields.body;
editContent.title = '';
editContent.body = '';
}
} else {
editContent.title = question.title;
editContent.body = question.body;
}
}
async function handleCommentDelete() {
const { error } = await supabase
.from('community_answer')
.delete()
.match({ id: deleteComment.commentId });
if (!error) {
question.comments = question.comments.filter((c) => c.id !== deleteComment.commentId);
deleteComment.shouldDelete = false;
deleteComment.commentId = null;
}
}
$: fetchCommunityQuestion(slug);
</script>
<svelte:head>
<title>{question?.title || 'Question'}</title>
</svelte:head>
<DeleteCommentModal
bind:open={deleteComment.shouldDelete}
onCancel={() => {
deleteComment.shouldDelete = false;
deleteComment.commentId = null;
}}
onDelete={handleCommentDelete}
/>
<section class="max-w-3xl mx-auto md:mx-10 lg:mb-20">
{#if !question}
<div class="py-10 px-5 mb-3">
<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" /> Go Back
</a>
<div class="my-5 flex justify-between items-center">
{#if isEditMode}
<TextField
bind:value={editContent.title}
className="w-full mr-2"
errorMessage={errors.title}
/>
{:else}
<div class="flex items-center">
<Vote
value={question.votes}
upVote={() => upvoteQuestion('question')}
disabled={voted.question}
/>
<h2 class="text-3xl">{question.title}</h2>
</div>
{/if}
{#if question.author.id === $profile.id}
<PrimaryButton
label={isEditMode ? 'Save' : 'Edit'}
variant={VARIANTS.OUTLINED}
onClick={handleQuestionEdit}
className="h-fit"
/>
{#if isEditMode}
<PrimaryButton
label="Cancel"
variant={VARIANTS.TEXT}
onClick={() => (isEditMode = !isEditMode)}
className="py-3 px-6 rounded-sm h-fit"
disablePadding={true}
/>
{/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">
{question.createdAt}
</p>
</div>
</header>
{#if isEditMode && editorInstance}
<div class="my-2">
<TextEditor
bind:value={editContent.body}
placeholder="Give an answer"
onChange={(html) => (editContent.body = html)}
/>
</div>
{:else}
<section class="prose prose-sm sm:prose p-2">
{@html question.body}
</section>
{/if}
</div>
<div class="my-8 font-bold">
{pluralize('answers', question.totalComments, true)}
</div>
{#each question.comments as comment}
<div class="my-5 px-1 flex items-start">
<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="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">
{comment.createdAt}
</p>
</div>
{#if isValidAnswer}
<CheckmarkOutlineIcon size={20} />
{/if}
{#if comment.authorId === $profile.id}
<IconButton
value="delete-comment"
onClick={() => {
deleteComment.shouldDelete = true;
deleteComment.commentId = comment.id;
}}
>
<TrashCanIcon size={16} class="carbon-icon dark:text-white" />
</IconButton>
{/if}
</header>
<article class="prose prose-sm sm:prose p-2">
{@html comment.comment}
</article>
</div>
</div>
{/each}
<hr />
<div>
{#if !editorInstance}
<TextEditor
bind:value={comment}
placeholder="Give an answer"
onChange={(html) => (comment = html)}
/>
{/if}
<div class="flex justify-end mr-2">
<PrimaryButton label="Comment" onClick={submitComment} />
</div>
</div>
</div>
{/if}
</section>
@@ -0,0 +1,89 @@
<script>
import { goto } from '$app/navigation';
import ArrowLeftIcon from 'carbon-icons-svelte/lib/ArrowLeft.svelte';
import { currentOrg } from '$lib/utils/store/org';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { askCommunityValidation } from '$lib/utils/functions/validator';
import { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$lib/components/Snackbar/store';
import generateSlug from '$lib/utils/functions/generateSlug';
import TextEditor from '$lib/components/TextEditor/index.svelte';
let errors = {};
let fields = {
title: '',
body: ''
};
async function handleSave() {
errors = askCommunityValidation(fields);
console.log('handleSave errors', errors);
if (Object.keys(errors).length) {
return;
}
const { data: question, error } = await supabase
.from('community_question')
.insert({
...fields,
organization_id: $currentOrg.id,
author_id: $currentOrg.memberId,
votes: 0,
slug: generateSlug(fields.title)
})
.select();
if (error) {
console.error('Error: asking question', error);
snackbar.error();
} else {
console.log('Success: asking question', question);
snackbar.success();
goto(`/lms/community/${question[0].slug}`);
}
}
</script>
<svelte:head>
<title>Ask the Community - ClassroomIO</title>
</svelte:head>
<section class="w-full max-w-3xl mx-auto">
<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" /> Go Back
</a>
<div class="flex items-center justify-between">
<h1 class="dark:text-white text-3xl font-bold">Ask the community</h1>
<PrimaryButton label="Publish" onClick={handleSave} />
</div>
</div>
<div class="mb-3 p-2">
<input
class="title rounded-lg border border-1 border-gray p-2 w-full focus:outline-none"
error="false"
autocomplete="off"
placeholder="Title"
aria-label="Title"
aria-describedby="title-input-validation"
type="text"
name="discussion_title"
id="discussion_title"
bind:value={fields.title}
/>
{#if errors.title}
<p class="dark:text-white text-red-500 text-sm">
{errors.title}
</p>
{/if}
</div>
<TextEditor
bind:value={fields.body}
placeholder="Ask the community any question you might have"
onChange={(html) => (fields.body = html)}
/>
</section>
@@ -0,0 +1,173 @@
<script lang="ts">
import { cloneDeep } from 'lodash';
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';
import type { LMSExercise } from '$lib/utils/services/lms/exercises';
import { calDateDiff } from '$lib/utils/functions/date';
const defaultSections = [
{
id: 0,
title: 'Not Submitted',
items: [],
className: 'text-[#E35353] bg-[#FDDFE4]'
},
{
id: 1,
title: 'Submitted',
items: [],
className: 'text-orange-700 bg-orange-200'
},
{
id: 2,
title: 'Grading In Progress',
items: [],
className: 'text-yellow-700 bg-yellow-200'
},
{
id: 3,
title: 'Graded',
value: 0,
items: [],
className: 'text-green-700 bg-green-200'
}
];
let sections: Section[] = cloneDeep(defaultSections);
interface Section {
id: number;
title: string;
className: string;
items: ExerciseItem[];
}
interface ExerciseItem {
courseTitle: string;
courseURL: string;
exerciseId: string;
exerciseTitle: string;
exerciseURL: string;
grade: string;
lessonNo: string;
lessonTitle: string;
lessonURL: string;
submissionStatus: number;
submissionUpdatedAt: string;
}
function generateSections(exercises: LMSExercise[]): Section[] {
const _sections: Section[] = cloneDeep(defaultSections);
for (const exercise of exercises) {
const { id, title, updated_at, submission, lesson, questions } = exercise;
const submissionItem = submission[0] || {
status_id: 0,
updated_at,
total: 0
};
const courseURL = `/courses/${lesson.course.id}`;
const lessonURL = `${courseURL}/lessons/${lesson.id}`;
const exerciseURL = `${lessonURL}/exercises/${id}`;
const grade = `${submissionItem.total}/${questions.reduce(
(acc, cur) => (acc += cur.points),
0
)}`;
const item: ExerciseItem = {
exerciseId: id,
courseTitle: lesson.course.title,
courseURL,
exerciseTitle: title,
exerciseURL,
lessonTitle: lesson.title,
lessonNo: lesson.order < 9 ? '0' + (lesson.order + 1) : `${lesson.order}`,
lessonURL,
submissionStatus: submissionItem.status_id,
submissionUpdatedAt: calDateDiff(submissionItem.updated_at),
grade
};
_sections[submissionItem.status_id].items.push(item);
}
return _sections;
}
async function fetchData(profileId: string, orgId: string) {
const { exercises, error } = await fetchLMSExercises(profileId, orgId);
console.log('exercises', exercises);
console.log('error', error);
if (error) {
snackbar.error('Fetching exercises failed, please reload the page');
return;
}
if (!exercises) return;
sections = generateSections(exercises);
console.log('sections', sections);
}
$: if (browser && $profile.id && $currentOrg.id) {
fetchData($profile.id, $currentOrg.id);
}
</script>
<section class="w-full max-w-6xl mx-auto">
<div class="p-5">
<div class="flex items-center justify-between mb-10">
<h1 class="dark:text-white text-3xl font-bold">Exercises</h1>
</div>
<div>
<div class="flex items-center w-full">
{#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"
>
<div class="flex items-center mb-2 gap-2">
<p class="dark:text-white ml-2 font-bold">{title}</p>
<Chip value={items.length} {className} />
</div>
<div class="pr-2 h-full overflow-y-auto pb-3">
{#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}
>
<p class="text-xs">{item.courseTitle}</p>
</a>
<a class="text-black dark:text-white text-md font-bold" 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">
Lesson: <span class="italic">{item.lessonTitle}</span>
</p>
</a>
<p class="dark:text-white text-gray-500 text-xs">
{item.submissionUpdatedAt}
</p>
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
</section>
@@ -0,0 +1,77 @@
<script lang="ts">
import { Search } from 'carbon-components-svelte';
import Tabs from '$lib/components/Tabs/index.svelte';
import TabContent from '$lib/components/TabContent/index.svelte';
import Courses from '$lib/components/Courses/index.svelte';
import { fetchCourses } from '$lib/components/Courses/api';
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
import { courses, courseMetaDeta, coursesComplete } from '$lib/components/Courses/store';
import { browser } from '$app/environment';
const tabs = [
{
label: 'In progress',
value: 1
},
{
label: 'Complete',
value: 2
}
];
let currentTab = tabs[0].value;
let hasFetched = false;
function onChange(tab: 0) {
return () => (currentTab = tab);
}
async function getCourses(userId: string | null, orgId: string) {
console.log('hasFetched', hasFetched);
if (hasFetched || !userId || !orgId) {
return;
}
hasFetched = true;
$courseMetaDeta.isLoading = true;
const coursesResult = await fetchCourses(userId, orgId);
console.log(`get courses result`, coursesResult);
$courseMetaDeta.isLoading = false;
if (!coursesResult) return;
courses.set(coursesResult.allCourses);
hasFetched = true;
}
$: if (browser && $profile.id && $currentOrg.id) {
getCourses($profile.id, $currentOrg.id);
}
</script>
<section class="max-w-6xl mx-auto">
<div class="m-2 md:m-5">
<div role="searchbox" class=" bg-gray-100 w-full md:w-[60%] lg:w-[30%]">
<Search placeholder="Search courses" class="dark:text-black" />
</div>
<h1 class="text-3xl font-semibold my-4">My Learning</h1>
<Tabs {tabs} {currentTab} {onChange}>
<slot:fragment slot="content">
<TabContent value={tabs[0].value} index={currentTab}>
<Courses
courses={$courses}
emptyTitle="No Course In progress"
emptyDescription="Any course that you start will be displayed here"
/>
</TabContent>
<TabContent value={tabs[1].value} index={currentTab}>
<Courses
courses={$coursesComplete}
emptyTitle="No Course Completed"
emptyDescription="Any course that you complete will be displayed here"
/>
</TabContent>
</slot:fragment>
</Tabs>
</div>
</section>
@@ -0,0 +1,47 @@
<script>
import { page } from '$app/stores';
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';
let selected = 0;
const tabs = [
{
key: 0,
label: 'Profile',
tabKey: '',
href: $page.url.pathname
},
{
key: 1,
label: 'Integrations',
tabKey: '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">Settings</h1>
</div>
<div class="">
<Tabs autoWidth bind:selected>
{#each tabs as tab}
<Tab label={tab.label} href={tab.href} />
{/each}
<svelte:fragment slot="content">
<TabContent>
<Profile />
</TabContent>
<TabContent>
<Integrations />
</TabContent>
</svelte:fragment>
</Tabs>
</div>
</div>
</section>
@@ -0,0 +1,109 @@
<script lang="ts">
import { page } from '$app/stores';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { getSupabase } from '$lib/utils/functions/supabase';
import { authValidation } from '$lib/utils/functions/validator';
import { LOGIN_FIELDS } from '$lib/utils/constants/authentication';
import AuthUI from '$lib/components/AuthUI/index.svelte';
import { currentOrg } from '$lib/utils/store/org';
import { onDestroy } from 'svelte';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
let formRef;
let supabase = getSupabase();
let fields = Object.assign({}, LOGIN_FIELDS);
let submitError: string;
let loading = false;
let errors = {};
let reloadTimeout: NodeJS.Timeout;
let query = new URLSearchParams($page.url.search);
let redirect = query.get('redirect');
async function handleSubmit() {
const validationRes = authValidation(fields);
console.log('validationRes', validationRes);
if (Object.keys(validationRes).length) {
errors = Object.assign(errors, validationRes);
return;
}
try {
loading = true;
const { data, error } = await supabase.auth.signInWithPassword({
email: fields.email,
password: fields.password
});
console.log('data', data);
if (error) throw error;
// reload page on org site cause for some reason the authlistener on +layout.svelte doesn't run sometimes
reloadTimeout = setTimeout(() => {
console.log('Forcing full page reload for auth listener');
window.location.reload();
}, 1500);
capturePosthogEvent('login', {
email: fields.email
});
} catch (error) {
submitError = error.error_description || error.message;
} finally {
loading = false;
}
}
onDestroy(() => {
clearTimeout(reloadTimeout);
});
</script>
<svelte:head>
<title>Welcome back to {$currentOrg.name ?? 'ClassroomIO'}</title>
</svelte:head>
<AuthUI {supabase} isLogin={true} {handleSubmit} isLoading={loading} bind:formRef>
<div class="mt-4 w-full">
<p class="dark:text-white text-lg font-semibold mb-6">Welcome back</p>
<TextField
label="Your email"
bind:value={fields.email}
type="email"
autoFocus={true}
placeholder="you@domain.com"
className="mb-6"
inputClassName="w-full"
isDisabled={loading}
errorMessage={errors.email}
/>
<TextField
label="Your password"
bind:value={fields.password}
type="password"
placeholder="************"
className="mb-6"
inputClassName="w-full"
isDisabled={loading}
errorMessage={errors.password}
/>
{#if submitError}
<p class="text-sm text-red-500">{submitError}</p>
{/if}
<div class="w-full text-right">
<a class="text-md text-primary-700" href="/forgot"> Forgot password? </a>
</div>
</div>
<div class="my-4 w-full flex justify-end items-center">
<!-- <a href="/login" class="text-primary-700 text-sm">Create an account</a> -->
<PrimaryButton
label="Log In"
type="submit"
className="sm:w-full w-full"
isDisabled={loading}
isLoading={loading}
/>
</div>
</AuthUI>
@@ -0,0 +1,365 @@
<script lang="ts">
import { PUBLIC_IP_REGISTRY_KEY } from '$env/static/public';
import { goto } from '$app/navigation';
import TextField from '$lib/components/Form/TextField.svelte';
import UserProfileIcon from '$lib/components/Icons/UserProfileIcon.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { profile } from '$lib/utils/store/user';
import { onboardingValidation } from '$lib/utils/functions/validator';
import { supabase } from '$lib/utils/functions/supabase';
import { blockedSubdomain } from '$lib/utils/constants/app';
import { welcomeModalStore } from '$lib/components/WelcomeModal/store';
import { getOrganizations } from '$lib/utils/services/org';
import { generateSitename } from '$lib/utils/functions/org';
import {
triggerSendEmail,
NOTIFICATION_NAME
} from '$lib/utils/services/notification/notification';
interface OnboardingField {
fullname?: string;
orgName?: string;
siteName?: string;
goal?: string;
source?: string;
metadata?: {};
}
interface Goal {
label: string;
value: string;
}
const maxSteps = 2;
let fields: OnboardingField = {
fullname: '',
orgName: '',
siteName: ''
};
let errors: OnboardingField = {};
let progress = 50;
let step = 1;
let loading = false;
let isSiteNameTouched = false;
const educatorGoals: Goal[] = [
{
label: 'Sell courses online',
value: 'sell-online'
},
{
label: 'Teach my current students online',
value: 'teach-online'
},
{
label: 'On another platform, expanding here',
value: 'expanding-platform'
},
{
label: 'Just here to explore',
value: 'explore'
}
];
const sources = [
{
label: 'Articles',
value: 'articles'
},
{
label: 'Search engine',
value: 'search-engine'
},
{
label: 'Social media',
value: 'social-media'
},
{
label: 'Friends and family',
value: 'friends-family'
}
];
async function setMetaData() {
if (!PUBLIC_IP_REGISTRY_KEY) return;
const response = await fetch(`https://api.ipregistry.co/?key=${PUBLIC_IP_REGISTRY_KEY}`);
const payload = await response.json();
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
console.log(fields);
errors = onboardingValidation(fields, step);
console.log('errors', errors);
if (Object.keys(errors).length || !$profile.id) {
loading = false;
return;
}
if (step === 1) {
// Validate if domain is among our seculeded subdomains
if (blockedSubdomain.includes(fields.siteName || '')) {
errors.siteName = 'Sitename already exists.';
loading = false;
return;
}
const { data: org, error } = await supabase
.from('organization')
.insert({
name: fields.orgName,
siteName: fields.siteName
})
.select();
console.log('Create organisation', org);
if (error) {
console.log('Error: create organisation', error);
errors.siteName = 'Sitename already exists.';
loading = false;
return;
}
if (Array.isArray(org) && org.length) {
const orgData = org[0];
const { data, error } = await supabase
.from('organizationmember')
.insert({
organization_id: orgData.id,
profile_id: $profile.id,
role_id: 1
})
.select();
console.log('Create organisation member', data);
if (error) {
console.log('Error: create organisation member', error);
errors.siteName =
'Something went wrong while creating this organization. Please reload and try again';
// Delete organization so it can be recreated.
await supabase.from('organization').delete().match({ siteName: fields.siteName });
loading = false;
return;
}
await getOrganizations($profile.id);
}
// client
}
if (step === maxSteps) {
// Submit filled
// Set extra metadata based off location
await setMetaData();
let { data, error } = await supabase
.from('profile')
.update({
...fields,
orgName: undefined,
siteName: undefined
})
.match({ id: $profile.id });
loading = false;
if (fields.fullname) {
triggerSendEmail(NOTIFICATION_NAME.WELCOME_TO_APP, {
to: $profile.email,
name: fields.fullname
});
}
console.log('data', data);
console.log('error', error);
$welcomeModalStore.open = true;
return goto(`/org/${fields.siteName}`);
}
// Move to next step
step = step + 1;
loading = false;
};
$: progress = Math.round((step / maxSteps) * 100);
$: fields.siteName = generateSitename(fields.siteName || '');
$: setOrgSiteName(fields.orgName, isSiteNameTouched);
</script>
{#if $profile.id}
<div class="w-full min-h-screen flex justify-center">
<div class="flex justify-center items-center flex-col w-9/12 max-w-md">
<!-- Header With Logo -->
<div class="flex flex-col items-center">
<div class="flex items-center w-full justify-center mb-4">
<img src="/logo-192.png" alt="ClassroomIO logo" height="50" width="50" data-atf="1" />
<h4 class="dark:text-white text-xl">ClassroomIO</h4>
</div>
<!-- Loggedin Email -->
<div
class="w-64 flex items-center justify-center mb-6 py-6 bg-gray-100 dark:bg-neutral-800 rounded-2xl border border-gray-300"
>
<UserProfileIcon />
<p class="dark:text-white text-sm ml-2">{$profile.email}</p>
</div>
</div>
<div class="form-container overflow-y-auto w-full px-2">
{#if step === 1}
<!-- Name/Organization Question -->
<div id="role-question" class="flex items-start flex-col mb-6">
<!-- Full name -->
<TextField
label="Full Name"
bind:value={fields.fullname}
name="fullname"
type="text"
placeholder="e.g Joke Silva"
className="mb-5 w-full"
labelClassName="text-lg font-normal"
errorMessage={errors.fullname}
/>
<!-- Org name -->
<TextField
label="Name of Organization"
bind:value={fields.orgName}
name="orgname"
type="text"
placeholder="e.g Education For All"
className="mb-5 w-full"
labelClassName="text-lg font-normal"
errorMessage={errors.orgName}
/>
<!-- Org Site Name -->
<TextField
label="Organisation Site name"
helperMessage={`https://${fields.siteName || ''}.classroomio.com`}
bind:value={fields.siteName}
name="sitename"
type="text"
placeholder="e.g edforall"
className="mb-5 w-full"
labelClassName="text-lg font-normal"
errorMessage={errors.siteName}
onChange={() => {
isSiteNameTouched = true;
}}
/>
</div>
{:else}
<!-- Goal/Source Question -->
<div id="goal-question" class="flex items-center flex-col mb-6">
<div class="w-10/12">
<!-- Goal Question -->
<div class="w-full flex items-start flex-col justify-between mb-10">
<label for="text-field" class="dark:text-white m-0 text-lg font-normal mb-3">
What brings you to ClassroomIO?
</label>
<!-- Loop through Goals -->
{#each educatorGoals as goal}
<label class="dark:text-white w-full inline-flex items-center mb-1 font-light">
<input
type="radio"
bind:group={fields.goal}
name="goal"
value={goal.value}
class="mr-2"
/>
{goal.label}
</label>
{/each}
<!-- Goal: Error message -->
{#if errors.goal}
<p class="text-sm text-red-500">
{errors.goal}
</p>
{/if}
</div>
<!-- Source Question -->
<div class="w-full flex items-start flex-col justify-between">
<label for="text-field" class="dark:text-white m-0 text-lg font-normal mb-3">
How did you hear about us?
</label>
<!-- Loop through Goals -->
{#each sources as source}
<label class="dark:text-white w-full inline-flex items-center mb-1 font-light">
<input
type="radio"
bind:group={fields.source}
name="source"
value={source.value}
class="mr-2"
/>
{source.label}
</label>
{/each}
<!-- Goal: Error message -->
{#if errors.source}
<p class="text-sm text-red-500">
{errors.source}
</p>
{/if}
</div>
</div>
</div>
{/if}
</div>
<!-- Footer -->
<div class="flex justify-between items-center mt-8 w-full">
<div class="w-24 h-2 bg-gray-300 relative">
<span
class="progress absolute top-0 left-0 bg-primary-700 h-full"
style="width: {progress}%;"
/>
</div>
<div class="flex">
{#if step > 1}
<PrimaryButton
label="Back"
variant={VARIANTS.NONE}
className="py-3 px-6 mr-2 text-primary-700"
onClick={() => (step = step - 1)}
/>
{/if}
<PrimaryButton
label="Continue"
variant={VARIANTS.CONTAINED}
type="button"
className="px-5 py-3"
isLoading={loading}
onClick={handleSubmit}
/>
</div>
</div>
</div>
</div>
{/if}
<style>
.form-container {
max-height: 66%;
}
</style>
@@ -0,0 +1,5 @@
export const load = ({ params = { slug: '' } }) => {
return {
orgName: params.slug
};
};
@@ -0,0 +1,402 @@
<script lang="ts">
import { profile } from '$lib/utils/store/user';
// import { supabase } from '$lib/utils/functions/supabase';
import { goto } from '$app/navigation';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import UnlockedIcon from 'carbon-icons-svelte/lib/Unlocked.svelte';
import LockedIcon from 'carbon-icons-svelte/lib/Locked.svelte';
// import Avatar from '$lib/components/Avatar/index.svelte';
import { InlineCalendar, Datepicker } from 'svelte-calendar';
import { formatUserUpcomingData, formatDate } from '$lib/utils/functions/routes/dashboard';
import { user } from '$lib/utils/store/user';
import { fetchUserUpcomingData } from '$lib/utils/services/dashboard';
import { isMobile } from '$lib/utils/store/useMobile';
import type { UserLessonDataType, LessonsByMonthIndexType } from '$lib/utils/types';
import WelcomeModal from '$lib/components/WelcomeModal/WelcomeModal.svelte';
import { onMount } from 'svelte';
import { Add } from 'carbon-icons-svelte';
import { isOrgAdmin, currentOrgPath, currentOrg, userUpcomingData } from '$lib/utils/store/org';
import { VARIANTS } from '$lib/components/PrimaryButton/constants.js';
import VisitOrgSiteButton from '$lib/components/Buttons/VisitOrgSite.svelte';
import { getGreeting } from '$lib/utils/functions/date';
import { appStore } from '$lib/utils/store/app';
export let data;
const { orgName } = data;
let theme = {};
let store;
let calendar: HTMLElement | null = null;
let lessonsByMonth: LessonsByMonthIndexType = {};
let prevSelectedDate: string = new Date().toUTCString();
let selectedDateLessonData: UserLessonDataType[] | [] = [];
const boxes = [
{
label: 'Revenue (NGN)',
value: '0.00'
},
{
label: 'Number of sales',
value: 0
},
{
label: 'Number of courses',
value: 0
},
{
label: 'Total students',
value: 0
}
];
function createCourse() {
goto(`/org/${orgName}/courses?create=true`);
}
const addDotsToCalendar = (currentMonthIndexInRenderedMonth: number) =>
setTimeout(() => {
if (!calendar) calendar = document.getElementById('calendar');
// console.log(
// `currentMonthIndexInRenderedMonth`,
// currentMonthIndexInRenderedMonth
// );
const renderedMonths =
calendar?.querySelectorAll('.contents .container .stage .grid .grid') || [];
// console.log(`renderedMonths`, renderedMonths);
const currentMonthIndex = new Date($store?.selected).getMonth();
const currentMonth = renderedMonths[currentMonthIndexInRenderedMonth];
// console.log(`currentMonth`, currentMonth);
// All elements for each day in the month - with prev or next month
const currentMonthElements =
currentMonth?.querySelectorAll<HTMLElement>('a:not(.outsider)') || [];
// console.log(`currentMonthElements`, currentMonthElements, '\n\n');
const currentMonthData = lessonsByMonth[currentMonthIndex];
if (!currentMonthData) return;
for (let i in currentMonthElements) {
if (!currentMonthElements.hasOwnProperty(i)) {
continue;
}
const dayElement = currentMonthElements[i];
dayElement.style.position = 'relative';
// The date is inside the element like <a>2</a>
const dateIndex = Number(dayElement.innerHTML);
if (!!currentMonthData[dateIndex]) {
// console.log(`dateIndex`, dateIndex);
// console.log(`lesson data`, currentMonthData[dateIndex]);
dayElement.insertAdjacentHTML('beforeend', '<span class="active-day">•</span>');
}
}
}, 500);
async function runFirstThings(currentSession: { id: string } | null, orgId: string) {
if (!currentSession || !currentSession.id || !orgId) return;
if (!$userUpcomingData.length) {
$userUpcomingData = await fetchUserUpcomingData(currentSession.id, orgId);
}
lessonsByMonth = formatUserUpcomingData($userUpcomingData);
// Only for desktop. Only run on mobile when the user clicks on the date
if (!$isMobile) {
addDotsToCalendar(1);
}
}
function getDataOfSelectedDate(_selectedDate: string, orgId: string): UserLessonDataType[] | [] {
if (!_selectedDate) return [];
const selectedDate = new Date(_selectedDate).toUTCString();
const monthIndex = new Date(selectedDate).getMonth();
const dateIndex = new Date(selectedDate).getDate();
// If we changed the month, update the calendar dots
const prevMonthIndex = new Date(prevSelectedDate).getMonth();
if (prevMonthIndex !== monthIndex) {
const currentMonthIndexInRenderedMonth = monthIndex < prevMonthIndex ? 1 : 2;
addDotsToCalendar(currentMonthIndexInRenderedMonth);
}
prevSelectedDate = selectedDate;
const monthLessonData = lessonsByMonth[monthIndex];
if (!monthLessonData) {
return [];
}
const dateLessonData = monthLessonData[dateIndex];
if (!dateLessonData) {
return [];
}
return dateLessonData;
}
onMount(() => {
// Remove default shouldEnlargeDay=true when <DatePicker /> on mobile
if ($store && $store.shouldEnlargeDay) {
$store.shouldEnlargeDay = false;
}
});
// This is here not called onMount because we want to call it as soon as we have the user's `currentSession` value
$: runFirstThings($user.currentSession, $currentOrg.id);
// Every time the date changes
$: selectedDateLessonData = getDataOfSelectedDate($store?.selected, $currentOrg.id);
$: theme = {
calendar: {
width: '250px',
height: '283px',
shadow: '0px 0px 5px rgba(0, 0, 0, 0.25)',
legend: {
height: '30px'
},
colors: {
text: {
primary: $appStore.isDark ? '#eee' : '#333',
highlight: '#fff'
},
background: {
primary: $appStore.isDark ? '#333' : '#fff',
highlight: $appStore.isDark ? '#5829d6' : '#eb7400',
hover: $appStore.isDark ? '#222' : '#eee'
},
border: $appStore.isDark ? '#222' : '#eee'
}
}
};
</script>
<svelte:head>
<title>Dashboard - ClassroomIO</title>
</svelte:head>
<WelcomeModal />
<div class="py-10 px-5 w-full max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-10">
<h1 class="dark:text-white text-2xl md:text-3xl font-bold mb-3">
{getGreeting()}
{$profile.fullname}!
</h1>
<div class="flex items-center">
<PrimaryButton
variant={VARIANTS.OUTLINED}
onClick={createCourse}
isDisabled={!$isOrgAdmin}
className="min-h-[36px]"
>
{#if $isMobile}
<Add size={24} />
{:else}
Create Course
{/if}
</PrimaryButton>
<VisitOrgSiteButton />
</div>
</div>
<div
class="w-full h-fit lg:h-[265px] flex md:items-center justify-between flex-col-reverse md:flex-row p-5 lg:p-10 rounded-md bg-primary-900 my-2"
>
<span>
<p class="w-full md:w-[75%] lg:w-[80%] text-white text-xs lg:text-xl font-normal mb-5">
Thank you for what you do ❤️. You are changing lives one classroom at a time and thanks to
you, the world is a better place. 😊
</p>
<PrimaryButton
label="Keep Building 🚀"
variant={VARIANTS.CONTAINED_WHITE}
onClick={() => goto(`${$currentOrgPath}/courses`)}
/>
</span>
<img
src="/images/student-learning.svg"
alt="student Learning Pictogram"
class="w-28 md:block md:w-1/3 lg:w-[200px] lg:max-h-[205px] mb-3 md:mb-0"
/>
</div>
<!-- <div class="flex items-start flex-wrap">
{#each boxes as box}
<div
class="w-full md:w-[246px] h-[165px] flex flex-col rounded border border-gray-200 justify-center px-5 md:mr-5 mb-5"
>
<p class="dark:text-white mb-2 text-sm">{box.label}</p>
<p class="dark:text-white text-xl font-bold">{box.value}</p>
</div>
{/each}
</div> -->
<div class="w-full">
<!-- Your Schedule -->
<div class="w-full xl:w-auto container">
<p class="dark:text-white font-bold mt-7 mb-4">Your Schedule</p>
<div
class="rounded border border-gray-200 gap-3 flex flex-col md:flex-row items-start px-2 md:px-5 py-5 w-full"
>
{#if !$isMobile}
<div id="calendar" class="flex justify-center mt-5 w-2/5 min-w-[250px]">
<InlineCalendar bind:store {theme} />
</div>
{:else}
<div class="header w-full flex items-center">
<Datepicker bind:store let:key let:send let:receive>
<button
class="text-lg font-bold text-primary-700 p-3 hover:bg-gray-300 rounded-md"
in:receive|local={{ key }}
out:send|local={{ key }}
on:click={() => {
addDotsToCalendar(1);
}}
>
{formatDate($store?.selected)}
</button>
</Datepicker>
</div>
{/if}
<div class="max-h-[400px] overflow-y-scroll w-full">
{#if !$isMobile}
<p class="dark:text-white font-bold m-5">
{formatDate($store?.selected)}
</p>
{/if}
{#each selectedDateLessonData as lessonData}
<div class="flex items-center justify-between p-5 lesson-data">
<div class="w-full">
<a
class="text-black-700 text-lg font-bold flex items-center"
href={`/courses/${lessonData.course_id}/lessons/${
lessonData.is_unlocked ? lessonData.lesson_id : ''
}`}
>
{lessonData.lesson_title}
{#if lessonData.is_unlocked}
<span class="ml-2 success">
<LockedIcon class="carbon-icon dark:text-white" />
</span>
{:else}
<UnlockedIcon class="carbon-icon dark:text-white" />
{/if}
</a>
<p class="dark:text-white text-grey text-sm flex items-center">
<a class="underline text-primary-700 my-2" href="/courses/{lessonData.course_id}">
{` ${lessonData.course_title}`}
</a>
</p>
</div>
{#if lessonData.is_unlocked}
<a
class="join-call rounded-3xl bg-primary-600 text-white py-3 font-bold shadow-lg {!lessonData.call_url &&
'opacity-50 pointer-events-none cursor-not-allowed'}"
href={!!lessonData.call_url ? lessonData.call_url : undefined}
target="_blank"
title={!!lessonData.call_url
? 'Click to join the call'
: 'No call link was added for this lesson. Ask your trainer'}
>
Join call
</a>
{/if}
</div>
{:else}
<p class="dark:text-white flex items-center justify-center w-full no-data">
No lesson on this day
</p>
{/each}
</div>
</div>
</div>
<!-- Your Activities -->
<!-- <div class="w-full">
<p class="dark:text-white font-bold mb-7">Your Activities</p>
<div
class="rounded border border-gray-200 md:min-w-[450px] activities-box py-4 px-2 md:px-5 w-full"
>
{#each activities as activity}
<div class="flex mb-3 pb-3 border-b border-gray-200">
<Avatar src={activity.avatar} name="avatar" />
<div class="ml-2 flex flex-col xl:flex-row">
<div class="mr-2">
<p class="dark:text-white">{activity.name}</p>
<p class="dark:text-white mb-2 mt-1 line-clamp-1">
{activity.description}
</p>
<a href={activity.link}>View</a>
</div>
<div>
<p class="dark:text-white">{activity.time}</p>
</div>
</div>
</div>
{/each}
</div>
</div> -->
</div>
</div>
<style>
.root {
min-height: 93vh;
}
.box {
width: 246px;
height: 165px;
}
.header {
border-bottom: 1px solid var(--border-color);
min-height: 61px;
}
.lesson-data {
border-bottom: 1px solid var(--border-color);
}
.no-data {
height: 100px;
}
.join-call {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
max-height: 48px;
width: 110px;
text-align: center;
}
:global(span.active-day) {
position: absolute;
bottom: -8px;
left: 40%;
color: var(--main-primary-color);
}
:global(#calendar .controls .button.label) {
font-size: 1rem;
}
@media (max-width: 640px) {
:global(span.active-day) {
font-size: 30px;
}
}
</style>
@@ -0,0 +1,26 @@
<script>
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import Audience from '$lib/components/Org/Audience/index.svelte';
let isLoading = false;
function exportAudience() {
isLoading = true;
alert('This feature is coming soon');
isLoading = false;
}
</script>
<svelte:head>
<title>Audience - 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">Audience</h1>
<PrimaryButton label="Export" onClick={exportAudience} isDisabled={isLoading} {isLoading} />
</div>
<Audience />
</div>
</section>
@@ -0,0 +1,33 @@
<script>
import { goto } from '$app/navigation';
import Community from '$lib/components/Org/Community/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { currentOrgPath } from '$lib/utils/store/org';
import { isMobile } from '$lib/utils/store/useMobile';
import AddFilled from 'carbon-icons-svelte/lib/AddFilled.svelte';
import Add from 'carbon-icons-svelte/lib/Add.svelte';
function askCommunity() {
goto(`${$currentOrgPath}/community/ask`);
}
</script>
<svelte:head>
<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">Community</h1>
{#if $isMobile}
<PrimaryButton onClick={askCommunity}>
<Add size={24} />
</PrimaryButton>
{:else}
<PrimaryButton label="Ask Community" onClick={askCommunity} />
{/if}
</div>
<Community />
</div>
</section>
@@ -0,0 +1,5 @@
export const load = ({ params = { slug: '' } }) => {
return {
slug: params.slug
};
};
@@ -0,0 +1,404 @@
<script>
import pluralize from 'pluralize';
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte';
import { SkeletonPlaceholder, SkeletonText } from 'carbon-components-svelte';
import ArrowLeftIcon from 'carbon-icons-svelte/lib/ArrowLeft.svelte';
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 { currentOrgPath, currentOrg } 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 { snackbar } from '$lib/components/Snackbar/store';
import TextField from '$lib/components/Form/TextField.svelte';
import DeleteCommentModal from '$lib/components/Org/Community/DeleteCommentModal.svelte';
import TextEditor from '$lib/components/TextEditor/index.svelte';
import { calDateDiff } from '$lib/utils/functions/date';
export let data;
const { slug } = data;
let question;
let comment = '';
let errors = {};
let isValidAnswer = false; // V2 allow admin mark an answer as accepted
let resetInput = 1;
let voted = { question: false, comment: {} };
let isEditMode = false;
let deleteComment = {
shouldDelete: false,
commentId: null
};
let editContent = {
title: '',
body: ''
};
let editorInstance = false;
function mapResToQuestion(data) {
return {
id: data.id,
title: data.title,
votes: data.votes,
author: {
id: data?.author?.profile?.id || '',
name: data?.author?.profile?.fullname || '',
avatar: data?.author?.profile?.avatar_url || ''
},
body: data.body,
createdAt: calDateDiff(data.created_at),
comments: data.comments.map((c) => ({
id: c.id,
authorId: c.author?.profile?.id || '',
name: c.author?.profile?.fullname || '',
avatar: c.author?.profile?.avatar_url || '',
votes: c.votes,
comment: c.body,
createdAt: calDateDiff(c.created_at)
})),
totalComments: 0
};
}
async function fetchCommunityQuestion(slug) {
if (!slug) return;
const { data, error } = await supabase
.from('community_question')
.select(
`
id,
title,
body,
votes,
created_at,
comments:community_answer(
id,
body,
votes,
created_at,
author:organizationmember!community_answer_author_id_fkey!inner(
profile!inner(id, fullname, avatar_url)
)
),
author:organizationmember!community_question_author_id_fkey!inner(
profile!inner(id, fullname, avatar_url)
)
`
)
.eq('slug', slug)
.single();
if (error) {
console.error('Error loading community', error);
return goto(`${currentOrgPath}`);
}
question = mapResToQuestion(data);
question.totalComments = question.comments.length;
}
async function submitComment() {
errors = commentInCommunityValidation({ comment });
console.log('submitComment errors', errors);
if (Object.keys(errors).length) {
return;
}
const { data, error } = await supabase
.from('community_answer')
.insert({
id: undefined,
body: comment,
question_id: question.id,
author_id: $currentOrg.memberId,
votes: 0
})
.select();
if (error) {
console.error('Error: commenting', error);
snackbar.error('Error - Please try again later');
} else {
console.log('Success: commenting', data);
snackbar.success('Success');
// Add to comment
const _c = data?.[0];
question.comments = [
...question.comments,
{
id: _c.id,
name: $profile?.fullname || '',
avatar: $profile?.avatar_url || '',
votes: 0,
comment: _c.body,
createdAt: calDateDiff(_c.created_at)
}
];
// Reset input
comment = '';
resetInput = new Date().getTime();
}
}
async function upvoteQuestion(type, commentId) {
const isQuestion = type === 'question';
if (isQuestion && voted.question) return;
if (!isQuestion && voted.comment[commentId]) return;
const table = isQuestion ? 'community_question' : 'community_answer';
const matchId = isQuestion ? question.id : commentId;
let votes = 0;
if (isQuestion) {
question.votes = question.votes + 1;
votes = question.votes;
} else {
question.comments = question.comments.map((c) => {
if (c.id === commentId) {
c.votes = c.votes + 1;
votes = c.votes;
}
return c;
});
}
const { error } = await supabase.from(table).update({ votes }).match({ id: matchId });
if (error) {
console.error('Error: upvoteQuestion', error);
snackbar.error('Error - Please try again later');
} else {
if (isQuestion) {
voted.question = true;
} else {
voted.comment[commentId] = true;
}
}
}
async function handleQuestionEdit() {
if (isEditMode) {
const fields = { title: editContent.title, body: editContent.body };
errors = askCommunityValidation(fields);
console.log('handleQuestionEdit errors', errors);
if (Object.keys(errors).length) {
return;
}
}
isEditMode = !isEditMode;
editorInstance = !editorInstance;
if (!isEditMode) {
const fields = { title: editContent.title, body: editContent.body };
errors = askCommunityValidation(fields);
console.log('handleQuestionEdit errors', errors);
if (Object.keys(errors).length) {
return;
}
const { error } = await supabase
.from('community_question')
.update(fields)
.match({ id: question.id });
if (error) {
console.error('Error: handleQuestionEdit', error);
snackbar.error('Error - Please try again later');
} else {
question.title = fields.title;
question.body = fields.body;
editContent.title = '';
editContent.body = '';
}
} else {
editContent.title = question.title;
editContent.body = question.body;
}
}
async function handleCommentDelete() {
const { error } = await supabase
.from('community_answer')
.delete()
.match({ id: deleteComment.commentId });
if (!error) {
question.comments = question.comments.filter((c) => c.id !== deleteComment.commentId);
deleteComment.shouldDelete = false;
deleteComment.commentId = null;
}
}
$: fetchCommunityQuestion(slug);
</script>
<svelte:head>
<title>{question?.title || 'Question'}</title>
</svelte:head>
<DeleteCommentModal
bind:open={deleteComment.shouldDelete}
onCancel={() => {
deleteComment.shouldDelete = false;
deleteComment.commentId = null;
}}
onDelete={handleCommentDelete}
/>
<section class="max-w-3xl mx-auto md:mx-10 lg:mb-20">
{#if !question}
<div class="py-10 px-5 mb-3">
<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={`${$currentOrgPath}/community`}
>
<ArrowLeftIcon size={24} class="carbon-icon dark:text-white" /> Go Back
</a>
<div class="my-5 flex justify-between items-center">
{#if isEditMode}
<TextField
bind:value={editContent.title}
className="w-full mr-2"
errorMessage={errors.title}
/>
{:else}
<div class="flex items-center">
<Vote
value={question.votes}
upVote={() => upvoteQuestion('question')}
disabled={voted.question}
/>
<h2 class="text-3xl">{question.title}</h2>
</div>
{/if}
{#if question.author.id === $profile.id}
<PrimaryButton
label={isEditMode ? 'Save' : 'Edit'}
variant={VARIANTS.OUTLINED}
onClick={handleQuestionEdit}
className="h-fit"
/>
{#if isEditMode}
<PrimaryButton
label="Cancel"
variant={VARIANTS.TEXT}
onClick={() => (isEditMode = !isEditMode)}
className="py-3 px-6 rounded-sm h-fit"
disablePadding={true}
/>
{/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">
{question.createdAt}
</p>
</div>
</header>
{#if isEditMode && editorInstance}
<div class="my-2">
<TextEditor
bind:value={editContent.body}
placeholder="Give an answer"
onChange={(html) => (editContent.body = html)}
/>
</div>
{:else}
<section class="prose prose-sm sm:prose p-2">
{@html question.body}
</section>
{/if}
</div>
<div class="my-8 font-bold">
{pluralize('answers', question.totalComments, true)}
</div>
{#each question.comments as comment}
<div class="my-5 px-1 flex items-start">
<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="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">
{comment.createdAt}
</p>
</div>
{#if isValidAnswer}
<CheckmarkOutlineIcon size={20} />
{/if}
{#if comment.authorId === $profile.id}
<IconButton
value="delete-comment"
onClick={() => {
deleteComment.shouldDelete = true;
deleteComment.commentId = comment.id;
}}
>
<TrashCanIcon size={16} class="carbon-icon dark:text-white" />
</IconButton>
{/if}
</header>
<article class="prose prose-sm sm:prose p-2">
{@html comment.comment}
</article>
</div>
</div>
{/each}
<hr />
<div>
{#if !editorInstance}
<TextEditor
bind:value={comment}
placeholder="Give an answer"
onChange={(html) => (comment = html)}
/>
{/if}
<div class="flex justify-end mr-2">
<PrimaryButton label="Comment" onClick={submitComment} />
</div>
</div>
</div>
{/if}
</section>
@@ -0,0 +1,92 @@
<script>
import { goto } from '$app/navigation';
import ArrowLeftIcon from 'carbon-icons-svelte/lib/ArrowLeft.svelte';
import { currentOrgPath, currentOrg } from '$lib/utils/store/org';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { askCommunityValidation } from '$lib/utils/functions/validator';
import { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$lib/components/Snackbar/store';
import generateSlug from '$lib/utils/functions/generateSlug';
import TextEditor from '$lib/components/TextEditor/index.svelte';
let errors = {};
let fields = {
title: '',
body: ''
};
async function handleSave() {
errors = askCommunityValidation(fields);
console.log('handleSave errors', errors);
if (Object.keys(errors).length) {
return;
}
const { data: question, error } = await supabase
.from('community_question')
.insert({
...fields,
organization_id: $currentOrg.id,
author_id: $currentOrg.memberId,
votes: 0,
slug: generateSlug(fields.title)
})
.select();
if (error) {
console.error('Error: asking question', error);
snackbar.error('Error - Please try again later');
} else {
console.log('Success: asking question', question);
snackbar.success('Success');
goto(`${$currentOrgPath}/community/${question[0].slug}`);
}
}
</script>
<svelte:head>
<title>Ask the Community - ClassroomIO</title>
</svelte:head>
<section class="w-full md:max-w-3xl md:mx-auto">
<div class="p-5">
<a
class="text-gray-500 dark:text-white text-md flex items-center"
href={`${$currentOrgPath}/community`}
>
<ArrowLeftIcon size={24} class="carbon-icon dark:text-white" /> Go Back
</a>
<div class="flex items-center gap-12 justify-between">
<h1 class="dark:text-white text-2xl md:text-3xl font-bold">Ask the community</h1>
<PrimaryButton label="Publish" onClick={handleSave} />
</div>
</div>
<div class="mb-3 p-2">
<input
class="title rounded-lg border border-1 border-gray p-2 w-full dark:text-black focus:outline-none"
error="false"
autocomplete="off"
placeholder="Title"
aria-label="Title"
aria-describedby="title-input-validation"
type="text"
name="discussion_title"
id="discussion_title"
bind:value={fields.title}
/>
{#if errors.title}
<p class="dark:text-white text-red-500 text-sm">
{errors.title}
</p>
{/if}
</div>
<div class="px-2">
<TextEditor
bind:value={fields.body}
placeholder="Ask the community any question you might have"
onChange={(html) => (fields.body = html)}
/>
</div>
</section>
@@ -0,0 +1,6 @@
export const load = ({ params = { slug: '' } }) => {
return {
allCourses: [],
cantFetch: true
};
};
@@ -0,0 +1,134 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Search, Dropdown } from 'carbon-components-svelte';
import { page } from '$app/stores';
import { profile } from '$lib/utils/store/user';
import { fetchCourses } from '$lib/components/Courses/api';
import Courses from '$lib/components/Courses/index.svelte';
import NewCourseModal from '$lib/components/Courses/components/NewCourseModal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { courses, createCourseModal, courseMetaDeta } from '$lib/components/Courses/store';
import { currentOrg } from '$lib/utils/store/org';
import { Add } from 'carbon-icons-svelte';
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';
export let data;
let { allCourses, cantFetch } = data;
let searchValue = '';
let selectedId: string;
let filteredCourses: Course[];
let hasFetched = false;
const urlParams = new URLSearchParams($page.url.search);
if (urlParams.get('create') === 'true') {
$createCourseModal.open = true;
}
async function getCourses(userId: string | null, orgId: string) {
if (cantFetch && typeof cantFetch === 'boolean' && !allCourses.length && orgId && !hasFetched) {
$courseMetaDeta.isLoading = true;
const coursesResult = await fetchCourses(userId, orgId);
console.log(`coursesResult`, coursesResult);
$courseMetaDeta.isLoading = false;
if (!coursesResult) return;
// organizationId = coursesResult.organizationId;
courses.set(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);
}
}
filteredCourses = courses.filter((course) => {
if (!searchValue || course.title.toLowerCase().includes(searchValue)) {
return true;
}
return false;
});
if (_selectedId === '0') {
filteredCourses = 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);
} else if (_selectedId === '2') {
filteredCourses = filteredCourses.sort((a, b) => b.total_lessons - a.total_lessons);
}
}
onMount(() => {
courses.set(allCourses);
});
$: filterCourses(searchValue, selectedId, $courses);
$: 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">Courses</h1>
{#if $isMobile}
<PrimaryButton isDisabled={!$isOrgAdmin} onClick={() => ($createCourseModal.open = true)}>
<Add size={24} />
</PrimaryButton>
{:else}
<PrimaryButton
label="Create Course"
isDisabled={!$isOrgAdmin}
onClick={() => ($createCourseModal.open = true)}
/>
{/if}
</div>
<div class="flex flex-row-reverse mb-5">
<div class="flex items-end justify-start">
<Search
placeholder="Find course"
bind:value={searchValue}
searchClass="mr-2"
class=" bg-gray-100 dark:bg-neutral-800"
/>
<Dropdown
class="h-full"
bind:selectedId
items={[
{ id: '0', text: 'Date Created' },
{ id: '1', text: 'Published' },
{ id: '2', text: 'Lessons' }
]}
/>
</div>
</div>
<NewCourseModal />
<Courses bind:courses={filteredCourses} />
</div>
</section>
<style>
:global(.bx--dropdown) {
max-height: unset;
height: 100%;
}
</style>
@@ -0,0 +1,33 @@
<script>
import Quizzes from '$lib/components/Org/Quiz/Quizzes.svelte';
import NewQuizModal from '$lib/components/Org/Quiz/NewQuizModal.svelte';
import DeleteModal from '$lib/components/Org/Quiz/DeleteModal.svelte';
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';
</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>
{#if $isMobile}
<PrimaryButton onClick={() => ($createQuizModal.open = true)}>
<Add size={24} />
</PrimaryButton>
{:else}
<PrimaryButton label="Create Quiz" onClick={() => ($createQuizModal.open = true)} />
{/if}
</div>
<NewQuizModal />
<DeleteModal />
<Quizzes />
</div>
</section>
@@ -0,0 +1,5 @@
export const load = ({ params = { slug: '' } }) => {
return {
quizId: params.slug
};
};
@@ -0,0 +1,383 @@
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import cloneDeep from 'lodash/cloneDeep';
import isBoolean from 'lodash/isBoolean';
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 { Select, SelectItem } from 'carbon-components-svelte';
import { currentOrgPath, deleteModal } from '$lib/utils/store/org';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { themeImages, allOptions, booleanOptions, allThemes } from '$lib/utils/constants/quiz';
import { quizStore, quizesStore } from '$lib/utils/store/org';
import DeleteModal from '$lib/components/Org/Quiz/DeleteModal.svelte';
import Preview from '$lib/components/Org/Quiz/Play/Preview.svelte';
import QuizQuestion from '$lib/components/Org/Quiz/QuizQuestion.svelte';
import { snackbar } from '$lib/components/Snackbar/store';
import { supabase } from '$lib/utils/functions/supabase';
export let data;
const { quizId } = data;
// Questionnaire State
let currentQuestion = $quizStore.questions[0] || {
options: []
};
// Behavioural State
let openPreview = false;
let type = 'multichoice';
let errors = [];
let currentError = {};
let isFocused = false;
let selectEl = null;
function activeClass(q, cq) {
if (q.id === cq.id) {
return 'bg-gray-200 dark:bg-black';
}
return '';
}
function addQuestion() {
$quizStore.questions = [
...$quizStore.questions,
{
id: new Date().getTime(),
label: '',
type: 'multichoice',
options: []
}
];
type = 'multichoice';
}
function addOption() {
const cOptIds = currentQuestion.options.map((o) => o.id);
const nextOption = cloneDeep(allOptions).find((o) => !cOptIds.includes(o.id));
if (!nextOption) return;
currentQuestion.options = [...currentQuestion.options, nextOption];
}
function deleteOption() {
const opt = cloneDeep(currentQuestion.options);
opt.pop();
currentQuestion.options = opt;
}
function handleQuestionTypeChange(type) {
const opt =
type === 'multichoice'
? cloneDeep(allOptions).filter((o, i) => i < 2)
: cloneDeep(booleanOptions);
currentQuestion.type = type;
currentQuestion.options = opt;
// $quizStore.questions = $quizStore.questions.map((q) =>
// q.id === currentQuestion.id ? cloneDeep(currentQuestion) : q
// );
}
function deleteQuestion() {
// Only leave one question
if ($quizStore.questions.length === 1) return;
$quizStore.questions = $quizStore.questions.filter((q) => q.id !== currentQuestion.id);
currentQuestion = $quizStore.questions[0];
}
function previewQuiz() {
openPreview = !openPreview;
}
function validateQuiz() {
const _errors = [];
$quizStore.questions.forEach((q) => {
const labelError = !!((q.label?.length || 0) < 3);
const options = [];
let hasOneAnswer = false;
q.options.forEach((o) => {
const valLabelError = !!(o.label.length < 3);
if (valLabelError) {
options.push({
id: o.id,
error: true
});
}
if (o.isCorrect) {
hasOneAnswer = true;
}
});
_errors.push({
isLabelEmpty: labelError,
hasOneAnswer,
id: q.id,
options
});
});
errors = _errors;
currentError = errors.find((e) => e.id === currentQuestion.id) || {};
return errors;
}
async function saveQuiz() {
const _errors = validateQuiz();
if (Array.isArray(_errors) && _errors.length && qHasError(null, _errors)) {
console.error('Please fix all errors', errors);
return;
}
const { data, error } = await supabase
.from('quiz')
.update({
...$quizStore,
updated_at: new Date()
})
.match({ id: quizId });
console.log('data', data);
console.log('error', error);
if (error) {
snackbar.error("Something's not right - Please try later");
return;
} else {
snackbar.success('Saved Successfully');
}
}
function qHasError(qId, _errs) {
return _errs.some((qe) => {
return (
(qId ? qe.id === qId : true) && (qe.isLabelEmpty || !qe.hasOneAnswer || !!qe.options.length)
);
});
}
function optionHasError(eId, _errs) {
if (Array.isArray(_errs) && _errs.length) {
return _errs.some((e) => e.id === eId && e.error);
}
return false;
}
onMount(() => {
const quiz = $quizesStore.find((q) => q.id === quizId);
if (!quiz) {
goto(`${$currentOrgPath}/quiz`);
return;
}
quizStore.set(quiz);
currentQuestion = quiz.questions[0];
});
</script>
<svelte:head>
<title>Quiz</title>
</svelte:head>
<DeleteModal onDelete={deleteQuestion} />
{#if openPreview}
<Preview exitPreview={previewQuiz} />
{/if}
<section class="w-screen h-full flex justify-between">
<!-- Questions list -->
<aside class="root w-1/5 p-4 bg-gray-100 dark:bg-neutral-800 h-full">
<div class="h-full flex flex-col">
<a
class="text-gray-500 dark:text-white text-md flex items-center"
href={`${$currentOrgPath}/quiz`}
>
<ArrowLeftIcon size={24} class="carbon-icon dark:text-white" /> Back to Quizzes
</a>
<h3 class="my-3">Quiz</h3>
<div class="mb-3">
{#each $quizStore.questions as question, i}
<button
class="w-full rounded p-3 mb-3 font-bold text-left text-gray-500 dark:text-white flex justify-between {activeClass(
question,
currentQuestion
)}"
on:click={() => {
currentQuestion = question;
type = question.type;
}}
>
Question {i + 1}
{#if qHasError(question.id, errors)}
<WarningFilledIcon size={20} class="carbon-icon error" />
{/if}
</button>
{/each}
</div>
<div class="w-full flex justify-end">
<PrimaryButton label="Add Question" variant={VARIANTS.CONTAINED} onClick={addQuestion} />
</div>
</div>
</aside>
<div class="container w-3/6 h-full">
<div
class="p-5 h-full"
style="background: url({themeImages[$quizStore.theme]
?.editor}) no-repeat center center fixed; -webkit-background-size: cover;-moz-background-size: cover;-o-background-size: cover;background-size: cover;"
>
<div class="content m-auto">
<h1 class="text-white font-bold my-5">{$quizStore.title}</h1>
<QuizQuestion {currentQuestion} {optionHasError} {currentError} />
{#if isBoolean(currentError.hasOneAnswer) && !currentError.hasOneAnswer}
<div class="mb-5">
<p class="text-red-500">Please select at least one correct answer</p>
</div>
{/if}
{#if currentQuestion.type !== 'boolean'}
<div class="w-full flex justify-center mb-4">
{#if currentQuestion.options.length < allOptions.length}
<PrimaryButton
label="+ Add more answers"
variant={VARIANTS.CONTAINED_WHITE}
onClick={addOption}
className="mr-5"
/>
{/if}
{#if currentQuestion.options.length > 0}
<PrimaryButton
label="- Remove last answer"
variant={VARIANTS.CONTAINED_WHITE}
onClick={deleteOption}
/>
{/if}
</div>
{/if}
</div>
</div>
</div>
<!-- Quiz Settings -->
<aside class="settings w-1/5 p-4 bg-gray-100 dark:bg-neutral-800 h-full">
<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="Delete question"
variant={VARIANTS.TEXT}
onClick={() => {
if ($quizStore.questions.length === 1) return;
$deleteModal.open = true;
$deleteModal.isQuestion = true;
}}
className="my-3"
/>
</div>
<div class="flex flex-col justify-evenly">
<div class="my-3">
<!-- Question type -->
<Select
labelText="Question type"
bind:this={selectEl}
bind:selected={type}
class="flex items-center mb-3"
on:focus={() => (isFocused = true)}
on:blur={() => (isFocused = false)}
on:change={() => {
if (!isFocused) return;
// Blur after change
const onBlur = selectEl?.$$?.callbacks?.blur?.[0];
const onFocus = selectEl?.$$?.callbacks?.focus?.[0];
setTimeout(() => {
onBlur();
onFocus();
}, 1000);
handleQuestionTypeChange(type);
}}
>
<SelectItem value="multichoice" text="Multi-choice answers" />
<SelectItem value="boolean" text="True or False" />
</Select>
<!-- -->
<Select
labelText="Time limit"
bind:selected={$quizStore.timelimit}
class="flex items-center mb-3"
>
<SelectItem value="10 seconds" text="10s" />
<SelectItem value="20 seconds" text="20s" />
<SelectItem value="30 seconds" text="30s" />
<SelectItem value="1 minute" text="1m" />
<SelectItem value="2 minute" text="2m" />
<SelectItem value="3 minute" text="3m" />
</Select>
</div>
<!-- Theme settings -->
<div class="my-3">
<p class="mb-2">Choose a theme</p>
{#each allThemes as _theme}
<div
class="theme rounded-md w-full border cursor-pointer mb-5 relative {$quizStore.theme ===
_theme.id && 'border-primary-700'}"
>
{#if $quizStore.theme === _theme.id}
<CheckmarkFilledIcon
size={24}
class="carbon-icon absolute top-4 right-4"
style="fill:white;"
/>
{/if}
<div
class="w-full rounded-md h-full border flex flex-col-reverse"
style="background: url({themeImages[_theme.id]?.card});"
on:click={() => ($quizStore.theme = _theme.id)}
>
<p class="ml-3 mb-3 text-white">{_theme.label}</p>
</div>
</div>
{/each}
</div>
</div>
</aside>
</section>
<style>
.container {
min-width: 696px;
}
.content {
max-width: 550px;
}
.theme {
height: 120px;
max-width: 260px;
}
</style>
@@ -0,0 +1,20 @@
<script>
import OrgSettings from '$lib/components/Org/Settings/index.svelte';
import ViewSiteBtn from '$lib/components/Buttons/VisitOrgSite.svelte';
</script>
<svelte:head>
<title>Settings - 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-3xl font-bold">Settings</h1>
<ViewSiteBtn />
</div>
<OrgSettings />
</div>
</section>
@@ -0,0 +1,21 @@
<script>
import Domains from '$lib/components/Org/Settings/Domains.svelte';
import { currentOrgPath } from '$lib/utils/store/org';
</script>
<svelte:head>
<title>Custom domain - ClassroomIO</title>
</svelte:head>
<section class="w-full max-w-6xl 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`}
>Back to Settings</a
>
<div class="flex items-center justify-between mb-10">
<h1 class="dark:text-white text-3xl font-bold">Custom domain</h1>
</div>
<Domains />
</div>
</section>
@@ -0,0 +1,21 @@
<script>
import TeamMembers from '$lib/components/Org/Settings/TeamMembers.svelte';
import { currentOrgPath } from '$lib/utils/store/org';
</script>
<svelte:head>
<title>Teams - ClassroomIO</title>
</svelte:head>
<section class="w-full max-w-6xl 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`}
>Back to Settings</a
>
<div class="flex items-center justify-between mb-10">
<h1 class="dark:text-white text-3xl font-bold">Team members</h1>
</div>
<TeamMembers />
</div>
</section>
@@ -0,0 +1,5 @@
export const load = ({ params = { id: '' } }) => {
return {
profileId: params.id
};
};
@@ -0,0 +1,216 @@
<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 LogoutButton from '$lib/components/Buttons/Logout/index.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>
<LogoutButton />
</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>
@@ -0,0 +1,106 @@
<script>
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 { 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;
async function handleSubmit(e) {
errors = resetValidation(fields);
console.log('errors', errors);
if (Object.keys(errors).length) {
return;
}
try {
loading = true;
const { data, error } = await supabase.auth.updateUser({
password: fields.password
});
console.log('data', data);
if (error) throw error;
formRef?.reset();
return goto('/login');
} catch (error) {
submitError = error.error_description || error.message;
} finally {
loading = false;
}
}
$: errors.confirmPassword = getConfirmPasswordError(fields);
$: disableSubmit = getDisableSubmit(fields);
</script>
<svelte:head>
<title>Join ClassroomIO</title>
</svelte:head>
<AuthUI
{supabase}
isLogin={false}
{handleSubmit}
showOnlyContent={true}
isLoading={loading}
showLogo={true}
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>
<TextField
label="Your Password"
bind:value={fields.password}
type="password"
placeholder="************"
className="mb-6"
inputClassName="w-full"
isDisabled={loading}
errorMessage={errors.password}
helperMessage="Password must be more than 6 characters"
isRequired
/>
<TextField
label="Confirm Password"
bind:value={fields.confirmPassword}
type="password"
placeholder="************"
className="mb-6"
inputClassName="w-full"
isDisabled={loading}
errorMessage={errors.confirmPassword}
isRequired
/>
{#if submitError}
<p class="text-sm text-red-500">{submitError}</p>
{/if}
</div>
<div class="my-4 w-full flex justify-end items-center">
<PrimaryButton
label="Reset Password"
type="submit"
className="sm:w-full w-full"
isDisabled={disableSubmit || loading}
isLoading={loading}
/>
</div>
</AuthUI>
@@ -0,0 +1,182 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { getSupabase } from '$lib/utils/functions/supabase';
import {
authValidation,
getConfirmPasswordError,
getDisableSubmit
} from '$lib/utils/functions/validator';
import { SIGNUP_FIELDS } from '$lib/utils/constants/authentication';
import AuthUI from '$lib/components/AuthUI/index.svelte';
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
let supabase = getSupabase();
let fields = Object.assign({}, SIGNUP_FIELDS);
let loading = false;
let success = false;
let errors: {
email?: string;
password?: string;
confirmPassword?: string;
} = {};
let submitError: string;
let disableSubmit = false;
let formRef: HTMLFormElement;
let query = new URLSearchParams($page.url.search);
let redirect = query.get('redirect');
async function handleSubmit() {
const validationRes = authValidation(fields);
console.log('validationRes', validationRes);
if (Object.keys(validationRes).length) {
errors = Object.assign(errors, validationRes);
return;
}
try {
loading = true;
const {
data: { session },
error
} = await supabase.auth.signUp({
email: fields.email,
password: fields.password
});
console.log('session', session);
if (error) throw error;
const { user: authUser } = session || {};
if (!authUser) {
throw 'Error creating user';
}
if (!$currentOrg.id) return;
const [regexUsernameMatch] = [...(authUser.email?.matchAll(/(.*)@/g) || [])];
const response = await fetch('https://api.ipregistry.co/?key=tryout');
const metadata = await response.json();
const profileRes = await supabase
.from('profile')
.insert({
id: authUser.id,
username: regexUsernameMatch[1] + `${new Date().getTime()}`,
fullname: regexUsernameMatch[1],
email: authUser.email,
metadata
})
.select();
console.log('profileRes', profileRes);
if (profileRes.error) {
throw profileRes.error;
}
// Setting profile
console.log('setting profile', profileRes.data[0]);
profile.set(profileRes.data[0]);
capturePosthogEvent('user_signed_up', {
distinct_id: $profile.id || '',
email: authUser.email,
username: regexUsernameMatch[1],
metadata
});
if (redirect) {
goto(redirect);
} else {
goto('/login');
}
formRef?.reset();
success = true;
fields = Object.assign({}, SIGNUP_FIELDS);
} catch (error) {
submitError = error?.error_description || error?.message;
} finally {
loading = false;
}
}
$: errors.confirmPassword = getConfirmPasswordError(fields);
$: disableSubmit = getDisableSubmit(fields);
</script>
<svelte:head>
<title>Join ClassroomIO</title>
</svelte:head>
<AuthUI {supabase} isLogin={false} {handleSubmit} isLoading={loading} bind:formRef>
<div class="mt-4 w-full">
<p class="dark:text-white text-lg font-semibold mb-6">Create a free account</p>
<!-- <TextField
label="Full Name"
bind:value={fields.name}
type="text"
autoFocus={true}
placeholder="e.g Joke Silva"
className="mb-6"
inputClassName="w-full"
isDisabled={loading}
errorMessage={errors.name}
isRequired
/> -->
<TextField
label="Your Email"
bind:value={fields.email}
type="email"
placeholder="you@domain.com"
className="mb-6"
inputClassName="w-full"
isDisabled={loading}
errorMessage={errors.email}
isRequired
/>
<TextField
label="Your Password"
bind:value={fields.password}
type="password"
placeholder="************"
className="mb-6"
inputClassName="w-full"
isDisabled={loading}
errorMessage={errors.password}
helperMessage="Password must be more than 6 characters"
isRequired
/>
<TextField
label="Confirm Password"
bind:value={fields.confirmPassword}
type="password"
placeholder="************"
className="mb-6"
inputClassName="w-full"
isDisabled={loading}
errorMessage={errors.confirmPassword}
isRequired
/>
{#if submitError}
<p class="text-sm text-red-500">{submitError}</p>
{/if}
</div>
<div class="my-4 w-full flex justify-end items-center">
<PrimaryButton
label="Create Account"
type="submit"
className="sm:w-full w-full"
isDisabled={disableSubmit || loading}
isLoading={loading}
/>
</div>
</AuthUI>