CLASSROOMIO-T-80: Replace sapper with sveltekit

This commit is contained in:
Rotimi Best
2023-08-02 19:35:27 +01:00
parent 8ffdf319b3
commit 7dfbb05451
320 changed files with 5207 additions and 18330 deletions
@@ -0,0 +1,6 @@
<script>
import PageNav from '$lib/components/PageNav/index.svelte';
</script>
<PageNav title="Attendance" />
All attendance will be here with table
@@ -0,0 +1,135 @@
<script>
import {
RadioButtonGroup,
RadioButton,
Toggle,
} from 'carbon-components-svelte';
import { goto } from '$app/navigation';
import { updateCourse } from '$lib/utils/services/courses';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import Professional from './certificates/Professional.svelte';
import Plain from './certificates/Plain.svelte';
import { course } from '$lib/components/Course/store';
import { currentOrg } from '$lib/utils/store/org';
const studentNamePlaceholder = 'Name of student';
const themes = ['professional', 'plain'];
let isSaving = false;
let Saved = false;
let errors = {};
const saveCertificate = async () => {
isSaving = true;
if (!$course.description) {
errors.description = 'Description cannot be empty';
isSaving = false;
return;
}
await updateCourse($course.id, undefined, {
description: $course.description,
is_certificate_downloadable: $course.is_certificate_downloadable,
certificate_theme: $course.certificate_theme,
});
Saved = true;
isSaving = false;
};
</script>
<svelte:head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Qwitcher+Grypen&family=Roboto:wght@300;400;700&display=swap"
rel="stylesheet"
/>
</svelte:head>
<main class="md:-ml-4 md:-mr-4 px-2">
<div
class="flex-1 flex flex-col md:flex-row justify-between gap-3 w-full mb-3 h-4/5"
>
<section class="w-full md:w-2/5 h-full">
<strong class="my-2 text-base font-semibold text-black"
>Certificate settings</strong
>
<p class="text-xs font-normal my-4">Choose a theme</p>
<RadioButtonGroup bind:selected={$course.certificate_theme} class="mb-10">
{#each themes as theme}
<div class="flex mr-3">
<RadioButton value={theme} size="sm" />
<img src={`/${theme}.svg`} alt="themes" />
</div>
{/each}
</RadioButtonGroup>
<div>
<p class="text-xs font-normal text-black my-2">Brand Logo</p>
<div>
<p class="text-base mt-1">
To update your brand image, go to <strong class="font-semibold"
>Settings &gt; Organisation settings</strong
> and upload you brand logo
</p>
<PrimaryButton
label="Go to Settings"
className="rounded-md mt-3"
onClick={() => goto(`org/${$currentOrg.siteName}/settings`)}
/>
</div>
<span class="my-4">
<p class="text-xs font-normal mt-4 mb-2">Course Description</p>
<TextArea
maxRows={6}
rows={6}
placeholder="a little description about the course"
bgColor="bg-gray-100"
bind:value={$course.description}
errorMessage={errors.description}
/>
</span>
<Toggle
labelText="Allow students download certificate"
bind:toggled={$course.is_certificate_downloadable}
class="my-4"
size="sm"
>
<span slot="labelA" style="color: #161616">Locked</span>
<span slot="labelB" style="color: green">Unlocked</span>
</Toggle>
</div>
</section>
<section
class="certificate-bg flex justify-center items-center rounded-md w-full md:w-3/5"
>
<div class="certificate-container flex justify-center items-center h-5/6">
{#if $course.certificate_theme === 'professional'}
<Professional studentName={studentNamePlaceholder} />
{:else}
<Plain studentName={studentNamePlaceholder} />
{/if}
</div>
</section>
</div>
<div class="h-1/5">
<PrimaryButton
label={Saved == true ? 'Saved' : 'Save Changes'}
className="rounded-md"
onClick={saveCertificate}
isLoading={isSaving}
/>
</div>
</main>
<style>
p {
color: #262626;
}
.certificate-container {
width: 95%;
max-width: 95%;
}
.certificate-bg {
background-color: #f5f8fe;
}
</style>
@@ -0,0 +1,83 @@
<script>
import { Toggle } from 'carbon-components-svelte';
import Modal from '$lib/components/Modal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import { issueCertificateModal, resetForm } from './store';
const onToggle = (e) => {
console.log(e);
};
const issueCertificate = () => {
resetForm();
};
</script>
<Modal
onClose={resetForm}
bind:open={$issueCertificateModal.open}
width="w-3/5"
maxWidth=""
modalHeading="Send Certificate"
>
<main>
<div>
<Toggle
labelText="Automatically send certificates on completion"
on:toggle={(e) => onToggle(e)}
size="sm"
class="mb-4"
>
<span slot="labelA" style="color: #161616">Automatic</span>
<span slot="labelB" style="color: green">Automatic</span>
</Toggle>
<p class="text-sm font-medium my-4">
If you set this as automatic, certificates will be issued after the
learner completes the course.
</p>
</div>
<p class="text-xs font-normal text-gray-500 my-4">
or send a personalised/ custom certificate below:
</p>
<form on:submit|preventDefault={issueCertificate}>
<div class="flex flex-col md:flex-row gap-2 w-full">
<TextField
label="Email address of the student"
className="w-full my-4"
labelClassName="text-xs font-normal"
inputClassName="text-sm placeholder:text-sm placeholder:font-medium "
placeholder="email,comma seperated"
bind:value={$issueCertificateModal.email}
/>
<TextField
label="Schedule date"
className="w-full my-4"
labelClassName="text-xs font-normal"
inputClassName="text-sm placeholder:text-sm placeholder:font-medium "
placeholder="12/06/2023"
bind:value={$issueCertificateModal.date}
/>
</div>
<TextArea
label="Add a personalized message"
labelClassName="text-xs font-normal"
bind:value={$issueCertificateModal.message}
rows="2"
maxRows="3"
placeholder="your message here"
className="mb-4"
/>
<div class="mt-5 flex w-full items-end justify-end">
<PrimaryButton
className="px-6 py-3 rounded-md text-sm font-medium"
label="Issue certificate"
type="submit"
/>
</div>
</form>
</main>
</Modal>
@@ -0,0 +1,99 @@
<script>
import { CertificateReportData, students } from './mockData';
import {
Search,
Checkbox,
StructuredList,
StructuredListHead,
StructuredListRow,
StructuredListCell,
StructuredListBody,
Pagination,
} from 'carbon-components-svelte';
let searchValue = '';
let checked = false;
</script>
<section class="flex flex-wrap items-start gap-2 mb-10 -ml-4 -mr-4">
{#each CertificateReportData as data (data.id)}
<div
class="box flex flex-col rounded border border-gray-200 justify-center px-2 mb-5"
>
<p class="text-xs font-normal">{data.title}</p>
<bold class="text-2xl font-medium">{data.value}</bold>
</div>
{/each}
</section>
<section>
<div class="flex flex-row items-center justify-between">
<p class="text-base font-semibold w-full">Certificate issued</p>
<Search
placeholder="search students"
bind:value={searchValue}
searchClass="border-b border-transparent"
/>
</div>
</section>
<section class="mt-14 border-t border-gray-300">
<StructuredList>
<StructuredListHead class="bg-blue-50 py-0">
<StructuredListRow head class="py-0">
<StructuredListCell head class="py-0">
<div
class="flex-3 flex flex-row items-center justify-start w-full text-blue-600 text-sm font-medium"
>
<Checkbox bind:checked />
<p class="text-blue-900 text-start w-full text-sm font-medium">
Name of student
</p>
</div>
</StructuredListCell>
<StructuredListCell head class="py-0">
<div
class="flex-2 items-center w-full text-blue-900 text-sm font-medium"
>
<p class="align-middle text-sm font-medium pt-1">Date issued</p>
<p class="opacity-0">personalized</p>
</div>
</StructuredListCell>
</StructuredListRow>
</StructuredListHead>
<StructuredListBody>
{#each students as item (item.id)}
<StructuredListRow label for="row-{item}">
<StructuredListCell>
<div
class="flex-3 flex flex-row items-center justify-start w-full text-sm font-medium pt-4"
>
<Checkbox />
<p class="text-start w-full text-sm font-normal">{item.name}</p>
</div>
</StructuredListCell>
<StructuredListCell>
<div class="flex-2 flex flex-row items-center gap-10 w-full">
<p class="text-sm font-normal align-middle">{item.issued}</p>
{#if item.peronalized == true}
<span class="text-center bg-blue-100 px-3 py-2 rounded-lg">
<p class="opacity-100 text-xs text-blue-600">personalized</p>
</span>
{:else}
<p class="opacity-0">personalized</p>
{/if}
</div>
</StructuredListCell>
</StructuredListRow>
{/each}
</StructuredListBody>
</StructuredList>
<Pagination totalItems={10} pageSizes={[10, 15, 20]} />
</section>
<style>
.box {
width: 246px;
height: 165px;
}
</style>
@@ -0,0 +1,13 @@
<script>
import { course } from '$lib/components/Course/store';
import UnlockedCertificate from './UnlockedCertificate.svelte';
import LockedCertificate from './LockedCertificate.svelte';
</script>
<div>
{#if $course.is_certificate_downloadable == true}
<UnlockedCertificate />
{:else}
<LockedCertificate />
{/if}
</div>
@@ -0,0 +1,20 @@
<script>
import { course } from '$lib/components/Course/store';
</script>
<section class="studentContainer flex items-center justify-center w-full">
<div class="flex flex-col items-center justify-center w-max h-full gap-2">
<img src={`./${$course.certificate_theme}.svg`} alt="Certificate" />
<p class="text-xl font-normal text-center">Certificates not available</p>
<p class="text-sm font-normal text-center">
Your instructor needs to unlock your certificate. <br /> If you need your certificate,
please contact your instructor.
</p>
</div>
</section>
<style>
.studentContainer {
height: 80vh;
}
</style>
@@ -0,0 +1,55 @@
<script>
import { course } from '$lib/components/Course/store';
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
let isLoading = false;
const downLoadCertificate = async () => {
isLoading = true;
const response = await fetch(
'https://classroomio-server.fly.dev/downloadCertificate',
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
theme: `${$course.certificate_theme}`,
studentName: `${$profile.fullname}`,
courseName: `${$course.title}`,
courseDescription: `${$course.description}`,
orgLogoUrl: `${$currentOrg.avatar_url}`,
orgName: `${$currentOrg.name}`,
}),
}
);
const data = await response.blob();
console.log(data);
const file = new Blob([data], { type: 'application/pdf' });
const fileURL = URL.createObjectURL(file);
isLoading = false;
window.open(fileURL);
};
</script>
<section class="studentContainer flex items-center justify-center w-full">
<div class="flex flex-col items-center justify-center w-max h-full gap-5">
<img src={`./${$course.certificate_theme}.svg`} alt="Certificate" />
<p class="text-xl font-normal text-center">Certificate is now available</p>
<PrimaryButton
label="DownLoad Certificate"
className="rounded-md"
onClick={downLoadCertificate}
{isLoading}
/>
</div>
</section>
<style>
.studentContainer {
height: 80vh;
}
</style>
@@ -0,0 +1,58 @@
<script>
import { course } from '$lib/components/Course/store';
import { currentOrg } from '$lib/utils/store/org';
export let studentName = '';
let borderwidth = 'border-4';
let bordercolor = 'border-pink-700';
let logo = './logo-512.png';
</script>
<div
class="flex flex-col items-center justify-center {borderwidth} {bordercolor} py-3"
>
<div class="flex items-center gap-1 my-2">
<img
src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo}
alt="logo"
class=" w-10 h-10 rounded-md"
/>
<p class="font-semibold capitalize">{$currentOrg.name}</p>
</div>
<div class="w-full px-5 py-3">
<p
class="text-sm text-center font-normal my-2 uppercase tracking-widest text-gray-500 w-full"
>
Certificate of completion
</p>
<div class="border-b border-gray-500 mb-4">
<p class="text-xs text-black font-medium">This is to certify that</p>
<p class="student-name text-center text-5xl">{studentName}</p>
</div>
<div class="mb-2">
<p class="text-xs text-black font-medium">
has successfully completed the course
</p>
<div>
<p
class="clamp bg-transparent text-base font-semibold text-pink-700 text-center uppercase"
>
{$course.title}
</p>
</div>
</div>
<p class="desc text-center">{$course.description}</p>
</div>
</div>
<style>
.student-name {
font-family: 'Qwitcher Grypen', cursive;
}
.desc {
font-size: 10px;
}
.clamp {
font-size: clamp(14px, 3vw, 16px);
}
</style>
@@ -0,0 +1,58 @@
<script>
import { course } from '$lib/components/Course/store';
import { currentOrg } from '$lib/utils/store/org';
export let studentName;
let borderwidth = 'border-l-8';
let bordercolor = 'border-blue-800';
let logo = './logo-512.png';
</script>
<div
class="flex items-center justify-center border border-dashed border-blue-400"
>
<div class="{borderwidth} {bordercolor}">
<div class="w-full px-5 py-3">
<p class="text-sm font-normal my-2 uppercase text-gray-500">
Certificate<br /> of completion
</p>
<div class="border-b border-gray-500 mb-4">
<p class="text-xs text-black font-medium">This is to certify that</p>
<p class="student-name text-center text-5xl">{studentName}</p>
</div>
<div class="mb-2">
<p class="text-xs text-black font-medium">
has successfully completed introductory courses in
</p>
<div>
<p
class="clamp bg-transparent text-base font-semibold text-blue-800 text-center uppercase my-2"
>
{$course.title}
</p>
</div>
</div>
<p class="desc mb-4 text-justify">{$course.description}</p>
<div class="flex items-center gap-1 my-2">
<img
src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo}
alt="logo"
class="w-10 h-10 rounded-md"
/>
<p class="font-semibold capitalize">{$currentOrg.name}</p>
</div>
</div>
</div>
</div>
<style>
.student-name {
font-family: 'Qwitcher Grypen', cursive;
}
.desc {
font-size: 10px;
}
.clamp {
font-size: clamp(14px, 3vw, 16px);
}
</style>
@@ -0,0 +1,85 @@
export const CertificateReportData = [
{
id: 1,
title: 'Total students taking course',
value: 100,
},
{
id: 2,
title: 'Number of certificates issued ',
value: 45,
},
{
id: 3,
title: 'Certificates left to be issued',
value: 55,
},
{
id: 4,
title: 'Personalised cetificates',
value: 15,
},
];
export const students = [
{
id: 1,
name: 'Anyanwu joy victory',
issued: '10 Dec 2020',
peronalized: false,
},
{
id: 2,
name: 'Anyanwu joy victory',
issued: '10 Dec 2020',
peronalized: false,
},
{
id: 3,
name: 'Anyanwu joy victory',
issued: '10 Dec 2020',
peronalized: true,
},
{
id: 4,
name: 'Anyanwu joy victory',
issued: '10 Dec 2020',
peronalized: false,
},
{
id: 5,
name: 'Anyanwu joy victory',
issued: '10 Dec 2020',
peronalized: false,
},
{
id: 6,
name: 'Anyanwu joy victory',
issued: '10 Dec 2020',
peronalized: false,
},
{
id: 7,
name: 'Anyanwu joy victory',
issued: '10 Dec 2020',
peronalized: false,
},
{
id: 8,
name: 'Anyanwu joy victory',
issued: '10 Dec 2020',
peronalized: false,
},
{
id: 9,
name: 'Anyanwu joy victory',
issued: '10 Dec 2020',
peronalized: false,
},
{
id: 10,
name: 'Anyanwu joy victory',
issued: '10 Dec 2020',
peronalized: true,
},
];
@@ -0,0 +1,18 @@
import { writable } from 'svelte/store';
export const issueCertificateModal = writable({
open: false,
automatic: false,
email: '',
date: '',
message: '',
});
export const resetForm = () =>
issueCertificateModal.update(() => ({
open: false,
automatic: false,
email: '',
date: '',
message: '',
}));
@@ -0,0 +1,49 @@
<script>
import Modal from '$lib/components/Modal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { deleteConfirmation } from '../store/exercise';
export let onDelete = () => {};
export let onCancel = () => {};
async function handleDelete() {
onDelete();
$deleteConfirmation.open = false;
}
</script>
<Modal
onClose={() => ($deleteConfirmation.open = false)}
bind:open={$deleteConfirmation.open}
width="w-96"
modalHeading="Delete question"
>
<div>
<h1 class="dark:text-white text-lg">
Are you sure you want to delete this question?
</h1>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label="No, please don't"
onClick={() => {
$deleteConfirmation.open = false;
onCancel();
}}
/>
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.CONTAINED_DANGER}
label="Yes, delete"
onClick={() => {
onDelete();
$deleteConfirmation.open = false;
}}
/>
</div>
</div>
</Modal>
@@ -0,0 +1,349 @@
<script>
import {
questionnaire,
deleteConfirmation,
questionnaireValidation,
handleAddOption,
handleRemoveOption,
handleRemoveQuestion,
handleCode,
handleAnswerSelect,
addDynamicValue,
} from '../store/exercise';
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte';
import AddFilledIcon from 'carbon-icons-svelte/lib/AddFilled.svelte';
import CheckmarkFilledIcon from 'carbon-icons-svelte/lib/CheckmarkFilled.svelte';
import CheckmarkOutlineIcon from 'carbon-icons-svelte/lib/CheckmarkOutline.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import Modal from '$lib/components/Modal/index.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import Checkbox from '$lib/components/Form/Checkbox.svelte';
import DateTime from '$lib/components/Form/DateTime.svelte';
import RadioItem from '$lib/components/Form/RadioItem.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import ErrorMessage from '$lib/components/ErrorMessage/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import EditContent from '$lib/components/EditContent/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import Select from '$lib/components/Form/Select.svelte';
import QuestionContainer from '$lib/components/QuestionContainer/index.svelte';
import DeleteConfirmationModal from './DeleteConfirmation.svelte';
import OrderModal from './OrderModal.svelte';
import {
QUESTION_TYPE,
QUESTION_TYPES,
} from '$lib/components/Question/constants';
import { filterOutDeleted } from './functions';
import { deleteExercise } from '$lib/utils/services/courses';
import { lesson } from '../store/lessons';
// import EditContent from "$lib/components/EditContent/index.svelte";
// let value = readme;
const extraActions = ['Code Snippets', 'Upload image'];
const initialQuestionsLength = $questionnaire.questions.length;
export let editDescription = false;
export let exerciseId;
export let goBack = () => {};
let errors = {};
let questionIdToDelete = null;
let questions = [];
let shouldDelete = false;
let isDeleting = false;
function shouldScrollToLast(questionId, questions) {
const currentQuestionsLength = questions.length;
const index = questions.findIndex((question) => question.id === questionId);
const isLast = index + 1 === currentQuestionsLength;
return isLast && initialQuestionsLength !== currentQuestionsLength;
}
function onInitDeleteClicked(questionId) {
return () => {
questionIdToDelete = questionId;
$deleteConfirmation.open = true;
};
}
function onFinalDeleteClicked() {
handleRemoveQuestion(questionIdToDelete)();
}
function handleOptionClick(questionIndex) {
return (actionIndex) => {
switch (actionIndex) {
case 0:
handleCode(questionIndex, true);
break;
case 1:
// uploadImage(questionIndex);
break;
default:
console.log('No option');
}
};
}
function getQuestionErrorMsg(errors, question, errorKey) {
return errors[question.id] ? errors[question.id][errorKey] : null;
}
async function handleDelete() {
console.log('delete');
isDeleting = true;
await deleteExercise(questions, exerciseId);
lesson.update((_lesson) => ({
..._lesson,
exercises: _lesson.exercises.filter(
(exercise) => exercise.id !== exerciseId
),
}));
shouldDelete = false;
goBack();
}
$: errors = $questionnaireValidation;
$: questions = filterOutDeleted($questionnaire.questions);
</script>
<DeleteConfirmationModal
onCancel={() => (questionIdToDelete = null)}
onDelete={onFinalDeleteClicked}
/>
<OrderModal />
<Modal
onClose={() => (editDescription = false)}
bind:open={editDescription}
width="w-2/4"
modalHeading="Update description"
>
{#if shouldDelete}
<form on:submit|preventDefault={() => (shouldDelete = false)}>
<h1 class="dark:text-white text-2xl">Are you sure?</h1>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label="No, cancel"
type="submit"
/>
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.CONTAINED_DANGER}
label={isDeleting ? 'Deleting...' : 'Yes, delete'}
isDisabled={isDeleting}
onClick={handleDelete}
/>
</div>
</form>
{:else}
<div>
<QuestionContainer isTitle={true}>
<TextField
placeholder="Title"
bind:value={$questionnaire.title}
className="mb-2"
onChange={() => ($questionnaire.is_title_dirty = true)}
/>
<DateTime
label="Due by"
className="w-50"
value={$questionnaire.due_by}
onInput={(e) => {
$questionnaire.due_by = e.target.value;
$questionnaire.is_due_by_dirty = true;
}}
/>
<EditContent
writeLabel="Description"
bind:value={$questionnaire.description}
placeholder="Start typing your lesson"
textAreaHeight="100px"
onInputChange={() => ($questionnaire.is_description_dirty = true)}
/>
</QuestionContainer>
<div class="float-right">
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.CONTAINED_DANGER}
label="Delete this exercise"
onClick={() => (shouldDelete = true)}
/>
</div>
</div>
{/if}
</Modal>
<div class="w-full">
{#if Object.values(errors).length}
<div class="w-full flex justify-center mb-4">
<ErrorMessage message="You have some errors" />
</div>
{/if}
<div class="questions pt-6 px-6">
{#each questions as question (question.id)}
<!-- {#key question.id} -->
<QuestionContainer
onClose={onInitDeleteClicked(question.id)}
scrollToQuestion={shouldScrollToLast(
question.id,
$questionnaire.questions
)}
bind:points={question.points}
hasError={!!errors[question.id]}
onPointsChange={() => {
question.is_dirty = true;
}}
>
<div class="flex justify-between items-center">
<div class="mr-2 w-3/5">
<TextField
placeholder="Question"
bind:value={question.title}
isRequired={true}
onChange={() => {
question.is_dirty = true;
}}
/>
</div>
<!-- <Dropdown
options={extraActions}
handleOptionClick={handleOptionClick(question.id)}
>
<AddFilledIcon class="carbon-icon" />
<p class="dark:text-white ml-2 text-gray-600">Add</p>
</Dropdown> -->
<Select
bind:value={question.question_type}
options={QUESTION_TYPES}
onChange={() => {
question.is_dirty = true;
}}
/>
</div>
{#if typeof question.code === 'string'}
<div class="flex justify-between items-center my-3 w-3/5">
<TextArea
bind:value={question.code}
rows="2"
placeholder="Write your code"
/>
<IconButton
value="write-code"
onClick={() => handleCode(question.id, false)}
>
<TrashCanIcon size={24} class="carbon-icon" />
</IconButton>
</div>
{/if}
<div class="flex flex-col mt-2">
{#if QUESTION_TYPE.RADIO === question.question_type.id}
{#each filterOutDeleted(question.options) as option}
<RadioItem
isEditable={true}
name={question.title || 'radio-name'}
bind:label={option.label}
onChange={addDynamicValue(question.id, option.id)}
>
<div slot="iconbutton" class="flex items-center">
<IconButton
value={option.id}
onClick={handleRemoveOption(question.id, option.id)}
>
<TrashCanIcon size={24} class="carbon-icon" />
</IconButton>
<IconButton
value={option.id}
onClick={handleAnswerSelect(question.id, option.id)}
buttonClassName={option.is_correct && 'success'}
>
{#if option.is_correct}
<CheckmarkFilledIcon size={24} class="carbon-icon" />
{:else}
<CheckmarkOutlineIcon size={24} class="carbon-icon" />
{/if}
</IconButton>
</div>
</RadioItem>
{/each}
{/if}
{#if QUESTION_TYPE.CHECKBOX === question.question_type.id}
{#each filterOutDeleted(question.options) as option}
<Checkbox
isEditable={true}
name={question || 'checkbox-name'}
bind:label={option.label}
onChange={addDynamicValue(question.id, option.id)}
>
<div slot="iconbutton" class="flex items-center">
<IconButton
value={option.id}
onClick={handleRemoveOption(question.id, option.id)}
>
<TrashCanIcon size={24} class="carbon-icon" />
</IconButton>
<IconButton
value={option.id}
onClick={handleAnswerSelect(question.id, option.id)}
buttonClassName={option.is_correct && 'success'}
>
{#if option.is_correct}
<CheckmarkFilledIcon size={24} class="carbon-icon" />
{:else}
<CheckmarkOutlineIcon size={24} class="carbon-icon" />
{/if}
</IconButton>
</div>
</Checkbox>
{/each}
{/if}
{#if QUESTION_TYPE.TEXTAREA === question.question_type.id}
<TextArea bind:value={question.value} />
{/if}
{#if getQuestionErrorMsg(errors, question, 'option')}
<ErrorMessage
message={getQuestionErrorMsg(errors, question, 'option')}
/>
{/if}
</div>
{#if QUESTION_TYPE.TEXTAREA !== question.question_type.id}
<div class="flex items-center mt-3">
<PrimaryButton
disablePadding={true}
className="p-2"
variant={VARIANTS.OUTLINED}
onClick={handleAddOption(question.id)}
>
<AddFilledIcon size={24} class="carbon-icon" />
<p class="dark:text-white ml-2">Add option</p>
</PrimaryButton>
</div>
{/if}
</QuestionContainer>
<!-- {/key} -->
{/each}
</div>
</div>
<style>
:global(#dnd-action-dragged-el div) {
z-index: 100;
}
</style>
@@ -0,0 +1,173 @@
<script>
import { onMount } from 'svelte';
import Modal from '$lib/components/Modal/index.svelte';
import Select from '$lib/components/Form/Select.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import Preview from './Preview.svelte';
import { SELECTABLE_STATUS } from './constants';
import { snackbarStore } from '$lib/components/Snackbar/store';
export let open = false;
export let onClose = () => {};
export let handleSave = () => {};
// export let submissionId;
export let data = {};
export let updateStatus = () => {};
let status = SELECTABLE_STATUS[0];
let total = 0;
let maxPoints = 0;
function getMaxPoints(questions) {
return (questions || []).reduce(
(acc, question) => acc + question.points,
0
);
}
function calculateTotal(grades) {
if (!grades) return 0;
return Object.values(grades).reduce(
(acc, grade) => acc + parseInt(grade),
0
);
}
function handleStatusChange() {
updateStatus({
submissionId: data.id,
prevStatusId: data.status_id,
nextStatusId: status.value,
total,
});
$snackbarStore.open = true;
$snackbarStore.message = `Submission updated to '${status.label}'`;
}
function setStatus(data) {
status = SELECTABLE_STATUS.find(
(status) => status.value === data.status_id
);
}
function getStatusColor(status) {
// if (!status)
return '';
// switch (status.value) {
// case STATUS.SUBMITTED:
// return '';
// case STATUS.IN_PROGRESS:
// return 'text-white bg-blue-700';
// case STATUS.GRADED:
// return 'text-white bg-green-700';
// }
}
onMount(() => {
console.log('mounting');
});
$: total = calculateTotal(data.questionAnswerByPoint);
$: maxPoints = getMaxPoints(data.questions);
$: setStatus(data);
</script>
<Modal
modalHeading={data.title}
bind:open
{onClose}
width="w-4/5"
containerClass="flex items-start"
>
<div class="w-full">
<Preview
questions={Array.isArray(data.questions)
? data.questions.sort((a, b) => a.order - b.order)
: []}
questionnaireMetaData={{
answers: data.answers || {},
isFinished: true,
}}
bind:grades={data.questionAnswerByPoint}
/>
</div>
<div class="ml-4 w-2/5 sticky top-0">
<div class="flex items-center justify-between">
<Select
bind:value={status}
options={SELECTABLE_STATUS}
selectClassName={getStatusColor(status)}
onChange={handleStatusChange}
/>
<PrimaryButton
onClick={() => {
handleSave(data);
}}
label="Save"
variant={VARIANTS.CONTAINED_SUCCESS}
className="py-3 px-8"
/>
</div>
<div class="border border-gray-300 rounded-md mt-2">
<div
class="hover:bg-gray-100 dark:bg-gray-700 border-b border-t-0 border-l-0 border-r-0 border-gray-300 p-3"
>
<p class="dark:text-white font-bold text-lg">
Details
{#if data.isEarly}
<span
class="ml-2 text-sm badge rounded-sm px-2 bg-green-500 text-white"
>
early
</span>
{:else}
<span
class="ml-2 badge text-sm rounded-sm px-2 bg-red-500 text-white"
>
late
</span>
{/if}
</p>
</div>
<div class="flex items-center text-sm p-3">
<p class="dark:text-white font-bold w-2/5">Total grade</p>
<p class="dark:text-white">{total} / {maxPoints}</p>
</div>
<!-- <div class="flex items-center text-sm p-3">
<p class="dark:text-white font-bold w-1/2">Status</p>
<div class="flex items-center">
<p class="dark:text-white rounded-full w-5 h-5 bg-yellow-300 mr-2" />
<p class="dark:text-white">Grading</p>
</div>
</div> -->
<div class="flex items-center text-sm p-3">
<p class="dark:text-white font-bold w-2/5">Student</p>
{#if data.student}
<img
alt="Student avatar"
class="block rounded-full h-6 w-6"
src={data.student.avatar_url}
/>
<p class="dark:text-white ml-2 text-sm">{data.student.fullname}</p>
{/if}
</div>
<!-- <div class="flex items-center text-sm p-3">
<p class="dark:text-white font-bold w-1/2">Teacher</p>
<p class="dark:text-white">rotimi-best</p>
</div> -->
</div>
</div>
</Modal>
<style>
.badge {
width: fit-content;
}
</style>
@@ -0,0 +1,100 @@
<script lang="ts">
import { dndzone } from 'svelte-dnd-action';
import CheckboxCheckedFilledIcon from 'carbon-icons-svelte/lib/CheckboxCheckedFilled.svelte';
import RadioButtonCheckedIcon from 'carbon-icons-svelte/lib/RadioButtonChecked.svelte';
import TextAlignJustifyIcon from 'carbon-icons-svelte/lib/TextAlignJustify.svelte';
import { flip } from 'svelte/animate';
import Modal from '$lib/components/Modal/index.svelte';
import { questionnaireOrder, questionnaire } from '../store/exercise';
import { filterOutDeleted } from './functions';
const flipDurationMs = 300;
interface Question {
id: number;
name: string;
type: number;
}
let items: Array<Question> = [];
function handleDndConsider(e) {
items = e.detail.items;
}
function handleDndFinalize(e) {
items = e.detail.items;
}
function handleClose() {
items.forEach((item, index) => {
const order = index + 1;
$questionnaire.questions.some((q) => {
if (q.id === item.id) {
q.is_dirty = true;
q.order = order;
return true;
}
});
});
$questionnaire.questions = $questionnaire.questions.sort(
(a, b) => a.order - b.order
);
$questionnaireOrder.open = false;
}
$: items = filterOutDeleted($questionnaire.questions).map((q) => ({
id: q.id,
name: q.title,
type: q.question_type.id,
}));
</script>
<Modal
onClose={handleClose}
bind:open={$questionnaireOrder.open}
width="w-96"
modalHeading="Order questions"
>
<section
use:dndzone={{
items,
flipDurationMs,
dropTargetStyle: {
border: '2px #1d4ed8 solid',
'border-style': 'dashed',
},
}}
on:consider={handleDndConsider}
on:finalize={handleDndFinalize}
class="w-full"
>
{#each items as item (item.id)}
<div
animate:flip={{ duration: flipDurationMs }}
class="flex items-center rounded-md p-4 border border-blue-600"
>
{#if item.type === 1}
<RadioButtonCheckedIcon size={16} class="carbon-icon active" />
{:else if item.type === 2}
<CheckboxCheckedFilledIcon size={16} class="carbon-icon active" />
{:else}
<TextAlignJustifyIcon size={16} class="carbon-icon active" />
{/if}
{` ${item.name}`}
</div>
{/each}
</section>
</Modal>
<style>
section {
padding: 0.3em;
overflow: auto;
}
div {
margin: 0.5em 0;
}
</style>
@@ -0,0 +1,70 @@
<script>
import RadioQuestion from '$lib/components/Question/RadioQuestion/index.svelte';
import CheckboxQuestion from '$lib/components/Question/CheckboxQuestion/index.svelte';
import TextareaQuestion from '$lib/components/Question/TextareaQuestion/index.svelte';
import { QUESTION_TYPE } from '$lib/components/Question/constants';
import { getPropsForQuestion } from './functions';
export let questions = [];
export let questionnaireMetaData = {};
export let grades = {};
export let onSubmit = () => {};
export let onPrevious = () => {};
export let handleGrade = () => {};
export let disableGrading = false;
</script>
{#each questions as currentQuestion, currentQuestionIndex}
{#if QUESTION_TYPE.RADIO === currentQuestion.question_type.id}
<RadioQuestion
{...getPropsForQuestion(
questions,
currentQuestion,
questionnaireMetaData,
currentQuestionIndex + 1,
onSubmit,
onPrevious,
true
)}
bind:grade={grades[currentQuestion.id]}
gradeMax={currentQuestion.points}
handleGrade={handleGrade(currentQuestion.id)}
{disableGrading}
disabled={true}
/>
{:else if QUESTION_TYPE.CHECKBOX === currentQuestion.question_type.id}
<CheckboxQuestion
{...getPropsForQuestion(
questions,
currentQuestion,
questionnaireMetaData,
currentQuestionIndex + 1,
onSubmit,
onPrevious,
true
)}
bind:grade={grades[currentQuestion.id]}
gradeMax={currentQuestion.points}
handleGrade={handleGrade(currentQuestion.id)}
{disableGrading}
disabled={true}
/>
{:else if QUESTION_TYPE.TEXTAREA === currentQuestion.question_type.id}
<TextareaQuestion
{...getPropsForQuestion(
questions,
currentQuestion,
questionnaireMetaData,
currentQuestionIndex + 1,
onSubmit,
onPrevious,
true
)}
bind:grade={grades[currentQuestion.id]}
gradeMax={currentQuestion.points}
handleGrade={handleGrade(currentQuestion.id)}
{disableGrading}
disabled={true}
/>
{/if}
{/each}
@@ -0,0 +1,305 @@
<script>
import { marked } from 'marked';
import { fly } from 'svelte/transition';
import { group, course } from '$lib/components/Course/store';
import { questionnaire } from '../store/exercise';
import { questionnaireMetaData } from '../store/answers';
import Preview from './Preview.svelte';
import RadioQuestion from '$lib/components/Question/RadioQuestion/index.svelte';
import CheckboxQuestion from '$lib/components/Question/CheckboxQuestion/index.svelte';
import TextareaQuestion from '$lib/components/Question/TextareaQuestion/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import Progress from '$lib/components/Progress/index.svelte';
import { removeDuplicate } from '$lib/utils/functions/removeDuplicate';
import { QUESTION_TYPE } from '$lib/components/Question/constants';
import { STATUS } from './constants';
import { getPropsForQuestion, filterOutDeleted } from './functions';
import {
formatAnswers,
getGroupMemberId,
} from '$lib/components/Course/function';
import { submitExercise } from '$lib/utils/services/courses';
import { fetchSubmission } from '$lib/utils/services/submissions';
import { profile } from '$lib/utils/store/user';
export let preview;
export let exerciseId;
let currentQuestion = {};
let renderProps = {};
let submission;
let hasSubmission = null;
function handleStart() {
$questionnaireMetaData.currentQuestionIndex += 1;
}
function onSubmit(id, value, moveToNextQuestion = false) {
const { answers } = $questionnaireMetaData;
const { questions } = $questionnaire;
const prevAnswer = answers[id] || [];
const formattedAnswer =
typeof value === 'string'
? value
: removeDuplicate([...prevAnswer, ...(value || [])]);
$questionnaireMetaData.answers = {
...answers,
[id]: formattedAnswer,
};
if (moveToNextQuestion) {
$questionnaireMetaData.currentQuestionIndex += 1;
}
const isFinished =
!questions[$questionnaireMetaData.currentQuestionIndex - 1];
console.log(`isFinished`, isFinished);
console.log(
`$questionnaireMetaData.currentQuestionIndex`,
$questionnaireMetaData.currentQuestionIndex
);
// If last question send to server
if (isFinished) {
$questionnaireMetaData.status = 1;
$questionnaireMetaData.totalPossibleGrade = getTotalPossibleGrade();
$questionnaireMetaData.grades = {};
submitExercise(
$questionnaireMetaData.answers,
questions,
exerciseId,
$course.id,
getGroupMemberId($group.people, $profile.id)
);
}
}
function onPrevious() {
$questionnaireMetaData.currentQuestionIndex -= 1;
}
function getProgressValue(currentQuestionIndex) {
if ($questionnaireMetaData.isFinished) {
return 100;
}
return (
Math.round(
((currentQuestionIndex - 1) / $questionnaire.questions.length) * 100
) || 0
);
}
function getTotalPossibleGrade() {
return $questionnaire.questions.reduce((acc, question) => {
acc += parseFloat(question.points, 10);
return acc;
}, 0);
}
async function checkForSubmission(people, profileId, courseId) {
if (!Array.isArray(people) || !profileId || !courseId || !!submission) {
return;
}
if (hasSubmission) return;
console.log('checkForSubmission');
const args = {
exerciseId,
courseId,
submittedBy: getGroupMemberId(people, profileId),
};
const { data } = await fetchSubmission(args);
hasSubmission = true;
if (Array.isArray(data) && data.length) {
submission = data[0];
$questionnaireMetaData.answers = formatAnswers({
questions: $questionnaire.questions,
answers: submission.answers,
});
$questionnaireMetaData.totalPossibleGrade = getTotalPossibleGrade();
$questionnaireMetaData.currentQuestionIndex =
$questionnaire.questions.length;
$questionnaireMetaData.isFinished = true;
$questionnaireMetaData.status = submission.status_id;
$questionnaireMetaData.finalTotalGrade = 0;
$questionnaireMetaData.grades = submission.answers.reduce(
(acc, answer) => {
acc[answer.question_id] = answer.point;
$questionnaireMetaData.finalTotalGrade += answer.point;
return acc;
},
{}
);
}
}
$: {
currentQuestion =
$questionnaire.questions[$questionnaireMetaData.currentQuestionIndex - 1];
if ($questionnaireMetaData.currentQuestionIndex > 0 && !currentQuestion) {
$questionnaireMetaData.isFinished = true;
}
if (currentQuestion) {
renderProps = getPropsForQuestion(
$questionnaire.questions,
currentQuestion,
$questionnaireMetaData,
$questionnaireMetaData.currentQuestionIndex,
onSubmit,
onPrevious,
preview
);
}
}
$: $questionnaireMetaData.progressValue = getProgressValue(
$questionnaireMetaData.currentQuestionIndex
);
$: checkForSubmission($group.people, $profile.id, $course.id);
</script>
{#if !preview && $questionnaire.questions.length && !$questionnaireMetaData.isFinished}
<Progress value={$questionnaireMetaData.progressValue} />
{/if}
{#if preview}
<Preview
questions={filterOutDeleted($questionnaire.questions)}
questionnaireMetaData={$questionnaireMetaData}
/>
{:else if !$questionnaire.questions.length}
<div
class="w-4/5 h-40 m-auto flex items-center justify-center text-center border-2 border-gray-200 rounded-md"
>
<h3 class="dark:text-white text-2xl">
No questions added. <br />
<RoleBasedSecurity allowedRoles={[1, 2]}>
Click the <span class="text-blue-700">Edit</span> button to add.
</RoleBasedSecurity>
</h3>
</div>
{:else if $questionnaireMetaData.currentQuestionIndex === 0}
<div>
<h2 class="my-1">{$questionnaire.title}</h2>
<div class="flex items-center">
<p class="dark:text-white mx-2">
<strong>{$questionnaire.questions.length}</strong> questions
</p>
|
<p class="dark:text-white mx-2">
<strong>{getTotalPossibleGrade()}</strong> points.
</p>
|
<p class="dark:text-white mx-2">All required</p>
{#if $questionnaire.due_by}
|
<p class="dark:text-white mx-2">
<strong>Due by:</strong>
{new Date($questionnaire.due_by).toLocaleString()}
</p>
{/if}
</div>
<article class="preview prose prose-sm sm:prose p-2">
{@html marked($questionnaire.description || 'No desription')}
</article>
<PrimaryButton
onClick={handleStart}
label="Start"
className="my-5 float-right"
type="button"
/>
</div>
{:else if $questionnaireMetaData.isFinished}
<div class="flex items-center justify-between">
<div class="flex flex-col justify-between w-full">
<h2 class="text-xl mb-2 mt-0">{$questionnaire.title}</h2>
{#if STATUS.GRADED === $questionnaireMetaData.status}
<span
class="status-text bg-green-700 text-white rounded-full py-3 px-6 text-center"
title="Status: Pending Review"
>
Graded
</span>
{:else}
<span
class="status-text bg-yellow-600 text-white rounded-full py-3 px-6 text-center"
title="Status: Pending Review"
>
Pending
</span>
{/if}
</div>
{#if STATUS.GRADED === $questionnaireMetaData.status}
<span
class="p-5 border-2 border-gray-700 rounded-full h-24 w-24 flex items-center justify-center text-2xl"
title="Status: Pending Review"
>
{$questionnaireMetaData.finalTotalGrade}/{$questionnaireMetaData.totalPossibleGrade}
</span>
{/if}
</div>
<Preview
questions={$questionnaire.questions.sort((a, b) => a.order - b.order)}
questionnaireMetaData={$questionnaireMetaData}
grades={$questionnaireMetaData.grades}
disableGrading={true}
/>
{:else if currentQuestion}
{#key currentQuestion.id}
<!-- <div transition:fade id="question"> -->
<div in:fly={{ x: 500, duration: 1000 }} id="question">
{#if QUESTION_TYPE.RADIO === currentQuestion.question_type.id}
<RadioQuestion {...renderProps} key={currentQuestion.id} />
{:else if QUESTION_TYPE.CHECKBOX === currentQuestion.question_type.id}
<CheckboxQuestion {...renderProps} key={currentQuestion.id} />
{:else if QUESTION_TYPE.TEXTAREA === currentQuestion.question_type.id}
<TextareaQuestion {...renderProps} key={currentQuestion.id} />
{/if}
</div>
{/key}
{/if}
<style>
.status-text {
width: fit-content;
}
:global(.shake) {
animation: shake 0.85s;
}
@keyframes shake {
10%,
90% {
transform: translate3d(-15px, 0, 0);
}
20%,
80% {
transform: translate3d(15px, 0, 0);
}
30%,
50%,
70% {
transform: translate3d(-15px, 0, 0);
}
40%,
60% {
transform: translate3d(15px, 0, 0);
}
}
</style>
@@ -0,0 +1,20 @@
export const STATUS = {
SUBMITTED: 1,
IN_PROGRESS: 2,
GRADED: 3,
};
export const SELECTABLE_STATUS = [
{
label: 'Submitted',
value: STATUS.SUBMITTED,
},
{
label: 'In progress',
value: STATUS.IN_PROGRESS,
},
{
label: 'Graded',
value: STATUS.GRADED,
},
];
@@ -0,0 +1,95 @@
import { QUESTION_TYPE } from '$lib/components/Question/constants';
import { toggleConfetti } from '$lib/components/Confetti/store';
export const isAnswerCorrect = (options, answer) => {
return options.some((option) => option.is_correct && option.value === answer);
};
export function wasCorrectAnswerSelected(currentQuestion, answers, isFinished) {
if (currentQuestion.question_type.id === QUESTION_TYPE.TEXTAREA) {
return true;
}
const answer = answers[currentQuestion.name];
const formattedAnswers = typeof answer === 'string' ? [answer] : answer;
const isCorrect =
formattedAnswers &&
formattedAnswers.some((answer) => {
if (isAnswerCorrect(currentQuestion.options, answer)) {
return true;
}
return false;
});
if (isCorrect && !isFinished) {
toggleConfetti();
setTimeout(toggleConfetti, 1500);
}
return isCorrect;
}
export function getPropsForQuestion(
questions,
question,
questionnaireMetaData,
questionIndex,
onSubmit,
onPrevious,
preview
) {
const { answers, isFinished } = questionnaireMetaData;
const isLast = questionIndex === questions.length;
const isOpenQuesiton = question.question_type.id === QUESTION_TYPE.TEXTAREA;
const isCorrect = wasCorrectAnswerSelected(question, answers, isFinished);
if (!isCorrect && document && document.getElementById('question')) {
const questionElement = document.getElementById('question');
// Shake if wrong answer was selected
questionElement.classList.toggle('shake');
setTimeout(() => {
// Remove so we can trigger animation again
questionElement.classList.toggle('shake');
}, 200);
}
return {
title: questionIndex + '. ' + question.title,
name: `${question.name}`,
options: question.options,
code: question.code,
defaultValue: isOpenQuesiton
? answers[question.name] || ''
: answers[question.name] || [],
onSubmit,
onPrevious,
disablePreviousButton: questionIndex === 1,
isLast,
isPreview: preview || isFinished,
isCorrect,
disabled: (isFinished && isCorrect) || isFinished,
nextButtonProps: isOpenQuesiton
? {
label: 'Next',
isActive: true,
}
: isCorrect
? {
label: isLast ? 'Finish' : 'Next',
isActive: true,
disableOptionSelect: true,
}
: {
label: 'Check',
isActive: false,
},
};
}
export function filterOutDeleted(array) {
return array.filter((item) => !item.deleted_at);
}
@@ -0,0 +1,142 @@
<script>
import { onDestroy } from 'svelte';
import AddAltIcon from 'carbon-icons-svelte/lib/AddAlt.svelte';
import TableIcon from 'carbon-icons-svelte/lib/Table.svelte';
import SettingsIcon from 'carbon-icons-svelte/lib/Settings.svelte';
import ViewIcon from 'carbon-icons-svelte/lib/View.svelte';
import ViewFilledIcon from 'carbon-icons-svelte/lib/ViewFilled.svelte';
import {
handleAddQuestion,
questionnaire,
validateQuestionnaire,
reset,
questionnaireOrder,
} from '../store/exercise';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import PageBody from '$lib/components/PageBody/index.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import ViewMode from './ViewMode.svelte';
import EditMode from './EditMode.svelte';
import MODES from '$lib/utils/constants/mode.js';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { upsertExercise } from '$lib/utils/services/courses';
export let exerciseId;
export let refetchExercise = () => {};
export let goBack = () => {};
let mode = MODES.view;
let preview = false;
let editDescription = false;
async function handleMode() {
if (mode === MODES.edit) {
const errors = validateQuestionnaire($questionnaire.questions);
if (Object.values(errors).length > 0) {
return;
}
mode = MODES.view;
reset();
const updatedQuestions = await upsertExercise($questionnaire, exerciseId);
questionnaire.update((q) => ({
...q,
is_title_dirty: false,
is_description_dirty: false,
questions: updatedQuestions,
}));
} else {
mode = MODES.edit;
preview = false;
}
}
function handleDeleteExercise() {}
function onCancel() {
mode = MODES.view;
refetchExercise();
}
onDestroy(() => {
reset();
});
</script>
<PageBody padding="px-4">
<svelte:fragment slot="header">
<div class="flex items-center">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<PrimaryButton
className="mr-2"
variant={VARIANTS.CONTAINED}
label={mode === MODES.edit ? 'Save' : 'Edit'}
onClick={handleMode}
/>
{#if mode === MODES.edit}
<PrimaryButton
className="mr-2"
variant={VARIANTS.OUTLINED}
label={'Cancel'}
onClick={onCancel}
/>
{/if}
</RoleBasedSecurity>
{#if mode !== MODES.edit}
<IconButton
onClick={() => (preview = !preview)}
toolTipProps={{ title: 'Toggle preview', direction: 'right' }}
selected={preview}
contained={true}
>
{#if preview}
<ViewFilledIcon size={32} class="carbon-icon" />
{:else}
<ViewIcon size={32} class="carbon-icon" />
{/if}
</IconButton>
{/if}
</div>
{#if mode === MODES.edit}
<div class="flex items-center">
<IconButton
onClick={handleAddQuestion}
toolTipProps={{ title: 'Add question', direction: 'bottom' }}
>
<AddAltIcon size={32} class="carbon-icon" />
</IconButton>
<IconButton
onClick={() => ($questionnaireOrder.open = true)}
toolTipProps={{ title: 'Order questionnaire', direction: 'bottom' }}
>
<TableIcon size={32} class="carbon-icon" />
</IconButton>
<IconButton
onClick={() => (editDescription = true)}
toolTipProps={{ title: 'Edit description', direction: 'bottom' }}
>
<SettingsIcon size={32} class="carbon-icon" />
</IconButton>
<!-- <IconButton
onClick={handleDeleteExercise}
toolTipProps={{ title: 'Delete questionnaire', direction: 'bottom' }}
>
<Delete32 class="carbon-icon" />
</IconButton> -->
</div>
{/if}
</svelte:fragment>
{#if mode === MODES.edit && !preview}
<EditMode bind:editDescription {exerciseId} {goBack} />
{:else}
<ViewMode {preview} {exerciseId} />
{/if}
</PageBody>
@@ -0,0 +1,193 @@
<script>
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import AudioConsoleIcon from 'carbon-icons-svelte/lib/AudioConsole.svelte';
import Box from '$lib/components/Box/index.svelte';
import { Moon } from 'svelte-loading-spinners';
import Backdrop from '$lib/components/Backdrop/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import PageBody from '$lib/components/PageBody/index.svelte';
import Modal from '$lib/components/Modal/index.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import Exercise from '../Exercise/index.svelte';
import { supabase } from '$lib/utils/functions/supabase';
import { createExercise } from '$lib/utils/services/courses';
import { QUESTION_TYPES } from '$lib/components/Question/constants';
import { lesson } from '../store/lessons';
import { questionnaire, isQuestionnaireFetching } from '../store/exercise';
export let path;
export let exerciseId;
export let lessonId;
let open = false;
let isFetching = false;
let newExercise = {
id: 1,
title: '',
description: '',
};
async function handleAddExercise() {
const { data, error } = await createExercise({
title: newExercise.title,
lesson_id: lessonId,
});
console.log(`data, error `, data, error);
lesson.update((_lesson) => ({
..._lesson,
exercises: [..._lesson.exercises, data[0]],
}));
handleCancelAddExercise();
}
function handleCancelAddExercise() {
open = false;
newExercise = {
id: $lesson.exercises.length + 1,
title: '',
description: '',
};
}
async function getExercises() {
if (!lessonId) return;
isFetching = true;
const exercisesData = await supabase
.from('exercise')
.select(`id, title, created_at`)
.match({ lesson_id: lessonId });
$lesson.exercises = exercisesData.data;
isFetching = false;
}
async function getExercise(exerciseId) {
if (!exerciseId) return;
isFetching = true;
isQuestionnaireFetching.update(() => true);
let { data, error } = await supabase
.from('exercise')
.select(
`
id, title, description, due_by,
questions:question(
*,
options:option(*),
question_type:question_type_id(id, label)
)
`
)
.eq('id', exerciseId)
.single();
if (error) {
return;
}
if (data && Array.isArray(data.questions)) {
// Need to set the question type inorder for the select in the questionnaire builder to match
data.questions.forEach((question) => {
question.question_type = QUESTION_TYPES.find(
(type) => type.id === question.question_type.id
);
return question;
});
data.questions = data.questions.sort((a, b) => a.order - b.order);
} else if (data) {
data.questions = [];
} else {
data = {};
}
data.is_title_dirty = false;
data.is_description_dirty = false;
data.is_due_by_dirty = false;
questionnaire.set(data);
console.log('getExercise', exerciseId, $questionnaire);
isQuestionnaireFetching.update(() => false);
isFetching = false;
}
function goBack() {
goto(path);
}
onMount(() => {
getExercises();
});
$: getExercise(exerciseId);
</script>
{#if isFetching}
<Backdrop>
<Moon size="60" color="#1d4ed8" unit="px" duration="1s" />
</Backdrop>
{/if}
{#if exerciseId}
<Exercise
{exerciseId}
{goBack}
refetchExercise={() => getExercise(exerciseId)}
/>
{:else}
<Modal
onClose={handleCancelAddExercise}
bind:open
modalHeading="Create an Exercise"
width="w-80"
>
<TextField
bind:value={newExercise.title}
autofocus={true}
placeholder="Exercise name"
/>
<div class="mt-5 flex items-center">
<PrimaryButton
className="px-6 py-3"
label="Submit"
onClick={handleAddExercise}
/>
</div>
</Modal>
<PageBody>
<slot:fragment slot="header">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<PrimaryButton
className="mr-2"
label="Add"
onClick={() => (open = !open)}
/>
</RoleBasedSecurity>
</slot:fragment>
<div class="flex flex-wrap">
{#each $lesson.exercises as exercise}
<a
class="w-52 bg-gray-100 dark:bg-gray-700 px-4 py-7 mr-4 mb-4"
href="{path}/{exercise.id}"
>
<h3 class="dark:text-white text-xl">{exercise.title}</h3>
<p class="dark:text-white mt-4 text-sm">Created Jul 3, 2021</p>
</a>
{:else}
<Box>
<AudioConsoleIcon size={32} class="carbon-icon w-80" />
<h3 class="text-3xl text-gray-500 dark:text-white">
No Exercise Added
</h3>
</Box>
{/each}
</div>
</PageBody>
{/if}
@@ -0,0 +1,17 @@
export const tabs = [
{
label: 'Slide',
value: 1,
badgeValue: 0,
},
{
label: 'Note',
value: 2,
badgeValue: 0,
},
{
label: 'Video',
value: 3,
badgeValue: 0,
},
];
@@ -0,0 +1,152 @@
<script>
import MODES from '$lib/utils/constants/mode.js';
import { formatYoutubeVideo } from '$lib/utils/functions/formatYoutubeVideo';
import Tabs from '$lib/components/Tabs/index.svelte';
import TabContent from '$lib/components/TabContent/index.svelte';
import MarkdownRender from '$lib/components/MarkdownRender/index.svelte';
import Box from '$lib/components/Box/index.svelte';
import EditContent from '$lib/components/EditContent/index.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import {
lesson,
handleUpdateLessonMaterials,
isLessonDirty,
} from '../store/lessons';
import * as CONSTANTS from './constants';
export let mode = MODES.view;
export let prevMode = null;
export let lessonId;
let tabs = CONSTANTS.tabs;
let currentTab = tabs[0].value;
let errors = {};
const onChange = (tab) => () => (currentTab = tab);
function handleSave(prevMode) {
if (prevMode === MODES.edit) {
handleUpdateLessonMaterials($lesson, lessonId);
}
}
function addBadgeValueToTab(materials) {
const { slide_url, video_url, note } = materials;
tabs = tabs.map((tab) => {
let badgeValue;
if (tab.value === 1) {
badgeValue = !!slide_url ? 1 : 0;
} else if (tab.value === 2) {
badgeValue = !!note ? 1 : 0;
} else {
badgeValue =
typeof video_url === 'string' && !!video_url
? video_url.split(',').length
: 0;
}
tab.badgeValue = badgeValue;
return tab;
});
}
$: handleSave(prevMode);
$: addBadgeValueToTab($lesson.materials);
</script>
<Tabs {tabs} {currentTab} {onChange}>
<slot:fragment slot="content">
<TabContent value={tabs[0].value} index={currentTab}>
{#if mode === MODES.edit}
<TextField
label="Slide link"
bind:value={$lesson.materials.slide_url}
onChange={() => ($isLessonDirty = true)}
/>
{:else if $lesson.materials.slide_url}
<iframe
title="Embeded Slides"
src={$lesson.materials.slide_url}
frameborder="0"
width="100%"
height="569"
class="iframe"
allowfullscreen="true"
mozallowfullscreen="true"
webkitallowfullscreen="true"
/>
{:else}
<Box>
<h3 class="text-3xl text-gray-500 dark:text-white">No slide added</h3>
<img alt="Slide not found" src="/notfound.webp" class="w-80" />
</Box>
{/if}
</TabContent>
<TabContent value={tabs[1].value} index={currentTab}>
{#if mode === MODES.edit}
<EditContent
writeLabel="Note"
bind:value={$lesson.materials.note}
placeholder="Start typing your lesson"
onInputChange={() => ($isLessonDirty = true)}
/>
{:else if $lesson.materials.note}
<MarkdownRender content={$lesson.materials.note} />
{:else}
<Box>
<h3 class="text-3xl text-gray-500 dark:text-white">No note added</h3>
<img alt="Lesson note not found" src="/notfound.webp" class="w-80" />
</Box>
{/if}
</TabContent>
<TabContent value={tabs[2].value} index={currentTab}>
{#if mode === MODES.edit}
<TextField
label="Youtube link"
bind:value={$lesson.materials.video_url}
onChange={() => ($isLessonDirty = true)}
/>
{:else if !!$lesson.materials.video_url && $lesson.materials.video_url.includes('youtube')}
{#each $lesson.materials.video_url.split(',') as url}
<div class="mb-5">
<iframe
width="100%"
height="569"
class="iframe"
src={formatYoutubeVideo(url, errors)}
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
/>
</div>
{/each}
{:else}
<Box>
<h3 class="text-3xl text-gray-500 dark:text-white">
No youtube video added
</h3>
<img alt="Video not found" src="/notfound.webp" class="w-80" />
</Box>
{/if}
</TabContent>
</slot:fragment>
</Tabs>
<style>
.iframe {
height: 569px;
}
@media (max-width: 760px) {
.iframe {
height: 209px;
}
}
</style>
@@ -0,0 +1,23 @@
export function getUniqueValue(label) {
return label.toLowerCase().replace(' ', '');
}
export function generateQuestion(questions) {
const generatedQuestions = [];
for (const question of questions) {
const name = getUniqueValue(question.title);
const options = question.options.map((option) => {
option.value = getUniqueValue(option.label);
return option;
});
generatedQuestions.push({
...question,
name,
options,
});
}
return generatedQuestions;
}
@@ -0,0 +1,25 @@
import { writable } from 'svelte/store';
import { STATUS } from '../Exercise/constants';
// export const questionnaireMetaData = writable({
// answers: {},
// scores: { gates: 60 },
// currentQuestionIndex: 5,
// isFinished: true,
// progressValue: 100,
// status: STATUS.GRADED,
// });
const initAnswerState = {
answers: {},
scores: {},
grades: {},
totalPossibleGrade: 0,
finalTotalGrade: 0,
currentQuestionIndex: 0,
isFinished: false,
progressValue: 100,
status: STATUS.PENDING,
};
export const questionnaireMetaData = writable(initAnswerState);
@@ -0,0 +1,248 @@
import { writable } from 'svelte/store';
import {
QUESTION_TEMPLATE,
QUESTION_TYPE,
} from '$lib/components/Question/constants';
import { questionnaireMetaData } from './answers';
import { isUUID } from '$lib/utils/functions/isUUID';
export const isQuestionnaireFetching = writable(false);
export const deleteConfirmation = writable({ open: false });
export const questionnaireOrder = writable({ open: false });
// {'question-id': { option: '', title: '', ... }}
export const questionnaireValidation = writable({});
export const questionnaire = writable({
title: '',
due_by: '',
is_due_by_dirty: false,
is_title_dirty: false,
description: '',
is_description_dirty: false,
questions: [],
});
export function reset() {
questionnaireMetaData.update((metaData) => {
metaData.answers = {};
metaData.currentQuestionIndex = 0;
metaData.isFinished = false;
return metaData;
});
}
export function validateQuestionnaire(questions) {
const errors = {};
for (const question of questions) {
if (
question.question_type.id === QUESTION_TYPE.TEXTAREA ||
question.deleted_at
) {
continue;
}
const qErrors = errors[question.id] || {};
const hasAnswer = question.options
.filter((o) => !o.deleted_at)
.some((option) => option.is_correct);
if (!hasAnswer) {
qErrors.option = 'Please mark an option as the correct answer';
errors[question.id] = { ...qErrors };
}
}
questionnaireValidation.set(errors);
return errors;
}
export function handleAddQuestion() {
questionnaire.update((q) => {
const { questions } = q;
return {
...q,
questions: [
...questions,
{
...QUESTION_TEMPLATE,
id: `${questions.length + 1}-form`,
name: `${questions.length + 1}-form`,
value: '',
points: 0,
order: questions.length,
question_type: {
id: QUESTION_TYPE.RADIO,
},
options: [
{
id: '1-form',
label: '',
value: null,
is_correct: false,
},
],
},
],
};
});
}
export function handleAddOption(questionId) {
return () => {
questionnaire.update((_questionnaire) => {
const { questions } = _questionnaire;
return {
..._questionnaire,
questions: questions.map((question) => {
if (question.id === questionId) {
return {
...question,
options: [
...question.options,
{
id: `${question.options.length + 1}-form`,
label: '',
value: '',
is_correct: false,
},
],
};
}
return question;
}),
};
});
};
}
export function handleRemoveOption(questionId, optionId) {
return () => {
questionnaire.update((q) => {
const { questions } = q;
return {
...q,
questions: questions.map((question) => {
if (question.id === questionId) {
return {
...question,
options: question.options.map((option) => {
if (option.id === optionId) {
option.deleted_at = new Date();
}
return option;
}),
};
}
return question;
}),
};
});
};
}
export function handleRemoveQuestion(questionId) {
return () => {
questionnaire.update((q) => {
const { questions } = q;
return {
...q,
questions: questions.map((q) => {
if (q.id === questionId) {
q.deleted_at = new Date();
}
return q;
}),
};
});
};
}
export function handleCode(questionId, shouldAdd = true) {
questionnaire.update((q) => {
const { questions } = q;
return {
...q,
questions: questions.map((question) => {
if (question.id === questionId) {
return {
...question,
code: shouldAdd ? question.code || '' : undefined,
};
}
return question;
}),
};
});
}
export function handleAnswerSelect(questionId, optionId) {
return () => {
questionnaire.update((q) => {
const { questions } = q;
return {
...q,
questions: questions.map((question) => {
if (question.id === questionId) {
return {
...question,
options: question.options.map((option) => {
if (option.id === optionId) {
option.is_correct = !option.is_correct;
option.is_dirty = true;
}
return option;
}),
};
}
return question;
}),
};
});
};
}
export function addDynamicValue(questionId, optionId) {
return (e) => {
questionnaire.update((q) => {
const { questions } = q;
return {
...q,
questions: questions.map((question) => {
if (question.id === questionId) {
return {
...question,
options: question.options.map((option) => {
if (option.id === optionId) {
const label = e.target.value;
if (!isUUID(option.value)) {
option.value = label.split(' ').join('-');
}
option.label = label;
option.is_dirty = true;
}
return option;
}),
};
}
return question;
}),
};
});
};
}
@@ -0,0 +1,105 @@
import { writable } from 'svelte/store';
import type { Writable, Updater } from 'svelte/store';
import {
createLesson,
updateLesson,
deleteLesson,
} from '$lib/utils/services/courses';
import type { Lesson, Course } from '$lib/utils/types';
export const lessons: Writable<Lesson[]> = writable([]);
export const lesson = writable({
id: null,
totalExercises: 0,
materials: {
note: null,
slide_url: null,
video_url: null,
},
exercises: [],
});
export const isLessonDirty = writable(false);
export function handleAddLesson() {
lessons.update(((_lessons) => {
return [
..._lessons,
{
id: null,
title: 'Untitled lesson',
// profile: undefined,
call_url: undefined,
lesson_at: new Date(),
is_complete: false,
},
];
}) as Updater<any>);
}
export function handleDelete(lessonId: Lesson['id']) {
return async () => {
// Need to implement soft delete
if (lessonId) {
const { error } = await deleteLesson(lessonId);
if (error) {
return alert('Please delete all exercises first');
}
}
lessons.update((_lessons) =>
_lessons.filter((lesson) => lesson.id !== lessonId)
);
console.log(`lessonId`, lessonId);
};
}
export async function handleSaveLesson(
lesson: Lesson,
course_id: Course['id']
) {
console.log(`handleSaveLesson lesson`, lesson);
const newLesson = {
title: lesson.title,
lesson_at: lesson.lesson_at,
call_url: lesson.call_url,
teacher_id: lesson.profile ? lesson.profile.id : undefined,
course_id,
is_complete: lesson.is_complete,
};
let updatedLesson: any[] | null = null;
if (typeof lesson.id === 'string') {
// No need to get the result of update cause we have all in local state
await updateLesson(newLesson, lesson.id);
} else {
const { data } = await createLesson(newLesson);
updatedLesson = data;
}
lessons.update((l) =>
l.map((lesson) => {
if (!lesson.id && updatedLesson) {
lesson.id = updatedLesson[0].id;
}
return lesson;
})
);
}
export async function handleUpdateLessonMaterials(
lesson: any,
lessonId: Lesson['id']
) {
const materials = {
...lesson.materials,
};
delete materials.totalExercises;
return await updateLesson(materials, lessonId);
}
@@ -0,0 +1,72 @@
<script>
import CaretSortDownIcon from 'carbon-icons-svelte/lib/CaretSortDown.svelte';
let isExpanded = true;
export let handleClick = () => {};
export let label = '';
export let hideSortIcon = false;
export let isGroupActive = false;
// export let subMenuItems = [];
function onClick() {
// isExpanded = !isExpanded;
handleClick();
}
</script>
<div>
<div
class="item relative flex items-center {isGroupActive && 'active'}"
role="button"
tabindex="0"
on:click={onClick}
>
{#if !hideSortIcon}
<CaretSortDownIcon
size={20}
class="carbon-icon-sort {isExpanded ? '' : 'carbon-icon-minimize'}"
/>
{/if}
<span class="dark:text-white font-bold">{label}</span>
</div>
{#if isExpanded}
<div class="flex flex-col">
<slot />
</div>
{/if}
</div>
<style>
:global(svg.carbon-icon-sort) {
position: absolute;
top: 8px;
left: 0;
}
:global(svg.carbon-icon-minimize) {
transform: rotate(270deg);
top: 13px;
left: -6px;
}
.active {
background-color: #cae2f9;
border-left: 3px solid var(--main-primary-color);
}
:global(body.dark) .active,
:global(body.dark) .item:hover {
background-color: rgba(107, 114, 128);
}
.active span {
padding-left: 1.3rem;
}
.item span {
padding: 12px 24px;
}
.item:hover {
background-color: #cae2f9;
}
</style>
@@ -0,0 +1,320 @@
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import hotkeys from 'hotkeys-js';
import HomeIcon from 'carbon-icons-svelte/lib/Home.svelte';
import CheckmarkFilledIcon from 'carbon-icons-svelte/lib/CheckmarkFilled.svelte';
import SettingsIcon from 'carbon-icons-svelte/lib/Settings.svelte';
import ChevronLeftIcon from 'carbon-icons-svelte/lib/ChevronLeft.svelte';
import ChevronRightIcon from 'carbon-icons-svelte/lib/ChevronRight.svelte';
import NavExpandable from './NavExpandable.svelte';
import PageNav from '$lib/components/PageNav/index.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import Avatar from '$lib/components/Avatar/index.svelte';
import {
getNavItemRoute,
getLessonsRoute,
getLectureNo,
} from '$lib/components/Course/function';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import Settings from '../Settings/index.svelte';
import { settingsDialog } from '../Settings/store';
import { lessons } from '../Lesson/store/lessons';
import { course } from '$lib/components/Course/store';
import { updateCourse } from '$lib/utils/services/courses';
import { profile } from '$lib/utils/store/user';
import { currentOrgPath } from '$lib/utils/store/org';
import { isMobile } from '$lib/utils/store/useMobile';
import { browser } from '$app/environment';
// export let lessonId;
export let path;
export let isStudent = null;
let show = null;
function handleMainGroupClick(href) {
return () => {
if ($isMobile) {
setTimeout(() => (show = false), 500);
}
goto(href);
};
}
let navItems = [];
function getHref(item) {
return item.to;
// return item.is_complete ? item.to : path || $page.url.pathname
}
function handleSaveTitle() {
updateCourse($course.id, { title: $course.title });
}
function handleMobileChange(isMobile) {
if (isMobile) show = false;
else show = true;
}
onMount(() => {
if (localStorage.getItem('hideCourseNav')) {
show = localStorage.getItem('hideCourseNav') === 'false';
} else {
show = true;
}
hotkeys('b', function (event, handler) {
event.preventDefault();
switch (handler.key) {
case 'b':
show = !show;
break;
}
});
});
$: handleMobileChange($isMobile);
$: {
if (browser) {
localStorage.setItem('hideCourseNav', `${!show}`);
}
}
$: {
navItems = [
{
label: 'Overview',
to: getNavItemRoute($course.id),
hideSortIcon: true,
},
{
label: 'Lessons',
to: getLessonsRoute($course.id),
hideSortIcon: false,
isLecture: true,
},
{
label: 'Attendance',
to: getNavItemRoute($course.id, 'attendance'),
hideSortIcon: true,
},
{
label: 'Submissions',
to: getNavItemRoute($course.id, 'submissions'),
hideSortIcon: true,
show() {
return !isStudent;
},
},
{
label: 'Marks',
to: getNavItemRoute($course.id, 'marks'),
hideSortIcon: true,
},
// {
// label: 'Scoreboard',
// to: getNavItemRoute($course.id, 'scoreboard'),
// hideSortIcon: true,
// },
{
label: 'People',
to: getNavItemRoute($course.id, 'people'),
hideSortIcon: true,
},
{
label: 'Certificates',
to: getNavItemRoute($course.id, 'certificates'),
hideSortIcon: true,
},
{
label: 'Landing Page',
to: getNavItemRoute($course.id, 'landingpage'),
hideSortIcon: true,
show() {
return !isStudent;
},
},
];
}
</script>
<Settings />
<div
class="root z-10 {!show && 'hide'} {$isMobile ? 'fixed shadow-xl' : 'sticky'}"
>
{#if show}
<div class="relative h-full dark:bg-gray-700">
<PageNav bind:title={$course.title} paddingClass="pl-2">
<slot:fragment slot="image">
<Avatar src={$course.logo} />
</slot:fragment>
<slot:fragment slot="widget">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<IconButton
onClick={() => ($settingsDialog.open = !$settingsDialog.open)}
>
<SettingsIcon size={20} class="carbon-icon" />
</IconButton>
</RoleBasedSecurity>
</slot:fragment>
</PageNav>
<div class="navItems">
{#each navItems as navItem}
{#if !navItem.show || (typeof navItem.show === 'function' && navItem.show())}
<NavExpandable
label={navItem.label}
handleClick={handleMainGroupClick(navItem.to)}
isGroupActive={(path || $page.url.pathname) === navItem.to}
hideSortIcon={navItem.hideSortIcon}
>
{#if navItem.isLecture}
{#each $lessons as item, index}
<a
class="item flex items-center {(
path || $page.url.pathname
).includes(item.id) && 'active'} pl-7 py-3 {isStudent &&
!item.is_complete
? 'cursor-not-allowed'
: ''}"
href={isStudent && !item.is_complete
? $page.url.pathname
: getLessonsRoute($course.id, item.id)}
on:click={() => {
if ($isMobile) {
setTimeout(() => (show = false), 500);
}
}}
>
<span class="course-counter">
{getLectureNo(index + 1)}
</span>
<span>{item.title}</span>
{#if item.is_complete}
<span class="ml-2 success">
<CheckmarkFilledIcon size={20} class="carbon-icon" />
</span>
{:else}
<span class="text-md ml-2">🔒</span>
{/if}
</a>
{/each}
{/if}
</NavExpandable>
{/if}
{/each}
</div>
<div
class="w-full footer p-3 absolute bottom-2 flex items-center justify-between"
>
<IconButton
value="toggle"
onClick={() => goto(`${$currentOrgPath}/courses`)}
buttonClassName="mx-3"
>
<HomeIcon size={32} class="carbon-icon" />
</IconButton>
<a class="block mx-3" href={`/profile/${$profile.id}`}>
<Avatar src={$profile.avatar_url} name={$profile.username} />
</a>
</div>
</div>
{/if}
<div class="toggler rounded-full shadow-lg absolute bottom-0">
<IconButton
value="toggle"
onClick={() => (show = !show)}
size={$isMobile ? 'large' : 'small'}
color="text-black"
toolTipProps={$isMobile
? {}
: {
title: 'Toggle sidebar',
hotkeys: ['B'],
direction: 'right',
}}
>
{#if show}
<ChevronLeftIcon size={16} />
{:else}
<ChevronRightIcon size={16} />
{/if}
</IconButton>
</div>
</div>
<style lang="scss">
.root {
height: 100vh;
display: flex;
flex-direction: column;
min-width: 360px;
max-width: 360px;
top: 0;
border-right: 1px solid var(--border-color);
background-color: rgb(250, 251, 252);
&.hide {
min-width: 15px;
max-width: 15px;
}
}
.footer {
border-top: 1px solid var(--border-color);
height: 50px;
}
.active {
background-color: #cae2f9;
border-left: 3px solid var(--main-primary-color);
padding-left: 1.5rem;
}
.course-counter {
font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace;
background-color: inherit;
font-size: 0.6875rem;
font-weight: 600;
line-height: 1.5;
margin-right: 1.5rem;
}
.item {
font-size: 0.875rem;
font-weight: bold;
&:hover {
background-color: #cae2f9;
}
}
:global(body.dark) .active,
:global(body.dark) .item:hover {
background-color: rgba(107, 114, 128);
}
.navItems {
height: 82%;
overflow-y: auto;
}
.toggler {
right: -15px;
z-index: 10;
border: 1px solid var(--border-color);
top: 50px;
height: fit-content;
background: var(--border-color);
}
@media (max-width: 760px) {
.root {
width: 98%;
min-width: unset;
max-width: unset;
}
}
</style>
@@ -0,0 +1,43 @@
<script>
import Modal from '$lib/components/Modal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { deleteMemberModal } from './store';
export let email;
export let deletePerson = () => {};
async function handleDelete() {
deletePerson();
$deleteMemberModal.open = false;
}
</script>
<Modal
onClose={() => ($deleteMemberModal.open = false)}
bind:open={$deleteMemberModal.open}
width="w-96"
modalHeading="Delete"
>
<div>
<h1 class="dark:text-white text-lg">
Are you sure you want to delete <strong>{email}</strong>?
</h1>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label="No"
onClick={() => ($deleteMemberModal.open = false)}
/>
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label="Yes"
onClick={handleDelete}
/>
</div>
</div>
</Modal>
@@ -0,0 +1,93 @@
<script>
import Modal from '$lib/components/Modal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import { ROLE } from '$lib/utils/constants/roles';
import { addGroupMember, fetchGroup } from '$lib/utils/services/courses';
import { invitationModal, resetForm } from './store';
import { validateForm } from '$lib/components/Courses/functions';
import { course, setCourse } from '$lib/components/Course/store';
let errors = {
emails: '',
};
function addPerson() {
const { hasError, fieldErrors, students, tutors } = validateForm({
...$invitationModal,
title: 'sfdsd',
description: 'dsfdsf',
});
errors = fieldErrors;
if (hasError) return;
let membersStack = [];
console.log(`$course`, $course);
// Create groupmemebers from group_id and add teachers and students
for (const student of students) {
membersStack.push({
email: student,
group_id: $course.group.id,
role_id: ROLE.STUDENT,
});
}
for (const tutor of tutors) {
membersStack.push({
email: tutor,
group_id: $course.group.id,
role_id: ROLE.TUTOR,
});
}
addGroupMember(membersStack).then(async (membersAdded) => {
const group = await fetchGroup($course.group.id);
setCourse(
{
...$course,
group: group.data,
},
false
);
resetForm();
});
}
</script>
<Modal
onClose={resetForm}
bind:open={$invitationModal.open}
width="w-2/5"
maxWidth="max-w-lg"
modalHeading="Invite people"
>
<form on:submit|preventDefault={addPerson}>
<TextArea
label="Invite Tutors"
bind:value={$invitationModal.tutors}
rows="2"
maxRows="3"
placeholder="tutor@company.com, john@gmail.com"
helperMessage="To invite people, add their emails separated by a comma"
errorMessage={errors.tutors}
className="mb-4"
/>
<TextArea
label="Invite Students"
bind:value={$invitationModal.students}
rows="2"
maxRows="3"
placeholder="student@gmail.com, john@gmail.com"
helperMessage="To invite people, add their emails separated by a comma"
errorMessage={errors.students}
className="mb-4"
/>
<div class="mt-5 flex items-center">
<PrimaryButton className="px-6 py-3" label="Finish" type="submit" />
</div>
</form>
</Modal>
@@ -0,0 +1,18 @@
import { writable } from 'svelte/store';
export const deleteMemberModal = writable({
open: false,
});
export const invitationModal = writable({
open: false,
tutors: '',
students: '',
});
export const resetForm = () =>
invitationModal.update(() => ({
open: false,
tutors: '',
students: '',
}));
@@ -0,0 +1,21 @@
export interface Profile {
id: string;
fullname: string;
username: string;
email: string;
avatar_url?: string;
}
export interface Person {
id: string;
email?: string;
role_id: number;
profile?: Profile;
profile_id?: string;
assigned_student_id: string;
}
export interface ProfileRole {
label: string;
value: string | number;
}
@@ -0,0 +1,111 @@
<script>
import { goto } from '$app/navigation';
import Modal from '$lib/components/Modal/index.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { settingsDialog } from './store';
import { course } from '$lib/components/Course/store';
import { updateCourse, deleteCourse } from '$lib/utils/services/courses';
let shouldDelete = false;
let avatar;
let errors = {};
async function updateDescription() {
if (!$course.title) {
errors.title = 'Title cannot be empty';
return;
}
if (!$course.description) {
errors.description = 'Description cannot be empty';
return;
}
$settingsDialog.open = false;
const logo = await updateCourse($course.id, avatar, {
title: $course.title,
description: $course.description,
});
if (logo) {
$course.logo = logo;
}
avatar = undefined;
}
async function handleDelete() {
$settingsDialog.open = false;
await deleteCourse($course.id);
goto('/courses');
}
// function setAvatar(logo) {
// avatar = logo;
// }
// $: setAvatar($course.logo);
</script>
<Modal
onClose={() => ($settingsDialog.open = false)}
bind:open={$settingsDialog.open}
width="w-96"
modalHeading="Update Course"
>
{#if shouldDelete}
<form on:submit|preventDefault={() => (shouldDelete = false)}>
<h1 class="dark:text-white text-2xl">Are you sure?</h1>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label="No, cancel"
type="submit"
/>
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label="Yes, delete"
onClick={handleDelete}
/>
</div>
</form>
{:else}
<form on:submit|preventDefault={updateDescription}>
<UploadImage bind:avatar src={$course.logo} />
<TextField
bind:value={$course.title}
placeholder="Course title"
className="mb-4"
isRequired={true}
errorMessage={errors.title}
/>
<TextArea
label="Short description"
bind:value={$course.description}
rows="4"
maxRows="4"
placeholder="A little description about this course"
className="mb-4"
isRequired={true}
errorMessage={errors.description}
/>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.CONTAINED_DANGER}
label="Delete"
onClick={() => (shouldDelete = true)}
/>
<PrimaryButton className="px-6 py-3" label="Update" type="submit" />
</div>
</form>
{/if}
</Modal>
@@ -0,0 +1,5 @@
import { writable } from 'svelte/store';
export const settingsDialog = writable({
open: false,
});
@@ -0,0 +1,26 @@
export default `# 1. Soft Skills
## Notes
Here I can write some notes to my students
## Community
The Next.js community can be found on [GitHub Discussions](https://github.com/vercel/next.js/discussions), where you can ask questions, voice ideas, and share your projects.
To chat with other community members you can join the [Next.js Discord](https://nextjs.org/discord).
Our [Code of Conduct](https://github.com/vercel/next.js/blob/canary/CODE_OF_CONDUCT.md) applies to all Next.js community channels.
## Contributing
Please see our [contributing.md](/contributing.md).
## Authors
- Tim Neutkens ([@timneutkens](https://twitter.com/timneutkens)) – [Vercel](https://vercel.com/about/timneutkens)
- Naoyuki Kanezawa ([@nkzawa](https://twitter.com/nkzawa)) – [Vercel](https://vercel.com/about/nkzawa)
- Guillermo Rauch ([@rauchg](https://twitter.com/rauchg)) – [Vercel](https://vercel.com/about/rauchg)
- Arunoda Susiripala ([@arunoda](https://twitter.com/arunoda))
- Tony Kovanen ([@tonykovanen](https://twitter.com/tonykovanen))
- Dan Zajdband ([@impronunciable](https://twitter.com/impronunciable))
`
+4
View File
@@ -0,0 +1,4 @@
export const ROUTES = {
COURSES: "courses",
LESSONS: "lessons",
};
+55
View File
@@ -0,0 +1,55 @@
import { ROUTES } from './constants';
export function getGroupMemberId(people, profileId) {
const groupMember = people.find((person) => person.profile_id === profileId);
return groupMember ? groupMember.id : null;
}
export function getNavItemRoute(courseId, routeId) {
const path = `/${ROUTES.COURSES}/${courseId}`;
if (!routeId) {
return path;
}
return `${path}/${routeId}`;
}
export function getLessonsRoute(courseId, lessonId) {
const path = getNavItemRoute(courseId, ROUTES.LESSONS);
if (!lessonId) {
return path;
}
return `${path}/${lessonId}`;
}
export function getLectureNo(index, initNo = '00') {
if (index <= 9) {
return `${initNo}${index}`;
}
return `${initNo.replace('0', '')}${index}`;
}
export function formatAnswers(data) {
const answers = {};
const questionByIdAndName = {};
for (const question of data.questions) {
questionByIdAndName[question.id] = question.name;
}
for (const answer of data.answers) {
const questionName = questionByIdAndName[answer.question_id];
answers[questionName] =
Array.isArray(answer.answers) && answer.answers.length
? answer.answers
: answer.open_answer;
}
return answers;
}
+58
View File
@@ -0,0 +1,58 @@
<script>
import { marked } from 'marked';
import RoleBasedSecurity from '../RoleBasedSecurity/index.svelte';
import PageNav from '../PageNav/index.svelte';
import MODES from '$lib/utils/constants/mode';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import PageBody from '../PageBody/index.svelte';
import EditContent from '../EditContent/index.svelte';
import { course } from './store';
import { updateCourse } from '$lib/utils/services/courses';
let mode = MODES.view;
let isDirty = false;
async function handleModeChange() {
if (mode === MODES.edit) {
mode = MODES.view;
if (isDirty) {
const { overview, id } = $course;
await updateCourse(id, undefined, { overview });
}
} else {
mode = MODES.edit;
}
}
</script>
<PageNav title="Overview" disableSticky={true}>
<svelte:fragment slot="widget">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="flex items-center">
<PrimaryButton
className="mr-2"
label={mode === MODES.edit ? 'Save' : 'Edit'}
onClick={handleModeChange}
/>
</div>
</RoleBasedSecurity>
</svelte:fragment>
</PageNav>
<PageBody width="max-w-3xl px-3">
{#if mode === MODES.edit}
<EditContent
writeLabel="Note"
bind:value={$course.overview}
placeholder="Start typing your lesson"
onInputChange={() => {
isDirty = true;
console.log('input change');
}}
/>
{:else}
<article class="preview prose prose-sm sm:prose p-2">
{@html marked($course.overview || '')}
</article>
{/if}
</PageBody>
+139
View File
@@ -0,0 +1,139 @@
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
import { lessons } from './components/Lesson/store/lessons';
//@ts-ignore
import { ROLE } from '$lib/utils/constants/roles';
import type { Course } from '$lib/utils/types';
export const course: Writable<Course> = writable({
id: '',
title: '',
description: '',
cost: 0,
currency: '',
is_certificate_downloadable: false,
certificate_theme: 'professional',
metadata: {
requirements: '',
description: '',
goals: '',
videoUrl: '',
showDiscount: false,
discount: 0,
reward: {
show: false,
description: '',
},
instructor: {
name: '',
role: '',
coursesNo: 0,
description: '',
imgUrl: '',
},
},
});
export const mockGroupMember = {
id: '434534534535',
group_id: '434534534535',
role_id: 1,
profile_id: 'vcvbcvbcvbb----434534534535',
email: null,
profile: {
id: 'vcvbcvbcvbb----434534534535',
fullname: 'Rotimi Best',
username: 'robertblake',
avatar_url: '',
created_at: '2021-08-08T13:42:13+00:00',
updated_at: '2021-08-08T13:42:13+00:00',
},
};
export const group = writable({
id: null,
tutors: [],
students: [],
people: [],
});
export async function setCourse(data: Course, setLesson = true) {
if (!data || !(Object.values(data) && Object.values(data).length)) return;
// const tutorsById = {};
if (data.group) {
const groupData = Object.assign(data.group, {
tutors: [],
students: [],
people: [],
});
// @ts-ignore
if (Array.isArray(groupData.members)) {
// @ts-ignore
for (const member of groupData.members) {
if (member.role_id === ROLE.STUDENT) {
// @ts-ignore
groupData.students.push(member);
} else if (member.profile) {
// @ts-ignore
groupData.tutors.push(member.profile);
// tutorsById[member.profile.id] = member.profile;
}
}
// @ts-ignore
groupData.people = groupData.members;
}
// @ts-ignore
delete groupData.members;
// @ts-ignore
group.set(groupData);
}
if (setLesson) {
// @ts-ignore
const orderedLessons = (data.lessons || [])
// @ts-ignore
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at))
// @ts-ignore
.sort((a, b) => a.order - b.order);
// .map((lesson) => ({
// ...lesson,
// profile: lesson.profile && tutorsById[lesson.profile.id],
// }));
lessons.set(orderedLessons);
}
//@ts-ignore
delete data.lessons;
if (data.metadata && !Object.values(data.metadata)) {
data.metadata = {
requirements: '',
description: '',
goals: '',
videoUrl: '',
showDiscount: false,
discount: 0,
reward: {
show: false,
description: '',
},
instructor: {
name: '',
role: '',
coursesNo: 0,
description: '',
imgUrl: '',
},
};
}
if (!data.certificate_theme) {
data.certificate_theme = 'professional';
}
course.set(data);
}