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

* Merge main into turbo-monrepo

* feat: rename apps

* feat: remove supbase videos and serve all from project

* feat: move supabase to root

* feat: update README

* docs: update link
This commit is contained in:
Rotimi Best
2023-11-25 21:56:02 +00:00
committed by GitHub
parent 03ca379094
commit 41e29014ef
806 changed files with 85853 additions and 5746 deletions
+31
View File
@@ -0,0 +1,31 @@
# Supabase setup - Required
PUBLIC_SUPABASE_PROJECT_REF=localhost
PUBLIC_SUPABASE_URL=http://localhost:54321
PUBLIC_SUPABASE_BUCKET_URL=http://localhost:54323
PUBLIC_SUPABASE_ANON_KEY=
# Email setup - Optional if you want to get/send emails
SMTP_PASSWORD_HELP=
SMTP_USER_HELP=
SMTP_HOST=
SMTP_USER=
SMTP_PASSWORD=
# Unsplash setup - Optional if you want to use Unsplash images
UNSPLASH_API_KEY=
PUBLIC_TINYMCE_API_KEY=
VITE_SENTRY_AUTH_TOKEN=
VITE_SENTRY_ORG_NAME=
VITE_SENTRY_PROJECT_NAME=
# OpenAI setup - Optional if you want to use OpenAI
OPENAI_API_KEY=
# Sever URL - Optional if you want to use video upload and pdf downloads
PUBLIC_SERVER_URL=
# For getting metadata for signup users.
PUBLIC_IP_REGISTRY_KEY=
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: ['custom/svelte']
};
+19
View File
@@ -0,0 +1,19 @@
.DS_Store
/node_modules/
node_modules
/src/node_modules/@sapper/
yarn-error.log
.svelte-kit/
.env
/dist/
package-lock.json
# Local Netlify folder
.netlify
# Sentry Auth Token
.env.sentry-build-plugin
/_supascripts/
data.sql
changes.sql
+1
View File
@@ -0,0 +1 @@
npm_config_registry=https://registry.npmjs.org
+1
View File
@@ -0,0 +1 @@
v18.16.0
+14
View File
@@ -0,0 +1,14 @@
{
"useTabs": false,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"overrides": [
{
"files": ["*.svelte"],
"options": {
"svelteBracketNewLine": true
}
}
]
}
+158
View File
@@ -0,0 +1,158 @@
<a href="https://classroomio.com/">
<img alt="Spend less time doing the boring stuffs, instead focus on impacting the lives of your students." src="https://classroomio.com/classroomio-opengraph-image.png" />
<h1 align="center">ClassroomIO</h1>
<p align="center">
The Open Source Teaching Platform for Bootcamps
<br />
<a href="https://classroomio.com">Website</a> | <a href="https://dub.sh/ciodiscord">Join Discord community</a>
</p>
</a>
[![Codacy Badge](https://api.codacy.com/project/badge/Grade/629e2bb8994345729513c4d69ccbe3d5)](https://app.codacy.com/gh/rotimi-best/classroomio?utm_source=github.com&utm_medium=referral&utm_content=rotimi-best/classroomio&utm_campaign=Badge_Grade)
## ✨ About ClassroomIO
<img alt="ClassroomIO Courses page" src="https://classroomio.com/classroomio-courses.png" />
ClassroomIO is a platform for bootcamps, individual educators, and training businesses that brings teaching and learning into one place while at the same time helping them be 10x more productive. With our platform, anyone can run multiple classes and cohorts all from one UI. The application is mobile-first, which means that students can access your lesson materials from any device.
### Mission: Provide Students with the best learning experience
At ClassroomIO, our mission is to provide students with the best possible learning experience. We believe in empowering educators with the tools they need to deliver high-quality education that is accessible, engaging, and effective.
### Key Features
1. **📚 Advanced Course Management:** You can create unlimited courses, create lessons, invite students, add assignments, grade their assignments, and even generate certificates.
2. **👨‍👩‍👦 Multi-Teacher Management:** You can invite other teachers into your organization and assign them individual courses.
3. **🤖 AI Integration:** We've got OpenAI integration for quick course creation where you can generate course content, lesson outlines, and even generate assignments right from your lesson notes.
4. **💬 Forum:** Students can ask questions in your dedicated community and get answers from either you or other students.
5. **🏆 Kahoot-Like Quiz:** You can create live quizzes to create more engagement in your classrooms.
6. **💻 Dedicated Student Dashboard:** Once you create an account, you get a dedicated dashboard where your students can access all their courses, assignments, and more.
7. **🔒 Fully open source:** You can self-hostable the whole stack on your servers.
### Roadmap Features
1. **Forms:** Instead of using Google Forms to collect vital information from your students, you will be able to create forms directly within the dashboard.
2. **Course Announcement:** You can make public announcements from the course dashboard to all your students.
3. **Course Templates:** You can clone a full course or share templates with other people.
4. **Analytics:** You can track data about your students across multiple courses.
5. **Run Courses on Messengers:** Students can just join a channel on slack/discord/telegram and a bot automatically sends daily lesson content to your students without you doing anything.
Please reach out to me on [twitter](https://x.com/rotimi_best) if you have any feature request.
## Built With
- [Svelekit](https://kit.svelte.dev/?ref=classroomio.com)
- [Supabase](https://supabase.com/?ref=classroomio.com)
- [TailwindCSS](https://tailwindcss.com/?ref=classroomio.com)
## Get a Demo
You can book a quick 15 min demo to see if ClassroomIO is a good fit for you
<a href="https://cal.com/classroomio/demo">
<img src="https://cal.com/book-with-cal-dark.svg" alt="Book a Call with ClassroomIO.com">
</a>
<!-- GETTING STARTED -->
## Getting Started
To get a local copy up and running, please follow these simple steps.
### Prerequisites
Here is what you need to be able to run Cal.com.
- [Node.js](https://nodejs.org/) (Version: >=18.x)
- [Supabase CLI](https://github.com/supabase/cli)
- [Docker](https://docs.docker.com/engine/install/)
- [NPM](https://www.npmjs.com/)
## Development
### Setup
1. Clone the repo
```bash
git clone https://github.com/rotimi-best/classroomio.git
```
2. Go to project folder
```bash
cd classroomio
```
3. Setup Node If your Node version does not meet the project's requirements as instructed by the docs, "nvm" (Node Version Manager) allows using Node at the version required by the project:
```bash
nvm use
```
You first might need to install the specific version and then use it:
```bash
nvm install && nvm use
```
You can install nvm from [here](https://github.com/nvm-sh/nvm).
4. Set up your `.env` file
- Duplicate `.env.example` to `.env`
5. Setup Supabase.
- Make sure you've downloaded the [supabase cli](https://github.com/supabase/cli)
- Install and Start [docker](https://docs.docker.com/engine/install/)
- Go to the project directory in your terminal and start supabase
```bash
supabase start
```
- You should get a result like this
```bash
supabase local development setup is running.
API URL: http://127.0.0.1:54321
GraphQL URL: http://127.0.0.1:54321/graphql/v1
DB URL: postgresql://postgres:postgres@127.0.0.1:54322/postgres
Studio URL: http://127.0.0.1:54323
Inbucket URL: http://127.0.0.1:54324
JWT secret: super-secret-jwt-token-with-at-least-32-characters-long
anon key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
service_role key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
```
- Supabase environment variables should be taken from the result of `supabase start`
```env
PUBLIC_SUPABASE_URL=<API URL>
PUBLIC_SUPABASE_ANON_KEY=<anon key>
PUBLIC_SUPABASE_BUCKET_URL=<Inbucket URL>
```
- To view the Supabase studio, open the Studio URL from the result of supabase start
6. Install the required dependencies
```bash
npm i
```
7. Run (in development mode)
```bash
npm run dev
```
## Related Repos
You can find other repos related to classroomio here:
- Help Docs: <https://github.com/rotimi-best/docs.classroomio.com>
- ClassroomIO.com: <https://github.com/rotimi-best/classroomio.com>
+17
View File
@@ -0,0 +1,17 @@
{
"folders": [
{
"name": "project-root",
"path": "./"
},
{
"name": "supabase-functions",
"path": "supabase/functions"
}
],
"settings": {
"files.exclude": {
"supabase/functions/": true
}
}
}
+80
View File
@@ -0,0 +1,80 @@
// Modules to control application life and create native browser window
const { app, BrowserWindow, dialog } = require('electron');
const path = require('path');
const serve = require('electron-serve');
const loadURL = serve({ directory: '__sapper__/export' });
// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let mainWindow;
function isDev() {
return !app.isPackaged;
}
function createWindow() {
// Create the browser window.
mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: true
},
// Use this in development mode.
icon: isDev()
? path.join(process.cwd(), 'static/logo-512.png')
: path.join(__dirname, '__sapper__/export/logo-512.png'),
// Use this in production mode.
// icon: path.join(__dirname, '__sapper__/export/logo-512.png'),
show: false
});
// This block of code is intended for development purpose only.
// Delete this entire block of code when you are ready to package the application.
if (isDev()) {
mainWindow.loadURL('http://localhost:3000/');
} else {
loadURL(mainWindow);
}
// Uncomment the following line of code when app is ready to be packaged.
// loadURL(mainWindow);
// Open the DevTools and also disable Electron Security Warning.
// process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = true;
// mainWindow.webContents.openDevTools();
// Emitted when the window is closed.
mainWindow.on('closed', function () {
// Dereference the window object, usually you would store windows
// in an array if your app supports multi windows, this is the time
// when you should delete the corresponding element.
mainWindow = null;
});
// Emitted when the window is ready to be shown
// This helps in showing the window gracefully.
mainWindow.once('ready-to-show', () => {
mainWindow.show();
});
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on('ready', createWindow);
// Quit when all windows are closed.
app.on('window-all-closed', function () {
// On macOS it is common for applications and their menu bar
// to stay active until the user quits explicitly with Cmd + Q
if (process.platform !== 'darwin') app.quit();
});
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (mainWindow === null) createWindow();
});
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.
+86
View File
@@ -0,0 +1,86 @@
{
"name": "dashboard",
"description": "classroomio",
"version": "0.0.1",
"main": "main.js",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint ./src",
"clean": "rm -rf node_modules",
"format": "prettier . --write .",
"export": "vite build --entry \"/ /404 login signup forgot onboarding courses org lms course\"",
"postexport": "mv __sapper__/export/404/index.html __sapper__/export/404.html",
"start": "node build",
"prepare": "svelte-kit sync"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.386.0",
"@carbon/charts-svelte": "^1.13.6",
"@sentry/browser": "^7.61.0",
"@sentry/integrations": "^7.61.0",
"@sentry/vite-plugin": "^2.6.2",
"@supabase/supabase-js": "^2.31.0",
"@sveltejs/adapter-auto": "^2.1.0",
"@sveltejs/adapter-vercel": "^3.0.3",
"@tailwindcss/forms": "^0.5.4",
"@tinymce/tinymce-svelte": "^1.0.1",
"@types/pluralize": "0.0.30",
"ai": "^2.1.31",
"axios": "^1.5.1",
"body-parser": "^1.20.2",
"canvas-confetti": "^1.6.0",
"cookie-parser": "^1.4.6",
"copy-to-clipboard": "^3.3.3",
"d3": "^7.8.5",
"d3-cloud": "^1.2.7",
"d3-sankey": "^0.12.3",
"dayjs": "^1.11.10",
"form-data": "^4.0.0",
"hotkeys-js": "^3.11.2",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"nodemailer": "^6.9.5",
"openai-edge": "^1.2.2",
"pluralize": "^8.0.0",
"posthog-js": "^1.87.2",
"sirv": "^2.0.3",
"supabase": "^1.112.0",
"svelte-email": "^0.0.4",
"svelte-ui": "workspace:*",
"unsplash-js": "^7.0.18",
"wait-on": "^7.0.1",
"zod": "^3.21.4"
},
"devDependencies": {
"@babel/core": "^7.22.9",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-runtime": "^7.22.9",
"@babel/preset-env": "^7.22.9",
"@babel/runtime": "^7.22.6",
"@sveltejs/kit": "^1.22.4",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.14.196",
"@types/nodemailer": "^6.4.11",
"autoprefixer": "^10.4.14",
"carbon-components-svelte": "^0.79.0",
"carbon-icons-svelte": "^12.1.0",
"dotenv": "^16.3.1",
"eslint-config-custom": "workspace:*",
"postcss": "^8.4.24",
"postcss-load-config": "^4.0.1",
"sass": "^1.64.2",
"svelte": "^4.1.2",
"svelte-calendar": "^3.1.6",
"svelte-dnd-action": "^0.9.24",
"svelte-loading-spinners": "^0.3.4",
"svelte-preprocess": "^5.0.4",
"tailwindcss": "^3.3.2",
"tsconfig": "workspace:*",
"tslib": "^2.6.1",
"typescript": "^5.1.6",
"vite": "^4.4.8"
},
"type": "module"
}
+13
View File
@@ -0,0 +1,13 @@
const tailwindcss = require('tailwindcss');
const autoprefixer = require('autoprefixer');
const config = {
plugins: [
//Some plugins, like tailwindcss/nesting, need to run before Tailwind,
tailwindcss(),
//But others, like autoprefixer, need to run after,
autoprefixer
]
};
module.exports = config;
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
+39
View File
@@ -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;
}
+66
View File
@@ -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 –&nbsp;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 –&nbsp;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 –&nbsp;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>
+638
View File
@@ -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;
}
+5
View File
@@ -0,0 +1,5 @@
export default {
supabaseConfig: {
bucketPath: 'https://tapaozmyjsuykgerrfkt.supabase.in/storage/v1/object/sign/'
}
};
+33
View File
@@ -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:support@classroomio.com"
>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}" />
@@ -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 &gt; 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>
@@ -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>
@@ -0,0 +1,13 @@
<script>
import { course } from '$lib/components/Course/store';
import UnlockedCertificate from './UnlockedCertificate.svelte';
import LockedCertificate from './LockedCertificate.svelte';
</script>
<div>
{#if $course.is_certificate_downloadable == true}
<UnlockedCertificate />
{:else}
<LockedCertificate />
{/if}
</div>
@@ -0,0 +1,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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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}
@@ -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}
@@ -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}
@@ -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
};
}
@@ -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>
@@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
import type { ExerciseSubmissions } from '$lib/utils/types';
export const submissions = writable<ExerciseSubmissions[]>([]);
@@ -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>
@@ -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}
@@ -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>
@@ -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}
@@ -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>
@@ -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
}
];
@@ -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}
@@ -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}
@@ -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>

Some files were not shown because too many files have changed in this diff Show More