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:
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user