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:
Vendored
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* These declarations tell TypeScript that we allow import of images, e.g.
|
||||
* ```
|
||||
<script lang='ts'>
|
||||
import successkid from 'images/successkid.jpg';
|
||||
</script>
|
||||
|
||||
<img src="{successkid}">
|
||||
```
|
||||
*/
|
||||
declare module '*.gif' {
|
||||
const value: string;
|
||||
export = value;
|
||||
}
|
||||
|
||||
declare module '*.jpg' {
|
||||
const value: string;
|
||||
export = value;
|
||||
}
|
||||
|
||||
declare module '*.jpeg' {
|
||||
const value: string;
|
||||
export = value;
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
const value: string;
|
||||
export = value;
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
const value: string;
|
||||
export = value;
|
||||
}
|
||||
|
||||
declare module '*.webp' {
|
||||
const value: string;
|
||||
export = value;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#333333" />
|
||||
|
||||
<!-- <link rel="stylesheet" href="global.css"> -->
|
||||
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
|
||||
<link rel="icon" type="image/png" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" />
|
||||
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" />
|
||||
|
||||
<title>ClassroomIO – The Open Source Platform for Tech Bootcamps</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Spend less time doing the boring stuffs, instead focus on impacting the lives of your students."
|
||||
/>
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta
|
||||
property="og:title"
|
||||
content="ClassroomIO – The Open Source Platform for Tech Bootcamps"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Spend less time doing the boring stuffs, instead focus on impacting the lives of your students."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
<meta property="og:image:width" content="1920" />
|
||||
<meta property="og:image:height" content="1080" />
|
||||
<meta
|
||||
property="og:image:secure_url"
|
||||
content="https://classroomio.com/classroomio-opengraph-image.png"
|
||||
/>
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:creator" content="@rotimi_best" />
|
||||
<meta
|
||||
name="twitter:title"
|
||||
content="ClassroomIO – The Open Source Platform for Tech Bootcamps"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Spend less time doing the boring stuffs, instead focus on impacting the lives of your students."
|
||||
/>
|
||||
|
||||
<!-- <link rel="stylesheet" href="global.css"> -->
|
||||
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
|
||||
<link rel="icon" type="image/png" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" />
|
||||
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" />
|
||||
|
||||
<!--Crisp Chat
|
||||
<script
|
||||
type="text/javascript">window.$crisp = []; window.CRISP_WEBSITE_ID = "2158dbf9-90d4-4e50-b962-e1d8a1a6deb7"; (function () { d = document; s = d.createElement("script"); s.src = "https://client.crisp.chat/l.js"; s.async = 1; d.getElementsByTagName("head")[0].appendChild(s); })();</script>
|
||||
-->
|
||||
<script src="https://cdn.plyr.io/3.7.3/plyr.js"></script>
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover" class="">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,638 @@
|
||||
/*Write your global styles here, in PostCSS syntax */
|
||||
@tailwind base;
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/** Global scrollbar styles */
|
||||
html {
|
||||
--scrollbarBG: #fff;
|
||||
--thumbBG: #ccc;
|
||||
scroll-behavior: smooth;
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
Segoe UI,
|
||||
Roboto,
|
||||
Helvetica Neue,
|
||||
Arial,
|
||||
Noto Sans,
|
||||
sans-serif,
|
||||
'Apple Color Emoji',
|
||||
'Segoe UI Emoji',
|
||||
Segoe UI Symbol,
|
||||
'Noto Color Emoji' !important;
|
||||
}
|
||||
|
||||
#calendar .grid {
|
||||
--sc-theme-calendar-colors-background-highlight: var(--main-primary-color);
|
||||
}
|
||||
|
||||
html .dark {
|
||||
--scrollbarBG: #1f2937;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--thumbBG) var(--scrollbarBG);
|
||||
}
|
||||
html .dark {
|
||||
--scrollbarBG: #1f2937;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: var(--scrollbarBG);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: var(--thumbBG);
|
||||
border-radius: 10px;
|
||||
border: 2px solid var(--scrollbarBG);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: revert;
|
||||
font-weight: revert;
|
||||
margin: 0.8em 0;
|
||||
}
|
||||
|
||||
.prose h1,
|
||||
.prose h2,
|
||||
.prose h3,
|
||||
.prose h4,
|
||||
.prose h5,
|
||||
.prose h6 {
|
||||
margin-top: 0px !important;
|
||||
margin-bottom: 16px !important;
|
||||
font-weight: 600 !important;
|
||||
line-height: 1.25 !important;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
margin-top: 0px !important;
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
|
||||
.dark .prose h1,
|
||||
.dark .prose h2,
|
||||
.dark .prose h3,
|
||||
.dark .prose h4,
|
||||
.dark .prose h5,
|
||||
.dark .prose h6,
|
||||
.dark .prose li,
|
||||
.dark .prose strong,
|
||||
.dark .prose li::marker,
|
||||
.dark .prose p {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
code {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
[aria-current] :global(svg.carbon-icon),
|
||||
.active :global(svg.carbon-icon) {
|
||||
fill: var(--main-primary-color);
|
||||
}
|
||||
|
||||
.prose a {
|
||||
color: var(--main-primary-color);
|
||||
}
|
||||
|
||||
.success :global(svg.carbon-icon) {
|
||||
fill: green;
|
||||
}
|
||||
|
||||
:global(svg.carbon-icon.error) {
|
||||
fill: red;
|
||||
}
|
||||
|
||||
@tailwind components;
|
||||
|
||||
@tailwind utilities;
|
||||
|
||||
/**
|
||||
Rose Theme
|
||||
*/
|
||||
.theme-rose .bg-primary-50 {
|
||||
background-color: #fff1f2;
|
||||
}
|
||||
.theme-rose .text-primary-50 {
|
||||
color: #fff1f2;
|
||||
}
|
||||
.theme-rose .border-primary-50 {
|
||||
border-color: #fff1f2;
|
||||
}
|
||||
|
||||
.theme-rose .bg-primary-100 {
|
||||
background-color: #ffe4e7;
|
||||
}
|
||||
.theme-rose .text-primary-100 {
|
||||
color: #ffe4e7;
|
||||
}
|
||||
.theme-rose .border-primary-100 {
|
||||
border-color: #ffe4e7;
|
||||
}
|
||||
|
||||
.theme-rose .bg-primary-200 {
|
||||
background-color: #fecdd4;
|
||||
}
|
||||
.theme-rose .text-primary-200 {
|
||||
color: #fecdd4;
|
||||
}
|
||||
.theme-rose .border-primary-200 {
|
||||
border-color: #fecdd4;
|
||||
}
|
||||
|
||||
.theme-rose .bg-primary-300 {
|
||||
background-color: #fda4b2;
|
||||
}
|
||||
.theme-rose .text-primary-300 {
|
||||
color: #fda4b2;
|
||||
}
|
||||
.theme-rose .border-primary-300 {
|
||||
border-color: #fda4b2;
|
||||
}
|
||||
|
||||
.theme-rose .bg-primary-400 {
|
||||
background-color: #fb7189;
|
||||
}
|
||||
.theme-rose .text-primary-400 {
|
||||
color: #fb7189;
|
||||
}
|
||||
.theme-rose .border-primary-400 {
|
||||
border-color: #fb7189;
|
||||
}
|
||||
|
||||
.theme-rose .bg-primary-500 {
|
||||
background-color: #f43f63;
|
||||
}
|
||||
.theme-rose .text-primary-500 {
|
||||
color: #f43f63;
|
||||
}
|
||||
.theme-rose .border-primary-500 {
|
||||
border-color: #f43f63;
|
||||
}
|
||||
|
||||
.theme-rose .bg-primary-600 {
|
||||
background-color: #e21d4e;
|
||||
}
|
||||
.theme-rose .text-primary-600 {
|
||||
color: #e21d4e;
|
||||
}
|
||||
.theme-rose .border-primary-600 {
|
||||
border-color: #e21d4e;
|
||||
}
|
||||
|
||||
.theme-rose .bg-primary-700 {
|
||||
background-color: #be1241;
|
||||
}
|
||||
.theme-rose .text-primary-700 {
|
||||
color: #be1241;
|
||||
}
|
||||
.theme-rose .border-primary-700 {
|
||||
border-color: #be1241;
|
||||
}
|
||||
|
||||
.theme-rose .bg-primary-800 {
|
||||
background-color: #9f123d;
|
||||
}
|
||||
.theme-rose .text-primary-800 {
|
||||
color: #9f123d;
|
||||
}
|
||||
.theme-rose .border-primary-800 {
|
||||
border-color: #9f123d;
|
||||
}
|
||||
|
||||
.theme-rose .bg-primary-900 {
|
||||
background-color: #88133a;
|
||||
}
|
||||
.theme-rose .text-primary-900 {
|
||||
color: #88133a;
|
||||
}
|
||||
.theme-rose .border-primary-900 {
|
||||
border-color: #88133a;
|
||||
}
|
||||
|
||||
/**
|
||||
Blue Theme
|
||||
*/
|
||||
.bg-primary-50 {
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
.text-primary-50 {
|
||||
color: #eff6ff;
|
||||
}
|
||||
.border-primary-50 {
|
||||
border-color: #eff6ff;
|
||||
}
|
||||
|
||||
.bg-primary-100 {
|
||||
background-color: #dbeafe;
|
||||
}
|
||||
.text-primary-100 {
|
||||
color: #dbeafe;
|
||||
}
|
||||
.border-primary-100 {
|
||||
border-color: #dbeafe;
|
||||
}
|
||||
|
||||
.bg-primary-200 {
|
||||
background-color: #bfdafe;
|
||||
}
|
||||
.text-primary-200 {
|
||||
color: #bfdafe;
|
||||
}
|
||||
.border-primary-200 {
|
||||
border-color: #bfdafe;
|
||||
}
|
||||
|
||||
.bg-primary-300 {
|
||||
background-color: #92c4fe;
|
||||
}
|
||||
.text-primary-300 {
|
||||
color: #92c4fe;
|
||||
}
|
||||
.border-primary-300 {
|
||||
border-color: #92c4fe;
|
||||
}
|
||||
|
||||
.bg-primary-400 {
|
||||
background-color: #5fa3fb;
|
||||
}
|
||||
.text-primary-400 {
|
||||
color: #5fa3fb;
|
||||
}
|
||||
.border-primary-400 {
|
||||
border-color: #5fa3fb;
|
||||
}
|
||||
|
||||
.bg-primary-500 {
|
||||
background-color: #3a7ff7;
|
||||
}
|
||||
.text-primary-500 {
|
||||
color: #3a7ff7;
|
||||
}
|
||||
.border-primary-500 {
|
||||
border-color: #3a7ff7;
|
||||
}
|
||||
|
||||
.bg-primary-600 {
|
||||
background-color: #2460ec;
|
||||
}
|
||||
.text-primary-600 {
|
||||
color: #2460ec;
|
||||
}
|
||||
.border-primary-600 {
|
||||
border-color: #2460ec;
|
||||
}
|
||||
|
||||
.bg-primary-700 {
|
||||
background-color: #1d4ee2;
|
||||
}
|
||||
.text-primary-700 {
|
||||
color: #1d4ee2;
|
||||
}
|
||||
.border-primary-700 {
|
||||
border-color: #1d4ee2;
|
||||
}
|
||||
|
||||
.bg-primary-800 {
|
||||
background-color: #1d3eb0;
|
||||
}
|
||||
.text-primary-800 {
|
||||
color: #1d3eb0;
|
||||
}
|
||||
.border-primary-800 {
|
||||
border-color: #1d3eb0;
|
||||
}
|
||||
|
||||
.bg-primary-900 {
|
||||
background-color: #1d398b;
|
||||
}
|
||||
.text-primary-900 {
|
||||
color: #1d398b;
|
||||
}
|
||||
.border-primary-900 {
|
||||
border-color: #1d398b;
|
||||
}
|
||||
|
||||
/**
|
||||
Green Theme
|
||||
*/
|
||||
.theme-green .bg-primary-50 {
|
||||
background-color: #effef0;
|
||||
}
|
||||
.theme-green .text-primary-50 {
|
||||
color: #effef0;
|
||||
}
|
||||
.theme-green .border-primary-50 {
|
||||
border-color: #effef0;
|
||||
}
|
||||
|
||||
.theme-green .bg-primary-100 {
|
||||
background-color: #d9ffdc;
|
||||
}
|
||||
.theme-green .text-primary-100 {
|
||||
color: #d9ffdc;
|
||||
}
|
||||
.theme-green .border-primary-100 {
|
||||
border-color: #d9ffdc;
|
||||
}
|
||||
|
||||
.theme-green .bg-primary-200 {
|
||||
background-color: #b6fcbc;
|
||||
}
|
||||
.theme-green .text-primary-200 {
|
||||
color: #b6fcbc;
|
||||
}
|
||||
.theme-green .border-primary-200 {
|
||||
border-color: #b6fcbc;
|
||||
}
|
||||
|
||||
.theme-green .bg-primary-300 {
|
||||
background-color: #7df88a;
|
||||
}
|
||||
.theme-green .text-primary-300 {
|
||||
color: #7df88a;
|
||||
}
|
||||
.theme-green .border-primary-300 {
|
||||
border-color: #7df88a;
|
||||
}
|
||||
|
||||
.theme-green .bg-primary-400 {
|
||||
background-color: #3deb51;
|
||||
}
|
||||
.theme-green .text-primary-400 {
|
||||
color: #3deb51;
|
||||
}
|
||||
.theme-green .border-primary-400 {
|
||||
border-color: #3deb51;
|
||||
}
|
||||
|
||||
.theme-green .bg-primary-500 {
|
||||
background-color: #12bf26;
|
||||
}
|
||||
.theme-green .text-primary-500 {
|
||||
color: #12bf26;
|
||||
}
|
||||
.theme-green .border-primary-500 {
|
||||
border-color: #12bf26;
|
||||
}
|
||||
|
||||
.theme-green .bg-primary-600 {
|
||||
background-color: #0aaf1d;
|
||||
}
|
||||
.theme-green .text-primary-600 {
|
||||
color: #0aaf1d;
|
||||
}
|
||||
.theme-green .border-primary-600 {
|
||||
border-color: #0aaf1d;
|
||||
}
|
||||
|
||||
.theme-green .bg-primary-700 {
|
||||
background-color: #0c891b;
|
||||
}
|
||||
.theme-green .text-primary-700 {
|
||||
color: #0c891b;
|
||||
}
|
||||
.theme-green .border-primary-700 {
|
||||
border-color: #0c891b;
|
||||
}
|
||||
|
||||
.theme-green .bg-primary-800 {
|
||||
background-color: #0f6c1b;
|
||||
}
|
||||
.theme-green .text-primary-800 {
|
||||
color: #0f6c1b;
|
||||
}
|
||||
.theme-green .border-primary-800 {
|
||||
border-color: #0f6c1b;
|
||||
}
|
||||
|
||||
.theme-green .bg-primary-900 {
|
||||
background-color: #0f581a;
|
||||
}
|
||||
.theme-green .text-primary-900 {
|
||||
color: #0f581a;
|
||||
}
|
||||
.theme-green .border-primary-900 {
|
||||
border-color: #0f581a;
|
||||
}
|
||||
|
||||
/**
|
||||
Orange Theme
|
||||
*/
|
||||
.theme-orange .bg-primary-50 {
|
||||
background-color: #fff8ec;
|
||||
}
|
||||
.theme-orange .text-primary-50 {
|
||||
color: #fff8ec;
|
||||
}
|
||||
.theme-orange .border-primary-50 {
|
||||
border-color: #fff8ec;
|
||||
}
|
||||
|
||||
.theme-orange .bg-primary-100 {
|
||||
background-color: #fff0d3;
|
||||
}
|
||||
.theme-orange .text-primary-100 {
|
||||
color: #fff0d3;
|
||||
}
|
||||
.theme-orange .border-primary-100 {
|
||||
border-color: #fff0d3;
|
||||
}
|
||||
|
||||
.theme-orange .bg-primary-200 {
|
||||
background-color: #ffdca5;
|
||||
}
|
||||
.theme-orange .text-primary-200 {
|
||||
color: #ffdca5;
|
||||
}
|
||||
.theme-orange .border-primary-200 {
|
||||
border-color: #ffdca5;
|
||||
}
|
||||
|
||||
.theme-orange .bg-primary-300 {
|
||||
background-color: #ffc26d;
|
||||
}
|
||||
.theme-orange .text-primary-300 {
|
||||
color: #ffc26d;
|
||||
}
|
||||
.theme-orange .border-primary-300 {
|
||||
border-color: #ffc26d;
|
||||
}
|
||||
|
||||
.theme-orange .bg-primary-400 {
|
||||
background-color: #ff9d32;
|
||||
}
|
||||
.theme-orange .text-primary-400 {
|
||||
color: #ff9d32;
|
||||
}
|
||||
.theme-orange .border-primary-400 {
|
||||
border-color: #ff9d32;
|
||||
}
|
||||
|
||||
.theme-orange .bg-primary-500 {
|
||||
background-color: #ff7f0a;
|
||||
}
|
||||
.theme-orange .text-primary-500 {
|
||||
color: #ff7f0a;
|
||||
}
|
||||
.theme-orange .border-primary-500 {
|
||||
border-color: #ff7f0a;
|
||||
}
|
||||
|
||||
.theme-orange .bg-primary-600 {
|
||||
background-color: #ff6600;
|
||||
}
|
||||
.theme-orange .text-primary-600 {
|
||||
color: #ff6600;
|
||||
}
|
||||
.theme-orange .border-primary-600 {
|
||||
border-color: #ff6600;
|
||||
}
|
||||
|
||||
.theme-orange .bg-primary-700 {
|
||||
background-color: #cc4902;
|
||||
}
|
||||
.theme-orange .text-primary-700 {
|
||||
color: #cc4902;
|
||||
}
|
||||
.theme-orange .border-primary-700 {
|
||||
border-color: #cc4902;
|
||||
}
|
||||
|
||||
.theme-orange .bg-primary-800 {
|
||||
background-color: #a1390b;
|
||||
}
|
||||
.theme-orange .text-primary-800 {
|
||||
color: #a1390b;
|
||||
}
|
||||
.theme-orange .border-primary-800 {
|
||||
border-color: #a1390b;
|
||||
}
|
||||
|
||||
.theme-orange .bg-primary-900 {
|
||||
background-color: #82310c;
|
||||
}
|
||||
.theme-orange .text-primary-900 {
|
||||
color: #82310c;
|
||||
}
|
||||
.theme-orange .border-primary-900 {
|
||||
border-color: #82310c;
|
||||
}
|
||||
|
||||
/**
|
||||
Violet Theme
|
||||
*/
|
||||
.theme-violet .bg-primary-50 {
|
||||
background-color: #fff2fe;
|
||||
}
|
||||
.theme-violet .text-primary-50 {
|
||||
color: #fff2fe;
|
||||
}
|
||||
.theme-violet .border-primary-50 {
|
||||
border-color: #fff2fe;
|
||||
}
|
||||
|
||||
.theme-violet .bg-primary-100 {
|
||||
background-color: #ffe3fe;
|
||||
}
|
||||
.theme-violet .text-primary-100 {
|
||||
color: #ffe3fe;
|
||||
}
|
||||
.theme-violet .border-primary-100 {
|
||||
border-color: #ffe3fe;
|
||||
}
|
||||
|
||||
.theme-violet .bg-primary-200 {
|
||||
background-color: #ffc6fe;
|
||||
}
|
||||
.theme-violet .text-primary-200 {
|
||||
color: #ffc6fe;
|
||||
}
|
||||
.theme-violet .border-primary-200 {
|
||||
border-color: #ffc6fe;
|
||||
}
|
||||
|
||||
.theme-violet .bg-primary-300 {
|
||||
background-color: #ff99f7;
|
||||
}
|
||||
.theme-violet .text-primary-300 {
|
||||
color: #ff99f7;
|
||||
}
|
||||
.theme-violet .border-primary-300 {
|
||||
border-color: #ff99f7;
|
||||
}
|
||||
|
||||
.theme-violet .bg-primary-400 {
|
||||
background-color: #ff5df2;
|
||||
}
|
||||
.theme-violet .text-primary-400 {
|
||||
color: #ff5df2;
|
||||
}
|
||||
.theme-violet .border-primary-400 {
|
||||
border-color: #ff5df2;
|
||||
}
|
||||
|
||||
.theme-violet .bg-primary-500 {
|
||||
background-color: #ff21f5;
|
||||
}
|
||||
.theme-violet .text-primary-500 {
|
||||
color: #ff21f5;
|
||||
}
|
||||
.theme-violet .border-primary-500 {
|
||||
border-color: #ff21f5;
|
||||
}
|
||||
|
||||
.theme-violet .bg-primary-600 {
|
||||
background-color: #fb00ff;
|
||||
}
|
||||
.theme-violet .text-primary-600 {
|
||||
color: #fb00ff;
|
||||
}
|
||||
.theme-violet .border-primary-600 {
|
||||
border-color: #fb00ff;
|
||||
}
|
||||
|
||||
.theme-violet .bg-primary-700 {
|
||||
background-color: #cf00ce;
|
||||
}
|
||||
.theme-violet .text-primary-700 {
|
||||
color: #cf00ce;
|
||||
}
|
||||
.theme-violet .border-primary-700 {
|
||||
border-color: #cf00ce;
|
||||
}
|
||||
|
||||
.theme-violet .bg-primary-800 {
|
||||
background-color: #a900a6;
|
||||
}
|
||||
.theme-violet .text-primary-800 {
|
||||
color: #a900a6;
|
||||
}
|
||||
.theme-violet .border-primary-800 {
|
||||
border-color: #a900a6;
|
||||
}
|
||||
|
||||
.theme-violet .bg-primary-900 {
|
||||
background-color: #890684;
|
||||
}
|
||||
.theme-violet .text-primary-900 {
|
||||
color: #890684;
|
||||
}
|
||||
.theme-violet .border-primary-900 {
|
||||
border-color: #890684;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
supabaseConfig: {
|
||||
bucketPath: 'https://tapaozmyjsuykgerrfkt.supabase.in/storage/v1/object/sign/'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<script>
|
||||
import ErrorIcon from '$lib/components/Icons/ErrorIcon.svelte';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="error-container m-auto flex flex-col-reverse lg:flex-row md:flex-col-reverse items-center justify-center h-screen w-screen 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="mailto:[email protected]"
|
||||
>Contact Us</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="w-72 lg:w-2/4 flex items-center justify-center">
|
||||
<ErrorIcon />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.error-container {
|
||||
max-width: 964px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import { useCompletion } from 'ai/svelte';
|
||||
import { Popover } from 'carbon-components-svelte';
|
||||
import type { PopoverProps } from 'carbon-components-svelte/types/Popover/Popover.svelte';
|
||||
import MagicWandFilled from 'carbon-icons-svelte/lib/MagicWandFilled.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import { Loading } from 'carbon-components-svelte';
|
||||
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
|
||||
|
||||
export let className = '';
|
||||
export let handleInsert = (v: string) => {};
|
||||
export let defaultPrompt = '';
|
||||
export let alignPopover: PopoverProps['align'] = 'left';
|
||||
export let isHTML = false;
|
||||
|
||||
let openPopover = false;
|
||||
let plainText = '';
|
||||
let textRef: HTMLParagraphElement;
|
||||
let prompt: string = defaultPrompt;
|
||||
|
||||
let isSubmitted = false;
|
||||
|
||||
const { input, handleSubmit, completion, isLoading } = useCompletion({
|
||||
api: '/api/completion/customprompt'
|
||||
});
|
||||
|
||||
function restart() {
|
||||
isSubmitted = false;
|
||||
plainText = '';
|
||||
}
|
||||
|
||||
function onCompletion(completion: string) {
|
||||
plainText = completion;
|
||||
if (textRef) {
|
||||
textRef.scrollTop = textRef.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function callAI(prompt: string, rephrase: boolean) {
|
||||
isSubmitted = true;
|
||||
|
||||
$input = rephrase ? `Can you please rephrase this: ${plainText}` : prompt;
|
||||
|
||||
setTimeout(() => {
|
||||
handleSubmit({ preventDefault: () => {} });
|
||||
}, 500);
|
||||
}
|
||||
|
||||
$: onCompletion($completion);
|
||||
$: prompt = defaultPrompt;
|
||||
</script>
|
||||
|
||||
<div class="{className} relative z-[100]">
|
||||
<IconButton
|
||||
contained={true}
|
||||
color="text-primary-700 dark:bg-neutral-500 dark:text-white"
|
||||
size="large"
|
||||
onClick={() => (openPopover = !openPopover)}
|
||||
>
|
||||
<MagicWandFilled size={16} class="carbon-icon" />
|
||||
<Popover caret align={alignPopover} bind:open={openPopover}>
|
||||
<button
|
||||
class="p-2 w-[300px] h-[220px] text-start"
|
||||
type="button"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
>
|
||||
{#if isSubmitted}
|
||||
<div class="h-[200px]">
|
||||
<div
|
||||
bind:this={textRef}
|
||||
class="h-[82%] bg-white dark:bg-neutral-700 p-2 overflow-y-auto text-sm text-start w-full my-1"
|
||||
>
|
||||
{#if isHTML}
|
||||
<HtmlRender content={plainText} />
|
||||
{:else}
|
||||
<p class="font-normal">
|
||||
{plainText}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-5 ml-2">
|
||||
<button
|
||||
class="text-xs px-4 py-2 border-[1px] cursor-pointer rounded-md font-medium"
|
||||
on:click={() => {
|
||||
handleInsert(plainText);
|
||||
openPopover = false;
|
||||
}}
|
||||
type="button"
|
||||
disabled={$isLoading}
|
||||
>
|
||||
{#if $isLoading}
|
||||
<Loading withOverlay={false} small />
|
||||
{:else}
|
||||
Insert
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="text-xs px-4 py-2 border-[1px] cursor-pointer rounded-md font-medium {$isLoading &&
|
||||
'opacity-25 cursor-not-allowed'}"
|
||||
on:click|preventDefault={() => callAI(prompt, true)}
|
||||
type="button"
|
||||
disabled={$isLoading}
|
||||
>
|
||||
Rephrase
|
||||
</button>
|
||||
{#if !$isLoading}
|
||||
<button
|
||||
class="text-xs px-4 py-2 border-[1px] cursor-pointer rounded-md font-medium"
|
||||
on:click|preventDefault={restart}
|
||||
type="button"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-full">
|
||||
<p class="text-sm font-medium mb-2 ml-1">Help me write</p>
|
||||
<textarea
|
||||
bind:value={prompt}
|
||||
class="w-full border-0 rounded-lg h-[65%] text-sm m-0 dark:text-black"
|
||||
placeholder="Help me write a short description"
|
||||
/>
|
||||
<button
|
||||
class="flex text-xs px-4 py-2 m-0 border rounded-md font-medium"
|
||||
on:click|preventDefault={() => callAI(prompt, false)}
|
||||
type="button"
|
||||
>
|
||||
Start typing...
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</Popover>
|
||||
</IconButton>
|
||||
</div>
|
||||
@@ -0,0 +1,240 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import PageNav from '$lib/components/PageNav/index.svelte';
|
||||
import CloseButton from '$lib/components/Buttons/Close/index.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import { getSupabase } from '$lib/utils/functions/supabase';
|
||||
import type {
|
||||
RealtimeChannel,
|
||||
RealtimePostgresChangesPayload,
|
||||
PostgrestSingleResponse
|
||||
} from '@supabase/supabase-js';
|
||||
import type { LessonCommentInsertPayload, GroupPerson, LessonComment } from '$lib/utils/types';
|
||||
import { lesson } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { group } from '$lib/components/Course/store';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
|
||||
export let handleClose = () => {};
|
||||
|
||||
const supabase = getSupabase();
|
||||
|
||||
let comment = '';
|
||||
let bodyRef: HTMLDivElement;
|
||||
let lessonCommentChannel: RealtimeChannel;
|
||||
let comments: LessonComment[] = [];
|
||||
let groupmember: GroupPerson | undefined;
|
||||
let isSaving: boolean = false;
|
||||
let isFetched = false;
|
||||
|
||||
interface FetchComments {
|
||||
comment: string;
|
||||
created_at: string;
|
||||
groupmember: {
|
||||
id: string;
|
||||
profile: {
|
||||
avatar_url: string;
|
||||
fullname: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
console.log('groupmember', groupmember);
|
||||
if (!comment || !groupmember) {
|
||||
return;
|
||||
}
|
||||
|
||||
comments = [
|
||||
...comments,
|
||||
{
|
||||
comment: comment,
|
||||
name: 'You',
|
||||
avatar: $profile.avatar_url,
|
||||
commentAt: new Date()
|
||||
}
|
||||
];
|
||||
$lesson.totalComments = comments.length;
|
||||
scrollToBottom();
|
||||
|
||||
isSaving = true;
|
||||
supabase
|
||||
.from('lesson_comment')
|
||||
.insert({
|
||||
lesson_id: $lesson.id,
|
||||
groupmember_id: groupmember.id,
|
||||
comment
|
||||
})
|
||||
.then(() => {
|
||||
isSaving = false;
|
||||
comment = '';
|
||||
});
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
setTimeout(() => {
|
||||
bodyRef.scrollTo({
|
||||
top: bodyRef.scrollHeight
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
async function handleInsert(payload: RealtimePostgresChangesPayload<LessonCommentInsertPayload>) {
|
||||
const insertedComment = payload.new as LessonCommentInsertPayload;
|
||||
|
||||
if (groupmember && groupmember.id === insertedComment.groupmember_id) {
|
||||
console.log('is my comment');
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
data,
|
||||
error
|
||||
}: PostgrestSingleResponse<{
|
||||
id: string;
|
||||
profile: {
|
||||
fullname: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
}> = await supabase
|
||||
.from('groupmember')
|
||||
.select('id, profile:profile_id(fullname, avatar_url)')
|
||||
.eq('id', insertedComment.groupmember_id)
|
||||
.single();
|
||||
|
||||
console.log('data', data);
|
||||
console.log('error', error);
|
||||
|
||||
if (error || !data) {
|
||||
return;
|
||||
}
|
||||
|
||||
comments = [
|
||||
...comments,
|
||||
{
|
||||
comment: insertedComment.comment,
|
||||
name: data.profile.fullname,
|
||||
avatar: data.profile.avatar_url,
|
||||
commentAt: insertedComment.created_at
|
||||
}
|
||||
];
|
||||
$lesson.totalComments = comments.length;
|
||||
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
async function fetchComments(people: GroupPerson[], lessonId?: string | null) {
|
||||
groupmember = people.find((person) => person.profile_id === $profile.id);
|
||||
|
||||
if (!groupmember || !lessonId) return;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('lesson_comment')
|
||||
.select(
|
||||
`
|
||||
groupmember_id,
|
||||
created_at,
|
||||
comment,
|
||||
groupmember:groupmember_id(
|
||||
id,
|
||||
profile:profile_id(fullname, avatar_url)
|
||||
)
|
||||
`
|
||||
)
|
||||
.match({
|
||||
lesson_id: lessonId
|
||||
})
|
||||
.returns<FetchComments[]>();
|
||||
|
||||
if (error || !data) {
|
||||
console.error('error');
|
||||
return;
|
||||
}
|
||||
console.log('fetch comments', data);
|
||||
|
||||
comments = data.map((lessonComment) => {
|
||||
return {
|
||||
comment: lessonComment.comment,
|
||||
avatar: lessonComment.groupmember.profile.avatar_url,
|
||||
commentAt: lessonComment.created_at,
|
||||
name:
|
||||
lessonComment.groupmember.id === groupmember?.id
|
||||
? 'You'
|
||||
: lessonComment.groupmember.profile.fullname
|
||||
};
|
||||
});
|
||||
isFetched = true;
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
lessonCommentChannel = supabase
|
||||
.channel('any')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: '*', schema: 'public', table: 'lesson_comment' },
|
||||
handleInsert
|
||||
)
|
||||
.subscribe();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
supabase.removeChannel(lessonCommentChannel);
|
||||
});
|
||||
|
||||
$: !isFetched && fetchComments($group.people, $lesson.id);
|
||||
</script>
|
||||
|
||||
<PageNav title="Comments" overidableStyle="padding: 0 10px">
|
||||
<div slot="widget">
|
||||
<CloseButton onClick={handleClose} />
|
||||
</div>
|
||||
</PageNav>
|
||||
|
||||
<div bind:this={bodyRef} class="overflow-y-auto h-[87%] pb-10 px-2 md:min-w-[350px] w-full">
|
||||
{#each comments as comment}
|
||||
<div class="pb-2 mt-2">
|
||||
<div class="flex items-start">
|
||||
<img
|
||||
alt="Placeholder"
|
||||
class="block rounded-full"
|
||||
width="24"
|
||||
height="20"
|
||||
src={comment.avatar}
|
||||
/>
|
||||
<div class="ml-2">
|
||||
<p class="dark:text-white text-sm font-bold">{comment.name}</p>
|
||||
<p class="dark:text-white text-xs font-normal text-gray-800">
|
||||
{calDateDiff(comment.commentAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<article class="prose prose-sm sm:prose ml-8 max-w-[300px] dark:text-white">
|
||||
{comment.comment}
|
||||
</article>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<TextField
|
||||
placeholder="Say something"
|
||||
bind:value={comment}
|
||||
onKeyDown={handleKeyDown}
|
||||
isDisabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.footer {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
import PageNav from '$lib/components/PageNav/index.svelte';
|
||||
import CloseButton from '$lib/components/Buttons/Close/index.svelte';
|
||||
|
||||
export let handleClose;
|
||||
</script>
|
||||
|
||||
<PageNav title="Note" overidableStyle="padding: 0 10px">
|
||||
<div slot="widget">
|
||||
<CloseButton onClick={handleClose} />
|
||||
</div>
|
||||
</PageNav>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script>
|
||||
export let name;
|
||||
export let src;
|
||||
export let className;
|
||||
</script>
|
||||
|
||||
<img alt={name} {src} class="w-10 h-10 rounded-full bg-primary-100 {className}" />
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import DateTime from '$lib/components/Form/DateTime.svelte';
|
||||
import type { PollType } from '../types';
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import { Add, TrashCan } from 'carbon-icons-svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
|
||||
export let title = 'Poll';
|
||||
export let isSaving = false;
|
||||
export let onSubmit = (p: PollType) => {};
|
||||
export let onCancel = () => {};
|
||||
|
||||
let poll: PollType = {
|
||||
id: new Date().getTime().toString(),
|
||||
courseId: $course.id || '',
|
||||
question: '',
|
||||
expiration: new Date().toDateString(),
|
||||
isPublic: false,
|
||||
status: 'draft',
|
||||
author: {
|
||||
id: $profile.id || '',
|
||||
username: $profile.username || '',
|
||||
fullname: $profile.fullname || '',
|
||||
avatarUrl: $profile.avatar_url || ''
|
||||
},
|
||||
options: [
|
||||
{
|
||||
id: '',
|
||||
label: '',
|
||||
selectedBy: []
|
||||
}
|
||||
]
|
||||
};
|
||||
let isCreateDisabled = false;
|
||||
|
||||
function handleAddOptions() {
|
||||
poll = {
|
||||
...poll,
|
||||
options: [
|
||||
...poll.options,
|
||||
{
|
||||
id: '',
|
||||
label: '',
|
||||
selectedBy: []
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function handleRemoveOptions(index: string | number) {
|
||||
return () => {
|
||||
poll.options = poll.options.filter((_, i) => i !== index);
|
||||
};
|
||||
}
|
||||
|
||||
function finishPoll() {
|
||||
onSubmit(poll);
|
||||
}
|
||||
|
||||
$: isCreateDisabled = poll.question === '' || poll.options.length < 2;
|
||||
</script>
|
||||
|
||||
<div class="border rounded-md">
|
||||
<div class="border-b-2 p-4 text-center">
|
||||
{title}
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<TextField
|
||||
label="Question"
|
||||
className="w-full mb-3"
|
||||
bind:value={poll.question}
|
||||
placeholder="Poll question"
|
||||
isRequired={true}
|
||||
/>
|
||||
<DateTime
|
||||
label="Expiration"
|
||||
className="w-full mb-3"
|
||||
bind:value={poll.expiration}
|
||||
isRequired={true}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p class="dark:text-white p-0 m-0 mb-1 text-md flex items-center gap-2">
|
||||
Options
|
||||
|
||||
<IconButton onClick={handleAddOptions} contained={true} size="small">
|
||||
<Add size={16} />
|
||||
</IconButton>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#each poll.options as option, index}
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<TextField
|
||||
label=""
|
||||
className="w-full"
|
||||
bind:value={option.label}
|
||||
placeholder="Option Label"
|
||||
isRequired={true}
|
||||
/>
|
||||
<IconButton onClick={handleRemoveOptions(index)} contained={true} size="small">
|
||||
<TrashCan size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-center gap-2 mb-3">
|
||||
<PrimaryButton
|
||||
label="Create poll"
|
||||
onClick={finishPoll}
|
||||
isLoading={isSaving}
|
||||
isDisabled={isCreateDisabled}
|
||||
/>
|
||||
<PrimaryButton label="Cancel" variant={VARIANTS.OUTLINED} onClick={onCancel} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
<p
|
||||
class="dark:text-white p-0 m-0 mb-1 uppercase text-sm font-bold text-gray-600"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
@@ -0,0 +1,204 @@
|
||||
<script lang="ts">
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import Label from './Label.svelte';
|
||||
import Avatar from './Avatar.svelte';
|
||||
import type { PollType } from '../types';
|
||||
import { updatePollStatus } from '../service';
|
||||
|
||||
export let poll: PollType;
|
||||
export let onSelect: (a: string) => void;
|
||||
export let handlePollDelete = () => {};
|
||||
export let currentUserId: number | string;
|
||||
|
||||
let viewResult = false;
|
||||
let selectedOptionToView: { selectedBy: PollType['options'][0]['selectedBy'] } | null;
|
||||
let isAuthor = false;
|
||||
let totalVotes = 0;
|
||||
let isThereAnyVote = false;
|
||||
|
||||
function getTotalVotes(options: PollType['options']) {
|
||||
return options.reduce((acc, cur) => acc + cur.selectedBy.length, 0);
|
||||
}
|
||||
|
||||
function calculatePercent(
|
||||
options: PollType['options'],
|
||||
currentOption: { selectedBy: string | PollType['options'][0]['selectedBy'] }
|
||||
) {
|
||||
const totalVoteOfAllOptions = getTotalVotes(options);
|
||||
const totalVotesOfCurrentOption = currentOption.selectedBy.length;
|
||||
|
||||
return Math.round((totalVotesOfCurrentOption / totalVoteOfAllOptions) * 100) || 0;
|
||||
}
|
||||
|
||||
function didUserSelect(
|
||||
option: { selectedBy: PollType['options'][0]['selectedBy'] },
|
||||
userId: number | string
|
||||
) {
|
||||
return option.selectedBy.find((user) => user.id === userId);
|
||||
}
|
||||
|
||||
let prevStatus = poll.status;
|
||||
function onStatusChange(status: string) {
|
||||
if (status === prevStatus || !status) return;
|
||||
|
||||
updatePollStatus(poll.id, status);
|
||||
prevStatus = status;
|
||||
}
|
||||
|
||||
$: onStatusChange(poll.status);
|
||||
$: isAuthor = currentUserId === poll.author.id;
|
||||
$: totalVotes = getTotalVotes(poll.options);
|
||||
$: {
|
||||
isThereAnyVote = poll?.options?.some((option) => option.selectedBy.length) || false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-4 flex-wrap bg-white dark:bg-black border-2 rounded-md mb-3 w-full">
|
||||
{#if selectedOptionToView}
|
||||
<div>
|
||||
<div class="flex items-center mb-5">
|
||||
<button class="text-xl w-10 px-3" on:click={() => (selectedOptionToView = null)}>
|
||||
{`<`}
|
||||
</button>
|
||||
<div class="flex-grow text-center">
|
||||
<h3 class="dark:text-white font-bold">Voted</h3>
|
||||
</div>
|
||||
</div>
|
||||
{#each selectedOptionToView.selectedBy as user}
|
||||
<div class="flex items-center mb-2">
|
||||
<Avatar src={user.avatarUrl} name={user.username} className="mr-2" />
|
||||
<p class="dark:text-white">
|
||||
{user.fullname}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-wrap gap-2 mb-3 justify-between">
|
||||
<div>
|
||||
<h3 class="dark:text-white font-bold">
|
||||
{poll.question}
|
||||
</h3>
|
||||
<p class="dark:text-white text-sm italic">
|
||||
Created by <span class="text-primary-500">@{poll.author.username}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if isAuthor}
|
||||
<div class="flex mb-3 justify-end items-center">
|
||||
<label class="mr-2 font-bold" for="user-select">Status:</label>
|
||||
|
||||
<select
|
||||
name="users"
|
||||
id="user-select"
|
||||
class="dark:text-white dark:bg-black"
|
||||
bind:value={poll.status}
|
||||
>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published">Publish</option>
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if viewResult}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="mr-5">
|
||||
<Label>Expiration</Label>
|
||||
<p class="dark:text-white text-sm text-gray-600 mb-4">
|
||||
{poll.expiration || 'Today'}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<Label>Total Votes</Label>
|
||||
<p class="dark:text-white text-sm text-gray-600 mb-4">
|
||||
{totalVotes}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Label>Responses</Label>
|
||||
{#each poll.options as option}
|
||||
<div class="border-b p-5">
|
||||
<p class="dark:text-white">
|
||||
{option.label}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="w-4/5 h-2 bg-gray-200 relative">
|
||||
<span
|
||||
class="absolute top-0 left-0 bg-primary-700 h-full"
|
||||
style="width: {calculatePercent(poll.options, option)}%;"
|
||||
/>
|
||||
</div>
|
||||
{#if option.selectedBy.length}
|
||||
<button class="text-xl px-3 ml-5" on:click={() => (selectedOptionToView = option)}>
|
||||
>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
{#each option.selectedBy.slice(0, 3) as user}
|
||||
<Avatar src={user.avatarUrl} name={user.username} className="mr-2" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each poll.options as option, index}
|
||||
<button
|
||||
class="bg-white dark:bg-black rounded-md border-2 border-gray-100 h-[50px] {didUserSelect(
|
||||
option,
|
||||
currentUserId
|
||||
) &&
|
||||
'focus:border-primary-700 border-primary-700'} text-black dark:text-white p-2 w-full mb-3 text-left relative"
|
||||
on:click={() => onSelect(option.id)}
|
||||
>
|
||||
{#if isThereAnyVote}
|
||||
<span
|
||||
class="progress absolute top-0 text-black left-0 {didUserSelect(option, currentUserId)
|
||||
? 'bg-primary-700'
|
||||
: 'bg-gray-300'} h-full bg-opacity-25"
|
||||
style="width: {calculatePercent(poll.options, option)}%;"
|
||||
/>
|
||||
{/if}
|
||||
<p
|
||||
class="absolute top-3 {didUserSelect(option, currentUserId)
|
||||
? 'text-white'
|
||||
: 'dark:text-white text-black'} left-2"
|
||||
>
|
||||
{option.label}
|
||||
({calculatePercent(poll.options, option)}%)
|
||||
</p>
|
||||
</button>
|
||||
{/each}
|
||||
<div class="flex items-center">
|
||||
<p class="dark:text-white text-sm text-gray-600">
|
||||
Expires: {poll.expiration || 'Today'}
|
||||
</p>
|
||||
<span class="mx-2">|</span>
|
||||
<p class="dark:text-white text-sm text-gray-600">
|
||||
Voted: {totalVotes}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isAuthor}
|
||||
<div class="w-full flex items-center mt-3">
|
||||
<PrimaryButton
|
||||
label={viewResult ? 'Back' : 'Result'}
|
||||
onClick={() => (viewResult = !viewResult)}
|
||||
/>
|
||||
<PrimaryButton label="Delete" variant={VARIANTS.TEXT_DANGER} onClick={handlePollDelete} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.progress {
|
||||
transition: width 0.5s linear;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import Chip from '$lib/components/Chip/index.svelte';
|
||||
import RoleBaseSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
|
||||
import { Add } from 'carbon-icons-svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import type { TabsType } from '../types';
|
||||
|
||||
export let onCreate: () => void;
|
||||
export let tabs: TabsType = [];
|
||||
|
||||
export let selectedTab: number;
|
||||
</script>
|
||||
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
{#each tabs as tab}
|
||||
<button
|
||||
class="mr-3 pb-2 border-t-0 border-l-0 border-r-0 {selectedTab === tab.value
|
||||
? 'border-b-4 border-indigo-600 focus:border-indigo-600'
|
||||
: 'border-b-0'}"
|
||||
on:click={() => (selectedTab = tab.value)}
|
||||
>
|
||||
{tab.label}
|
||||
<Chip bind:value={tab.number} />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<RoleBaseSecurity allowedRoles={[1, 2]}>
|
||||
<IconButton onClick={onCreate} contained={true} size="large">
|
||||
<Add size={16} />
|
||||
</IconButton>
|
||||
</RoleBaseSecurity>
|
||||
</div>
|
||||
@@ -0,0 +1,288 @@
|
||||
<script lang="ts">
|
||||
import PageNav from '$lib/components/PageNav/index.svelte';
|
||||
import CloseButton from '$lib/components/Buttons/Close/index.svelte';
|
||||
import CreatePollForm from './components/CreatePollForm.svelte';
|
||||
import Poll from './components/Poll.svelte';
|
||||
import Tabs from './components/Tabs.svelte';
|
||||
import { polls } from './store';
|
||||
import type { PollType, TabsType, PollOptionsSubmissionType } from './types';
|
||||
import { apps } from '$lib/components/Apps/store';
|
||||
import { getSupabase } from '$lib/utils/functions/supabase';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { group, course } from '$lib/components/Course/store';
|
||||
import type { Groupmember } from '$lib/utils/types';
|
||||
import { fetchPolls, handleVote } from './service';
|
||||
import { getPollsData } from './utils';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import type {
|
||||
RealtimeChannel,
|
||||
RealtimePostgresChangesPayload,
|
||||
PostgrestSingleResponse
|
||||
} from '@supabase/supabase-js';
|
||||
|
||||
export let handleClose = () => {};
|
||||
|
||||
const supabase = getSupabase();
|
||||
|
||||
let selectedTab = 0;
|
||||
let currentGroupMember: Groupmember | undefined;
|
||||
let openCreatePollForm = false;
|
||||
let isCreating = false;
|
||||
let isLoading = false;
|
||||
let author: PollType['author'] = {
|
||||
id: '',
|
||||
username: '',
|
||||
fullname: '',
|
||||
avatarUrl: ''
|
||||
};
|
||||
let pollSubmissionsChannel: RealtimeChannel;
|
||||
|
||||
let tabs: TabsType = [];
|
||||
let activePolls: PollType[] = [];
|
||||
let expiredPolls: PollType[] = [];
|
||||
|
||||
function setCoursePolls() {
|
||||
$course.polls = $polls.map((p) => ({ status: p.status }));
|
||||
}
|
||||
|
||||
async function createPoll(poll: PollType) {
|
||||
if (!currentGroupMember || !$course.id) return;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('apps_poll')
|
||||
.insert({
|
||||
question: poll.question,
|
||||
authorId: currentGroupMember.id,
|
||||
status: poll.status,
|
||||
expiration: poll.expiration,
|
||||
courseId: $course.id
|
||||
})
|
||||
.select('id');
|
||||
|
||||
if (error || !data) {
|
||||
console.log(error);
|
||||
snackbar.error('An error occurred while creating poll');
|
||||
return;
|
||||
}
|
||||
|
||||
const pollId = data[0].id;
|
||||
const appsPollOptions = poll.options.map((option) => ({
|
||||
poll_id: pollId,
|
||||
label: option.label
|
||||
}));
|
||||
const { data: appsPollOptionsData, error: appsPollOptionsError } = await supabase
|
||||
.from('apps_poll_option')
|
||||
.insert(appsPollOptions)
|
||||
.select();
|
||||
|
||||
if (appsPollOptionsError || !appsPollOptionsData) {
|
||||
console.log({ appsPollOptionsError });
|
||||
snackbar.error('An error occurred while creating poll');
|
||||
return;
|
||||
}
|
||||
|
||||
$polls = [
|
||||
{
|
||||
...poll,
|
||||
id: pollId,
|
||||
courseId: $course.id,
|
||||
author,
|
||||
options: appsPollOptionsData.map((option) => ({
|
||||
...option,
|
||||
id: option.id,
|
||||
selectedBy: []
|
||||
}))
|
||||
},
|
||||
...$polls
|
||||
];
|
||||
|
||||
setCoursePolls();
|
||||
}
|
||||
|
||||
// now the most interesting part,
|
||||
// how do I implement real time functionality for polls?
|
||||
// the only thing that is real time is the apps_poll_submission
|
||||
// table, so I will have to listen to changes in that table
|
||||
// and update the polls accordingly.
|
||||
|
||||
async function handleInsert(payload: RealtimePostgresChangesPayload<PollOptionsSubmissionType>) {
|
||||
const newVote = payload.new as PollOptionsSubmissionType;
|
||||
|
||||
const {
|
||||
data,
|
||||
error
|
||||
}: PostgrestSingleResponse<{
|
||||
profile: {
|
||||
username: string;
|
||||
fullname: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
}> = await supabase
|
||||
.from('groupmember')
|
||||
.select('profile:profile_id(username, fullname, avatar_url)')
|
||||
.eq('id', newVote.selected_by_id)
|
||||
.single();
|
||||
|
||||
console.log('newVote => data', data);
|
||||
console.log('newVote => error', error);
|
||||
|
||||
if (error || !data) {
|
||||
return;
|
||||
}
|
||||
|
||||
$polls = $polls.map((poll) => {
|
||||
if (poll.id === newVote.poll_id) {
|
||||
return {
|
||||
...poll,
|
||||
options: poll.options.map((option) => {
|
||||
if (option.id === newVote.poll_option_id) {
|
||||
if (option.selectedBy.some((s) => s.id === newVote.selected_by_id)) {
|
||||
return option;
|
||||
}
|
||||
|
||||
return {
|
||||
...option,
|
||||
selectedBy: [
|
||||
...option.selectedBy,
|
||||
{
|
||||
id: newVote.selected_by_id,
|
||||
username: data.profile.username,
|
||||
fullname: data.profile.fullname,
|
||||
avatarUrl: data.profile.avatar_url
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
return option;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
return poll;
|
||||
});
|
||||
}
|
||||
|
||||
async function handlePollCreate(poll: PollType) {
|
||||
if (!currentGroupMember) return;
|
||||
|
||||
isCreating = true;
|
||||
await createPoll(poll);
|
||||
isCreating = false;
|
||||
|
||||
openCreatePollForm = false;
|
||||
}
|
||||
|
||||
function handlePollDelete(pollId: string) {
|
||||
return async () => {
|
||||
await supabase.from('apps_poll_submission').delete().match({ poll_id: pollId });
|
||||
await supabase.from('apps_poll_option').delete().match({ poll_id: pollId });
|
||||
await supabase.from('apps_poll').delete().match({ id: pollId });
|
||||
|
||||
$polls = [...$polls.filter((p, i) => p.id !== pollId)];
|
||||
|
||||
setCoursePolls();
|
||||
};
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!$course.id) return;
|
||||
|
||||
isLoading = true;
|
||||
const { data, error } = await fetchPolls($course.id);
|
||||
|
||||
if (!data || error) {
|
||||
console.log(error);
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
polls.set(getPollsData(data, $apps.isStudent));
|
||||
|
||||
setCoursePolls();
|
||||
|
||||
isLoading = false;
|
||||
|
||||
pollSubmissionsChannel = supabase
|
||||
.channel('any')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'INSERT', schema: 'public', table: 'apps_poll_submission' },
|
||||
handleInsert
|
||||
)
|
||||
.subscribe();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
supabase.removeChannel(pollSubmissionsChannel);
|
||||
});
|
||||
|
||||
$: currentGroupMember = $group.people.find((person) => person.profile_id === $profile.id);
|
||||
$: author = {
|
||||
id: currentGroupMember?.id || '',
|
||||
username: $profile.username || '',
|
||||
fullname: $profile.fullname || '',
|
||||
avatarUrl: $profile.avatar_url || ''
|
||||
};
|
||||
|
||||
$: {
|
||||
activePolls = $polls.filter(
|
||||
(poll) => new Date(poll.expiration).getTime() >= new Date().getTime()
|
||||
);
|
||||
expiredPolls = $polls.filter(
|
||||
(poll) => new Date(poll.expiration).getTime() <= new Date().getTime()
|
||||
);
|
||||
tabs = [
|
||||
{
|
||||
label: 'Active polls',
|
||||
value: 0,
|
||||
number: activePolls.length
|
||||
},
|
||||
{
|
||||
label: 'Expired polls',
|
||||
value: 1,
|
||||
number: expiredPolls.length
|
||||
}
|
||||
];
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageNav title="Poll" overidableStyle="padding: 0 10px" paddingClass="w-full">
|
||||
<div slot="widget">
|
||||
<CloseButton onClick={handleClose} />
|
||||
</div>
|
||||
</PageNav>
|
||||
|
||||
<div class="p-2 overlow-y-auto md:max-w-[350px] md:min-w-[340px] w-full">
|
||||
{#if openCreatePollForm}
|
||||
<CreatePollForm
|
||||
onSubmit={handlePollCreate}
|
||||
onCancel={() => (openCreatePollForm = !openCreatePollForm)}
|
||||
bind:isSaving={isCreating}
|
||||
/>
|
||||
{:else if currentGroupMember}
|
||||
<div>
|
||||
<Tabs {tabs} bind:selectedTab onCreate={() => (openCreatePollForm = !openCreatePollForm)} />
|
||||
|
||||
{#if isLoading}
|
||||
Loading...
|
||||
{:else}
|
||||
{#each selectedTab === tabs[0].value ? activePolls : expiredPolls as poll}
|
||||
<Poll
|
||||
bind:poll
|
||||
onSelect={handleVote(poll.id, currentGroupMember?.id || '', author)}
|
||||
handlePollDelete={handlePollDelete(poll.id)}
|
||||
bind:currentUserId={currentGroupMember.id}
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="bg-gray-100 dark:bg-neutral-800 border rounded-md h-60 flex items-center justify-center"
|
||||
>
|
||||
<h2 class="text-xl font-bold">No polls to display</h2>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,141 @@
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import type { PollType, PollOptionType, FetchPollsResponse } from './types';
|
||||
import { getSupabase } from '$lib/utils/functions/supabase';
|
||||
import { polls } from './store';
|
||||
|
||||
const supabase = getSupabase();
|
||||
|
||||
export async function fetchPolls(courseId: string) {
|
||||
return await supabase
|
||||
.from('apps_poll')
|
||||
.select(
|
||||
`
|
||||
*,
|
||||
created_at,
|
||||
author:groupmember(
|
||||
profile(
|
||||
username,
|
||||
fullname,
|
||||
avatar_url
|
||||
)
|
||||
),
|
||||
options: apps_poll_option (
|
||||
id,
|
||||
label,
|
||||
submissions:apps_poll_submission(
|
||||
selectedBy:groupmember(
|
||||
id,
|
||||
profile(
|
||||
username,
|
||||
fullname,
|
||||
avatar_url
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
`
|
||||
)
|
||||
.match({ courseId })
|
||||
.order('created_at', { ascending: false })
|
||||
.returns<FetchPollsResponse>();
|
||||
}
|
||||
|
||||
export const updatePollStatus = async (pollId: string, status: PollType['status']) => {
|
||||
const { error } = await supabase.from('apps_poll').update({ status }).match({ id: pollId });
|
||||
|
||||
if (error) {
|
||||
console.log(error);
|
||||
snackbar.error('An error occurred while updating poll status');
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
export const togglePollSubmission = async (
|
||||
pollId: PollType['id'],
|
||||
pollOptionId: PollOptionType['id'],
|
||||
groupmemberId: string,
|
||||
add: boolean
|
||||
) => {
|
||||
if (add) {
|
||||
const { error } = await supabase.from('apps_poll_submission').insert({
|
||||
poll_id: pollId,
|
||||
poll_option_id: pollOptionId,
|
||||
selected_by_id: groupmemberId
|
||||
});
|
||||
if (error) {
|
||||
console.log(error);
|
||||
snackbar.error('An error occurred while submitting poll');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const { error } = await supabase.from('apps_poll_submission').delete().match({
|
||||
poll_id: pollId,
|
||||
poll_option_id: pollOptionId,
|
||||
selected_by_id: groupmemberId
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.log(error);
|
||||
snackbar.error('An error occurred while submitting poll');
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function isOptionSelectedByCurrentUser(option: PollOptionType, groupmemberId: string) {
|
||||
return option.selectedBy.some((gmember) => gmember.id === groupmemberId);
|
||||
}
|
||||
|
||||
export function handleVote(pollId: string, groupmemberId: string, author: PollType['author']) {
|
||||
return (optionId: string) => {
|
||||
// we have the pollId and the optionId
|
||||
polls.update((_polls) => {
|
||||
return [
|
||||
..._polls.map((poll) => {
|
||||
// Prevent user from voting on their own poll
|
||||
// if (poll.author.id === currentGroupMember?.id) return poll;
|
||||
|
||||
// Prevent user from voting on a poll that has expired
|
||||
if (poll.expiration && new Date(poll.expiration) < new Date()) return poll;
|
||||
|
||||
if (poll.id === pollId) {
|
||||
// Prevent user from voting on a poll that the status is not published
|
||||
if (poll.status !== 'published') {
|
||||
snackbar.info('Poll is not published yet');
|
||||
return poll;
|
||||
}
|
||||
|
||||
// Prevent user from voting twice
|
||||
if (
|
||||
poll.options.some((option) => isOptionSelectedByCurrentUser(option, groupmemberId))
|
||||
) {
|
||||
snackbar.info('You can only vote once');
|
||||
return poll;
|
||||
}
|
||||
|
||||
poll.options = poll.options.map((option) => {
|
||||
const isSelected = isOptionSelectedByCurrentUser(option, groupmemberId);
|
||||
|
||||
// If user has not voted on this option, add their vote
|
||||
if (option.id === optionId && !isSelected) {
|
||||
option.selectedBy = [...option.selectedBy, author];
|
||||
|
||||
togglePollSubmission(pollId, optionId, groupmemberId, true);
|
||||
}
|
||||
// else if (isSelected) {
|
||||
// // If user has voted on this option, remove their vote
|
||||
// option.selectedBy = option.selectedBy.filter(
|
||||
// (gmember) => gmember.id !== groupmemberId
|
||||
// );
|
||||
// }
|
||||
|
||||
return option;
|
||||
});
|
||||
}
|
||||
|
||||
return poll;
|
||||
})
|
||||
];
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { writable, type Writable } from 'svelte/store';
|
||||
import type { PollType } from './types';
|
||||
|
||||
export const polls: Writable<PollType[]> = writable([]);
|
||||
@@ -0,0 +1,69 @@
|
||||
export type PollOptionType = {
|
||||
id: string;
|
||||
label: string;
|
||||
selectedBy: {
|
||||
id: string;
|
||||
username: string;
|
||||
fullname: string;
|
||||
avatarUrl: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type PollType = {
|
||||
courseId: string;
|
||||
id: string;
|
||||
question: string;
|
||||
author: {
|
||||
id: string;
|
||||
username: string;
|
||||
fullname: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
status: string;
|
||||
isPublic: boolean;
|
||||
expiration: string | Date;
|
||||
options: PollOptionType[];
|
||||
};
|
||||
|
||||
export type TabsType = {
|
||||
label: string;
|
||||
value: number;
|
||||
number: number;
|
||||
}[];
|
||||
|
||||
export type PollOptionsSubmissionType = {
|
||||
poll_id: string;
|
||||
poll_option_id: string;
|
||||
selected_by_id: string;
|
||||
};
|
||||
|
||||
export type FetchPollsResponse = {
|
||||
id: string;
|
||||
courseId: string;
|
||||
expiration: string;
|
||||
authorId: string;
|
||||
status: string;
|
||||
question: string;
|
||||
created_at: string;
|
||||
author: {
|
||||
profile: {
|
||||
username: string;
|
||||
fullname: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
};
|
||||
options: {
|
||||
id: string;
|
||||
label: string;
|
||||
submissions: {
|
||||
selectedBy: {
|
||||
id: string;
|
||||
profile: {
|
||||
username: string;
|
||||
fullname: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
};
|
||||
}[];
|
||||
}[];
|
||||
}[];
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { PollType, FetchPollsResponse } from './types';
|
||||
|
||||
export const getPollsData = (data: FetchPollsResponse, isStudent: boolean) => {
|
||||
const pollsData: PollType[] = data.map((appsPollItem) => ({
|
||||
id: appsPollItem.id,
|
||||
courseId: appsPollItem.courseId,
|
||||
expiration: appsPollItem.expiration,
|
||||
status: appsPollItem.status,
|
||||
question: appsPollItem.question,
|
||||
author: {
|
||||
id: appsPollItem.authorId,
|
||||
username: appsPollItem.author.profile.username,
|
||||
fullname: appsPollItem.author.profile.fullname,
|
||||
avatarUrl: appsPollItem.author.profile.avatar_url
|
||||
},
|
||||
options: appsPollItem.options.map((option) => ({
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
selectedBy: option.submissions.map((submission) => {
|
||||
return {
|
||||
id: submission.selectedBy.id,
|
||||
username: submission.selectedBy.profile.username,
|
||||
fullname: submission.selectedBy.profile.fullname,
|
||||
avatarUrl: submission.selectedBy.profile.avatar_url
|
||||
};
|
||||
})
|
||||
}))
|
||||
}));
|
||||
|
||||
return pollsData.filter((p) => (isStudent ? p.status === 'published' : true));
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
<script>
|
||||
import CheckmarkOutline from 'carbon-icons-svelte/lib/CheckmarkOutline.svelte';
|
||||
import PageNav from '$lib/components/PageNav/index.svelte';
|
||||
import CloseButton from '$lib/components/Buttons/Close/index.svelte';
|
||||
import Vote from '$lib/components/Vote/index.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
|
||||
export let handleClose;
|
||||
let bodyRef;
|
||||
let question;
|
||||
|
||||
let questions = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Evgeniy',
|
||||
question: "I'am looking at this and it looks great",
|
||||
vote: 1,
|
||||
answered: false
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Sergey Semko',
|
||||
question:
|
||||
'Thanks for the feedback, what do you think about my website. https://rotimibest.com',
|
||||
vote: 4,
|
||||
answered: false
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Vitalii Marushko',
|
||||
question: 'Всем привет',
|
||||
vote: 20,
|
||||
answered: false
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Natasha',
|
||||
question: 'Привет Виталий',
|
||||
vote: 0,
|
||||
answered: false
|
||||
}
|
||||
];
|
||||
|
||||
function handleSend() {
|
||||
if (!question) {
|
||||
return;
|
||||
}
|
||||
|
||||
questions = [
|
||||
...questions,
|
||||
{
|
||||
id: questions.length,
|
||||
name: 'You',
|
||||
question,
|
||||
vote: 0,
|
||||
answered: false
|
||||
}
|
||||
];
|
||||
|
||||
question = null;
|
||||
|
||||
setTimeout(() => {
|
||||
bodyRef.scrollTo({
|
||||
top: bodyRef.scrollHeight
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function handleMarkAnswered(id) {
|
||||
return () => {
|
||||
questions = questions
|
||||
.map((question) => {
|
||||
if (question.id === id) {
|
||||
question.answered = !question.answered;
|
||||
}
|
||||
|
||||
return question;
|
||||
})
|
||||
.sort((a, b) => Number(a.answered) - Number(b.answered));
|
||||
};
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (e.key === 'Enter') {
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageNav title="Q/A" overidableStyle="padding: 0 10px">
|
||||
<div slot="widget">
|
||||
<CloseButton onClick={handleClose} />
|
||||
</div>
|
||||
</PageNav>
|
||||
|
||||
<div bind:this={bodyRef} class="body">
|
||||
{#each questions as question}
|
||||
<div class="flex items-start ml-2 mb-2 mt-2">
|
||||
<Vote bind:value={question.vote} />
|
||||
<div class="ml-2 flex-grow">
|
||||
<p class="dark:text-white text-sm font-bold">{question.name}</p>
|
||||
<article class="prose prose-sm sm:prose">
|
||||
{question.question}
|
||||
</article>
|
||||
</div>
|
||||
<button class={`${question.answered && 'active'}`} on:click={handleMarkAnswered(question.id)}>
|
||||
<CheckmarkOutline size={20} class="carbon-icon dark:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<TextField placeholder="Say something" bind:value={question} onKeyDown={handleKeyDown} />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.body {
|
||||
overflow: auto;
|
||||
height: 80%;
|
||||
|
||||
& button {
|
||||
display: none;
|
||||
}
|
||||
& div:hover button,
|
||||
& button.active {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
.footer {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
APPS: {
|
||||
QANDA: 'QANDA',
|
||||
NOTES: 'NOTES',
|
||||
COMMENTS: 'COMMENTS',
|
||||
POLL: 'POLL'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import SendAlt from 'carbon-icons-svelte/lib/SendAlt.svelte';
|
||||
import ChartPie from 'carbon-icons-svelte/lib/ChartPie.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import Settings from 'carbon-icons-svelte/lib/Settings.svelte';
|
||||
import { apps } from './store';
|
||||
|
||||
import Chip from '$lib/components/Chip/index.svelte';
|
||||
import QandA from './components/QandA/index.svelte';
|
||||
import Comments from './components/Comments/index.svelte';
|
||||
import Notes from './components/Notes/index.svelte';
|
||||
import Poll from './components/Poll/index.svelte';
|
||||
import APPS_CONSTANTS from './constants';
|
||||
import { browser } from '$app/environment';
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { lesson } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
|
||||
let appBarRef: HTMLDivElement;
|
||||
let appContentRef: HTMLDivElement;
|
||||
let topPadding = $apps.isStudent ? '48px' : '109px';
|
||||
|
||||
const getResizableSidebar = () => {
|
||||
if (!browser) return;
|
||||
|
||||
return window.innerWidth >= 1024;
|
||||
};
|
||||
|
||||
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.dropdown = !$apps.dropdown;
|
||||
}
|
||||
|
||||
function updateTopPadding(isStudent: boolean) {
|
||||
topPadding = isStudent ? '48px' : '109px';
|
||||
}
|
||||
|
||||
function getAppClass(appName: string, selected: string | undefined) {
|
||||
return `transition duration-500 delay-150 ease-in-out border-t-0 border-b-0 border-r-0 ${
|
||||
appName === selected ? 'border-l-4 border-indigo-600 focus:border-indigo-600' : 'border-l-0'
|
||||
}`;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
updateTopPadding($apps.isStudent);
|
||||
});
|
||||
|
||||
$: updateTopPadding($apps.isStudent);
|
||||
$: getResizableSidebar();
|
||||
</script>
|
||||
|
||||
<div
|
||||
style={`--top-padding:${topPadding}`}
|
||||
class={`${$apps.open ? 'open ' : 'close'} root bg-white dark:bg-black`}
|
||||
bind:this={appBarRef}
|
||||
>
|
||||
<div class="apps">
|
||||
<div class="lg:hidden mb-2">
|
||||
<IconButton
|
||||
buttonClassName="lg:hidden"
|
||||
toolTipProps={{ title: 'Settings', hotkeys: [] }}
|
||||
onClick={handleAppClick}
|
||||
>
|
||||
<Settings size={24} class="carbon-icon dark:text-white lg:hidden" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 relative {getAppClass(APPS_CONSTANTS.APPS.COMMENTS, $apps.selectedApp)}">
|
||||
<IconButton
|
||||
toolTipProps={{ title: 'Comments', hotkeys: ['Ctrl/Command', '1'] }}
|
||||
value={APPS_CONSTANTS.APPS.COMMENTS}
|
||||
onClick={handleAppClick}
|
||||
buttonClassName="relative"
|
||||
>
|
||||
<Chip
|
||||
value={$lesson.totalComments}
|
||||
className="absolute -top-1 right-0 bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-white"
|
||||
/>
|
||||
<SendAlt size={24} class="carbon-icon dark:text-white" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div class="mb-2 {getAppClass(APPS_CONSTANTS.APPS.POLL, $apps.selectedApp)}">
|
||||
<IconButton
|
||||
toolTipProps={{ title: 'Poll', hotkeys: ['Ctrl/Command', '2'] }}
|
||||
value={APPS_CONSTANTS.APPS.POLL}
|
||||
onClick={handleAppClick}
|
||||
>
|
||||
<Chip
|
||||
value={$course.polls.length}
|
||||
className="absolute -top-1 right-0 bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-white"
|
||||
/>
|
||||
<ChartPie size={24} class="carbon-icon dark:text-white" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<!-- <div class="mb-2">
|
||||
<IconButton
|
||||
toolTipProps={{ title: 'QandA', hotkeys: ['A', '3'] }}
|
||||
value={APPS_CONSTANTS.APPS.QANDA}
|
||||
onClick={handleAppClick}
|
||||
contained={APPS_CONSTANTS.APPS.QANDA === $apps.selectedApp}
|
||||
>
|
||||
<Forum size={24} class="carbon-icon dark:text-white" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<IconButton
|
||||
toolTipProps={{ title: 'Notes', hotkeys: ['A', '3'] }}
|
||||
value={APPS_CONSTANTS.APPS.NOTES}
|
||||
onClick={handleAppClick}
|
||||
contained={APPS_CONSTANTS.APPS.NOTES === $apps.selectedApp}
|
||||
>
|
||||
<AlignBoxTopLeft size={24} class="carbon-icon dark:text-white" />
|
||||
</IconButton>
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
{#if !!$apps.selectedApp}
|
||||
<div
|
||||
class="app max-w-[350px] transition ease-in-out delay-150 duration-100"
|
||||
bind:this={appContentRef}
|
||||
>
|
||||
{#key $apps.selectedApp}
|
||||
<div class="h-full w-full" transition:fade={{ duration: 200 }}>
|
||||
{#if $apps.selectedApp === APPS_CONSTANTS.APPS.QANDA}
|
||||
<QandA {handleClose} />
|
||||
{:else if $apps.selectedApp === APPS_CONSTANTS.APPS.COMMENTS}
|
||||
<Comments {handleClose} />
|
||||
{:else if $apps.selectedApp === APPS_CONSTANTS.APPS.POLL}
|
||||
<Poll {handleClose} />
|
||||
{:else if $apps.selectedApp === APPS_CONSTANTS.APPS.NOTES}
|
||||
<Notes {handleClose} />
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.root {
|
||||
height: calc(100vh - 48px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
border-left: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
z-index: 1;
|
||||
|
||||
& .apps {
|
||||
padding-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 60px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
& .app {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) and (max-width: 1023px) {
|
||||
.app {
|
||||
width: 50vw;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.app {
|
||||
width: 100vw;
|
||||
min-width: calc(100vw - 60px);
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1023px) {
|
||||
.root {
|
||||
position: absolute;
|
||||
height: calc(100vh - var(--top-padding));
|
||||
top: var(--top-padding);
|
||||
right: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
.open {
|
||||
position: absolute;
|
||||
transform: translateX(0);
|
||||
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.close {
|
||||
position: absolute;
|
||||
transform: translateX(100%);
|
||||
display: none;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1024px) {
|
||||
.root {
|
||||
height: calc(100vh - 48px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
border-left: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
z-index: 1;
|
||||
|
||||
& .apps {
|
||||
padding-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 60px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
& .app {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const apps = writable<{
|
||||
open: boolean;
|
||||
dropdown: boolean;
|
||||
selectedApp: string | undefined;
|
||||
isStudent: boolean;
|
||||
}>({
|
||||
open: false,
|
||||
dropdown: false,
|
||||
selectedApp: undefined,
|
||||
isStudent: false
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import GoogleIconColored from '../Icons/GoogleIconColored.svelte';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
export let supabase: SupabaseClient;
|
||||
export let handleSubmit = () => {};
|
||||
export let isLogin = true;
|
||||
export let showOnlyContent = false;
|
||||
export let isLoading = false;
|
||||
export let showLogo = false;
|
||||
export let formRef;
|
||||
export let hideGoogleAuth = false;
|
||||
|
||||
async function signInWithGoogle() {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
console.log('signInWithGoogle');
|
||||
const { error, data } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'google'
|
||||
});
|
||||
|
||||
console.log('data', data);
|
||||
console.log('error', error);
|
||||
} catch (error) {
|
||||
console.log('catch error', error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="app-background w-full min-h-screen flex items-center justify-center">
|
||||
<div class="container border border-gray bg-white dark:bg-black">
|
||||
<div class="flex items-center flex-col p-2 lg:px-8 lg:py-3">
|
||||
{#if !showOnlyContent || showLogo}
|
||||
<div class="flex flex-col items-center justify-center w-full pt-2">
|
||||
<Avatar
|
||||
src={$currentOrg.avatar_url ? $currentOrg.avatar_url : '/logo-192.png'}
|
||||
name={$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}
|
||||
shape="rounded-md"
|
||||
width="w-10"
|
||||
height="h-10"
|
||||
className="mr-2"
|
||||
/>
|
||||
<h4 class="dark:text-white text-xl mt-0">
|
||||
{$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}
|
||||
</h4>
|
||||
</div>
|
||||
{/if}
|
||||
<form
|
||||
bind:this={formRef}
|
||||
on:submit|preventDefault={handleSubmit}
|
||||
class="flex items-center flex-col w-10/12"
|
||||
>
|
||||
<slot />
|
||||
</form>
|
||||
{#if !showOnlyContent && !hideGoogleAuth}
|
||||
<div class="w-10/12 mb-3">
|
||||
<p class="dark:text-white text-sm mb-5">or sign up with:</p>
|
||||
<PrimaryButton
|
||||
variant={VARIANTS.OUTLINED}
|
||||
onClick={signInWithGoogle}
|
||||
isDisabled={isLoading}
|
||||
className="py-3 sm:w-full w-full"
|
||||
>
|
||||
<GoogleIconColored />
|
||||
<span class="ml-2">{isLogin ? 'Sign in' : 'Sign up'} with Google</span>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !showOnlyContent}
|
||||
<div class="w-full p-6 border-t border-grey text-center">
|
||||
{#if isLogin}
|
||||
Not registered yet? <a
|
||||
class="hover:underline text-primary-700"
|
||||
href="/signup{$page.url.search}">Sign up</a
|
||||
>
|
||||
{:else}
|
||||
Already have an account? <a
|
||||
class="hover:underline text-primary-700"
|
||||
href="/login{$page.url.search}">Log In</a
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
width: 450px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
export let name = '';
|
||||
export let src = '';
|
||||
export let className = '';
|
||||
export let shape = 'rounded-full';
|
||||
export let width = 'w-10';
|
||||
export let height = 'h-10';
|
||||
</script>
|
||||
|
||||
<img alt={name} {src} class="{className} {width} {height} {shape}" />
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
export let disableCenteredContent = false;
|
||||
export let className = '';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="root {!className.includes('opacity') && 'opacity-100'} {!disableCenteredContent &&
|
||||
'center'} {className}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
position: fixed;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
color: #fff;
|
||||
z-index: 1201;
|
||||
transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script>
|
||||
export let className = '';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="{className} w-full h-[517px] flex items-center justify-center flex-col border rounded border-gray-200"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script>
|
||||
import CloseIcon from 'carbon-icons-svelte/lib/Close.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
|
||||
export let onClick = () => {};
|
||||
export let contained = false;
|
||||
export let color = 'text-primary-700 bg-primary-200 dark:bg-neutral-500';
|
||||
export let size = 'small';
|
||||
</script>
|
||||
|
||||
<IconButton {onClick} {color} {contained} {size}>
|
||||
{#if size === 'large'}
|
||||
<CloseIcon size={24} class="carbon-icon dark:text-white" title="Delete" />
|
||||
{:else}
|
||||
<CloseIcon size={20} class="carbon-icon dark:text-white" title="Delete" />
|
||||
{/if}
|
||||
</IconButton>
|
||||
<!-- color="text-primary-700 bg-primary-200 dark:bg-gray-200 dark:text-black" -->
|
||||
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
import Close from 'carbon-icons-svelte/lib/Close.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
|
||||
export let onClick = () => {};
|
||||
</script>
|
||||
|
||||
<IconButton {onClick}>
|
||||
<Close size={24} class="carbon-icon dark:text-white" title="Close" />
|
||||
</IconButton>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import posthog from 'posthog-js';
|
||||
import { goto } from '$app/navigation';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { capturePosthogEvent } from '$lib/utils/services/posthog';
|
||||
|
||||
async function logout() {
|
||||
const { error } = await supabase.auth.signOut();
|
||||
console.error('Error logging out: ', error);
|
||||
|
||||
capturePosthogEvent('user_logged_out');
|
||||
posthog.reset();
|
||||
goto('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<PrimaryButton variant={VARIANTS.OUTLINED} label="Log out" onClick={logout} />
|
||||
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
import SendFilled from 'carbon-icons-svelte/lib/SendFilled.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
|
||||
export let onClick;
|
||||
</script>
|
||||
|
||||
<IconButton {onClick}>
|
||||
<SendFilled size={24} class="carbon-icon dark:text-white" />
|
||||
</IconButton>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import ArrowUpRightIcon from '$lib/components/Icons/ArrowTopRight.svelte';
|
||||
import { currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { isMobile } from '$lib/utils/store/useMobile';
|
||||
|
||||
export let className = '';
|
||||
</script>
|
||||
|
||||
<a href={$currentOrgDomain} target="_blank" class="{className} ml-2 hover:no-underline">
|
||||
<PrimaryButton>
|
||||
{#if !$isMobile}
|
||||
View site
|
||||
{/if}
|
||||
<ArrowUpRightIcon />
|
||||
</PrimaryButton>
|
||||
</a>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
export let className = '';
|
||||
export let value: string | number = '';
|
||||
export let size = '';
|
||||
export let shape = 'rounded-md';
|
||||
</script>
|
||||
|
||||
<p
|
||||
class="{className} {size === 'sm' ? 'py-1 px-1' : 'py-2 px-2'} org-avatar {className.includes(
|
||||
'bg-'
|
||||
)
|
||||
? ''
|
||||
: 'bg-gray-200 dark:text-black'} {shape} text-center"
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
|
||||
<style>
|
||||
.org-avatar {
|
||||
width: fit-content;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script>
|
||||
export let value = 0;
|
||||
export let className = '';
|
||||
</script>
|
||||
|
||||
<p class={className ? className : 'bg-set mt-2'}>{value}</p>
|
||||
|
||||
<style>
|
||||
p {
|
||||
display: inline-block;
|
||||
min-width: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 2em;
|
||||
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.bg-set {
|
||||
background-color: #d1d5da80;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script>
|
||||
import Editor from 'cl-editor/src/Editor.svelte';
|
||||
|
||||
export let html = '';
|
||||
export let editor;
|
||||
|
||||
let colors = [
|
||||
'#000000',
|
||||
'#e60000',
|
||||
'#ff9900',
|
||||
'#ffff00',
|
||||
'#008a00',
|
||||
'#0066cc',
|
||||
'#9933ff',
|
||||
'#ffffff',
|
||||
'#facccc',
|
||||
'#ffebcc',
|
||||
'#ffffcc',
|
||||
'#cce8cc',
|
||||
'#cce0f5',
|
||||
'#ebd6ff',
|
||||
'#bbbbbb',
|
||||
'#f06666',
|
||||
'#ffc266',
|
||||
'#ffff66',
|
||||
'#66b966',
|
||||
'#66a3e0',
|
||||
'#c285ff',
|
||||
'#888888',
|
||||
'#a10000',
|
||||
'#b26b00',
|
||||
'#b2b200',
|
||||
'#006100',
|
||||
'#0047b2',
|
||||
'#6b24b2',
|
||||
'#444444',
|
||||
'#5c0000',
|
||||
'#663d00',
|
||||
'#666600',
|
||||
'#003700',
|
||||
'#002966',
|
||||
'#3d1466'
|
||||
];
|
||||
</script>
|
||||
|
||||
<Editor bind:this={editor} {html} {colors} on:change={(evt) => (html = evt.detail)} />
|
||||
@@ -0,0 +1,7 @@
|
||||
<script>
|
||||
export let code = '';
|
||||
</script>
|
||||
|
||||
<div class="bg-gray-200 dark:bg-gray-500 p-5 rounded-md mb-3">
|
||||
{@html code}
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script>
|
||||
export let label = 'Coming soon';
|
||||
</script>
|
||||
|
||||
<p
|
||||
class="org-avatar text-xs bg-primary-200 text-primary-700 font-medium py-1 px-2 rounded-md text-center"
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
|
||||
<style>
|
||||
.org-avatar {
|
||||
width: fit-content;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import confetti from 'canvas-confetti';
|
||||
import { confettiStore } from './store';
|
||||
|
||||
let myCanvas;
|
||||
let myConfetti;
|
||||
|
||||
function showConfetti(show) {
|
||||
if (!show) return;
|
||||
|
||||
const duration = 5 * 1000;
|
||||
const animationEnd = Date.now() + duration;
|
||||
const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 };
|
||||
|
||||
function randomInRange(min, max) {
|
||||
return Math.random() * (max - min) + min;
|
||||
}
|
||||
|
||||
const interval = setInterval(function () {
|
||||
const timeLeft = animationEnd - Date.now();
|
||||
|
||||
if (timeLeft <= 0) {
|
||||
return clearInterval(interval);
|
||||
}
|
||||
|
||||
const particleCount = 50 * (timeLeft / duration);
|
||||
// since particles fall down, start a bit higher than random
|
||||
myConfetti(
|
||||
Object.assign({}, defaults, {
|
||||
particleCount,
|
||||
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
|
||||
})
|
||||
);
|
||||
myConfetti(
|
||||
Object.assign({}, defaults, {
|
||||
particleCount,
|
||||
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
|
||||
})
|
||||
);
|
||||
}, 250);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
myConfetti = confetti.create(myCanvas, {
|
||||
resize: true,
|
||||
useWorker: true,
|
||||
});
|
||||
});
|
||||
|
||||
$: showConfetti($confettiStore.show);
|
||||
</script>
|
||||
|
||||
<canvas bind:this={myCanvas} />
|
||||
|
||||
<style>
|
||||
canvas {
|
||||
position: absolute;
|
||||
width: 60%;
|
||||
height: 70%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const confettiStore = writable({
|
||||
show: false
|
||||
});
|
||||
|
||||
export function toggleConfetti() {
|
||||
confettiStore.update((confetti) => ({
|
||||
...confetti,
|
||||
show: !confetti.show
|
||||
}));
|
||||
}
|
||||
@@ -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,134 @@
|
||||
<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 { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
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';
|
||||
import { appStore } from '$lib/utils/store/app';
|
||||
|
||||
const studentNamePlaceholder = 'Name of student';
|
||||
const themes = ['professional', 'plain'];
|
||||
|
||||
let isSaving = false;
|
||||
let errors = {
|
||||
description: ''
|
||||
};
|
||||
|
||||
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 || false,
|
||||
certificate_theme: $course.certificate_theme || ''
|
||||
});
|
||||
isSaving = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="true" />
|
||||
<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-3 md:-mr-3 px-2">
|
||||
<div class="flex-1 flex flex-col lg:flex-row justify-between gap-3 w-full mb-3 h-4/5">
|
||||
<section class="w-full lg:w-2/5 h-full">
|
||||
<strong class="my-2 text-base font-semibold text-black dark:text-gray-100"
|
||||
>Certificate settings</strong
|
||||
>
|
||||
<p class="text-xs font-normal my-4 dark:text-gray-100">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} />
|
||||
<img
|
||||
src={`/images/certificate_theme_${theme}.png`}
|
||||
alt="themes"
|
||||
class="w-[110px] h-[82px]"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</RadioButtonGroup>
|
||||
<div>
|
||||
<p class="text-xs font-normal text-black my-2 dark:text-gray-100">Brand Logo</p>
|
||||
<div>
|
||||
<p class="text-base mt-1 dark:text-gray-100">
|
||||
To update your brand image, go to <strong class="font-semibold"
|
||||
>Settings > Organisation settings</strong
|
||||
> and upload your brand logo
|
||||
</p>
|
||||
<PrimaryButton
|
||||
label="Go to Settings"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
className="rounded-md mt-3"
|
||||
onClick={() => goto(`org/${$currentOrg.siteName}/settings`)}
|
||||
/>
|
||||
</div>
|
||||
<span class="my-4">
|
||||
<p class="dark:text-gray-100 text-xs font-normal mt-4 mb-2">Course Description</p>
|
||||
<TextArea
|
||||
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={$appStore.isDark ? 'color: white' : 'color: #161616'}
|
||||
>Locked</span
|
||||
>
|
||||
<span slot="labelB" style="color: green">Unlocked</span>
|
||||
</Toggle>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="bg-gray-100 dark:bg-neutral-800 flex justify-center items-center rounded-md w-full lg: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="Save Changes"
|
||||
className="rounded-md"
|
||||
onClick={saveCertificate}
|
||||
isLoading={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
p {
|
||||
color: #262626;
|
||||
}
|
||||
.certificate-container {
|
||||
width: 95%;
|
||||
max-width: 95%;
|
||||
}
|
||||
</style>
|
||||
+83
@@ -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,93 @@
|
||||
<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-primary-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-primary-600 text-sm font-medium"
|
||||
>
|
||||
<Checkbox bind:checked />
|
||||
<p class="text-primary-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-primary-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-primary-100 px-3 py-2 rounded-lg">
|
||||
<p class="opacity-100 text-xs text-primary-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>
|
||||
+13
@@ -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>
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<script>
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
</script>
|
||||
|
||||
<Box>
|
||||
<div class="flex flex-col items-center justify-center w-max h-full gap-2">
|
||||
<img src="/professional.png" 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>
|
||||
</Box>
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<script>
|
||||
import { PUBLIC_SERVER_URL } from '$env/static/public';
|
||||
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';
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
|
||||
let isLoading = false;
|
||||
|
||||
const downLoadCertificate = async () => {
|
||||
isLoading = true;
|
||||
const response = await fetch(PUBLIC_SERVER_URL + '/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);
|
||||
|
||||
let a = document.createElement('a');
|
||||
document.body.append(a);
|
||||
a.download = 'Certificate of Completion - ' + $currentOrg.name;
|
||||
a.href = fileURL;
|
||||
a.click();
|
||||
a.remove();
|
||||
|
||||
isLoading = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<Box>
|
||||
<div class="flex flex-col items-center justify-center w-max h-full gap-5">
|
||||
<img src="/professional.png" alt="Certificate" />
|
||||
<p class="text-xl font-normal text-center">Certificate is now available</p>
|
||||
<PrimaryButton
|
||||
label="DownLoad Certificate"
|
||||
className="rounded-md"
|
||||
onClick={downLoadCertificate}
|
||||
isDisabled={!PUBLIC_SERVER_URL}
|
||||
{isLoading}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
<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="certificate-bg 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 dark:text-black">{$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 dark:text-black">{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 dark:text-black">{$course.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.student-name {
|
||||
font-family: 'Qwitcher Grypen', cursive;
|
||||
}
|
||||
.desc {
|
||||
font-size: 10px;
|
||||
}
|
||||
.clamp {
|
||||
font-size: clamp(14px, 3vw, 16px);
|
||||
}
|
||||
.certificate-bg {
|
||||
background-color: #f5f8fe;
|
||||
}
|
||||
</style>
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
<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-primary-800';
|
||||
let logo = '/logo-512.png';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="certificate-bg flex items-center justify-center border border-dashed border-primary-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 dark:text-black">{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-primary-800 text-center uppercase my-2"
|
||||
>
|
||||
{$course.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="desc mb-4 text-justify dark:text-black">{$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 dark:text-black">{$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);
|
||||
}
|
||||
.certificate-bg {
|
||||
background-color: #f5f8fe;
|
||||
}
|
||||
</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,134 @@
|
||||
<script>
|
||||
import UnlockedIcon from 'carbon-icons-svelte/lib/Unlocked.svelte';
|
||||
import LockedIcon from 'carbon-icons-svelte/lib/Locked.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 {
|
||||
lessons,
|
||||
handleSaveLesson
|
||||
} from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { course, group } from '$lib/components/Course/store';
|
||||
import TextChip from '$lib/components/Chip/Text.svelte';
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
export let openModal = false;
|
||||
export let isStudent = false;
|
||||
let errors = {
|
||||
title: ''
|
||||
};
|
||||
let lesson = {
|
||||
id: null,
|
||||
title: '',
|
||||
profile: undefined,
|
||||
call_url: undefined,
|
||||
lesson_at: new Date(),
|
||||
is_unlocked: false
|
||||
};
|
||||
|
||||
function getLessonOrder(id = '') {
|
||||
const index = $lessons.findIndex((lesson) => lesson.id === id);
|
||||
if (index < 9) {
|
||||
return '0' + (index + 1);
|
||||
} else {
|
||||
return index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (lesson.title.trim() === '') {
|
||||
errors.title = 'title cannot be empty';
|
||||
return;
|
||||
}
|
||||
const savedLesson = await handleSaveLesson(lesson, $course.id);
|
||||
if (Array.isArray(savedLesson) && savedLesson[0]) {
|
||||
const newLesson = savedLesson[0];
|
||||
lesson.id = newLesson.id;
|
||||
$lessons = [...$lessons, lesson];
|
||||
goto('/courses/' + $course.id + '/lessons/' + lesson.id);
|
||||
}
|
||||
openModal = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
onClose={() => (openModal = false)}
|
||||
bind:open={openModal}
|
||||
width="w-[80%] md:w-[60%]"
|
||||
containerClass="overflow-hidden"
|
||||
modalHeading="Add New Lesson"
|
||||
>
|
||||
<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-2 md:py-3 px-2 md:px-5 mb-2 md:mb-4 flex flex-wrap 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-full md:w-4/5">
|
||||
<div class="flex flex-row gap-1 md:gap-2">
|
||||
<TextField
|
||||
bind:value={lesson.title}
|
||||
autofocus={true}
|
||||
className=" flex-1 min-w-lg max-w-lg"
|
||||
isRequired={true}
|
||||
errorMessage={errors.title}
|
||||
/>
|
||||
<IconButton
|
||||
disabled={isStudent}
|
||||
toolTipProps={isStudent
|
||||
? {}
|
||||
: {
|
||||
title: `Click to ${lesson.is_unlocked ? 'lock' : 'unlock'}`,
|
||||
direction: 'right'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
on:click={() => {
|
||||
lesson.is_unlocked = !lesson.is_unlocked;
|
||||
handleSaveLesson(lesson, $course.id);
|
||||
}}
|
||||
class="p-1"
|
||||
>
|
||||
{#if lesson.is_unlocked}
|
||||
<UnlockedIcon size={24} class="carbon-icon dark:text-white" />
|
||||
{:else}
|
||||
<LockedIcon size={24} class="carbon-icon dark:text-white" />
|
||||
{/if}
|
||||
</button>
|
||||
</IconButton>
|
||||
</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">
|
||||
<Select
|
||||
bind:value={lesson.profile}
|
||||
options={$group.tutors}
|
||||
labelKey="fullname"
|
||||
className="sm:my-1 w-[100%]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center lg:mb-0">
|
||||
<input
|
||||
type="date"
|
||||
name="lesson-date-picker"
|
||||
class="p-2 my-2 rounded-md sm:w-[179px] dark:bg-neutral-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-3 lg:mb-0">
|
||||
<TextField className="w-[179px]" placeholder="https://meet.google.com/mga-dsjs-fmb" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PrimaryButton label="Save" onClick={handleSave} />
|
||||
</Modal>
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<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}
|
||||
label="Yes, delete"
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
$deleteConfirmation.open = false;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -0,0 +1,273 @@
|
||||
<script>
|
||||
import {
|
||||
questionnaire,
|
||||
deleteConfirmation,
|
||||
questionnaireValidation,
|
||||
handleAddOption,
|
||||
handleRemoveOption,
|
||||
handleRemoveQuestion,
|
||||
handleCode,
|
||||
handleAnswerSelect,
|
||||
addDynamicValue
|
||||
} from '../store/exercise';
|
||||
import { Select, SelectItem } from 'carbon-components-svelte';
|
||||
|
||||
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 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 { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
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';
|
||||
|
||||
const initialQuestionsLength = $questionnaire.questions.length;
|
||||
|
||||
export let shouldDeleteExercise = false;
|
||||
export let exerciseId;
|
||||
export let goBack = () => {};
|
||||
|
||||
let errors = {};
|
||||
let questionIdToDelete = null;
|
||||
let questions = [];
|
||||
let isDeleting = false;
|
||||
|
||||
function shouldScrollToLast(questionId, questions) {
|
||||
const [lastQuestion] = questions.slice(-1);
|
||||
const currentQuestionsLength = questions.length;
|
||||
const isLast = lastQuestion.id === questionId;
|
||||
|
||||
return isLast && initialQuestionsLength !== currentQuestionsLength;
|
||||
}
|
||||
|
||||
function onInitDeleteClicked(questionId) {
|
||||
return () => {
|
||||
questionIdToDelete = questionId;
|
||||
$deleteConfirmation.open = true;
|
||||
};
|
||||
}
|
||||
|
||||
function onFinalDeleteClicked() {
|
||||
handleRemoveQuestion(questionIdToDelete)();
|
||||
}
|
||||
|
||||
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)
|
||||
}));
|
||||
goBack();
|
||||
}
|
||||
|
||||
$: errors = $questionnaireValidation;
|
||||
$: questions = filterOutDeleted($questionnaire.questions);
|
||||
</script>
|
||||
|
||||
<DeleteConfirmationModal
|
||||
onCancel={() => (questionIdToDelete = null)}
|
||||
onDelete={onFinalDeleteClicked}
|
||||
/>
|
||||
|
||||
<OrderModal />
|
||||
|
||||
<Modal
|
||||
onClose={() => (shouldDeleteExercise = false)}
|
||||
bind:open={shouldDeleteExercise}
|
||||
width="w-2/4"
|
||||
modalHeading="Delete Modal"
|
||||
>
|
||||
<form on:submit|preventDefault>
|
||||
<h1 class="dark:text-white text-xl">Are you sure you want to delete this exercise?</h1>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<PrimaryButton
|
||||
className="px-6 py-3"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
label="No, cancel"
|
||||
type="submit"
|
||||
onClick={() => (shouldDeleteExercise = false)}
|
||||
/>
|
||||
<PrimaryButton
|
||||
className="px-6 py-3"
|
||||
variant={VARIANTS.CONTAINED}
|
||||
label={isDeleting ? 'Deleting...' : 'Yes, delete'}
|
||||
isDisabled={isDeleting}
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</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-5 w-3/5">
|
||||
<TextField
|
||||
placeholder="Question"
|
||||
bind:value={question.title}
|
||||
isRequired={true}
|
||||
onChange={() => {
|
||||
question.is_dirty = true;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
size="xl"
|
||||
class="w-[50px]"
|
||||
bind:selected={question.question_type.id}
|
||||
on:change={(e) => {
|
||||
const id = parseInt(e.target.value);
|
||||
question.question_type = QUESTION_TYPES.find((q) => q.id === id);
|
||||
question.is_dirty = true;
|
||||
}}
|
||||
>
|
||||
{#each QUESTION_TYPES as type}
|
||||
<SelectItem value={type.id} text={type.label} />
|
||||
{/each}
|
||||
</Select>
|
||||
</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 dark:text-white" />
|
||||
</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 dark:text-white" />
|
||||
</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 dark:text-white" />
|
||||
{:else}
|
||||
<CheckmarkOutlineIcon size={24} class="carbon-icon dark:text-white" />
|
||||
{/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 dark:text-white" />
|
||||
</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 dark:text-white" />
|
||||
{:else}
|
||||
<CheckmarkOutlineIcon size={24} class="carbon-icon dark:text-white" />
|
||||
{/if}
|
||||
</IconButton>
|
||||
</div>
|
||||
</Checkbox>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if QUESTION_TYPE.TEXTAREA === question.question_type.id}
|
||||
<TextArea bind:value={question.value} disabled={true} />
|
||||
{/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 dark:text-white" />
|
||||
<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>
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
<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 { snackbar } 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
|
||||
});
|
||||
|
||||
snackbar.success(`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-primary-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="border border-gray-300 rounded-md mt-2">
|
||||
<div
|
||||
class="hover:bg-gray-100 dark:bg-neutral-800 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-2/5">Status</p>
|
||||
<Select
|
||||
bind:value={status}
|
||||
options={SELECTABLE_STATUS}
|
||||
selectClassName={getStatusColor(status)}
|
||||
onChange={handleStatusChange}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-center w-full p-3">
|
||||
<PrimaryButton
|
||||
onClick={() => {
|
||||
handleSave(data);
|
||||
}}
|
||||
label="Submit Grades"
|
||||
variant={VARIANTS.CONTAINED_SUCCESS}
|
||||
className="py-3 px-8 w-full"
|
||||
/>
|
||||
</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,98 @@
|
||||
<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-primary-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}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import { Loading } from 'carbon-components-svelte';
|
||||
import type { ExerciseSubmissions } from '$lib/utils/types';
|
||||
import { questionnaire } from '../../store/exercise';
|
||||
import { submissions } from './store';
|
||||
|
||||
export let isLoading = false;
|
||||
|
||||
const defaultImg =
|
||||
'https://images.unsplash.com/photo-1599566150163-29194dcaad36?auto=format&fit=crop&q=60&w=800&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8NHx8YXZhdGFyc3xlbnwwfHwwfHx8Mg%3D%3D';
|
||||
|
||||
let studentSelected = 0;
|
||||
|
||||
const isSelected = (
|
||||
student: ExerciseSubmissions,
|
||||
option: { question_id: number; value: string[] }
|
||||
) => {
|
||||
if ($submissions) {
|
||||
let submittedAnswer = student?.answers;
|
||||
let filteredAnswer = submittedAnswer.filter(
|
||||
(ans: { question_id: number }) => ans.question_id === option.question_id
|
||||
);
|
||||
if (filteredAnswer.map((ans) => ans.answers.length > 1)) {
|
||||
return filteredAnswer.some((ans) => ans.answers.includes(option.value));
|
||||
} else {
|
||||
return filteredAnswer.map((ans: { answers: any }) => ans.answers) == option.value;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isCorrect = (
|
||||
student: ExerciseSubmissions,
|
||||
option: { is_correct?: any; question_id?: number; value?: string[] }
|
||||
) => {
|
||||
if (!isSelected(student, option)) return;
|
||||
if (option.is_correct) {
|
||||
return 'border-green-700';
|
||||
} else {
|
||||
return 'border-red-700';
|
||||
}
|
||||
};
|
||||
const getOpenAnswer = (student: ExerciseSubmissions, q: never) => {
|
||||
let submittedAnswer = student?.answers;
|
||||
let filteredAnswer = submittedAnswer.filter(
|
||||
(ans: { question_id: number }) => ans.question_id === q.id
|
||||
);
|
||||
if (filteredAnswer.some((ans: { open_answer: string }) => ans.open_answer == '')) {
|
||||
return 'No Answer was provided';
|
||||
} else {
|
||||
return filteredAnswer.map((ans: { open_answer: any }) => ans.open_answer);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if isLoading}
|
||||
<Loading withOverlay={true} />
|
||||
{:else if $submissions?.length}
|
||||
<div class="flex gap-1 w-full overflow-auto mt-2 mb-5">
|
||||
{#each $submissions as student, i}
|
||||
<button on:click={() => (studentSelected = i)} class="flex flex-col items-center w-20">
|
||||
<div
|
||||
class={`flex items-center justify-center w-12 h-12 rounded-full ${
|
||||
studentSelected == i ? 'border-[3px] border-primary-700' : ''
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={student.submitted_by.profile.avatar_url
|
||||
? student.submitted_by.profile.avatar_url
|
||||
: defaultImg}
|
||||
alt="student"
|
||||
class={`w-10 h-10 rounded-full bg-white m-1`}
|
||||
/>
|
||||
</div>
|
||||
<p class="line-clamp-2 w-20 whitespace-pre-wrap leading-4">
|
||||
{student.submitted_by.profile.fullname}
|
||||
</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="font-medium mb-2">
|
||||
{$submissions[studentSelected].submitted_by.profile.fullname}'s Individual Answers
|
||||
</p>
|
||||
{#if $submissions[studentSelected]}
|
||||
{#if $questionnaire.questions}
|
||||
{#each $questionnaire.questions as q, i}
|
||||
<div class="pb-4">
|
||||
<h3>{i + 1}. {q.title}</h3>
|
||||
{#if q.question_type_id !== 3}
|
||||
{#each q.options as option, i}
|
||||
<div
|
||||
class={`flex gap-2 items-center my-2 border-2 border-gray-300 rounded-md p-2 ${isCorrect(
|
||||
$submissions[studentSelected],
|
||||
option
|
||||
)}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name=""
|
||||
id=""
|
||||
class="form-radio"
|
||||
checked={isSelected($submissions[studentSelected], option)}
|
||||
/>
|
||||
<span class="dark:text-white">{option.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="rounded bg-slate-100 dark:bg-slate-300 p-2 my-1 w-full">
|
||||
<span class="text-base font-medium text-black">
|
||||
{getOpenAnswer($submissions[studentSelected], q)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import type { ExerciseSubmissions } from '$lib/utils/types';
|
||||
import { questionnaire } from '../../store/exercise';
|
||||
import { replaceHTMLTag } from '$lib/utils/functions/course';
|
||||
import type {
|
||||
BarChartOptions,
|
||||
PieChartOptions,
|
||||
PieChart,
|
||||
BarChartSimple
|
||||
} from '@carbon/charts-svelte';
|
||||
import { getChartOptions } from './functions';
|
||||
import { submissions } from './store';
|
||||
import '@carbon/charts-svelte/styles.css';
|
||||
import { Loading } from 'carbon-components-svelte';
|
||||
|
||||
export let isLoading = true;
|
||||
|
||||
type TranformedQuestionChartData = {
|
||||
group: string;
|
||||
value: number;
|
||||
isCorrect?: boolean;
|
||||
};
|
||||
interface TranformedQuestion {
|
||||
title: string;
|
||||
type: number;
|
||||
chartData: TranformedQuestionChartData[];
|
||||
}
|
||||
|
||||
let transformedQuestions: TranformedQuestion[] = [];
|
||||
let barChart: typeof BarChartSimple;
|
||||
let pieChart: typeof PieChart;
|
||||
let pieOptions: PieChartOptions;
|
||||
let barOptions: BarChartOptions;
|
||||
|
||||
function getAnswerToQuestionOfStudent(
|
||||
questionId: number,
|
||||
isTextArea: boolean,
|
||||
submission: ExerciseSubmissions
|
||||
): string[] {
|
||||
let questionSubmission: string[] = [];
|
||||
|
||||
// find the answer by this student to this question (questionId)
|
||||
const studentAnswer = submission.answers.find((a) => a.question_id === questionId);
|
||||
|
||||
if (studentAnswer) {
|
||||
const { answers, open_answer } = studentAnswer;
|
||||
if (isTextArea) {
|
||||
questionSubmission = [open_answer];
|
||||
} else {
|
||||
questionSubmission = answers;
|
||||
}
|
||||
}
|
||||
|
||||
return questionSubmission;
|
||||
}
|
||||
|
||||
const getTransformedData = ($submissions: ExerciseSubmissions[]): void => {
|
||||
const _transformedQuestions: TranformedQuestion[] = [];
|
||||
|
||||
$questionnaire.questions.forEach(
|
||||
(question: {
|
||||
id: number;
|
||||
title: string;
|
||||
question_type_id: number;
|
||||
options: { value: string; is_correct: boolean; label: string }[];
|
||||
}) => {
|
||||
const transformedQuestion: {
|
||||
title: string;
|
||||
type: number;
|
||||
chartData: TranformedQuestionChartData[];
|
||||
} = {
|
||||
title: question.title,
|
||||
type: question.question_type_id,
|
||||
chartData: []
|
||||
};
|
||||
|
||||
// If textarea don't calculate the value just get the student's answer
|
||||
if (transformedQuestion.type === 3) {
|
||||
$submissions.forEach((submission) => {
|
||||
const chartData: TranformedQuestionChartData = {
|
||||
group: getAnswerToQuestionOfStudent(question.id, true, submission)[0],
|
||||
value: 0
|
||||
};
|
||||
|
||||
// Update the transformed question chartData
|
||||
transformedQuestion.chartData.push(chartData);
|
||||
});
|
||||
} else {
|
||||
// radio or checkbox
|
||||
question.options.forEach((option) => {
|
||||
const { value, is_correct, label } = option;
|
||||
const chartData: TranformedQuestionChartData = {
|
||||
group: replaceHTMLTag(label),
|
||||
value: 0,
|
||||
isCorrect: is_correct
|
||||
};
|
||||
|
||||
$submissions.forEach((studentSubmission) => {
|
||||
const studentAnswer = getAnswerToQuestionOfStudent(
|
||||
question.id,
|
||||
false,
|
||||
studentSubmission
|
||||
);
|
||||
|
||||
if (studentAnswer.includes(value)) {
|
||||
chartData.value += 1;
|
||||
}
|
||||
});
|
||||
transformedQuestion.chartData.push(chartData);
|
||||
});
|
||||
}
|
||||
|
||||
_transformedQuestions.push(transformedQuestion);
|
||||
}
|
||||
);
|
||||
|
||||
transformedQuestions = [..._transformedQuestions];
|
||||
|
||||
console.log({ transformedQuestions });
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
const charts = await import('@carbon/charts-svelte');
|
||||
barChart = charts.BarChartSimple;
|
||||
pieChart = charts.PieChart;
|
||||
});
|
||||
|
||||
$: browser && $submissions?.length && getTransformedData($submissions);
|
||||
|
||||
$: {
|
||||
let chartOptions = getChartOptions(isLoading);
|
||||
barOptions = chartOptions.barOptions;
|
||||
pieOptions = chartOptions.pieOptions;
|
||||
|
||||
console.log({ chartOptions });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isLoading}
|
||||
<Loading withOverlay={true} />
|
||||
{:else if browser}
|
||||
<div>
|
||||
<p class="text-2xl mb-3">Question Charts</p>
|
||||
{#each transformedQuestions as q}
|
||||
<div class="mb-4">
|
||||
<p>{q.title}</p>
|
||||
{#if q.type === 1}
|
||||
<svelte:component this={pieChart} data={q.chartData} options={pieOptions} />
|
||||
{:else if q.type === 2}
|
||||
<svelte:component this={barChart} data={q.chartData} options={barOptions} />
|
||||
{:else}
|
||||
<div class="max-h-[250px] overflow-auto">
|
||||
<ul>
|
||||
{#each q.chartData as answer (answer)}
|
||||
{#if answer.group}
|
||||
<div class="rounded bg-slate-100 dark:bg-slate-300 p-2 my-1 w-full">
|
||||
<li class="text-base font-medium text-black">{answer.group}</li>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { ScaleTypes, Alignments } from '@carbon/charts-svelte';
|
||||
import type { BarChartOptions, PieChartOptions } from '@carbon/charts-svelte';
|
||||
|
||||
export const pieOptions: PieChartOptions = {
|
||||
resizable: true,
|
||||
legend: {
|
||||
alignment: Alignments.CENTER
|
||||
},
|
||||
pie: {
|
||||
alignment: Alignments.CENTER
|
||||
},
|
||||
height: '400px'
|
||||
};
|
||||
|
||||
export const barOptions: BarChartOptions = {
|
||||
axes: {
|
||||
left: {
|
||||
mapsTo: 'value'
|
||||
},
|
||||
bottom: {
|
||||
mapsTo: 'group',
|
||||
scaleType: ScaleTypes['LABELS']
|
||||
}
|
||||
},
|
||||
height: '400px'
|
||||
};
|
||||
|
||||
export function getChartOptions(isLoading: boolean) {
|
||||
const barOptions: BarChartOptions = {
|
||||
axes: {
|
||||
left: {
|
||||
mapsTo: 'value'
|
||||
},
|
||||
bottom: {
|
||||
mapsTo: 'group',
|
||||
scaleType: ScaleTypes['LABELS']
|
||||
}
|
||||
},
|
||||
height: '400px'
|
||||
};
|
||||
|
||||
const pieOptions: PieChartOptions = {
|
||||
resizable: true,
|
||||
legend: {
|
||||
alignment: Alignments.CENTER
|
||||
},
|
||||
pie: {
|
||||
alignment: Alignments.CENTER
|
||||
},
|
||||
height: '400px'
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
barOptions.data = {
|
||||
loading: true
|
||||
};
|
||||
pieOptions.data = {
|
||||
loading: true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
barOptions,
|
||||
pieOptions
|
||||
};
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { fetchSubmission } from '$lib/utils/services/submissions';
|
||||
import { browser } from '$app/environment';
|
||||
import Summary from './Summary.svelte';
|
||||
import Individual from './Individual.svelte';
|
||||
import { submissions } from './store';
|
||||
|
||||
export let exerciseId: string;
|
||||
|
||||
const tabs = [
|
||||
{ label: 'Summary', value: 'summary' },
|
||||
{ label: 'Individual', value: 'individual' }
|
||||
];
|
||||
let currentTab = tabs[0].value;
|
||||
|
||||
let isLoading = true;
|
||||
|
||||
const onChange = (tabValue: string) => () => (currentTab = tabValue);
|
||||
|
||||
async function fetchSubmissions(id: string | undefined) {
|
||||
if (!id) return;
|
||||
isLoading = true;
|
||||
|
||||
const { data } = await fetchSubmission({
|
||||
exerciseId: id
|
||||
});
|
||||
|
||||
if (!data) return;
|
||||
|
||||
submissions.set(data);
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
$: browser && fetchSubmissions(exerciseId);
|
||||
</script>
|
||||
|
||||
<div class="w-full flex flex-col">
|
||||
<div class="border rounded-md mb-2 px-4">
|
||||
<div class="mt-4">
|
||||
<p class="text-lg">{$submissions.length} Submissions</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-center overflow-x-auto">
|
||||
{#each tabs as tab}
|
||||
<button
|
||||
class="relative {currentTab === tab.value
|
||||
? 'text-primary-700'
|
||||
: 'dark:bg-gray-500 dark:text-white'} dark:bg-transparent font-semibold focus:outline-none w-fit mr-4 text-center py-3 px-2"
|
||||
on:click={onChange(tab.value)}
|
||||
>
|
||||
<div class="flex items-center justify-center w-full text-center">
|
||||
{tab.label}
|
||||
</div>
|
||||
<span
|
||||
class="absolute bottom-0 left-0 h-[2px] bg-primary-700 transition-all ease-in-out duration-500 {currentTab ===
|
||||
tab.value
|
||||
? 'w-full'
|
||||
: 'w-0'}"
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if currentTab === tabs[0].value}
|
||||
<Summary bind:isLoading />
|
||||
{:else}
|
||||
<Individual bind:isLoading />
|
||||
{/if}
|
||||
</div>
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { ExerciseSubmissions } from '$lib/utils/types';
|
||||
|
||||
export const submissions = writable<ExerciseSubmissions[]>([]);
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import QuestionContainer from '$lib/components/QuestionContainer/index.svelte';
|
||||
import DateTime from '$lib/components/Form/DateTime.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import { questionnaire } from '../store/exercise';
|
||||
import TextEditor from '$lib/components/TextEditor/index.svelte';
|
||||
|
||||
export let preview: boolean;
|
||||
|
||||
function getTotalPossibleGrade(questions: { points: string }[]) {
|
||||
return questions.reduce((acc, question) => {
|
||||
acc += parseFloat(question.points, 10);
|
||||
return acc;
|
||||
}, 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-5 {!preview ? 'px-6' : 'px-2'}">
|
||||
<QuestionContainer isTitle={true}>
|
||||
{#if !preview}
|
||||
<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;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="mt-3">
|
||||
<p class="mb-1">Description</p>
|
||||
<TextEditor
|
||||
value={$questionnaire.description}
|
||||
onChange={(html) => {
|
||||
$questionnaire.is_description_dirty = true;
|
||||
$questionnaire.description = html;
|
||||
}}
|
||||
placeholder="Describe the exercise to your student"
|
||||
maxHeight={300}
|
||||
/>
|
||||
</div>
|
||||
{:else if preview}
|
||||
<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($questionnaire.questions)}</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="mt-3 preview prose prose-sm sm:prose p-2">
|
||||
{@html $questionnaire.description || 'No description'}
|
||||
</article>
|
||||
{/if}
|
||||
</QuestionContainer>
|
||||
</div>
|
||||
@@ -0,0 +1,360 @@
|
||||
<script lang="ts">
|
||||
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 Box from '$lib/components/Box/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';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import {
|
||||
NOTIFICATION_NAME,
|
||||
triggerSendEmail
|
||||
} from '$lib/utils/services/notification/notification';
|
||||
import { lesson } from '../store/lessons';
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export let preview: false;
|
||||
export let exerciseId = '';
|
||||
|
||||
let currentQuestion = {};
|
||||
let renderProps = {};
|
||||
let submission;
|
||||
let hasSubmission = false;
|
||||
let isLoadingAutoSavedData = false;
|
||||
let alreadyCheckedAutoSavedData = false;
|
||||
|
||||
function handleStart() {
|
||||
$questionnaireMetaData.currentQuestionIndex += 1;
|
||||
}
|
||||
|
||||
const getStudent = (people, profileId) => {
|
||||
return people.find((person) => person.profile_id === profileId);
|
||||
};
|
||||
|
||||
const sendEmail = () => {
|
||||
const student = getStudent($group.students, $profile.id);
|
||||
const teacherFullname = $group.tutors[0]?.fullname;
|
||||
const teacherEmail = $group.tutors[0]?.email;
|
||||
console.log({
|
||||
student,
|
||||
teacherFullname,
|
||||
teacherEmail
|
||||
});
|
||||
if (!student || !teacherFullname || !teacherEmail) return;
|
||||
|
||||
const baseUrl = `${window.location.origin}/courses/${$course.id}`;
|
||||
const exerciseLink = `${baseUrl}/lessons/${$lesson.id}/exercises/${exerciseId}`;
|
||||
const submissionLink = `${baseUrl}/submissions`;
|
||||
const content = `
|
||||
<p>Hello ${teacherFullname},</p>
|
||||
<p>A student ${student.profile.fullname} just submitted an exercise <a href=${exerciseLink}>${$questionnaire.title}</a>
|
||||
<p>You can get started grading by clicking "Open Submissions"</p>
|
||||
<div>
|
||||
<a class="button" href=${submissionLink}>Open Submissions</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
triggerSendEmail(NOTIFICATION_NAME.EXERCISE_SUBMISSION_UPDATE, {
|
||||
to: teacherEmail,
|
||||
content,
|
||||
orgName: $currentOrg?.name,
|
||||
exerciseTitle: $questionnaire.title
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
localStorage.setItem(
|
||||
`autosave-exercise-${exerciseId}`,
|
||||
JSON.stringify($questionnaireMetaData)
|
||||
);
|
||||
}
|
||||
|
||||
const isFinished = !questions[$questionnaireMetaData.currentQuestionIndex - 1];
|
||||
console.log(`isFinished`, isFinished);
|
||||
console.log(
|
||||
`$questionnaireMetaData.currentQuestionIndex`,
|
||||
$questionnaireMetaData.currentQuestionIndex
|
||||
);
|
||||
|
||||
// If last question send to server
|
||||
if (isFinished) {
|
||||
localStorage.removeItem(`autosave-exercise-${exerciseId}`);
|
||||
$questionnaireMetaData.status = 1;
|
||||
$questionnaireMetaData.totalPossibleGrade = getTotalPossibleGrade($questionnaire.questions);
|
||||
$questionnaireMetaData.grades = {};
|
||||
submitExercise(
|
||||
$questionnaireMetaData.answers,
|
||||
questions,
|
||||
exerciseId,
|
||||
$course.id,
|
||||
getGroupMemberId($group.people, $profile.id)
|
||||
);
|
||||
sendEmail();
|
||||
}
|
||||
}
|
||||
|
||||
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(questions) {
|
||||
return questions.reduce((acc, question) => {
|
||||
acc += parseFloat(question.points, 10);
|
||||
return acc;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async function checkForSubmission(people, profileId: string, courseId: string) {
|
||||
if (!Array.isArray(people) || !profileId || !courseId || !!submission) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasSubmission) return;
|
||||
|
||||
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($questionnaire.questions);
|
||||
|
||||
$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;
|
||||
}, {});
|
||||
}
|
||||
}
|
||||
|
||||
function getAutoSavedData() {
|
||||
isLoadingAutoSavedData = true;
|
||||
|
||||
const stringifiedQuestionnaireMetaData = localStorage.getItem(
|
||||
`autosave-exercise-${exerciseId}`
|
||||
);
|
||||
|
||||
if (stringifiedQuestionnaireMetaData) {
|
||||
const autoSavedData = JSON.parse(stringifiedQuestionnaireMetaData);
|
||||
if (autoSavedData) {
|
||||
$questionnaireMetaData = autoSavedData;
|
||||
}
|
||||
}
|
||||
isLoadingAutoSavedData = false;
|
||||
alreadyCheckedAutoSavedData = true;
|
||||
}
|
||||
|
||||
$: browser && !alreadyCheckedAutoSavedData && getAutoSavedData();
|
||||
|
||||
// Reactive code
|
||||
$: if (alreadyCheckedAutoSavedData && $questionnaire.questions.length > 2) {
|
||||
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}
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<Preview
|
||||
questions={filterOutDeleted($questionnaire.questions)}
|
||||
questionnaireMetaData={$questionnaireMetaData}
|
||||
/>
|
||||
</RoleBasedSecurity>
|
||||
{:else if !$questionnaire.questions.length}
|
||||
<Box>
|
||||
<img src="/images/empty-exercise-icon.svg" alt="Exercise svg" class="my-2.5" />
|
||||
<h2 class="text-xl my-1.5">No question added for this exercise</h2>
|
||||
<p class="text-sm text-center px-44">
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
Click the <span class="text-primary-700">Edit</span> button to add.
|
||||
</RoleBasedSecurity>
|
||||
</p>
|
||||
</Box>
|
||||
{:else if $questionnaireMetaData.currentQuestionIndex === 0}
|
||||
<RoleBasedSecurity allowedRoles={[3]}>
|
||||
<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($questionnaire.questions)}</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="mt-3 preview prose prose-sm sm:prose p-2">
|
||||
{@html $questionnaire.description || 'No desription'}
|
||||
</article>
|
||||
|
||||
<PrimaryButton
|
||||
onClick={handleStart}
|
||||
label="Start"
|
||||
className="my-5 float-right"
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
</RoleBasedSecurity>
|
||||
{:else if $questionnaireMetaData.isFinished}
|
||||
{#if !isLoadingAutoSavedData}
|
||||
<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}
|
||||
/>
|
||||
{/if}
|
||||
{:else if currentQuestion && currentQuestion?.id}
|
||||
{#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,94 @@
|
||||
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 {
|
||||
index: questionIndex + '. ',
|
||||
title: 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,151 @@
|
||||
<script>
|
||||
import { onDestroy } from 'svelte';
|
||||
import AddAltIcon from 'carbon-icons-svelte/lib/AddAlt.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 {
|
||||
ContentSwitcher,
|
||||
Switch,
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
OverflowMenu,
|
||||
OverflowMenuItem
|
||||
} from 'carbon-components-svelte';
|
||||
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 { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { upsertExercise } from '$lib/utils/services/courses';
|
||||
import UpdateDescription from './UpdateDescription.svelte';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import Analytics from './Submissions/index.svelte';
|
||||
|
||||
export let exerciseId = '';
|
||||
export let path = '';
|
||||
export let goBack = () => {};
|
||||
export let isStudent = true;
|
||||
|
||||
let preview = false;
|
||||
let shouldDeleteExercise = false;
|
||||
let isSaving = false;
|
||||
let selectedIndex = 0;
|
||||
|
||||
async function handleSave() {
|
||||
if (isStudent) return;
|
||||
|
||||
const errors = validateQuestionnaire($questionnaire.questions);
|
||||
if (Object.values(errors).length > 0) {
|
||||
return;
|
||||
}
|
||||
isSaving = true;
|
||||
|
||||
reset();
|
||||
try {
|
||||
const updatedQuestions = await upsertExercise($questionnaire, exerciseId);
|
||||
|
||||
questionnaire.update((q) => ({
|
||||
...q,
|
||||
is_title_dirty: false,
|
||||
is_description_dirty: false,
|
||||
// @ts-ignore
|
||||
questions: updatedQuestions
|
||||
}));
|
||||
snackbar.success('Saved Successfully');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
snackbar.error();
|
||||
}
|
||||
isSaving = false;
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
reset();
|
||||
});
|
||||
|
||||
$: $questionnaire?.questions?.length < 1 && handleAddQuestion();
|
||||
console.log('question', $questionnaire.questions);
|
||||
</script>
|
||||
|
||||
<PageBody bind:isPageNavHidden={isStudent} padding="px-4 overflow-x-hidden">
|
||||
<div class="bg-gray-100 dark:bg-neutral-800 top-0 z-10 sticky p-2 mb-3">
|
||||
<Breadcrumb noTrailingSlash class="mb-5">
|
||||
<BreadcrumbItem href={path}>All Exercises</BreadcrumbItem>
|
||||
<BreadcrumbItem href={`${path}${exerciseId}`} isCurrentPage>
|
||||
{$questionnaire.title}
|
||||
</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<ContentSwitcher bind:selectedIndex class="mb-2">
|
||||
<Switch text="Questions ({$questionnaire.questions.length})" />
|
||||
<Switch text="Submissions ({$questionnaire.totalSubmissions})" />
|
||||
</ContentSwitcher>
|
||||
|
||||
{#if selectedIndex === 0}
|
||||
<div class="flex items-center justify-end right-0 w-full">
|
||||
<div class="flex items-center">
|
||||
<PrimaryButton
|
||||
className="mr-2"
|
||||
variant={VARIANTS.CONTAINED}
|
||||
label="Save"
|
||||
onClick={handleSave}
|
||||
isLoading={isSaving}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => (preview = !preview)}
|
||||
contained={preview}
|
||||
toolTipProps={{
|
||||
title: 'Preview',
|
||||
direction: 'bottom',
|
||||
hotkeys: []
|
||||
}}
|
||||
>
|
||||
{#if preview}
|
||||
<ViewFilledIcon size={24} class="carbon-icon dark:text-white" />
|
||||
{:else}
|
||||
<ViewIcon size={24} class="carbon-icon dark:text-white" />
|
||||
{/if}
|
||||
</IconButton>
|
||||
<IconButton onClick={() => handleAddQuestion()} size="small">
|
||||
<AddAltIcon size={24} class="carbon-icon dark:text-white" />
|
||||
</IconButton>
|
||||
<OverflowMenu flipped>
|
||||
<OverflowMenuItem
|
||||
text="Reorder Questions"
|
||||
on:click={() => ($questionnaireOrder.open = true)}
|
||||
/>
|
||||
<OverflowMenuItem
|
||||
danger
|
||||
text="Delete Exercise"
|
||||
on:click={() => (shouldDeleteExercise = true)}
|
||||
/>
|
||||
</OverflowMenu>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</RoleBasedSecurity>
|
||||
</div>
|
||||
|
||||
{#if selectedIndex === 0}
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<UpdateDescription {preview} />
|
||||
</RoleBasedSecurity>
|
||||
{#if !isStudent && !preview}
|
||||
<EditMode bind:shouldDeleteExercise {exerciseId} {goBack} />
|
||||
{:else}
|
||||
<ViewMode {preview} {exerciseId} />
|
||||
{/if}
|
||||
{:else if selectedIndex === 1}
|
||||
<Analytics bind:exerciseId />
|
||||
{/if}
|
||||
</PageBody>
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
<script lang="ts">
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import CheckmarkFilledIcon from 'carbon-icons-svelte/lib/CheckmarkFilled.svelte';
|
||||
import CheckmarkOutlineIcon from 'carbon-icons-svelte/lib/CheckmarkOutline.svelte';
|
||||
import ComingSoon from '$lib/components/ComingSoon/index.svelte';
|
||||
import { Tag } from 'carbon-components-svelte';
|
||||
import { TEMPLATES, TAGS } from '$lib/mocks';
|
||||
import type { ExerciseTemplate } from '$lib/utils/types';
|
||||
import { lesson } from '../store/lessons';
|
||||
import { useCompletion } from 'ai/svelte';
|
||||
import Confetti from '$lib/components/Confetti/index.svelte';
|
||||
import { toggleConfetti } from '$lib/components/Confetti/store';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { getTextFromHTML } from '$lib/utils/functions/course';
|
||||
import { writable } from 'svelte/store';
|
||||
import { Circle3 } from 'svelte-loading-spinners';
|
||||
|
||||
export let open = false;
|
||||
export let handleAddExercise = () => {};
|
||||
export let handleCancelAddExercise = () => {};
|
||||
export let handleTemplateCreate: (template: ExerciseTemplate) => Promise<void>;
|
||||
export let title = '';
|
||||
|
||||
enum Type {
|
||||
SCRATCH = 0,
|
||||
TEMPLATE = 1,
|
||||
AI = 2
|
||||
}
|
||||
|
||||
let step = 0;
|
||||
let type: Type = Type.SCRATCH;
|
||||
let questionNumber = 5;
|
||||
let optionNumber = 5;
|
||||
let isLoading = writable(false);
|
||||
let isAIStarted = false;
|
||||
let note = '';
|
||||
|
||||
const options = [
|
||||
{
|
||||
title: 'From Scratch',
|
||||
subtitle: 'Create your exercise from scratch if you have your questions already prepared',
|
||||
type: Type.SCRATCH,
|
||||
isDisabled: false
|
||||
},
|
||||
{
|
||||
title: 'Use a Template',
|
||||
subtitle: 'Select from 100+ templates of predefined exercise to help you get started',
|
||||
type: Type.TEMPLATE,
|
||||
isDisabled: false
|
||||
},
|
||||
{
|
||||
title: 'Use AI',
|
||||
subtitle: 'You can generate an exercise with AI from your notes',
|
||||
type: Type.AI,
|
||||
isDisabled: false
|
||||
}
|
||||
];
|
||||
|
||||
const tags = Object.values(TAGS);
|
||||
let selectedTag = tags[0];
|
||||
let selectedTemplateId = '';
|
||||
let isTemplateFinishedLoading = false;
|
||||
|
||||
const { input, handleSubmit, completion } = useCompletion({
|
||||
api: '/api/completion/exerciseprompt',
|
||||
onFinish: async (prompt: string, completion: string) => {
|
||||
if (!$lesson.id) return;
|
||||
|
||||
toggleConfetti();
|
||||
const template: ExerciseTemplate = JSON.parse($completion);
|
||||
await handleTemplateCreate(template);
|
||||
toggleConfetti();
|
||||
$isLoading = false;
|
||||
}
|
||||
});
|
||||
|
||||
function handleNext() {
|
||||
step = step + 1;
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
step = step - 1;
|
||||
}
|
||||
|
||||
function callAI() {
|
||||
$input = JSON.stringify({
|
||||
questionNumber,
|
||||
optionNumber,
|
||||
lessonNote: note
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
isAIStarted = true;
|
||||
$isLoading = true;
|
||||
handleSubmit({ preventDefault: () => {} });
|
||||
}, 500);
|
||||
}
|
||||
|
||||
$: note = getTextFromHTML($lesson?.materials?.note || '');
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
onClose={handleCancelAddExercise}
|
||||
bind:open
|
||||
modalHeading="New Exercise"
|
||||
maxWidth="max-w-4xl"
|
||||
width="w-4/5"
|
||||
>
|
||||
{#if !$isLoading && isAIStarted}
|
||||
<Confetti />
|
||||
{/if}
|
||||
{#if step === 0}
|
||||
<div>
|
||||
<h2 class="text-2xl font-medium my-5">How do you want to create your exercise?</h2>
|
||||
|
||||
<div class="flex gap-2 justify-between my-8">
|
||||
{#each options as option}
|
||||
<button
|
||||
class="w-[261px] h-[240px] p-5 rounded-md dark:bg-neutral-700 border-2 {option.type ===
|
||||
type
|
||||
? 'border-primary-400'
|
||||
: `border-gray-200 dark:border-gray-400 ${
|
||||
!option.isDisabled && 'hover:scale-95'
|
||||
}`} flex flex-col {option.isDisabled &&
|
||||
'cursor-not-allowed opacity-60'} transition-all ease-in-out"
|
||||
type="button"
|
||||
on:click={!option.isDisabled ? () => (type = option.type) : undefined}
|
||||
>
|
||||
<div class="w-full flex flex-row-reverse h-[70%]">
|
||||
{#if option.type === type}
|
||||
<CheckmarkFilledIcon
|
||||
size={16}
|
||||
class="carbon-icon text-primary-600 dark:text-primary-200"
|
||||
/>
|
||||
{:else if !option.isDisabled}
|
||||
<CheckmarkOutlineIcon size={16} class="carbon-icon" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="font-bold text-start flex items-center">
|
||||
<span class="mr-2 text-sm">{option.title}</span>
|
||||
{#if option.isDisabled}
|
||||
<ComingSoon />
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-xs font-light text-start">{option.subtitle}</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex items-center flex-row-reverse">
|
||||
<PrimaryButton className="px-6 py-3" label="Next" onClick={handleNext} />
|
||||
</div>
|
||||
</div>
|
||||
{:else if step === 1}
|
||||
{#if type === Type.SCRATCH}
|
||||
<div class="flex items-center justify-center w-96 m-auto min-h-[300px]">
|
||||
<div class="w-full">
|
||||
<h2 class="text-2xl font-medium my-5">Give your exercise a title</h2>
|
||||
<TextField
|
||||
bind:value={title}
|
||||
autoFocus={true}
|
||||
placeholder="Exercise name"
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<PrimaryButton
|
||||
className="px-6 py-3"
|
||||
label="Back"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
onClick={handleBack}
|
||||
/>
|
||||
<PrimaryButton className="px-6 py-3" label="Finish" onClick={handleAddExercise} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if type === Type.TEMPLATE}
|
||||
<div>
|
||||
<h2 class="text-2xl font-medium my-5">Select a template</h2>
|
||||
|
||||
<div>
|
||||
<div class="mb-5 flex items-center gap-2">
|
||||
{#each tags as tag}
|
||||
<Tag
|
||||
type={selectedTag === tag ? 'warm-gray' : 'outline'}
|
||||
class={selectedTag === tag ? 'bg-primary-400' : ''}
|
||||
interactive
|
||||
on:click={() => {
|
||||
selectedTag = tag;
|
||||
selectedTemplateId = '';
|
||||
}}>{tag}</Tag
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-start gap-2">
|
||||
{#each TEMPLATES[selectedTag] as template}
|
||||
<button
|
||||
class="w-[161px] h-[140px] hover:scale-95 p-5 rounded-md dark:bg-neutral-700 border-2 {template.id ===
|
||||
selectedTemplateId
|
||||
? 'border-primary-400'
|
||||
: `border-gray-200 dark:border-gray-400`} flex flex-col transition-all ease-in-out"
|
||||
type="button"
|
||||
on:click={() => (selectedTemplateId = template.id)}
|
||||
>
|
||||
<div class="flex flex-col justify-evenly h-full">
|
||||
<p class="font-bold text-sm text-start flex items-center">
|
||||
{template.title}
|
||||
</p>
|
||||
<div class="flex flex-col items-start justify-between gap-1">
|
||||
<p class="text-xs font-normal">
|
||||
{template.questions} Questions
|
||||
</p>
|
||||
<p class="text-xs font-normal text-start">
|
||||
{template.points} Points
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<PrimaryButton
|
||||
className="px-6 py-3"
|
||||
label="Back"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
onClick={handleBack}
|
||||
/>
|
||||
<PrimaryButton
|
||||
isDisabled={!selectedTemplateId}
|
||||
className="px-6 py-3"
|
||||
label="Finish"
|
||||
isLoading={isTemplateFinishedLoading}
|
||||
onClick={async () => {
|
||||
isTemplateFinishedLoading = true;
|
||||
const template = TEMPLATES[selectedTag].find((t) => t.id === selectedTemplateId);
|
||||
if (!template) return;
|
||||
|
||||
console.log('Selected template', template);
|
||||
await handleTemplateCreate(template.data);
|
||||
isTemplateFinishedLoading = true;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if type === Type.AI}
|
||||
<div>
|
||||
<div class="flex flex-row justify-between max-h-[500px]">
|
||||
<div class="w-[60%] mr-1 border px-3 py-2 rounded-md">
|
||||
{#if note.length}
|
||||
<h3>Create exercises from Notes with AI</h3>
|
||||
<p class="text-sm mb-4">
|
||||
Choose how many questions and options you want and AI will help you create an
|
||||
exercise out of your note. Let's go.
|
||||
</p>
|
||||
<TextField
|
||||
label="How many questions do you want to create?"
|
||||
type="number"
|
||||
bind:value={questionNumber}
|
||||
placeholder="5"
|
||||
className="mb-2"
|
||||
isRequired
|
||||
/>
|
||||
<TextField
|
||||
label="How many options per questions do you want?"
|
||||
type="number"
|
||||
bind:value={optionNumber}
|
||||
placeholder="5"
|
||||
isRequired
|
||||
/>
|
||||
{:else}
|
||||
<h3>Please add a note to use this feature</h3>
|
||||
{/if}
|
||||
<div class="mt-5 flex items-center flex-row-reverse">
|
||||
<PrimaryButton
|
||||
onClick={callAI}
|
||||
isLoading={$isLoading}
|
||||
isDisabled={$isLoading || !note}
|
||||
variant={VARIANTS.OUTLINED}
|
||||
>
|
||||
Generate
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="w-[40%] px-5 py-3 border rounded-md overflow-y-auto flex justify-center items-center"
|
||||
>
|
||||
{#if $isLoading}
|
||||
<Circle3 size="60" unit="px" duration="1s" />
|
||||
{:else if isAIStarted}
|
||||
<p class="max-h-[200px] leading-7 text-sm">AI Generation Complete</p>
|
||||
{:else}
|
||||
<p class="max-h-[200px] leading-7 text-sm">Click "Generate" for some magic</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<PrimaryButton
|
||||
className="px-6 py-3"
|
||||
label="Back"
|
||||
variant={VARIANTS.TEXT}
|
||||
onClick={handleBack}
|
||||
/>
|
||||
<PrimaryButton className="px-6 py-3" label="Finish" onClick={handleAddExercise} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Modal>
|
||||
@@ -0,0 +1,200 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
import { Breadcrumb, BreadcrumbItem } from 'carbon-components-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 Exercise from '../Exercise/index.svelte';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { createExercise, createExerciseFromTemplate } from '$lib/utils/services/courses';
|
||||
import { QUESTION_TYPES } from '$lib/components/Question/constants';
|
||||
import { lesson } from '../store/lessons';
|
||||
import { questionnaire, isQuestionnaireFetching } from '../store/exercise';
|
||||
import NewExerciseModal from '$lib/components/Course/components/Lesson/Exercises/NewExerciseModal.svelte';
|
||||
import type { ExerciseTemplate } from '$lib/utils/types';
|
||||
|
||||
export let path = '';
|
||||
export let exerciseId = '';
|
||||
export let lessonId = '';
|
||||
export let isStudent = true;
|
||||
|
||||
let open = false;
|
||||
let isFetching = false;
|
||||
let newExercise = {
|
||||
id: 1,
|
||||
title: '',
|
||||
description: ''
|
||||
};
|
||||
|
||||
async function handleTemplateCreate(template: ExerciseTemplate): Promise<void> {
|
||||
const newExercise = await createExerciseFromTemplate(lessonId, template);
|
||||
console.log('newExercise', newExercise);
|
||||
if (newExercise) {
|
||||
lesson.update((_lesson) => ({
|
||||
..._lesson,
|
||||
exercises: [..._lesson.exercises, newExercise]
|
||||
}));
|
||||
|
||||
handleCancelAddExercise();
|
||||
goto(path + '/' + newExercise.id);
|
||||
}
|
||||
}
|
||||
|
||||
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: new Date().getTime(),
|
||||
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: string | undefined) {
|
||||
if (!exerciseId) return;
|
||||
isFetching = true;
|
||||
isQuestionnaireFetching.update(() => true);
|
||||
|
||||
let { data, error } = await supabase
|
||||
.from('exercise')
|
||||
.select(
|
||||
`
|
||||
id, title, description, due_by,
|
||||
totalSubmissions:submission(count),
|
||||
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 = [];
|
||||
}
|
||||
|
||||
questionnaire.set({
|
||||
...(data || {}),
|
||||
is_title_dirty: false,
|
||||
is_description_dirty: false,
|
||||
is_due_by_dirty: false,
|
||||
questions: Array.isArray(data.questions) ? data.questions : [],
|
||||
totalSubmissions: data?.totalSubmissions?.[0]?.count || 0
|
||||
});
|
||||
|
||||
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} bind:path bind:isStudent />
|
||||
{:else}
|
||||
<NewExerciseModal
|
||||
bind:open
|
||||
{handleCancelAddExercise}
|
||||
{handleAddExercise}
|
||||
{handleTemplateCreate}
|
||||
bind:title={newExercise.title}
|
||||
/>
|
||||
|
||||
<PageBody bind:isPageNavHidden={isStudent}>
|
||||
<slot:fragment slot="header">
|
||||
<Breadcrumb class="my-2">
|
||||
<BreadcrumbItem href={path}>All Exercises</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<PrimaryButton className="mr-2 my-2" label="Add" onClick={() => (open = !open)} />
|
||||
</RoleBasedSecurity>
|
||||
</slot:fragment>
|
||||
|
||||
<div class="flex flex-wrap mt-5">
|
||||
{#each $lesson.exercises as exercise}
|
||||
<a
|
||||
class="w-52 bg-gray-100 dark:bg-neutral-800 px-4 py-7 mr-4 mb-4 rounded-lg"
|
||||
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 className="mt-3 text-center">
|
||||
<div class="flex justify-between flex-col items-center w-[80%] md:w-96">
|
||||
<img src="/images/empty-exercise-icon.svg" alt="Exercise" class="my-2.5 mx-auto" />
|
||||
<h2 class="text-xl my-1.5 font-normal">No exercises added for this lesson</h2>
|
||||
<p class="text-sm text-center text-slate-500">
|
||||
{#if isStudent}
|
||||
Your tutor hasn't assigned any exercise to this lesson. For the main time, you can
|
||||
<strong>chill with the big boys :)</strong>
|
||||
{:else}
|
||||
Share your knowledge with the world by creating engaging exercises. Add an exercise
|
||||
now.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</Box>
|
||||
{/each}
|
||||
</div>
|
||||
</PageBody>
|
||||
{/if}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<script>
|
||||
import YoutubeVideo from './YoutubeVideo.svelte';
|
||||
import LocalVideo from './LocalVideo.svelte';
|
||||
import * as CONSTANTS from './constants';
|
||||
|
||||
export let lessonId = '';
|
||||
|
||||
let tabs = CONSTANTS.videoTabs;
|
||||
let currentTab = tabs[0].value;
|
||||
|
||||
const onChange = (tab) => () => (currentTab = tab);
|
||||
</script>
|
||||
|
||||
<section class="w-full h-full flex flex-col md:flex-row items-start gap-3">
|
||||
<div class="flex flex-row md:flex-col gap-2 items-center">
|
||||
<p class="text-sm font-normal text-start text-[#4F4B4B] dark:text-[#b0a9a9] mb-3 w-full">
|
||||
Add By
|
||||
</p>
|
||||
{#each tabs as item (item.value)}
|
||||
<button
|
||||
on:click={onChange(item.value)}
|
||||
class={`w-full px-4 py-3 my-1 border ${
|
||||
currentTab === item.value
|
||||
? 'border border-[#0233BD] bg-[#F5F8FE] dark:text-black'
|
||||
: 'border border-gray-200'
|
||||
} rounded-md cursor-pointer flex flex-row items-center justify-start gap-2 whitespace-nowrap`}
|
||||
>
|
||||
<svelte:component
|
||||
this={item.icon}
|
||||
size={20}
|
||||
color={`${currentTab === item.value ? 'dark:invert-0' : 'dark:invert'}`}
|
||||
/>
|
||||
<p>{item.title}</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<main class="w-full h-full">
|
||||
{#if currentTab === 1}
|
||||
<YoutubeVideo />
|
||||
{:else}
|
||||
<LocalVideo {lessonId} />
|
||||
{/if}
|
||||
</main>
|
||||
</section>
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import { PUBLIC_SERVER_URL } from '$env/static/public';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import {
|
||||
lesson,
|
||||
deleteLessonVideo,
|
||||
uploadCourseVideoStore
|
||||
} from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { Moon } from 'svelte-loading-spinners';
|
||||
|
||||
export let lessonId = '';
|
||||
|
||||
let formRes;
|
||||
let isLoaded = false;
|
||||
let fileInput;
|
||||
let submit;
|
||||
let uploadedFileUrl = '';
|
||||
let isLoading = false;
|
||||
let timeoutkey;
|
||||
|
||||
const uploadingTexts = [
|
||||
'Sending your video to our server...',
|
||||
'Generating a link to your video...',
|
||||
'Generating transcription for your video...',
|
||||
'Wrapping things up'
|
||||
];
|
||||
let uploadingLoadingText = uploadingTexts[0];
|
||||
|
||||
function isVideoAdded(link) {
|
||||
return $lesson.materials?.videos?.find((v) => v.link === link);
|
||||
}
|
||||
|
||||
async function onUpload(e) {
|
||||
isLoading = true;
|
||||
if (!fileInput) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('videoFile', fileInput.files[0]);
|
||||
|
||||
try {
|
||||
const response = await axios({
|
||||
method: 'POST',
|
||||
url: PUBLIC_SERVER_URL + '/uploadVideo?lessonId=' + lessonId,
|
||||
data: formData,
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data; boundary=MyBoundary'
|
||||
}
|
||||
});
|
||||
|
||||
formRes = response.data;
|
||||
console.log('Upload res', formRes);
|
||||
isLoading = false;
|
||||
isLoaded = false;
|
||||
} catch (err) {
|
||||
console.error('Error uploading video', err, '\n\n', err.response);
|
||||
if (err.response) {
|
||||
formRes = err.response.data;
|
||||
console.log('formRes', formRes);
|
||||
}
|
||||
}
|
||||
|
||||
isLoaded = true;
|
||||
}
|
||||
|
||||
async function isDoneUploading(response) {
|
||||
uploadedFileUrl = response?.success && response?.url;
|
||||
|
||||
if (uploadedFileUrl && !isVideoAdded(uploadedFileUrl)) {
|
||||
$lesson.materials.videos = [
|
||||
...$lesson.materials.videos,
|
||||
{
|
||||
type: 'muse',
|
||||
link: uploadedFileUrl,
|
||||
metadata: response?.metadata || {}
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function tryAgain() {
|
||||
formRes = null;
|
||||
isLoaded = false;
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
$: {
|
||||
if (isLoading) {
|
||||
timeoutkey = setTimeout(() => {
|
||||
console.log('timeout');
|
||||
const i = uploadingTexts.findIndex((text) => text === uploadingLoadingText);
|
||||
const next = uploadingTexts[i + 1];
|
||||
|
||||
if (next) {
|
||||
console.log('uploadingLoadingText', next);
|
||||
uploadingLoadingText = next;
|
||||
}
|
||||
}, 5000);
|
||||
} else if (timeoutkey) {
|
||||
console.log('clearTimeout');
|
||||
clearTimeout(timeoutkey);
|
||||
uploadingLoadingText = uploadingTexts[0];
|
||||
}
|
||||
}
|
||||
|
||||
$: isDoneUploading(formRes);
|
||||
</script>
|
||||
|
||||
{#if !isLoaded}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (fileInput && !isLoading ? fileInput.click() : null)}
|
||||
class="w-full h-full"
|
||||
disabled={isLoading || !PUBLIC_SERVER_URL}
|
||||
>
|
||||
<form
|
||||
class="h-full w-full flex flex-col items-center justify-center border border-primary-300 border-dashed rounded-xl"
|
||||
on:submit|preventDefault={onUpload}
|
||||
>
|
||||
{#if isLoading}
|
||||
<Moon size="40" color="#1d4ed8" unit="px" duration="1s" />
|
||||
<p class="mt-5">{uploadingLoadingText}</p>
|
||||
{:else}
|
||||
<img src="/upload-video.svg" alt="upload" />
|
||||
<span class="pt-3">
|
||||
<h3 class="text-center text-xl font-normal dark:text-white">Upload video</h3>
|
||||
<p class=" text-center text-sm font-normal">
|
||||
Select file( Mp4, MOV, AVI) to upload to your lesson.
|
||||
</p>
|
||||
<p>(Max 80 MB)</p>
|
||||
</span>
|
||||
{/if}
|
||||
<input
|
||||
style="display:none;"
|
||||
type="file"
|
||||
accept="video/*"
|
||||
name="videoFile"
|
||||
on:change={() => submit.click()}
|
||||
bind:this={fileInput}
|
||||
/>
|
||||
<input type="text" name="lessonId" value={lessonId} style="display: none;" />
|
||||
<input style="display:none;" type="submit" bind:this={submit} />
|
||||
</form>
|
||||
</button>
|
||||
{:else if formRes?.type === 'FILE_TOO_LARGE'}
|
||||
<div class="h-full w-full flex flex-col items-center justify-center rounded-xl">
|
||||
<img src="/video-upload-error.svg" alt="upload error" />
|
||||
<span class="pt-3 pb-2">
|
||||
<h3 class="text-center text-base font-normal dark:text-white">
|
||||
Oops 😬, couldn’t upload video
|
||||
</h3>
|
||||
<p class="text-center text-xs font-normal text-[#ADADAD]">
|
||||
Sorry we video wasn’t uploaded. The file size is too big,<br /> maximum size is 30 MB. Try again!
|
||||
</p>
|
||||
</span>
|
||||
<PrimaryButton label="Try another file" onClick={tryAgain} />
|
||||
</div>
|
||||
{:else if !formRes?.success}
|
||||
<div class="h-full w-full flex flex-col items-center justify-center rounded-xl">
|
||||
<img src="/video-upload-error.svg" alt="upload error" />
|
||||
<span class="pt-3 pb-2">
|
||||
<h3 class="text-center text-base font-normal dark:text-white">
|
||||
Oops 😬, couldn’t upload video
|
||||
</h3>
|
||||
<p class="text-center text-xs font-normal text-[#ADADAD]">
|
||||
Sorry we video wasn’t uploaded, the file format isn’t supported or something went wrong on
|
||||
the server.<br /> Upload MP4, MOV and AVI files.
|
||||
</p>
|
||||
</span>
|
||||
<PrimaryButton label="Please try again" onClick={tryAgain} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col w-full h-full items-start justify-between">
|
||||
<div class="h-auto w-full border rounded-md px-8 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center gap-2">
|
||||
<div class="rounded-sm overflow-hidden">
|
||||
<video class="w-[200px]">
|
||||
<source src={uploadedFileUrl} type="video/mp4" />
|
||||
<!-- <source src="/path/to/video.webm" type="video/webm" /> -->
|
||||
<!-- Captions are optional -->
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
</div>
|
||||
<p>{fileInput?.files?.[0]?.name || uploadedFileUrl}</p>
|
||||
</span>
|
||||
<button on:click={deleteLessonVideo}>
|
||||
<img src="/delete-video.svg" alt="deletevideo" class="dark:invert" />
|
||||
</button>
|
||||
</div>
|
||||
<PrimaryButton label="Done" onClick={() => ($uploadCourseVideoStore.isModalOpen = false)} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
<script>
|
||||
import { CopyButton, Tag } from 'carbon-components-svelte';
|
||||
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import { lesson, isLessonDirty } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
|
||||
let youtubeLinks = '';
|
||||
let error = '';
|
||||
|
||||
function getVideoUrls(urls = '') {
|
||||
return (urls || '').split(',').filter((url) => !!url.trim());
|
||||
}
|
||||
|
||||
function addVideo() {
|
||||
const links = getVideoUrls(youtubeLinks);
|
||||
const validLinks = links.filter(isValidYouTubeLink);
|
||||
|
||||
if (validLinks.length === 0) {
|
||||
error = 'Invalid YouTube link(s)';
|
||||
} else {
|
||||
const existingLinks = $lesson?.materials?.videos || [];
|
||||
|
||||
$lesson.materials.videos = [
|
||||
...existingLinks,
|
||||
...validLinks.map((link = '') => ({
|
||||
type: 'youtube',
|
||||
link,
|
||||
metadata: {}
|
||||
}))
|
||||
];
|
||||
youtubeLinks = '';
|
||||
error = '';
|
||||
}
|
||||
}
|
||||
|
||||
function isValidYouTubeLink(link = '') {
|
||||
const youtubeRegex =
|
||||
/^(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
|
||||
|
||||
return youtubeRegex.test(link.trim());
|
||||
}
|
||||
|
||||
function removeVideo(index = 0) {
|
||||
$lesson.materials.videos = $lesson.materials.videos.filter((v, i) => i !== index);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full flex items-{error ? 'center' : 'end'} justify-between gap-5">
|
||||
<TextField
|
||||
label="Youtube link"
|
||||
bind:value={youtubeLinks}
|
||||
className="flex-1 text-left "
|
||||
inputClassName="text-sm"
|
||||
onChange={() => ($isLessonDirty = true)}
|
||||
placeholder="https://www.youtube.com/watch?v="
|
||||
errorMessage={error}
|
||||
/>
|
||||
<PrimaryButton label="Add Video" className="rounded-md" onClick={addVideo} />
|
||||
</div>
|
||||
<p class="mt-4 pl-2 text-sm">
|
||||
Videos added: <strong>
|
||||
{$lesson?.materials?.videos.filter((v) => v.type === 'youtube' && isValidYouTubeLink(v.link))
|
||||
.length || 0}
|
||||
</strong>
|
||||
</p>
|
||||
<div class="">
|
||||
{#each $lesson?.materials?.videos as video, index}
|
||||
{#if video.type === 'youtube'}
|
||||
<div class="flex items-center gap-1">
|
||||
<Tag type="blue">
|
||||
{video.link}
|
||||
</Tag>
|
||||
<CopyButton text={video.link} feedback="Copied to clipboard" />
|
||||
<IconButton value="delete-video" onClick={() => removeVideo(index)}>
|
||||
<TrashCanIcon size={16} class="carbon-icon dark:text-white" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import Link from 'carbon-icons-svelte/lib/Link.svelte';
|
||||
import Video from 'carbon-icons-svelte/lib/Video.svelte';
|
||||
export const videoTabs = [
|
||||
{
|
||||
value: 1,
|
||||
title: 'Youtube Link',
|
||||
icon: Link
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
title: 'Video',
|
||||
icon: Video
|
||||
}
|
||||
];
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { lesson } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
|
||||
|
||||
function isNoteEmpty(note: string) {
|
||||
if (!note || typeof note !== 'string') return true;
|
||||
|
||||
if (!document) return false;
|
||||
|
||||
const dummyDiv = document.createElement('div');
|
||||
dummyDiv.innerHTML = note;
|
||||
|
||||
const rawText = dummyDiv.textContent?.trim();
|
||||
|
||||
return rawText === '';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !isNoteEmpty($lesson.materials?.note)}
|
||||
<HtmlRender className="m-auto">
|
||||
<svelte:fragment slot="content">
|
||||
<div>
|
||||
{@html $lesson.materials.note}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</HtmlRender>
|
||||
{/if}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<script>
|
||||
import { lesson } from '../../store/lessons';
|
||||
</script>
|
||||
|
||||
{#if $lesson.materials.slide_url}
|
||||
<iframe
|
||||
title="Embeded Slides"
|
||||
src={$lesson.materials.slide_url}
|
||||
frameborder="0"
|
||||
width="100%"
|
||||
height="569"
|
||||
class="iframe my-3"
|
||||
allowfullscreen="true"
|
||||
mozallowfullscreen="true"
|
||||
webkitallowfullscreen="true"
|
||||
/>
|
||||
{/if}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { formatYoutubeVideo } from '$lib/utils/functions/formatYoutubeVideo';
|
||||
import { lesson } from '../../store/lessons';
|
||||
|
||||
let errors = {};
|
||||
let player = null;
|
||||
|
||||
function initPlyr(_player: any, _video: string | undefined) {
|
||||
if (!_player) return;
|
||||
|
||||
const players = Array.from(document.querySelectorAll('.plyr-video-trigger')).map((p) => {
|
||||
// @ts-ignore
|
||||
return new Plyr(p);
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
window.players = players;
|
||||
}
|
||||
|
||||
$: initPlyr(player, $lesson.materials.videos);
|
||||
</script>
|
||||
|
||||
{#if $lesson.materials.videos.length}
|
||||
<div class="w-full">
|
||||
{#each $lesson.materials.videos as video}
|
||||
<div class="w-full overflow-hidden mb-5">
|
||||
{#key video.link}
|
||||
<div class="mb-5">
|
||||
{#if video.type === 'youtube'}
|
||||
<iframe
|
||||
class="iframe"
|
||||
src={formatYoutubeVideo(video.link, errors)}
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
/>
|
||||
{:else if video.metadata?.svid}
|
||||
<div style="position:relative;padding-bottom:51.416579%">
|
||||
<iframe
|
||||
src="https://muse.ai/embed/{video.metadata
|
||||
?.svid}?logo=https://app.classroomio.com/logo-512.png&subtitles=auto&cover_play_position=center"
|
||||
style="width:100%;height:100%;position:absolute;left:0;top:0"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
title="Muse AI Video Embed"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<video bind:this={player} class="plyr-video-trigger" playsinline controls>
|
||||
<source src={video.link} type="video/mp4" />
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.iframe {
|
||||
width: 100%;
|
||||
height: 450px;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.iframe {
|
||||
height: 209px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Video, Notebook, PresentationFile } from 'carbon-icons-svelte';
|
||||
|
||||
export let tabs = [
|
||||
{
|
||||
label: 'Note',
|
||||
icon: Notebook,
|
||||
value: 1,
|
||||
badgeValue: 0
|
||||
},
|
||||
{
|
||||
label: 'Slide',
|
||||
icon: PresentationFile,
|
||||
value: 2,
|
||||
badgeValue: 0
|
||||
},
|
||||
{
|
||||
label: 'Video',
|
||||
icon: Video,
|
||||
value: 3,
|
||||
badgeValue: 0
|
||||
}
|
||||
];
|
||||
|
||||
export function orderedTabs(tabs, Settingtabs) {
|
||||
if (!Array.isArray(Settingtabs)) return tabs;
|
||||
const reorderedTabs = [];
|
||||
|
||||
const tabMap = new Map(tabs.map((tab) => [tab.label, tab]));
|
||||
|
||||
Settingtabs.forEach((settingTab, index) => {
|
||||
const tab = tabMap.get(settingTab.name);
|
||||
if (tab) {
|
||||
reorderedTabs[index] = tab;
|
||||
}
|
||||
});
|
||||
|
||||
const filteredReorderedTabs = reorderedTabs.filter((tab) => tab !== undefined);
|
||||
return filteredReorderedTabs;
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
<script lang="ts">
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useCompletion } from 'ai/svelte';
|
||||
import MODES from '$lib/utils/constants/mode.js';
|
||||
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import { formatYoutubeVideo } from '$lib/utils/functions/formatYoutubeVideo';
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import { Popover } from 'carbon-components-svelte';
|
||||
import AlignBoxTopLeftIcon from 'carbon-icons-svelte/lib/AlignBoxTopLeft.svelte';
|
||||
import ListIcon from 'carbon-icons-svelte/lib/List.svelte';
|
||||
import IbmWatsonKnowledgeStudioIcon from 'carbon-icons-svelte/lib/IbmWatsonKnowledgeStudio.svelte';
|
||||
import MagicWandFilled from 'carbon-icons-svelte/lib/MagicWandFilled.svelte';
|
||||
import Tabs from '$lib/components/Tabs/index.svelte';
|
||||
import TabContent from '$lib/components/TabContent/index.svelte';
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import {
|
||||
lesson,
|
||||
lessons,
|
||||
handleUpdateLessonMaterials,
|
||||
isLessonDirty,
|
||||
uploadCourseVideoStore,
|
||||
deleteLessonVideo
|
||||
} from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import VideoUploader from '$lib/components/Course/components/Lesson/Materials/Video/Index.svelte';
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import TextEditor from '$lib/components/TextEditor/index.svelte';
|
||||
import * as CONSTANTS from './constants';
|
||||
import { orderedTabs } from './constants';
|
||||
import ComponentNote from './components/ComponentNote.svelte';
|
||||
import ComponentSlide from './components/ComponentSlide.svelte';
|
||||
import ComponentVideo from './components/ComponentVideo.svelte';
|
||||
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
|
||||
import type { LessonPage } from '$lib/utils/types';
|
||||
import { getTextFromHTML } from '$lib/utils/functions/course';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
|
||||
export let mode = MODES.view;
|
||||
export let prevMode = '';
|
||||
export let lessonId = '';
|
||||
export let isSaving = false;
|
||||
export let toggleMode = () => {};
|
||||
|
||||
let lessonTitle = '';
|
||||
let initAutoSave = false;
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
let tabs = CONSTANTS.tabs;
|
||||
let currentTab = tabs[0].value;
|
||||
let errors: {
|
||||
video: string;
|
||||
};
|
||||
let editorWindowRef: Window;
|
||||
let aiButtonRef: HTMLDivElement;
|
||||
let openPopover = false;
|
||||
let player: HTMLVideoElement;
|
||||
let componentsToRender = getComponentOrder(tabs);
|
||||
let aiButtonClass =
|
||||
'flex items-center px-5 py-2 border border-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md w-full mb-2';
|
||||
|
||||
const onChange =
|
||||
(tab = 0) =>
|
||||
() =>
|
||||
(currentTab = tab);
|
||||
|
||||
const getValue = (label: string) => {
|
||||
const tabValue = tabs.find((tab) => tab.label === label)?.value;
|
||||
return tabValue;
|
||||
};
|
||||
async function saveLesson(materials?: LessonPage['materials']) {
|
||||
const _lesson = !!materials
|
||||
? {
|
||||
...$lesson,
|
||||
materials
|
||||
}
|
||||
: $lesson;
|
||||
return handleUpdateLessonMaterials(_lesson, lessonId);
|
||||
}
|
||||
|
||||
function isNoteEmpty(note: string) {
|
||||
if (!note || typeof note !== 'string') return true;
|
||||
|
||||
if (!document) return false;
|
||||
|
||||
return getTextFromHTML(note) === '';
|
||||
}
|
||||
|
||||
function isMaterialsEmpty(materials: LessonPage['materials']) {
|
||||
const { slide_url, videos, note } = materials;
|
||||
|
||||
return isNoteEmpty(note) && !slide_url && isEmpty(videos);
|
||||
}
|
||||
|
||||
function handleSave(prevMode: string) {
|
||||
if (prevMode === MODES.edit) {
|
||||
saveLesson();
|
||||
}
|
||||
}
|
||||
|
||||
function addBadgeValueToTab(materials: LessonPage['materials']) {
|
||||
const { slide_url, videos, note } = materials;
|
||||
|
||||
tabs = tabs.map((tab) => {
|
||||
let badgeValue = 0;
|
||||
|
||||
if (tab.value === 1 && !isNoteEmpty(note)) {
|
||||
badgeValue = 1;
|
||||
} else if (tab.value === 2 && !!slide_url) {
|
||||
badgeValue = 1;
|
||||
} else if (tab.value === 3 && !isEmpty(videos)) {
|
||||
badgeValue = 1;
|
||||
}
|
||||
tab.badgeValue = badgeValue;
|
||||
return tab;
|
||||
});
|
||||
}
|
||||
|
||||
const openAddVideoModal = () => {
|
||||
$uploadCourseVideoStore.isModalOpen = true;
|
||||
};
|
||||
|
||||
const { input, handleSubmit, completion, isLoading } = useCompletion({
|
||||
api: '/api/completion'
|
||||
});
|
||||
|
||||
function updateNoteByCompletion(completion: string) {
|
||||
$lesson.materials.note = completion;
|
||||
|
||||
autoSave($lesson.materials);
|
||||
|
||||
if (editorWindowRef) {
|
||||
const tmceBody = editorWindowRef?.document?.querySelector('body');
|
||||
if (typeof tmceBody?.scrollHeight === 'number') {
|
||||
editorWindowRef?.scrollTo(0, tmceBody.scrollHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function callAI(type = '') {
|
||||
const _lesson = $lessons.find((les) => les.id === $lesson.id);
|
||||
$input = JSON.stringify({
|
||||
type,
|
||||
lessonTitle: _lesson?.title || '',
|
||||
courseTitle: $course.title
|
||||
});
|
||||
setTimeout(() => {
|
||||
handleSubmit({ preventDefault: () => {} });
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function initPlyr(_player: any, _video: LessonPage['materials']['videos']) {
|
||||
if (!_player) return;
|
||||
|
||||
const players = Array.from(document.querySelectorAll('.plyr-video-trigger')).map((p) => {
|
||||
// @ts-ignore
|
||||
return new Plyr(p);
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
window.players = players;
|
||||
}
|
||||
|
||||
function autoSave(
|
||||
updatedMaterials: LessonPage['materials'],
|
||||
_isLoading?: boolean,
|
||||
lessonId?: string
|
||||
) {
|
||||
// don't autosave on view mode
|
||||
if (mode === MODES.view) return;
|
||||
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
console.log('autosaving');
|
||||
if (!initAutoSave) {
|
||||
initAutoSave = true;
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
timeoutId = setTimeout(async () => {
|
||||
const { error } = await saveLesson(updatedMaterials);
|
||||
|
||||
if (error) {
|
||||
console.log('error saving lesson', error);
|
||||
snackbar.error('We apologise, there was an error saving your lesson.');
|
||||
}
|
||||
isSaving = false;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function handleInputChange() {
|
||||
$isLessonDirty = true;
|
||||
}
|
||||
|
||||
function onLessonIdChange(_lid: string) {
|
||||
initAutoSave = false;
|
||||
isSaving = false;
|
||||
|
||||
tabs = orderedTabs(tabs, $course.metadata?.lessonTabsOrder);
|
||||
currentTab = tabs[0].value;
|
||||
componentsToRender = getComponentOrder(tabs);
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
$uploadCourseVideoStore.isModalOpen = false;
|
||||
};
|
||||
|
||||
function getComponentOrder(tabs = CONSTANTS.tabs) {
|
||||
const componentMap = {
|
||||
Video: ComponentVideo,
|
||||
Slide: ComponentSlide,
|
||||
Note: ComponentNote
|
||||
};
|
||||
|
||||
const componentNames = tabs
|
||||
.map((tab) => {
|
||||
// @ts-ignore
|
||||
const component = componentMap[tab.label];
|
||||
return component || null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return componentNames;
|
||||
}
|
||||
|
||||
$: autoSave($lesson.materials, $isLoading, lessonId);
|
||||
|
||||
$: onLessonIdChange(lessonId);
|
||||
|
||||
$: handleSave(prevMode);
|
||||
|
||||
$: addBadgeValueToTab($lesson.materials);
|
||||
|
||||
$: console.log('$lesson.materials', $lesson.materials);
|
||||
|
||||
$: updateNoteByCompletion($completion);
|
||||
|
||||
$: initPlyr(player, $lesson.materials.videos);
|
||||
|
||||
$: lessonTitle = $lessons.find((les) => les.id === $lesson.id)?.title || '';
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
{onClose}
|
||||
bind:open={$uploadCourseVideoStore.isModalOpen}
|
||||
width="w-4/5 w-[90%] h-[80%] md:h-[566px]"
|
||||
modalHeading="Add a Video"
|
||||
>
|
||||
<VideoUploader {lessonId} />
|
||||
</Modal>
|
||||
|
||||
<HtmlRender className="m-auto text-center">
|
||||
<svelte:fragment slot="content">
|
||||
<h1 class="text-2xl md:text-4xl mt-0 capitalize">
|
||||
{lessonTitle}
|
||||
</h1>
|
||||
</svelte:fragment>
|
||||
</HtmlRender>
|
||||
|
||||
{#if mode === MODES.edit}
|
||||
<Tabs {tabs} {currentTab} {onChange}>
|
||||
<slot:fragment slot="content">
|
||||
<TabContent value={getValue('Note')} index={currentTab}>
|
||||
<div bind:this={aiButtonRef} class="w-full flex flex-row-reverse">
|
||||
<PrimaryButton
|
||||
className="flex items-center relative"
|
||||
onClick={() => {
|
||||
openPopover = !openPopover;
|
||||
}}
|
||||
isLoading={$isLoading}
|
||||
isDisabled={$isLoading}
|
||||
variant={VARIANTS.OUTLINED}
|
||||
>
|
||||
<MagicWandFilled size={20} class="carbon-icon mr-3" />
|
||||
AI
|
||||
<Popover
|
||||
caret
|
||||
align="left"
|
||||
bind:open={openPopover}
|
||||
on:click:outside={({ detail }) => {
|
||||
openPopover = aiButtonRef?.contains(detail.target);
|
||||
}}
|
||||
>
|
||||
<div class="p-2">
|
||||
<button class={aiButtonClass} on:click={() => callAI('outline')}>
|
||||
<ListIcon class="carbon-icon mr-2" />
|
||||
Generate Lesson Outline
|
||||
</button>
|
||||
<button class={aiButtonClass} on:click={() => callAI('note')}>
|
||||
<AlignBoxTopLeftIcon class="carbon-icon mr-2" />
|
||||
Generate Lesson Note
|
||||
</button>
|
||||
<button class={aiButtonClass} on:click={() => callAI('activities')}>
|
||||
<IbmWatsonKnowledgeStudioIcon class="carbon-icon mr-2" />
|
||||
Generate Lesson Activities
|
||||
</button>
|
||||
</div>
|
||||
</Popover>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
<div class="h-[60vh] mt-5">
|
||||
<TextEditor
|
||||
id={lessonId}
|
||||
bind:editorWindowRef
|
||||
value={$lesson.materials.note}
|
||||
onChange={(html) => ($lesson.materials.note = html)}
|
||||
placeholder="Write your lesson note here"
|
||||
/>
|
||||
</div>
|
||||
</TabContent>
|
||||
|
||||
<TabContent value={getValue('Slide')} index={currentTab}>
|
||||
{#if mode === MODES.edit}
|
||||
<TextField
|
||||
label="Slide link"
|
||||
bind:value={$lesson.materials.slide_url}
|
||||
onInputChange={handleInputChange}
|
||||
/>
|
||||
{/if}
|
||||
</TabContent>
|
||||
<TabContent value={getValue('Video')} index={currentTab}>
|
||||
<PrimaryButton label="Add/Edit Video(s)" onClick={openAddVideoModal} className="mb-2" />
|
||||
{#if $lesson.materials.videos.length}
|
||||
<div class="flex flex-col items-start w-full h-full">
|
||||
{#each $lesson.materials.videos as video, index}
|
||||
{#if mode === MODES.edit}
|
||||
<div class="ml-auto">
|
||||
<IconButton
|
||||
value="delete-video"
|
||||
contained={true}
|
||||
onClick={() => deleteLessonVideo(index)}
|
||||
>
|
||||
<TrashCanIcon size={20} class="carbon-icon dark:text-white" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="w-full h-full flex flex-col gap-2 overflow-hidden">
|
||||
{#key video.link}
|
||||
<div class="mb-5">
|
||||
{#if video.type === 'youtube'}
|
||||
<iframe
|
||||
width="100%"
|
||||
height="569"
|
||||
class="iframe"
|
||||
src={formatYoutubeVideo(video.link, errors)}
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
/>
|
||||
{:else if video.metadata?.svid}
|
||||
<div style="position:relative;padding-bottom:51.416579%">
|
||||
<iframe
|
||||
src="https://muse.ai/embed/{video.metadata
|
||||
?.svid}?logo=https://app.classroomio.com/logo-512.png&subtitles=auto&cover_play_position=center"
|
||||
style="width:100%;height:100%;position:absolute;left:0;top:0"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
title="Muse AI Video Embed"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<video bind:this={player} class="plyr-video-trigger" playsinline controls>
|
||||
<source src={video.link} type="video/mp4" />
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</TabContent>
|
||||
</slot:fragment>
|
||||
</Tabs>
|
||||
{:else if !isMaterialsEmpty($lesson.materials)}
|
||||
<div class="w-full">
|
||||
{#each componentsToRender as Component}
|
||||
<svelte:component this={Component} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<Box className="text-center">
|
||||
<img src="/no-video.svg" alt="Video not found" />
|
||||
<h3 class="text-xl font-normal dark:text-white py-2">
|
||||
No note, video or slide added for this lesson yet
|
||||
</h3>
|
||||
<p class="text-sm text-center font-normal py-2">
|
||||
Share your knowledge with your students by creating engaging content<br />
|
||||
Start by clicking on <strong>Get started</strong> button.
|
||||
</p>
|
||||
<PrimaryButton label="Get started" className="rounded-md" onClick={toggleMode} />
|
||||
</Box>
|
||||
{/if}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { LessonCompletion } from '$lib/utils/types';
|
||||
|
||||
export function getUniqueValue(label: string) {
|
||||
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;
|
||||
}
|
||||
|
||||
export function getIsLessonComplete(
|
||||
completions: LessonCompletion[],
|
||||
profileId: string | undefined
|
||||
): boolean {
|
||||
if (!Array.isArray(completions)) return false;
|
||||
return completions.some((c) => {
|
||||
return c.is_complete && c.profile_id === profileId;
|
||||
});
|
||||
}
|
||||
@@ -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,243 @@
|
||||
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: [],
|
||||
totalSubmissions: 0
|
||||
});
|
||||
|
||||
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,104 @@
|
||||
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, LessonPage, LessonComment } from '$lib/utils/types';
|
||||
|
||||
export const uploadCourseVideoStore = writable({
|
||||
isModalOpen: false
|
||||
});
|
||||
|
||||
export const lessons: Writable<Lesson[]> = writable([]);
|
||||
|
||||
export const lesson = writable<LessonPage>({
|
||||
id: null,
|
||||
totalExercises: 0,
|
||||
totalComments: 0,
|
||||
isSaving: false,
|
||||
materials: {
|
||||
note: '',
|
||||
slide_url: '',
|
||||
videos: []
|
||||
},
|
||||
exercises: [],
|
||||
lesson_completion: []
|
||||
});
|
||||
|
||||
export let lessonComments = writable<LessonComment[]>([]);
|
||||
|
||||
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_unlocked: 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_unlocked: lesson.is_unlocked
|
||||
};
|
||||
|
||||
let newLessonData: 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);
|
||||
|
||||
newLessonData = data;
|
||||
}
|
||||
return newLessonData;
|
||||
}
|
||||
|
||||
export async function handleUpdateLessonMaterials(lesson: any, lessonId: Lesson['id']) {
|
||||
const materials = {
|
||||
...lesson.materials
|
||||
};
|
||||
delete materials.lesson_completion;
|
||||
delete materials.profile;
|
||||
|
||||
return await updateLesson(materials, lessonId);
|
||||
}
|
||||
|
||||
export const deleteLessonVideo = (index: any) => {
|
||||
lesson.update((currentLesson) => ({
|
||||
...currentLesson,
|
||||
materials: {
|
||||
...currentLesson.materials,
|
||||
videos: currentLesson.materials.videos.filter((video, i) => i !== index)
|
||||
}
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
<script>
|
||||
import { slide } from 'svelte/transition';
|
||||
import ChevronDownIcon from 'carbon-icons-svelte/lib/ChevronDown.svelte';
|
||||
import ChevronUpIcon from 'carbon-icons-svelte/lib/ChevronUp.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import { NavClasses } from '$lib/utils/constants/reusableClass';
|
||||
import NavIcons from './NavIcons.svelte';
|
||||
import { SkeletonPlaceholder, SkeletonText } from 'carbon-components-svelte';
|
||||
|
||||
export let handleClick = () => {};
|
||||
export let label = '';
|
||||
export let isGroupActive = false;
|
||||
export let isExpanded = true;
|
||||
export let total = 0;
|
||||
export let isLoading = true;
|
||||
// export let subMenuItems = [];
|
||||
|
||||
function onClick() {
|
||||
handleClick();
|
||||
}
|
||||
|
||||
function toggleIsExpanded() {
|
||||
isExpanded = !isExpanded;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="">
|
||||
{#if isLoading}
|
||||
<div class="w-11/12 mx-auto">
|
||||
<SkeletonText class="py-3 px-4 mb-3 rounded-md" style="height: 40px;" />
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="item relative flex items-center py-3 px-4 ml-2 mb-1 {NavClasses.item} {isGroupActive &&
|
||||
NavClasses.active} w-[95%]"
|
||||
tabindex="0"
|
||||
on:click={onClick}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<NavIcons name={label} />
|
||||
<span class="font-bold text-md">{label}</span>
|
||||
{#if total}
|
||||
<span class="ml-1">({total})</span>
|
||||
{/if}
|
||||
<span class="grow" />
|
||||
|
||||
{#if label === 'Lessons' && !isLoading}
|
||||
<IconButton size="small" stopPropagation={true} onClick={toggleIsExpanded}>
|
||||
{#if isExpanded}
|
||||
<ChevronUpIcon class="carbon-icon dark:text-white" />
|
||||
{:else}
|
||||
<ChevronDownIcon class="carbon-icon dark:text-white" />
|
||||
{/if}
|
||||
</IconButton>
|
||||
{/if}
|
||||
</button>
|
||||
{#if isExpanded}
|
||||
<div in:slide out:slide class="flex flex-col">
|
||||
<slot />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script>
|
||||
import TableOfContents from 'carbon-icons-svelte/lib/TableOfContents.svelte'; // overview
|
||||
import Result from 'carbon-icons-svelte/lib/Result.svelte';
|
||||
import Workspace from 'carbon-icons-svelte/lib/Workspace.svelte';
|
||||
import ListChecked from 'carbon-icons-svelte/lib/ListChecked.svelte';
|
||||
import Group from 'carbon-icons-svelte/lib/Group.svelte';
|
||||
import ApplicationWeb from 'carbon-icons-svelte/lib/ApplicationWeb.svelte';
|
||||
import Badge from 'carbon-icons-svelte/lib/Badge.svelte';
|
||||
import IbmWatsonKnowledgeCatalog from 'carbon-icons-svelte/lib/IbmWatsonKnowledgeCatalog.svelte';
|
||||
import Settings from 'carbon-icons-svelte/lib/Settings.svelte';
|
||||
|
||||
export let name = '';
|
||||
</script>
|
||||
|
||||
{#if name === 'Overview'}
|
||||
<TableOfContents size={20} class="carbon-icon mr-2" />
|
||||
{:else if name === 'Lessons'}
|
||||
<IbmWatsonKnowledgeCatalog size={20} class="carbon-icon mr-2" />
|
||||
{:else if name === 'Attendance'}
|
||||
<ListChecked size={20} class="carbon-icon mr-2" />
|
||||
{:else if name === 'Submissions'}
|
||||
<Workspace size={20} class="carbon-icon mr-2" />
|
||||
{:else if name === 'Marks'}
|
||||
<Result size={20} class="carbon-icon mr-2" />
|
||||
{:else if name === 'People'}
|
||||
<Group size={20} class="carbon-icon mr-2" />
|
||||
{:else if name === 'Landing Page'}
|
||||
<ApplicationWeb size={20} class="carbon-icon mr-2" />
|
||||
{:else if name === 'Certificates'}
|
||||
<Badge size={20} class="carbon-icon mr-2" />
|
||||
{:else if name === 'Settings'}
|
||||
<Settings size={20} class="carbon-icon mr-2" />
|
||||
{/if}
|
||||
@@ -0,0 +1,287 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { browser } from '$app/environment';
|
||||
import LockedIcon from 'carbon-icons-svelte/lib/Locked.svelte';
|
||||
import CheckmarkFilled from 'carbon-icons-svelte/lib/CheckmarkFilled.svelte';
|
||||
import NavExpandable from './NavExpandable.svelte';
|
||||
import { getNavItemRoute, getLessonsRoute, getLectureNo } from '$lib/components/Course/function';
|
||||
import TextChip from '$lib/components/Chip/Text.svelte';
|
||||
import { lessons } from '../Lesson/store/lessons';
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { NavClasses } from '$lib/utils/constants/reusableClass';
|
||||
import { isMobile } from '$lib/utils/store/useMobile';
|
||||
import { sideBar } from '$lib/components/Org/store';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { getIsLessonComplete } from '../Lesson/functions';
|
||||
|
||||
export let path: string;
|
||||
export let isStudent: boolean = false;
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
to: string;
|
||||
hideSortIcon: boolean;
|
||||
isLecture?: boolean;
|
||||
show?: () => boolean;
|
||||
}
|
||||
|
||||
let show: boolean = false;
|
||||
let isLessonActive: boolean = false;
|
||||
let resize = false;
|
||||
let isDragging = false;
|
||||
let startX: number;
|
||||
let initialWidth: number;
|
||||
let sidebarRef: HTMLElement;
|
||||
let menuContentRef: HTMLUListElement;
|
||||
|
||||
const toggleSidebar = (defaultValue?: boolean) => {
|
||||
$sideBar.hidden = defaultValue ?? !$sideBar.hidden;
|
||||
};
|
||||
const toggleSidebarOnMobile = () => $isMobile && toggleSidebar();
|
||||
|
||||
function handleMainGroupClick(href: string) {
|
||||
return () => {
|
||||
goto(href);
|
||||
toggleSidebarOnMobile();
|
||||
};
|
||||
}
|
||||
|
||||
let navItems: NavItem[] = [];
|
||||
|
||||
function handleMobileChange(isMobile: boolean) {
|
||||
if (isMobile) show = false;
|
||||
else show = true;
|
||||
}
|
||||
|
||||
function handleCursor(event: MouseEvent) {
|
||||
if (!resize && sidebarRef) {
|
||||
const isNearLeftBorder = event.clientX - sidebarRef.getBoundingClientRect().left < 8;
|
||||
const isNearRightBorder = sidebarRef.getBoundingClientRect().right - event.clientX < 8;
|
||||
|
||||
if (isNearLeftBorder || isNearRightBorder) {
|
||||
sidebarRef.style.cursor = 'ew-resize';
|
||||
} else {
|
||||
sidebarRef.style.cursor = 'auto';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startDragging(event: MouseEvent) {
|
||||
if (event.button === 0 && sidebarRef) {
|
||||
event.preventDefault();
|
||||
|
||||
const isNearRightBorder = sidebarRef.getBoundingClientRect().right - event.clientX < 8;
|
||||
const isNearLeftBorder = event.clientX - sidebarRef.getBoundingClientRect().left < 8;
|
||||
|
||||
if (
|
||||
(isNearRightBorder || isNearLeftBorder) &&
|
||||
event.clientX >= 0 &&
|
||||
event.clientX <= window.innerWidth
|
||||
) {
|
||||
isDragging = true;
|
||||
resize = true;
|
||||
startX = event.clientX;
|
||||
initialWidth = parseInt(getComputedStyle(sidebarRef).width, 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopDragging() {
|
||||
isDragging = false;
|
||||
resize = false;
|
||||
}
|
||||
|
||||
function dragSidebar(event: MouseEvent) {
|
||||
if (!(window.innerWidth >= 1024)) return;
|
||||
if (!isDragging) return;
|
||||
|
||||
const deltaX = event.clientX - startX;
|
||||
let newWidth = initialWidth + deltaX;
|
||||
|
||||
if (newWidth < 150) {
|
||||
sidebarRef.style.width = '0';
|
||||
menuContentRef.style.display = 'none';
|
||||
$sideBar.hidden = true;
|
||||
isDragging = false;
|
||||
resize = false;
|
||||
} else if (newWidth > window.innerWidth / 3 && window.innerWidth >= 1280) {
|
||||
sidebarRef.style.width = '40vw';
|
||||
} else if (newWidth > window.innerWidth / 4 && window.innerWidth < 1280) {
|
||||
sidebarRef.style.width = '28vw';
|
||||
} else {
|
||||
sidebarRef.style.width = newWidth + 'px';
|
||||
menuContentRef.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!$isMobile) {
|
||||
sidebarRef.addEventListener('mousedown', startDragging);
|
||||
document.addEventListener('mousemove', dragSidebar);
|
||||
document.addEventListener('mouseup', stopDragging);
|
||||
document.addEventListener('mousemove', handleCursor);
|
||||
}
|
||||
|
||||
toggleSidebar(false);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (!browser) {
|
||||
return;
|
||||
}
|
||||
if (!$isMobile) {
|
||||
sidebarRef.removeEventListener('mousedown', startDragging);
|
||||
document.removeEventListener('mousemove', dragSidebar);
|
||||
document.removeEventListener('mouseup', stopDragging);
|
||||
document.removeEventListener('mousemove', handleCursor);
|
||||
}
|
||||
});
|
||||
|
||||
$: handleMobileChange($isMobile);
|
||||
|
||||
$: isLessonActive = $page.url.pathname.includes('/lessons');
|
||||
|
||||
$: {
|
||||
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;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
to: getNavItemRoute($course.id, 'settings'),
|
||||
hideSortIcon: true,
|
||||
show() {
|
||||
return !isStudent;
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class={`
|
||||
${
|
||||
$sideBar.hidden
|
||||
? '-translate-x-[100%] absolute z-[40]'
|
||||
: 'translate-x-0 absolute md:relative z-[40]'
|
||||
}
|
||||
transition w-[90vw] md:w-[300px] lg:w-[350px] bg-gray-100 dark:bg-black h-[calc(100vh-48px)]
|
||||
|
||||
${
|
||||
resize && 'border-r-8 border-r-blue-500'
|
||||
} overflow-y-auto border border-l-0 border-t-0 border-b-0 border-r-1`}
|
||||
style={$sideBar.hidden === true ? 'width:0' : 'width:300px'}
|
||||
bind:this={sidebarRef}
|
||||
>
|
||||
<div class="sidebar-contenth-full flex flex-col">
|
||||
<ul
|
||||
class="sidebar-content my-5"
|
||||
bind:this={menuContentRef}
|
||||
style={$sideBar.hidden === true ? '' : 'display:block'}
|
||||
>
|
||||
{#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}
|
||||
isExpanded={isLessonActive}
|
||||
total={navItem.isLecture ? ($lessons || []).length : 0}
|
||||
isLoading={!$course.id}
|
||||
>
|
||||
{#if navItem.isLecture}
|
||||
{#each $lessons as item, index}
|
||||
<a
|
||||
class="pl-7 w-[95%] text-[0.80rem] mb-1 text-black dark:text-white {isStudent &&
|
||||
!item.is_unlocked
|
||||
? 'cursor-not-allowed'
|
||||
: ''}"
|
||||
href={isStudent && !item.is_unlocked
|
||||
? $page.url.pathname
|
||||
: getLessonsRoute($course.id, item.id)}
|
||||
on:click={toggleSidebarOnMobile}
|
||||
aria-disabled={!item.is_unlocked}
|
||||
>
|
||||
<div
|
||||
class="flex items-center py-3 px-4 {NavClasses.item} {(
|
||||
path || $page.url.pathname
|
||||
).includes(item.id) && NavClasses.active}"
|
||||
>
|
||||
<TextChip
|
||||
value={getLectureNo(index + 1)}
|
||||
className="bg-primary-200 text-primary-600 text-xs mr-2"
|
||||
size="sm"
|
||||
shape="rounded-full"
|
||||
/>
|
||||
<span>{item.title}</span>
|
||||
<span class="grow" />
|
||||
{#if !item.is_unlocked}
|
||||
<span class="text-md ml-2" title="This lesson is locked.">
|
||||
<LockedIcon class="carbon-icon dark:text-white" />
|
||||
</span>
|
||||
{:else if getIsLessonComplete(item.lesson_completion, $profile.id)}
|
||||
<span class="ml-2" title="You have completed this lesson">
|
||||
<CheckmarkFilled class="carbon-icon dark:text-white" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</NavExpandable>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -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,192 @@
|
||||
<script lang="ts">
|
||||
import { Popover } from 'carbon-components-svelte';
|
||||
import Link from 'carbon-icons-svelte/lib/Link.svelte';
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { ROLE } from '$lib/utils/constants/roles';
|
||||
import { addGroupMember, fetchGroup } from '$lib/utils/services/courses';
|
||||
import { invitationModal, resetForm } from './store';
|
||||
import { course, setCourse } from '$lib/components/Course/store';
|
||||
import { MultiSelect, Loading } from 'carbon-components-svelte';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { getOrgTeam } from '$lib/utils/services/org';
|
||||
import type { OrgTeamMember } from '$lib/utils/types/org';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { getStudentInviteLink } from '$lib/utils/functions/course';
|
||||
import {
|
||||
triggerSendEmail,
|
||||
NOTIFICATION_NAME
|
||||
} from '$lib/utils/services/notification/notification';
|
||||
|
||||
interface Tutor {
|
||||
id: number;
|
||||
text: string;
|
||||
email: string;
|
||||
profileId?: string;
|
||||
}
|
||||
let tutors: Tutor[] = [];
|
||||
let selectedIds: Array<number> = [];
|
||||
let selectedTutors: Tutor[] = [];
|
||||
let isLoadingTutors = false;
|
||||
let link = '';
|
||||
let copied = false;
|
||||
|
||||
function notEmpty<TValue>(value: TValue | null | undefined): value is TValue {
|
||||
return value !== null && value !== undefined;
|
||||
}
|
||||
const formatSelected = (i: Array<number>): Tutor[] =>
|
||||
i.length === 0 ? [] : i.map((id) => tutors.find((tutor) => tutor.id === id)).filter(notEmpty);
|
||||
|
||||
function onSubmit() {
|
||||
if (!selectedTutors.length) {
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
|
||||
let membersStack = [];
|
||||
console.log(`$course`, $course);
|
||||
|
||||
for (const tutor of selectedTutors) {
|
||||
membersStack.push({
|
||||
profile_id: tutor.profileId,
|
||||
group_id: $course.group?.id,
|
||||
role_id: ROLE.TUTOR
|
||||
});
|
||||
|
||||
triggerSendEmail(NOTIFICATION_NAME.WELCOME_TEACHER_TO_COURSE, {
|
||||
to: tutor.email,
|
||||
name: tutor.text,
|
||||
orgName: $currentOrg.name,
|
||||
orgSiteName: $currentOrg.siteName,
|
||||
courseName: $course.title
|
||||
});
|
||||
}
|
||||
|
||||
addGroupMember(membersStack).then(async (membersAdded) => {
|
||||
if (!$course?.group?.id) return;
|
||||
|
||||
const group = await fetchGroup($course?.group?.id);
|
||||
|
||||
setCourse(
|
||||
{
|
||||
...$course,
|
||||
group: group.data
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
resetForm();
|
||||
});
|
||||
}
|
||||
|
||||
function getTutors(team: OrgTeamMember[]) {
|
||||
const existingTutors = $course.group?.tutors || [];
|
||||
return team
|
||||
.filter((teamMember) => teamMember.verified)
|
||||
.filter((teamMember) => {
|
||||
// filter out teamMembers that have already been added
|
||||
return !existingTutors.some((t) => t.id === teamMember.profileId);
|
||||
})
|
||||
.map((teamMember) => ({
|
||||
id: teamMember.id,
|
||||
text: teamMember.fullname,
|
||||
profileId: teamMember.profileId,
|
||||
email: teamMember.email
|
||||
}));
|
||||
}
|
||||
|
||||
async function setTutors(orgId: string | undefined, _open: boolean) {
|
||||
if (!orgId) return;
|
||||
|
||||
isLoadingTutors = true;
|
||||
const { team, error } = await getOrgTeam(orgId);
|
||||
if (error) {
|
||||
console.error('Error fetching teams', error);
|
||||
isLoadingTutors = false;
|
||||
return;
|
||||
}
|
||||
|
||||
tutors = getTutors(team);
|
||||
|
||||
isLoadingTutors = false;
|
||||
}
|
||||
|
||||
function copyLink() {
|
||||
copy(link);
|
||||
copied = true;
|
||||
setTimeout(() => {
|
||||
copied = false;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
$: {
|
||||
selectedTutors = formatSelected(selectedIds);
|
||||
console.log('selectedTutors', selectedTutors);
|
||||
}
|
||||
|
||||
$: setTutors($currentOrg.id, $invitationModal.open);
|
||||
|
||||
$: link = getStudentInviteLink($course, $currentOrg.siteName);
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
onClose={resetForm}
|
||||
bind:open={$invitationModal.open}
|
||||
width="w-4/5 md:w-2/5"
|
||||
maxWidth="max-w-lg"
|
||||
modalHeading="Invite people"
|
||||
>
|
||||
<form on:submit|preventDefault={onSubmit}>
|
||||
<div class="mb-8">
|
||||
<p class="text-base mb-1 font-semibold">Invite Tutors</p>
|
||||
<MultiSelect
|
||||
disabled={isLoadingTutors}
|
||||
label="Select tutors in organization..."
|
||||
bind:selectedIds
|
||||
items={tutors}
|
||||
/>
|
||||
{#if isLoadingTutors}
|
||||
<span>
|
||||
<Loading withOverlay={false} small />
|
||||
</span>
|
||||
{:else}
|
||||
<span>
|
||||
To add tutor to your organization,
|
||||
<a
|
||||
href={`/org/${$currentOrg.siteName}/settings/teams`}
|
||||
class="underline text-primary-600"
|
||||
>
|
||||
go to organization settings
|
||||
</a>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mb-8 w-[90%] flex justify-between items-center">
|
||||
<div class="w-3/5">
|
||||
<p class="text-base mb-1 font-semibold">Invite Students</p>
|
||||
<p class=" text-sm">You can invite students via an invite link</p>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<PrimaryButton
|
||||
disablePadding={true}
|
||||
className="text-sm py-2 px-3"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
onClick={copyLink}
|
||||
>
|
||||
<Link size={16} class="mr-1" />
|
||||
Copy link
|
||||
</PrimaryButton>
|
||||
<Popover caret open={copied} align="bottom">
|
||||
<div style="padding: 5px">Copied Successfully</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex items-center flex-row-reverse">
|
||||
<PrimaryButton className="px-6 py-3" label="Finish" type="submit" />
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
@@ -0,0 +1,14 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const deleteMemberModal = writable({
|
||||
open: false
|
||||
});
|
||||
|
||||
export const invitationModal = writable({
|
||||
open: false
|
||||
});
|
||||
|
||||
export const resetForm = () =>
|
||||
invitationModal.update(() => ({
|
||||
open: false
|
||||
}));
|
||||
@@ -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,40 @@
|
||||
<script>
|
||||
import { flip } from 'svelte/animate';
|
||||
import { settings } from './store';
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
import Draggable from 'carbon-icons-svelte/lib/Draggable.svelte';
|
||||
|
||||
const flipDurationMs = 300;
|
||||
|
||||
function handleDndConsider(e) {
|
||||
$settings.tabs = e.detail.items;
|
||||
}
|
||||
|
||||
function handleDndFinalize(e) {
|
||||
$settings.tabs = e.detail.items;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section
|
||||
use:dndzone={{
|
||||
items: $settings.tabs,
|
||||
flipDurationMs,
|
||||
dropTargetStyle: {
|
||||
border: '2px #1d4ed8 solid',
|
||||
'border-style': 'dashed'
|
||||
}
|
||||
}}
|
||||
on:consider={handleDndConsider}
|
||||
on:finalize={handleDndFinalize}
|
||||
class="w-fit flex gap-1 md:gap-3 p-1"
|
||||
>
|
||||
{#each $settings.tabs as item (item.id)}
|
||||
<div
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
class="flex items-center justify-start text-center text-xs md:text-base gap-1 md:gap-2 dark:text-white bg-slate-100 dark:bg-slate-700 p-2 rounded-md"
|
||||
>
|
||||
<Draggable size={16} />
|
||||
{item.name}
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
@@ -0,0 +1,357 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { CodeSnippet, Grid, Row, Column, Toggle } from 'carbon-components-svelte';
|
||||
import { PUBLIC_SERVER_URL } from '$env/static/public';
|
||||
|
||||
import SectionTitle from '$lib/components/Org/SectionTitle.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 DragAndDrop from './DragAndDrop.svelte';
|
||||
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { settings } from './store';
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import type { Course } from '$lib/utils/types';
|
||||
import { updateCourse, deleteCourse } from '$lib/utils/services/courses';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
import { isObject } from '$lib/utils/functions/isObject';
|
||||
import { lessons } from '../Lesson/store/lessons';
|
||||
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
|
||||
import { handleOpenWidget } from '$lib/components/CourseLandingPage/store';
|
||||
|
||||
let isSaving = false;
|
||||
let isLoading = false;
|
||||
let isDeleting = false;
|
||||
let errors: {
|
||||
title: string | undefined;
|
||||
description: string | undefined;
|
||||
} = { title: undefined, description: undefined };
|
||||
let avatar: string | undefined;
|
||||
|
||||
function widgetControl() {
|
||||
$handleOpenWidget.open = true;
|
||||
}
|
||||
|
||||
function getLessonOrder(id: string) {
|
||||
const index = $lessons.findIndex((lesson) => lesson.id === id);
|
||||
if (index < 9) {
|
||||
return '0' + (index + 1);
|
||||
} else {
|
||||
return index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const downloadCourse = async () => {
|
||||
isLoading = true;
|
||||
try {
|
||||
const lessonsList = $lessons.map((lesson) => ({
|
||||
lessonTitle: lesson.title,
|
||||
lessonNumber: getLessonOrder(lesson.id),
|
||||
lessonNote: lesson.note,
|
||||
slideUrl: lesson.slide_url || '',
|
||||
video: lesson.videos || ''
|
||||
}));
|
||||
const response = await fetch(PUBLIC_SERVER_URL + '/downloadCourse', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
courseTitle: $course.title,
|
||||
orgName: $currentOrg.name,
|
||||
orgTheme: $currentOrg.theme || '',
|
||||
lessons: lessonsList
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
const data = await response.blob();
|
||||
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 + ' - ' + 'Course ';
|
||||
a.href = fileURL;
|
||||
a.click();
|
||||
a.remove();
|
||||
|
||||
snackbar.success('Download Complete');
|
||||
} catch (error) {
|
||||
snackbar.error("Something's not right - Please try later");
|
||||
}
|
||||
isLoading = false;
|
||||
};
|
||||
|
||||
const deleteBannerImage = () => {
|
||||
$course.logo = '';
|
||||
};
|
||||
|
||||
async function handleDeleteCourse() {
|
||||
isDeleting = true;
|
||||
try {
|
||||
await deleteCourse($course.id);
|
||||
isDeleting = false;
|
||||
goto($currentOrgPath + '/courses');
|
||||
} catch (error) {
|
||||
snackbar.error('Something went wrong - Please try later');
|
||||
isDeleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!$settings.course_title) {
|
||||
errors.title = 'title cannot be empty';
|
||||
return;
|
||||
}
|
||||
if (!$settings.course_description) {
|
||||
errors.description = 'description cannot be empty';
|
||||
return;
|
||||
}
|
||||
isSaving = true;
|
||||
try {
|
||||
const {
|
||||
course_title,
|
||||
course_description,
|
||||
logo,
|
||||
tabs,
|
||||
grading,
|
||||
allow_new_students,
|
||||
lesson_download,
|
||||
is_published
|
||||
} = $settings;
|
||||
await updateCourse($course.id, avatar, {
|
||||
title: course_title,
|
||||
description: course_description,
|
||||
logo: logo,
|
||||
is_published,
|
||||
metadata: {
|
||||
...(isObject($course.metadata) ? $course.metadata : {}),
|
||||
lessonTabsOrder: tabs,
|
||||
grading: grading,
|
||||
lessonDownload: lesson_download,
|
||||
allowNewStudent: allow_new_students
|
||||
}
|
||||
});
|
||||
|
||||
$course.title = course_title;
|
||||
$course.description = course_description;
|
||||
$course.logo = logo;
|
||||
$course.is_published = is_published;
|
||||
$course.metadata = {
|
||||
...(isObject($course.metadata) ? $course.metadata : {}),
|
||||
lessonTabsOrder: tabs,
|
||||
grading: grading,
|
||||
lessonDownload: lesson_download,
|
||||
allowNewStudent: allow_new_students
|
||||
};
|
||||
snackbar.success('Saved successfully');
|
||||
} catch (error) {
|
||||
snackbar.error();
|
||||
}
|
||||
|
||||
isSaving = false;
|
||||
};
|
||||
|
||||
function setDefault(course: Course) {
|
||||
if (course && Object.keys(course).length) {
|
||||
$settings = {
|
||||
course_title: course.title,
|
||||
course_description: course.description,
|
||||
logo: course.logo || '',
|
||||
tabs: course.metadata.lessonTabsOrder || $settings.tabs,
|
||||
grading: !!course.metadata.grading,
|
||||
lesson_download: !!course.metadata.lessonDownload,
|
||||
is_published: !!course.is_published,
|
||||
allow_new_students: course.metadata.allowNewStudent
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
$: $settings.course_description = $course.description;
|
||||
$: $settings.course_title = $course.title;
|
||||
|
||||
$: setDefault($course);
|
||||
</script>
|
||||
|
||||
<Grid class="border-c rounded border-gray-200">
|
||||
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
<SectionTitle>Cover Image</SectionTitle>
|
||||
<p>
|
||||
This optional image will show up on your welcome page. If you include one, it should be at
|
||||
least 280 x 200"
|
||||
</p>
|
||||
<span class="flex items-center justify-start">
|
||||
<PrimaryButton
|
||||
variant={VARIANTS.OUTLINED}
|
||||
label="Replace"
|
||||
className="mr-2"
|
||||
onClick={widgetControl}
|
||||
/>
|
||||
<PrimaryButton
|
||||
variant={VARIANTS.CONTAINED_DANGER}
|
||||
label="Delete"
|
||||
onClick={deleteBannerImage}
|
||||
/>
|
||||
</span>
|
||||
{#if $handleOpenWidget.open}
|
||||
<UploadWidget bind:imageURL={$settings.logo} />
|
||||
{/if}
|
||||
</Column>
|
||||
|
||||
<Column sm={8} md={8} lg={6}>
|
||||
<div class="w-fit relative z-[20]">
|
||||
<img
|
||||
style="min-width:280px; min-height:200px"
|
||||
alt="About us"
|
||||
src={$settings.logo ? $settings.logo : '/images/classroomio-course-img-template.jpg'}
|
||||
class="mt-2 md:mt-0 w-[280px] h-[200px] rounded-md relative"
|
||||
/>
|
||||
</div>
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
<SectionTitle>Course details</SectionTitle>
|
||||
</Column>
|
||||
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
<TextField
|
||||
label="Course title"
|
||||
placeholder="Write the course title here"
|
||||
className="w-full mb-5"
|
||||
bind:value={$settings.course_title}
|
||||
errorMessage={errors.title}
|
||||
/>
|
||||
<TextArea
|
||||
label="Course description"
|
||||
placeholder="Write your course description here"
|
||||
className="w-full mb-5"
|
||||
bind:value={$settings.course_description}
|
||||
errorMessage={errors.description}
|
||||
/>
|
||||
<div class="">
|
||||
<p class="text-md">Course Link</p>
|
||||
{#if $course.slug}
|
||||
<CodeSnippet wrapText type="multi" code={`${$currentOrgDomain}/course/${$course.slug}`} />
|
||||
{:else}
|
||||
<CodeSnippet code="Setup landing page to get course link" />
|
||||
{/if}
|
||||
</div>
|
||||
</Column>
|
||||
</Row>
|
||||
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
<SectionTitle>Grading</SectionTitle>
|
||||
<p>Make grading reports available for lessons</p>
|
||||
</Column>
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
<Toggle size="sm" bind:toggled={$settings.grading}>
|
||||
<span slot="labelA" style="color: gray">Disabled</span>
|
||||
<span slot="labelB" style="color: gray">Enabled</span>
|
||||
</Toggle>
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
<SectionTitle>Order Lessons Tab</SectionTitle>
|
||||
<p>Drag and drop the labels to re-order your material tabs</p>
|
||||
</Column>
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
<DragAndDrop />
|
||||
</Column>
|
||||
</Row>
|
||||
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
<SectionTitle>Lesson Download</SectionTitle>
|
||||
<p>Make the Lesson available for Download in pdf for registered students</p>
|
||||
</Column>
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
<Toggle size="sm" bind:toggled={$settings.lesson_download}>
|
||||
<span slot="labelA" style="color: gray">Disabled</span>
|
||||
<span slot="labelB" style="color: gray">Enabled</span>
|
||||
</Toggle>
|
||||
</Column>
|
||||
</Row>
|
||||
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
<SectionTitle>Course Download</SectionTitle>
|
||||
<p>Make the Course available for Download for registered students</p>
|
||||
</Column>
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
<PrimaryButton
|
||||
variant={VARIANTS.OUTLINED}
|
||||
label="Download"
|
||||
onClick={downloadCourse}
|
||||
isDisabled={isLoading || !PUBLIC_SERVER_URL}
|
||||
{isLoading}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
<SectionTitle>Allow New Students</SectionTitle>
|
||||
<p>Allow new students to access this course</p>
|
||||
</Column>
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
<Toggle size="sm" bind:toggled={$settings.allow_new_students}>
|
||||
<span slot="labelA" style="color: gray">Disabled</span>
|
||||
<span slot="labelB" style="color: gray">Enabled</span>
|
||||
</Toggle>
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
<!-- Publish Course -->
|
||||
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
<SectionTitle>Publish Course</SectionTitle>
|
||||
<p>This determines if your course displays on your landing page</p>
|
||||
</Column>
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
<Toggle
|
||||
size="sm"
|
||||
bind:toggled={$settings.is_published}
|
||||
on:toggle={(e) => {
|
||||
$settings.allow_new_students = e.detail.toggled;
|
||||
}}
|
||||
>
|
||||
<span slot="labelA" style="color: gray">Unpublished</span>
|
||||
<span slot="labelB" style="color: gray">Published</span>
|
||||
</Toggle>
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
<SectionTitle>Delete course</SectionTitle>
|
||||
<p>Delete this course,this action cannot be undone</p>
|
||||
</Column>
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
<PrimaryButton
|
||||
variant={VARIANTS.CONTAINED_DANGER}
|
||||
label="Delete Course"
|
||||
onClick={handleDeleteCourse}
|
||||
isLoading={isDeleting}
|
||||
isDisabled={isDeleting}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
<Row class="p-5 w-full flex items-center justify-end">
|
||||
<PrimaryButton
|
||||
label="Save Changes"
|
||||
isLoading={isSaving}
|
||||
isDisabled={isSaving}
|
||||
onClick={handleSave}
|
||||
/>
|
||||
</Row>
|
||||
</Grid>
|
||||
@@ -0,0 +1,16 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const settings = writable({
|
||||
logo: '',
|
||||
course_title: '',
|
||||
course_description: '',
|
||||
grading: false,
|
||||
allow_new_students: false,
|
||||
tabs: [
|
||||
{ id: 1, name: 'Note' },
|
||||
{ id: 2, name: 'Slide' },
|
||||
{ id: 3, name: 'Video' }
|
||||
],
|
||||
lesson_download: false,
|
||||
is_published: 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))
|
||||
`;
|
||||
@@ -0,0 +1,4 @@
|
||||
export const ROUTES = {
|
||||
COURSES: 'courses',
|
||||
LESSONS: 'lessons'
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
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 = '0') {
|
||||
if (index <= 9) {
|
||||
return `${initNo}${index}`;
|
||||
}
|
||||
|
||||
return 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;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<script>
|
||||
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 { course } from './store';
|
||||
import { updateCourse } from '$lib/utils/services/courses';
|
||||
import TextEditor from '$lib/components/TextEditor/index.svelte';
|
||||
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
|
||||
|
||||
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}
|
||||
<TextEditor
|
||||
value={$course.overview}
|
||||
onChange={(html) => {
|
||||
isDirty = true;
|
||||
$course.overview = html;
|
||||
}}
|
||||
placeholder="Write your overview here"
|
||||
height={700}
|
||||
/>
|
||||
{:else}
|
||||
<HtmlRender className="p-2" content={$course.overview || ''} />
|
||||
{/if}
|
||||
</PageBody>
|
||||
@@ -0,0 +1,164 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { lessons } from './components/Lesson/store/lessons';
|
||||
import { ROLE } from '$lib/utils/constants/roles';
|
||||
import type { Course, GroupPerson } from '$lib/utils/types';
|
||||
|
||||
export const defaultCourse: Course = {
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
cost: 0,
|
||||
currency: '',
|
||||
status: 'ACTIVE',
|
||||
is_certificate_downloadable: false,
|
||||
certificate_theme: 'professional',
|
||||
is_published: false,
|
||||
created_at: new Date().toDateString(),
|
||||
updated_at: new Date().toDateString(),
|
||||
attendance: [],
|
||||
polls: [],
|
||||
metadata: {
|
||||
requirements: '',
|
||||
description: '',
|
||||
goals: '',
|
||||
videoUrl: '',
|
||||
showDiscount: false,
|
||||
discount: 0,
|
||||
paymentLink: '',
|
||||
reward: {
|
||||
show: false,
|
||||
description: ''
|
||||
},
|
||||
instructor: {
|
||||
name: '',
|
||||
role: '',
|
||||
coursesNo: 0,
|
||||
description: '',
|
||||
imgUrl: ''
|
||||
},
|
||||
lessonTabsOrder: [
|
||||
{ id: 1, name: 'Note' },
|
||||
{ id: 2, name: 'Slide' },
|
||||
{ id: 3, name: 'Video' }
|
||||
],
|
||||
grading: false,
|
||||
lessonDownload: true,
|
||||
allowNewStudent: false
|
||||
}
|
||||
};
|
||||
|
||||
export const course: Writable<Course> = writable({ ...defaultCourse });
|
||||
|
||||
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: number | null;
|
||||
tutors: GroupPerson[];
|
||||
students: GroupPerson[];
|
||||
people: GroupPerson[];
|
||||
}>({
|
||||
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,
|
||||
memberId: member.id
|
||||
});
|
||||
|
||||
// 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: ''
|
||||
},
|
||||
allowNewStudent: false
|
||||
};
|
||||
}
|
||||
|
||||
if (!data.certificate_theme) {
|
||||
data.certificate_theme = 'professional';
|
||||
}
|
||||
course.set(data);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { Moon } from 'svelte-loading-spinners';
|
||||
import { browser } from '$app/environment';
|
||||
import Navigation from '../Course/components/Navigation/index.svelte';
|
||||
import Backdrop from '$lib/components/Backdrop/index.svelte';
|
||||
import { course, group, setCourse, defaultCourse } from '../Course/store';
|
||||
import Confetti from '../Confetti/index.svelte';
|
||||
import { isMobile } from '$lib/utils/store/useMobile';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { apps } from '$lib/components/Apps/store';
|
||||
import { fetchCourse } from '$lib/utils/services/courses';
|
||||
|
||||
export let courseId = '';
|
||||
export let path = '';
|
||||
export let isExercisePage = false;
|
||||
export let isStudent = false;
|
||||
export let isFetching = false;
|
||||
|
||||
let prevCourseId = '';
|
||||
|
||||
async function onCourseIdChange(courseId = '') {
|
||||
console.log('courseid changed', courseId);
|
||||
if (!courseId || prevCourseId === courseId || !browser || $course.id === courseId) return;
|
||||
|
||||
console.log('making request', courseId);
|
||||
isFetching = true;
|
||||
course.set(defaultCourse);
|
||||
|
||||
const { data: _data } = await fetchCourse(courseId);
|
||||
|
||||
if (_data) {
|
||||
setCourse(_data);
|
||||
}
|
||||
|
||||
isFetching = false;
|
||||
prevCourseId = courseId;
|
||||
}
|
||||
|
||||
function filterPollsByStatus(shouldFilter: boolean) {
|
||||
if (!shouldFilter) return;
|
||||
|
||||
$course.polls = $course.polls.filter((poll) => poll.status === 'published');
|
||||
}
|
||||
|
||||
$: onCourseIdChange(courseId);
|
||||
|
||||
$: {
|
||||
const user = $group.people.find((person) => person.profile_id === $profile.id);
|
||||
|
||||
if (user) {
|
||||
isStudent = user.role_id === 3;
|
||||
$apps.isStudent = isStudent;
|
||||
|
||||
filterPollsByStatus(isStudent);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$course.title || 'ClassroomIO Course'}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if isFetching}
|
||||
<Backdrop>
|
||||
<Moon size="60" color="#1d4ed8" unit="px" duration="1s" />
|
||||
</Backdrop>
|
||||
{/if}
|
||||
|
||||
<div class="root">
|
||||
<Navigation {path} {isStudent} />
|
||||
<div class="rightBar" class:isMobile={$isMobile}>
|
||||
{#if isExercisePage}
|
||||
<Confetti />
|
||||
{/if}
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rightBar {
|
||||
flex-grow: 1;
|
||||
width: calc(100% - 360px);
|
||||
}
|
||||
</style>
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<script>
|
||||
import ChevronLeftIcon from 'carbon-icons-svelte/lib/ChevronLeft.svelte';
|
||||
import ChevronRightIcon from 'carbon-icons-svelte/lib/ChevronRight.svelte';
|
||||
import { isMobile } from '$lib/utils/store/useMobile';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
|
||||
export let show = true;
|
||||
</script>
|
||||
|
||||
<section class="root z-10">
|
||||
{#if show}
|
||||
<div class="relative h-full bg-white dark:bg-black">
|
||||
<slot />
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
.root {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 300px;
|
||||
max-width: 300px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-right: 1px solid var(--border-color);
|
||||
background-color: rgb(250, 251, 252);
|
||||
}
|
||||
|
||||
.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) {
|
||||
}
|
||||
</style>
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
<script>
|
||||
import TextEditor from '$lib/components/TextEditor/index.svelte';
|
||||
|
||||
export let value = '';
|
||||
</script>
|
||||
|
||||
<div class="h-2/5">
|
||||
<TextEditor bind:value />
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script>
|
||||
import TextEditor from '$lib/components/TextEditor/index.svelte';
|
||||
|
||||
export let value = '';
|
||||
</script>
|
||||
|
||||
<div class="h-2/5">
|
||||
<TextEditor bind:value />
|
||||
</div>
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { handleOpenWidget } from '../../store';
|
||||
|
||||
export let course = {};
|
||||
|
||||
function widgetControl() {
|
||||
$handleOpenWidget.open = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<TextField className="mt-5" labelClassName="font-bold" label="Title" bind:value={course.title} />
|
||||
|
||||
<TextArea
|
||||
label="Description"
|
||||
bind:value={course.description}
|
||||
rows="6"
|
||||
className="mt-5"
|
||||
labelClassName="font-bold"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
className="mt-5"
|
||||
labelClassName="font-bold"
|
||||
label="Short Video"
|
||||
placeholder="www.youtube.com/watch?v=uYRq60G5XTk"
|
||||
helperMessage="Enter a link to a youtube video"
|
||||
type="text"
|
||||
bind:value={course.metadata.videoUrl}
|
||||
/>
|
||||
<div class="mt-7">
|
||||
<p class="font-bold mb-3">Replace cover image</p>
|
||||
<PrimaryButton label="Replace" variant={VARIANTS.OUTLINED} onClick={widgetControl} />
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user