Compare commits
106 Commits
version-1
...
copy-minor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9bf4e2c58 | ||
|
|
78698cfcbe | ||
|
|
64c02f14a9 | ||
|
|
2f68fc0d6e | ||
|
|
16570469e6 | ||
|
|
a62b754d28 | ||
|
|
93859d6635 | ||
|
|
20fab8dbd3 | ||
|
|
13536aac51 | ||
|
|
ccb8721674 | ||
|
|
fbe1423edd | ||
|
|
d2713d7824 | ||
|
|
f24a15b4f8 | ||
|
|
bf74868bd4 | ||
|
|
6d75b8a3a2 | ||
|
|
055f917c61 | ||
|
|
d892a28069 | ||
|
|
838bc1fac2 | ||
|
|
a98d36513b | ||
|
|
2cfa6771ac | ||
|
|
4960c47377 | ||
|
|
a46306720b | ||
|
|
efcdba3a29 | ||
|
|
e7263d0566 | ||
|
|
5de7f5e283 | ||
|
|
c7bdf68bc6 | ||
|
|
998ff51c58 | ||
|
|
4eb9d84d8b | ||
|
|
5f000d8017 | ||
|
|
0cf9ad5228 | ||
|
|
71a526e7aa | ||
|
|
c5d9adb7fd | ||
|
|
05fdf163a9 | ||
|
|
de0a983033 | ||
|
|
44ee9b644f | ||
|
|
bd116c3e7b | ||
|
|
02e8a97f85 | ||
|
|
3525e4c90b | ||
|
|
e6d3819092 | ||
|
|
f15862cef4 | ||
|
|
86748b301d | ||
|
|
cc07dd849c | ||
|
|
63bcbb6506 | ||
|
|
83a1b03bb7 | ||
|
|
2126b4f657 | ||
|
|
0ce7c74778 | ||
|
|
b9f6a23412 | ||
|
|
9ae96bd1fa | ||
|
|
e863abe37c | ||
|
|
80e9984db0 | ||
|
|
8f504a8043 | ||
|
|
60a917e60c | ||
|
|
5317aa8fb5 | ||
|
|
36c4e2f4dc | ||
|
|
084eeba2ed | ||
|
|
6401497422 | ||
|
|
684601f31b | ||
|
|
d7d222842b | ||
|
|
f59f6c617a | ||
|
|
af48ccfb57 | ||
|
|
4b9d3bd996 | ||
|
|
53eb95612c | ||
|
|
8f317d2f44 | ||
|
|
f4e581f6cb | ||
|
|
9671c4d63f | ||
|
|
b07940951c | ||
|
|
1f18ef4362 | ||
|
|
bf57a19e2c | ||
|
|
0845a6e2a3 | ||
|
|
041bae16e0 | ||
|
|
3313db844c | ||
|
|
3a5977a718 | ||
|
|
bcee74ce77 | ||
|
|
1a6a119f35 | ||
|
|
09ae61492f | ||
|
|
3a33f047f5 | ||
|
|
10cdd712d2 | ||
|
|
21959eef7b | ||
|
|
41c3522285 | ||
|
|
7087fde686 | ||
|
|
e1d61c9eb9 | ||
|
|
afcb15148f | ||
|
|
5adb36deaf | ||
|
|
4065b1b8cc | ||
|
|
eb3afbbad1 | ||
|
|
fbe219a888 | ||
|
|
5928b8e5f9 | ||
|
|
372425bed2 | ||
|
|
d2922fd361 | ||
|
|
e7b6001e5f | ||
|
|
4053984ca2 | ||
|
|
a1e06bf316 | ||
|
|
c50f2147fd | ||
|
|
d4671fb888 | ||
|
|
77cda10419 | ||
|
|
6de879cd2a | ||
|
|
25f24b98c6 | ||
|
|
11079dae00 | ||
|
|
d00da31f84 | ||
|
|
644fb698d8 | ||
|
|
92edb3a1bf | ||
|
|
0e2feac81e | ||
|
|
63bcf15900 | ||
|
|
25bcd10e93 | ||
|
|
0a784766b4 | ||
|
|
7678b89995 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -9,4 +9,6 @@ __pycache__/
|
|||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
node_modules
|
node_modules
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
lms/public/frontend
|
||||||
|
lms/www/lms.html
|
||||||
@@ -32,7 +32,7 @@ repos:
|
|||||||
rev: v2.7.1
|
rev: v2.7.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
types_or: [javascript]
|
types_or: [javascript, vue]
|
||||||
# Ignore any files that might contain jinja / bundles
|
# Ignore any files that might contain jinja / bundles
|
||||||
exclude: |
|
exclude: |
|
||||||
(?x)^(
|
(?x)^(
|
||||||
|
|||||||
Submodule frappe-ui updated: 2898a0bdd1...c5faaae38e
5
frontend/.gitignore
vendored
Normal file
5
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
4
frontend/.prettierrc.json
Normal file
4
frontend/.prettierrc.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true
|
||||||
|
}
|
||||||
42
frontend/README.md
Normal file
42
frontend/README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Frappe UI Starter
|
||||||
|
|
||||||
|
This template should help get you started developing custom frontend for Frappe
|
||||||
|
apps with Vue 3 and the Frappe UI package.
|
||||||
|
|
||||||
|
This boilerplate sets up Vue 3, Vue Router, TailwindCSS, and Frappe UI out of
|
||||||
|
the box.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
This template is meant to be cloned inside an existing Frappe App. Assuming your
|
||||||
|
apps name is `todo`. Clone this template in the root folder of your app using `degit`.
|
||||||
|
|
||||||
|
```
|
||||||
|
cd apps/todo
|
||||||
|
npx degit netchampfaris/frappe-ui-starter frontend
|
||||||
|
cd frontend
|
||||||
|
yarn
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
In a development environment, you need to put the below key-value pair in your `site_config.json` file:
|
||||||
|
|
||||||
|
```
|
||||||
|
"ignore_csrf": 1
|
||||||
|
```
|
||||||
|
|
||||||
|
This will prevent `CSRFToken` errors while using the vite dev server. In production environment, the `csrf_token` is attached to the `window` object in `index.html` for you.
|
||||||
|
|
||||||
|
The Vite dev server will start on the port `8080`. This can be changed from `vite.config.js`.
|
||||||
|
The development server is configured to proxy your frappe app (usually running on port `8000`). If you have a site named `todo.test`, open `http://todo.test:8080` in your browser. If you see a button named "Click to send 'ping' request", congratulations!
|
||||||
|
|
||||||
|
If you notice the browser URL is `/frontend`, this is the base URL where your frontend app will run in production.
|
||||||
|
To change this, open `src/router.js` and change the base URL passed to `createWebHistory`.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Vue 3](https://v3.vuejs.org/guide/introduction.html)
|
||||||
|
- [Vue Router](https://next.router.vuejs.org/guide/)
|
||||||
|
- [Frappe UI](https://github.com/frappe/frappe-ui)
|
||||||
|
- [TailwindCSS](https://tailwindcss.com/docs/utility-first)
|
||||||
|
- [Vite](https://vitejs.dev/guide/)
|
||||||
49
frontend/index.html
Normal file
49
frontend/index.html
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Frappe Learning</title>
|
||||||
|
<meta name="title" content="{{ meta.title }}" />
|
||||||
|
<meta name="image" content="{{ meta.image }}" />
|
||||||
|
<meta name="description" content="{{ meta.description }}" />
|
||||||
|
<meta name="keywords" content="{{ meta.keywords }}" />
|
||||||
|
<meta property="og:title" content="{{ meta.title }}" />
|
||||||
|
<meta property="og:image" content="{{ meta.image }}" />
|
||||||
|
<meta property="og:description" content="{{ meta.description }}" />
|
||||||
|
<meta name="twitter:title" content="{{ meta.title }}" />
|
||||||
|
<meta name="twitter:image" content="{{ meta.image }}" />
|
||||||
|
<meta name="twitter:description" content="{{ meta.description }}" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div id="seo-content">
|
||||||
|
<h1>{{ meta.title }}</h1>
|
||||||
|
<p>
|
||||||
|
{{ meta.description }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The content here is just for seo purposes. The actual content will be loaded in a few seconds.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Seo checks if a page has more than 300 words. So, here are some more words to make it more than 300 words.
|
||||||
|
Page descriptions are the HTML meta tags that provide a brief summary of a web page.
|
||||||
|
Search engines use meta descriptions to help identify the page's topic - they don't use them to rank the page, but they do use them to determine whether or not to display the page in search results.
|
||||||
|
Meta descriptions are important because they're often the first thing people see when they're deciding which search result to click on.
|
||||||
|
They're also important because they can help improve your click-through rate (CTR) from search results.
|
||||||
|
A good meta description can entice people to click on your page instead of someone else's.
|
||||||
|
</p>
|
||||||
|
<a href="{{ meta.link }}">Know More</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="modals"></div>
|
||||||
|
<div id="popovers"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.csrf_token = '{{ csrf_token }}'
|
||||||
|
document.getElementById('seo-content').style.display = 'none';
|
||||||
|
</script>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
38
frontend/package.json
Normal file
38
frontend/package.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "frappe-ui-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"serve": "vite preview",
|
||||||
|
"build": "vite build --base=/assets/lms/frontend/ && yarn copy-html-entry",
|
||||||
|
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@editorjs/checklist": "^1.6.0",
|
||||||
|
"@editorjs/editorjs": "^2.29.0",
|
||||||
|
"@editorjs/embed": "^2.7.0",
|
||||||
|
"@editorjs/header": "^2.8.1",
|
||||||
|
"@editorjs/image": "^2.9.0",
|
||||||
|
"@editorjs/nested-list": "^1.4.2",
|
||||||
|
"@editorjs/paragraph": "^2.11.3",
|
||||||
|
"chart.js": "^4.4.1",
|
||||||
|
"dayjs": "^1.11.6",
|
||||||
|
"feather-icons": "^4.28.0",
|
||||||
|
"frappe-ui": "^0.1.50",
|
||||||
|
"lucide-vue-next": "^0.309.0",
|
||||||
|
"markdown-it": "^14.0.0",
|
||||||
|
"pinia": "^2.0.33",
|
||||||
|
"socket.io-client": "^4.7.2",
|
||||||
|
"tailwindcss": "^3.3.3",
|
||||||
|
"vue": "^3.2.25",
|
||||||
|
"vue-chartjs": "^5.3.0",
|
||||||
|
"vue-router": "^4.0.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.3",
|
||||||
|
"autoprefixer": "^10.4.2",
|
||||||
|
"postcss": "^8.4.5",
|
||||||
|
"vite": "^5.0.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
BIN
frontend/public/favicon.png
Normal file
BIN
frontend/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 440 B |
25
frontend/src/App.vue
Normal file
25
frontend/src/App.vue
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<Layout>
|
||||||
|
<router-view />
|
||||||
|
</Layout>
|
||||||
|
<Dialogs />
|
||||||
|
<Toasts />
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Toasts } from 'frappe-ui'
|
||||||
|
import { Dialogs } from '@/utils/dialogs'
|
||||||
|
import { computed, defineAsyncComponent } from 'vue'
|
||||||
|
import { useScreenSize } from './utils/composables'
|
||||||
|
import DesktopLayout from './components/DesktopLayout.vue'
|
||||||
|
import MobileLayout from './components/MobileLayout.vue'
|
||||||
|
|
||||||
|
const screenSize = useScreenSize()
|
||||||
|
|
||||||
|
const Layout = computed(() => {
|
||||||
|
if (screenSize.width < 640) {
|
||||||
|
return MobileLayout
|
||||||
|
} else {
|
||||||
|
return DesktopLayout
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
BIN
frontend/src/assets/Inter/Inter-Black.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-Black.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Black.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-Black.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-BlackItalic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-BlackItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-BlackItalic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-BlackItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Bold.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-Bold.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Bold.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-Bold.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-BoldItalic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-BoldItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-BoldItalic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ExtraBold.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-ExtraBold.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ExtraBold.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-ExtraBold.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ExtraBoldItalic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-ExtraBoldItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ExtraBoldItalic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-ExtraBoldItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ExtraLight.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-ExtraLight.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ExtraLight.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-ExtraLight.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ExtraLightItalic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-ExtraLightItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ExtraLightItalic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-ExtraLightItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Italic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-Italic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Italic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-Italic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Light.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-Light.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Light.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-Light.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-LightItalic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-LightItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-LightItalic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-LightItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Medium.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-Medium.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Medium.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-Medium.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-MediumItalic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-MediumItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-MediumItalic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-MediumItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Regular.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-Regular.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Regular.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-Regular.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-SemiBold.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-SemiBold.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-SemiBold.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-SemiBold.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-SemiBoldItalic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-SemiBoldItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-SemiBoldItalic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-SemiBoldItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Thin.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-Thin.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Thin.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-Thin.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ThinItalic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-ThinItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ThinItalic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-ThinItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-italic.var.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-italic.var.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-roman.var.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-roman.var.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter.var.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter.var.woff2
Normal file
Binary file not shown.
152
frontend/src/assets/Inter/inter.css
Normal file
152
frontend/src/assets/Inter/inter.css
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-Thin.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-Thin.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 100;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-ThinItalic.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-ThinItalic.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 200;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-ExtraLight.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-ExtraLight.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 200;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-ExtraLightItalic.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-ExtraLightItalic.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-Light.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-Light.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 300;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-LightItalic.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-LightItalic.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-Regular.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-Regular.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-Italic.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-Italic.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-Medium.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-Medium.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-MediumItalic.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-MediumItalic.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-SemiBold.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-SemiBold.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-SemiBoldItalic.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-SemiBoldItalic.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-Bold.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-Bold.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-BoldItalic.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-BoldItalic.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-ExtraBold.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-ExtraBold.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 800;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-ExtraBoldItalic.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-ExtraBoldItalic.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-Black.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-Black.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-BlackItalic.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-BlackItalic.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
62
frontend/src/components/Annoucements.vue
Normal file
62
frontend/src/components/Annoucements.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="communications.data?.length">
|
||||||
|
<div v-for="comm in communications.data">
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Avatar :label="comm.sender_full_name" size="lg" />
|
||||||
|
<div class="ml-2">
|
||||||
|
{{ comm.sender_full_name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm">
|
||||||
|
{{ timeAgo(comm.communication_date) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="prose prose-sm bg-gray-50 !min-w-full px-4 py-2 rounded-md"
|
||||||
|
v-html="comm.content"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm italic text-gray-600">
|
||||||
|
{{ __('No announcements') }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { createListResource, Avatar } from 'frappe-ui'
|
||||||
|
import { timeAgo } from '@/utils'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
batch: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const communications = createListResource({
|
||||||
|
doctype: 'Communication',
|
||||||
|
fields: [
|
||||||
|
'subject',
|
||||||
|
'content',
|
||||||
|
'recipients',
|
||||||
|
'cc',
|
||||||
|
'communication_date',
|
||||||
|
'sender',
|
||||||
|
'sender_full_name',
|
||||||
|
],
|
||||||
|
filters: {
|
||||||
|
reference_doctype: 'LMS Batch',
|
||||||
|
reference_name: props.batch,
|
||||||
|
},
|
||||||
|
orderBy: 'communication_date desc',
|
||||||
|
auto: true,
|
||||||
|
cache: ['batch', props.batch],
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.prose-sm p {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
55
frontend/src/components/AppSidebar.vue
Normal file
55
frontend/src/components/AppSidebar.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out bg-gray-50"
|
||||||
|
:class="isSidebarCollapsed ? 'w-14' : 'w-56'"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-col overflow-hidden"
|
||||||
|
:class="isSidebarCollapsed ? 'items-center' : ''"
|
||||||
|
>
|
||||||
|
<UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" />
|
||||||
|
<div class="flex flex-col overflow-y-auto">
|
||||||
|
<SidebarLink
|
||||||
|
v-for="link in links"
|
||||||
|
:link="link"
|
||||||
|
:isCollapsed="isSidebarCollapsed"
|
||||||
|
class="mx-2 my-0.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SidebarLink
|
||||||
|
:link="{
|
||||||
|
label: isSidebarCollapsed ? 'Expand' : 'Collapse',
|
||||||
|
}"
|
||||||
|
:isCollapsed="isSidebarCollapsed"
|
||||||
|
@click="isSidebarCollapsed = !isSidebarCollapsed"
|
||||||
|
class="m-2"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
||||||
|
<CollapseSidebar
|
||||||
|
class="h-4.5 w-4.5 text-gray-700 duration-300 ease-in-out"
|
||||||
|
:class="{ '[transform:rotateY(180deg)]': isSidebarCollapsed }"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</SidebarLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import UserDropdown from '@/components/UserDropdown.vue'
|
||||||
|
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
||||||
|
import SidebarLink from '@/components/SidebarLink.vue'
|
||||||
|
import { useStorage } from '@vueuse/core'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { getSidebarLinks } from '../utils'
|
||||||
|
|
||||||
|
const links = getSidebarLinks()
|
||||||
|
|
||||||
|
const getSidebarFromStorage = () => {
|
||||||
|
return useStorage('sidebar_is_collapsed', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
let isSidebarCollapsed = ref(getSidebarFromStorage())
|
||||||
|
</script>
|
||||||
98
frontend/src/components/Assessments.vue
Normal file
98
frontend/src/components/Assessments.vue
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-semibold mb-4">
|
||||||
|
{{ __('Assessments') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="assessments.data?.length">
|
||||||
|
<ListView
|
||||||
|
:columns="getAssessmentColumns()"
|
||||||
|
:rows="assessments.data"
|
||||||
|
row-key="name"
|
||||||
|
:options="{
|
||||||
|
selectable: false,
|
||||||
|
showTooltip: false,
|
||||||
|
getRowRoute: (row) => {
|
||||||
|
if (row.submission) {
|
||||||
|
return {
|
||||||
|
name: 'AssignmentSubmission',
|
||||||
|
params: {
|
||||||
|
assignmentName: row.assessment_name,
|
||||||
|
submissionName: row.submission.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
name: 'AssignmentSubmission',
|
||||||
|
params: {
|
||||||
|
assignmentName: row.assessment_name,
|
||||||
|
submissionName: 'new',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
</ListView>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm italic text-gray-600">
|
||||||
|
{{ __('No Assessments') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { ListView, createResource } from 'frappe-ui'
|
||||||
|
import { inject } from 'vue'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
batch: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
rows: {
|
||||||
|
type: Array,
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
type: Array,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
selectable: true,
|
||||||
|
totalCount: 0,
|
||||||
|
rowCount: 0,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const assessments = createResource({
|
||||||
|
url: 'lms.lms.utils.get_assessments',
|
||||||
|
params: {
|
||||||
|
batch: props.batch,
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const getAssessmentColumns = () => {
|
||||||
|
let columns = [
|
||||||
|
{
|
||||||
|
label: 'Assessment',
|
||||||
|
key: 'title',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Type',
|
||||||
|
key: 'assessment_type',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!user.data?.is_moderator) {
|
||||||
|
columns.push({
|
||||||
|
label: 'Status/Score',
|
||||||
|
key: 'status',
|
||||||
|
align: 'center',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return columns
|
||||||
|
}
|
||||||
|
</script>
|
||||||
75
frontend/src/components/BatchCard.vue
Normal file
75
frontend/src/components/BatchCard.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col border border-gray-200 rounded-md p-4 h-full"
|
||||||
|
style="min-height: 150px"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
v-if="batch.seat_count && batch.seats_left > 0"
|
||||||
|
theme="green"
|
||||||
|
class="self-start mb-2"
|
||||||
|
>
|
||||||
|
{{ batch.seats_left }} {{ __('Seat Left') }}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-else-if="batch.seat_count && batch.seats_left <= 0"
|
||||||
|
theme="red"
|
||||||
|
class="self-start mb-2"
|
||||||
|
>
|
||||||
|
{{ __('Sold Out') }}
|
||||||
|
</Badge>
|
||||||
|
<div class="text-xl font-semibold mb-1">
|
||||||
|
{{ batch.title }}
|
||||||
|
</div>
|
||||||
|
<div class="short-introduction">
|
||||||
|
{{ batch.description }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-auto">
|
||||||
|
<div v-if="batch.amount" class="font-semibold text-lg mb-4">
|
||||||
|
{{ batch.price }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
|
<BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
|
<span> {{ batch.courses.length }} {{ __('Courses') }} </span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
|
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
|
<span>
|
||||||
|
{{ dayjs(batch.start_date).format('DD MMM YYYY') }} -
|
||||||
|
{{ dayjs(batch.end_date).format('DD MMM YYYY') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
|
<span>
|
||||||
|
{{ formatTime(batch.start_time) }} - {{ formatTime(batch.end_time) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Calendar, Clock, BookOpen } from 'lucide-vue-next'
|
||||||
|
import { inject } from 'vue'
|
||||||
|
import { Badge } from 'frappe-ui'
|
||||||
|
import { formatTime } from '../utils'
|
||||||
|
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
|
const props = defineProps({
|
||||||
|
batch: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.short-introduction {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0.25rem 0 1.25rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
141
frontend/src/components/BatchCourses.vue
Normal file
141
frontend/src/components/BatchCourses.vue
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="text-xl font-semibold">
|
||||||
|
{{ __('Courses') }}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="user.data?.is_moderator"
|
||||||
|
variant="solid"
|
||||||
|
@click="openCourseModal()"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
{{ __('Add Course') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div v-if="courses.data?.length">
|
||||||
|
<ListView
|
||||||
|
:columns="getCoursesColumns()"
|
||||||
|
:rows="courses.data"
|
||||||
|
row-key="name"
|
||||||
|
:options="{ showTooltip: false }"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem :item="item" v-for="item in getCoursesColumns()">
|
||||||
|
<template #prefix="{ item }">
|
||||||
|
<component
|
||||||
|
v-if="item.icon"
|
||||||
|
:is="item.icon"
|
||||||
|
class="h-4 w-4 stroke-1.5 ml-4"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ListHeaderItem>
|
||||||
|
</ListHeader>
|
||||||
|
<ListRows>
|
||||||
|
<ListRow :row="row" v-for="row in courses.data">
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||||
|
<div>
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</ListRows>
|
||||||
|
<ListSelectBanner>
|
||||||
|
<template #actions="{ unselectAll, selections }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button variant="ghost" @click="removeCourses(selections)">
|
||||||
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListSelectBanner>
|
||||||
|
</ListView>
|
||||||
|
</div>
|
||||||
|
<BatchCourseModal
|
||||||
|
v-model="showCourseModal"
|
||||||
|
:batch="batch"
|
||||||
|
v-model:courses="courses"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { ref, inject } from 'vue'
|
||||||
|
import BatchCourseModal from '@/components/Modals/BatchCourseModal.vue'
|
||||||
|
import {
|
||||||
|
createResource,
|
||||||
|
Button,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListSelectBanner,
|
||||||
|
ListRow,
|
||||||
|
ListRows,
|
||||||
|
ListView,
|
||||||
|
ListRowItem,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const showCourseModal = ref(false)
|
||||||
|
const user = inject('$user')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
batch: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const courses = createResource({
|
||||||
|
url: 'lms.lms.utils.get_batch_courses',
|
||||||
|
params: {
|
||||||
|
batch: props.batch,
|
||||||
|
},
|
||||||
|
cache: ['batchCourses', props.batchName],
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const openCourseModal = () => {
|
||||||
|
showCourseModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCoursesColumns = () => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Title',
|
||||||
|
key: 'title',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Lessons',
|
||||||
|
key: 'lesson_count',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Enrollments',
|
||||||
|
key: 'enrollment_count',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCourse = createResource({
|
||||||
|
url: 'frappe.client.delete',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'Batch Course',
|
||||||
|
name: values.course,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeCourses = (selections) => {
|
||||||
|
console.log(selections)
|
||||||
|
selections.forEach(async (course) => {
|
||||||
|
removeCourse.submit({ course })
|
||||||
|
await setTimeout(1000)
|
||||||
|
})
|
||||||
|
courses.reload()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
26
frontend/src/components/BatchDashboard.vue
Normal file
26
frontend/src/components/BatchDashboard.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<UpcomingEvaluations
|
||||||
|
:batch="batch.data.name"
|
||||||
|
:endDate="batch.data.evaluation_end_date"
|
||||||
|
:courses="batch.data.courses"
|
||||||
|
:isStudent="isStudent"
|
||||||
|
/>
|
||||||
|
<Assessments :batch="batch.data.name" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||||
|
import Assessments from '@/components/Assessments.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
batch: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
isStudent: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
115
frontend/src/components/BatchOverlay.vue
Normal file
115
frontend/src/components/BatchOverlay.vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="batch.data" class="shadow rounded-md p-5" style="width: 300px">
|
||||||
|
<Badge
|
||||||
|
v-if="batch.data.seat_count && seats_left > 0"
|
||||||
|
theme="green"
|
||||||
|
class="self-start mb-2 float-right"
|
||||||
|
>
|
||||||
|
{{ seats_left }} {{ __('Seat Left') }}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-else-if="batch.data.seat_count && seats_left <= 0"
|
||||||
|
theme="red"
|
||||||
|
class="self-start mb-2 float-right"
|
||||||
|
>
|
||||||
|
{{ __('Sold Out') }}
|
||||||
|
</Badge>
|
||||||
|
<div v-if="batch.data.amount" class="text-lg font-semibold mb-3">
|
||||||
|
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
|
<BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
|
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
|
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
|
<span>
|
||||||
|
{{ dayjs(batch.data.start_date).format('DD MMM YYYY') }} -
|
||||||
|
{{ dayjs(batch.data.end_date).format('DD MMM YYYY') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
|
<span>
|
||||||
|
{{ formatTime(batch.data.start_time) }} -
|
||||||
|
{{ formatTime(batch.data.end_time) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<router-link
|
||||||
|
v-if="user?.data?.is_moderator"
|
||||||
|
:to="{
|
||||||
|
name: 'Batch',
|
||||||
|
params: {
|
||||||
|
batchName: batch.data.name,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button variant="solid" class="w-full mt-4">
|
||||||
|
<span>
|
||||||
|
{{ __('Manage Batch') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'Billing',
|
||||||
|
params: {
|
||||||
|
type: 'batch',
|
||||||
|
name: batch.data.name,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
v-else-if="batch.data.paid_batch"
|
||||||
|
>
|
||||||
|
<Button class="w-full mt-4" variant="solid">
|
||||||
|
<span>
|
||||||
|
{{ __('Register Now') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
class="w-full mt-2"
|
||||||
|
v-else-if="batch.data.allow_self_enrollment"
|
||||||
|
>
|
||||||
|
{{ __('Enroll Now') }}
|
||||||
|
</Button>
|
||||||
|
<router-link
|
||||||
|
v-if="user?.data?.is_moderator"
|
||||||
|
:to="{
|
||||||
|
name: 'BatchCreation',
|
||||||
|
params: {
|
||||||
|
batchName: batch.data.name,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button class="w-full mt-2">
|
||||||
|
<span>
|
||||||
|
{{ __('Edit') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { formatNumberIntoCurrency, formatTime } from '@/utils'
|
||||||
|
import { BookOpen, Calendar, Clock } from 'lucide-vue-next'
|
||||||
|
import { inject, computed } from 'vue'
|
||||||
|
import { Badge, Button } from 'frappe-ui'
|
||||||
|
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
|
const user = inject('$user')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
batch: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const seats_left = computed(() => {
|
||||||
|
if (props.batch.data?.seat_count) {
|
||||||
|
return props.batch.data?.seat_count - props.batch.data?.students?.length
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
151
frontend/src/components/BatchStudents.vue
Normal file
151
frontend/src/components/BatchStudents.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<template>
|
||||||
|
<Button class="float-right mb-3" variant="solid" @click="openStudentModal()">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
{{ __('Add Student') }}
|
||||||
|
</Button>
|
||||||
|
<div class="text-lg font-semibold mb-4">
|
||||||
|
{{ __('Students') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="students.data?.length">
|
||||||
|
<ListView
|
||||||
|
:columns="getStudentColumns()"
|
||||||
|
:rows="students.data"
|
||||||
|
row-key="name"
|
||||||
|
:options="{ showTooltip: false }"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem :item="item" v-for="item in getStudentColumns()">
|
||||||
|
<template #prefix="{ item }">
|
||||||
|
<component
|
||||||
|
v-if="item.icon"
|
||||||
|
:is="item.icon"
|
||||||
|
class="h-4 w-4 stroke-1.5 ml-4"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ListHeaderItem>
|
||||||
|
</ListHeader>
|
||||||
|
<ListRows>
|
||||||
|
<ListRow :row="row" v-for="row in students.data">
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||||
|
<template #prefix>
|
||||||
|
<div v-if="column.key == 'full_name'">
|
||||||
|
<Avatar
|
||||||
|
class="flex items-center"
|
||||||
|
:image="row['user_image']"
|
||||||
|
:label="item"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div>
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</ListRows>
|
||||||
|
<ListSelectBanner>
|
||||||
|
<template #actions="{ unselectAll, selections }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button variant="ghost" @click="removeStudents(selections)">
|
||||||
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListSelectBanner>
|
||||||
|
</ListView>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm italic text-gray-600">
|
||||||
|
{{ __('There are no students in this batch.') }}
|
||||||
|
</div>
|
||||||
|
<StudentModal
|
||||||
|
:batch="props.batch"
|
||||||
|
v-model="showStudentModal"
|
||||||
|
v-model:reloadStudents="students"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
createResource,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListSelectBanner,
|
||||||
|
ListRow,
|
||||||
|
ListRows,
|
||||||
|
ListView,
|
||||||
|
ListRowItem,
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { Trash2, Plus } from 'lucide-vue-next'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import StudentModal from '@/components/Modals/StudentModal.vue'
|
||||||
|
|
||||||
|
const showStudentModal = ref(false)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
batch: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const students = createResource({
|
||||||
|
url: 'lms.lms.utils.get_batch_students',
|
||||||
|
cache: ['students', props.batch],
|
||||||
|
params: {
|
||||||
|
batch: props.batch,
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const getStudentColumns = () => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Full Name',
|
||||||
|
key: 'full_name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Courses Done',
|
||||||
|
key: 'courses_completed',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Assessments Done',
|
||||||
|
key: 'assessments_completed',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Last Active',
|
||||||
|
key: 'last_active',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const openStudentModal = () => {
|
||||||
|
showStudentModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeStudent = createResource({
|
||||||
|
url: 'frappe.client.delete',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'Batch Student',
|
||||||
|
name: values.student,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeStudents = (selections) => {
|
||||||
|
selections.forEach(async (student) => {
|
||||||
|
removeStudent.submit({ student })
|
||||||
|
await setTimeout(1000)
|
||||||
|
})
|
||||||
|
students.reload()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
277
frontend/src/components/Controls/Autocomplete.vue
Normal file
277
frontend/src/components/Controls/Autocomplete.vue
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
<template>
|
||||||
|
<Combobox v-model="selectedValue" nullable v-slot="{ open: isComboboxOpen }">
|
||||||
|
<Popover class="w-full" v-model:show="showOptions">
|
||||||
|
<template #target="{ open: openPopover, togglePopover }">
|
||||||
|
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
||||||
|
<div class="w-full">
|
||||||
|
<button
|
||||||
|
class="flex w-full items-center justify-between focus:outline-none"
|
||||||
|
:class="inputClasses"
|
||||||
|
@click="() => togglePopover()"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<slot name="prefix" />
|
||||||
|
<span
|
||||||
|
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
|
||||||
|
v-if="selectedValue"
|
||||||
|
>
|
||||||
|
{{ displayValue(selectedValue) }}
|
||||||
|
</span>
|
||||||
|
<span class="text-base leading-5 text-gray-500" v-else>
|
||||||
|
{{ placeholder || '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown class="h-4 w-4 stroke-1.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
|
<template #body="{ isOpen }">
|
||||||
|
<div v-show="isOpen">
|
||||||
|
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl">
|
||||||
|
<div class="relative px-1.5 pt-0.5">
|
||||||
|
<ComboboxInput
|
||||||
|
ref="search"
|
||||||
|
class="form-input w-full"
|
||||||
|
type="text"
|
||||||
|
@change="
|
||||||
|
(e) => {
|
||||||
|
query = e.target.value
|
||||||
|
}
|
||||||
|
"
|
||||||
|
:value="query"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="Search"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
|
||||||
|
@click="selectedValue = null"
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4 stroke-1.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ComboboxOptions
|
||||||
|
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
|
||||||
|
static
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mt-1.5"
|
||||||
|
v-for="group in groups"
|
||||||
|
:key="group.key"
|
||||||
|
v-show="group.items.length > 0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="group.group && !group.hideLabel"
|
||||||
|
class="px-2.5 py-1.5 text-sm font-medium text-gray-500"
|
||||||
|
>
|
||||||
|
{{ group.group }}
|
||||||
|
</div>
|
||||||
|
<ComboboxOption
|
||||||
|
as="template"
|
||||||
|
v-for="option in group.items"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option"
|
||||||
|
v-slot="{ active, selected }"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
:class="[
|
||||||
|
'flex items-center rounded px-2.5 py-1.5 text-base',
|
||||||
|
{ 'bg-gray-100': active },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
name="item-prefix"
|
||||||
|
v-bind="{ active, selected, option }"
|
||||||
|
/>
|
||||||
|
<slot
|
||||||
|
name="item-label"
|
||||||
|
v-bind="{ active, selected, option }"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</slot>
|
||||||
|
</li>
|
||||||
|
</ComboboxOption>
|
||||||
|
</div>
|
||||||
|
<li
|
||||||
|
v-if="groups.length == 0"
|
||||||
|
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-gray-600"
|
||||||
|
>
|
||||||
|
No results found
|
||||||
|
</li>
|
||||||
|
</ComboboxOptions>
|
||||||
|
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
|
||||||
|
<slot
|
||||||
|
name="footer"
|
||||||
|
v-bind="{ value: search?.el._value, close }"
|
||||||
|
></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Popover>
|
||||||
|
</Combobox>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Combobox,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxOptions,
|
||||||
|
ComboboxOption,
|
||||||
|
} from '@headlessui/vue'
|
||||||
|
import { Popover, Button } from 'frappe-ui'
|
||||||
|
import { ChevronDown, X } from 'lucide-vue-next'
|
||||||
|
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'md',
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'subtle',
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
filterable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue', 'update:query', 'change'])
|
||||||
|
|
||||||
|
const query = ref('')
|
||||||
|
const showOptions = ref(false)
|
||||||
|
const search = ref(null)
|
||||||
|
|
||||||
|
const attrs = useAttrs()
|
||||||
|
const slots = useSlots()
|
||||||
|
|
||||||
|
const valuePropPassed = computed(() => 'value' in attrs)
|
||||||
|
|
||||||
|
const selectedValue = computed({
|
||||||
|
get() {
|
||||||
|
return valuePropPassed.value ? attrs.value : props.modelValue
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
query.value = ''
|
||||||
|
if (val) {
|
||||||
|
showOptions.value = false
|
||||||
|
}
|
||||||
|
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
showOptions.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = computed(() => {
|
||||||
|
if (!props.options || props.options.length == 0) return []
|
||||||
|
|
||||||
|
let groups = props.options[0]?.group
|
||||||
|
? props.options
|
||||||
|
: [{ group: '', items: props.options }]
|
||||||
|
|
||||||
|
return groups
|
||||||
|
.map((group, i) => {
|
||||||
|
return {
|
||||||
|
key: i,
|
||||||
|
group: group.group,
|
||||||
|
hideLabel: group.hideLabel || false,
|
||||||
|
items: props.filterable ? filterOptions(group.items) : group.items,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((group) => group.items.length > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
function filterOptions(options) {
|
||||||
|
if (!query.value) {
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
return options.filter((option) => {
|
||||||
|
let searchTexts = [option.label, option.value]
|
||||||
|
return searchTexts.some((text) =>
|
||||||
|
(text || '').toString().toLowerCase().includes(query.value.toLowerCase())
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayValue(option) {
|
||||||
|
if (typeof option === 'string') {
|
||||||
|
let allOptions = groups.value.flatMap((group) => group.items)
|
||||||
|
let selectedOption = allOptions.find((o) => o.value === option)
|
||||||
|
return selectedOption?.label || option
|
||||||
|
}
|
||||||
|
return option?.label
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(query, (q) => {
|
||||||
|
emit('update:query', q)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(showOptions, (val) => {
|
||||||
|
if (val) {
|
||||||
|
nextTick(() => {
|
||||||
|
search.value.el.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const textColor = computed(() => {
|
||||||
|
return props.disabled ? 'text-gray-600' : 'text-gray-800'
|
||||||
|
})
|
||||||
|
|
||||||
|
const inputClasses = computed(() => {
|
||||||
|
let sizeClasses = {
|
||||||
|
sm: 'text-base rounded h-7',
|
||||||
|
md: 'text-base rounded h-8',
|
||||||
|
lg: 'text-lg rounded-md h-10',
|
||||||
|
xl: 'text-xl rounded-md h-10',
|
||||||
|
}[props.size]
|
||||||
|
|
||||||
|
let paddingClasses = {
|
||||||
|
sm: 'py-1.5 px-2',
|
||||||
|
md: 'py-1.5 px-2.5',
|
||||||
|
lg: 'py-1.5 px-3',
|
||||||
|
xl: 'py-1.5 px-3',
|
||||||
|
}[props.size]
|
||||||
|
|
||||||
|
let variant = props.disabled ? 'disabled' : props.variant
|
||||||
|
let variantClasses = {
|
||||||
|
subtle:
|
||||||
|
'border border-gray-100 bg-gray-100 placeholder-gray-500 hover:border-gray-200 hover:bg-gray-200 focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400',
|
||||||
|
outline:
|
||||||
|
'border border-gray-300 bg-white placeholder-gray-500 hover:border-gray-400 hover:shadow-sm focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400',
|
||||||
|
disabled: [
|
||||||
|
'border bg-gray-50 placeholder-gray-400',
|
||||||
|
props.variant === 'outline' ? 'border-gray-300' : 'border-transparent',
|
||||||
|
],
|
||||||
|
}[variant]
|
||||||
|
|
||||||
|
return [
|
||||||
|
sizeClasses,
|
||||||
|
paddingClasses,
|
||||||
|
variantClasses,
|
||||||
|
textColor.value,
|
||||||
|
'transition-colors w-full',
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({ query })
|
||||||
|
</script>
|
||||||
146
frontend/src/components/Controls/Link.vue
Normal file
146
frontend/src/components/Controls/Link.vue
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label class="block" :class="labelClasses" v-if="attrs.label">
|
||||||
|
{{ attrs.label }}
|
||||||
|
</label>
|
||||||
|
<Autocomplete
|
||||||
|
ref="autocomplete"
|
||||||
|
:options="options.data"
|
||||||
|
v-model="value"
|
||||||
|
:size="attrs.size || 'sm'"
|
||||||
|
:variant="attrs.variant"
|
||||||
|
:placeholder="attrs.placeholder"
|
||||||
|
:filterable="false"
|
||||||
|
>
|
||||||
|
<template #target="{ open, togglePopover }">
|
||||||
|
<slot name="target" v-bind="{ open, togglePopover }" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #prefix>
|
||||||
|
<slot name="prefix" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item-prefix="{ active, selected, option }">
|
||||||
|
<slot name="item-prefix" v-bind="{ active, selected, option }" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item-label="{ active, selected, option }">
|
||||||
|
<slot name="item-label" v-bind="{ active, selected, option }" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="attrs.onCreate" #footer="{ value, close }">
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="w-full !justify-start"
|
||||||
|
label="Create New"
|
||||||
|
@click="attrs.onCreate(value, close)"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="h-4 w-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Autocomplete>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import Autocomplete from '@/components/Controls/Autocomplete.vue'
|
||||||
|
import { watchDebounced } from '@vueuse/core'
|
||||||
|
import { createResource, Button } from 'frappe-ui'
|
||||||
|
import { Plus } from 'lucide-vue-next'
|
||||||
|
import { useAttrs, computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
doctype: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'change'])
|
||||||
|
|
||||||
|
const attrs = useAttrs()
|
||||||
|
|
||||||
|
const valuePropPassed = computed(() => 'value' in attrs)
|
||||||
|
|
||||||
|
const value = computed({
|
||||||
|
get: () => (valuePropPassed.value ? attrs.value : props.modelValue),
|
||||||
|
set: (val) => {
|
||||||
|
return (
|
||||||
|
val?.value &&
|
||||||
|
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val?.value)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const autocomplete = ref(null)
|
||||||
|
const text = ref('')
|
||||||
|
|
||||||
|
watchDebounced(
|
||||||
|
() => autocomplete.value?.query,
|
||||||
|
(val) => {
|
||||||
|
val = val || ''
|
||||||
|
if (text.value === val) return
|
||||||
|
text.value = val
|
||||||
|
reload(val)
|
||||||
|
},
|
||||||
|
{ debounce: 300, immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watchDebounced(
|
||||||
|
() => props.doctype,
|
||||||
|
() => reload(''),
|
||||||
|
{ debounce: 300, immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const options = createResource({
|
||||||
|
url: 'frappe.desk.search.search_link',
|
||||||
|
cache: [props.doctype, text.value],
|
||||||
|
method: 'POST',
|
||||||
|
params: {
|
||||||
|
txt: text.value,
|
||||||
|
doctype: props.doctype,
|
||||||
|
filters: props.filters,
|
||||||
|
},
|
||||||
|
transform: (data) => {
|
||||||
|
return data.map((option) => {
|
||||||
|
return {
|
||||||
|
label: option.value,
|
||||||
|
value: option.value,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function reload(val) {
|
||||||
|
options.update({
|
||||||
|
params: {
|
||||||
|
txt: val,
|
||||||
|
doctype: props.doctype,
|
||||||
|
filters: props.filters,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
options.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelClasses = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
sm: 'text-xs',
|
||||||
|
md: 'text-base',
|
||||||
|
}[attrs.size || 'sm'],
|
||||||
|
'text-gray-600',
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
38
frontend/src/components/Controls/Rating.vue
Normal file
38
frontend/src/components/Controls/Rating.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex text-center">
|
||||||
|
<div v-for="index in 5">
|
||||||
|
<Star
|
||||||
|
:class="index <= rating ? 'fill-orange-500' : ''"
|
||||||
|
class="h-6 w-6 fill-gray-400 text-gray-50 mr-1 cursor-pointer"
|
||||||
|
@click="markRating(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Star } from 'lucide-vue-next'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
const props = defineProps({
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
let rating = ref(props.modelValue)
|
||||||
|
|
||||||
|
let emitChange = (value) => {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function markRating(index) {
|
||||||
|
emitChange(index)
|
||||||
|
rating.value = index
|
||||||
|
}
|
||||||
|
</script>
|
||||||
187
frontend/src/components/CourseCard.vue
Normal file
187
frontend/src/components/CourseCard.vue
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="course.title"
|
||||||
|
class="flex flex-col h-full rounded-md shadow-md text-base overflow-auto"
|
||||||
|
style="min-height: 320px"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="course-image"
|
||||||
|
:class="{ 'default-image': !course.image }"
|
||||||
|
:style="{ backgroundImage: 'url(' + encodeURI(course.image) + ')' }"
|
||||||
|
>
|
||||||
|
<div class="flex relative top-4 left-4 w-fit flex-wrap">
|
||||||
|
<Badge theme="gray" size="md" class="mr-2" v-for="tag in course.tags">
|
||||||
|
{{ tag }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div v-if="!course.image" class="image-placeholder">
|
||||||
|
{{ course.title[0] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col flex-auto p-4">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div v-if="course.lesson_count">
|
||||||
|
<Tooltip :text="__('Lessons')">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<BookOpen class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
||||||
|
{{ course.lesson_count }}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="course.enrollment_count">
|
||||||
|
<Tooltip :text="__('Enrolled Students')">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<Users class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
||||||
|
{{ course.enrollment_count }}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="course.avg_rating">
|
||||||
|
<Tooltip :text="__('Average Rating')">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<Star class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
||||||
|
{{ course.avg_rating }}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="course.status != 'Approved'">
|
||||||
|
<Badge
|
||||||
|
variant="solid"
|
||||||
|
:theme="course.status === 'Under Review' ? 'orange' : 'blue'"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{{ course.status }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xl font-semibold leading-6">
|
||||||
|
{{ course.title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="short-introduction">
|
||||||
|
{{ course.short_introduction }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="user && course.membership"
|
||||||
|
class="w-full bg-gray-200 rounded-full h-1 mb-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-gray-900 h-1 rounded-full"
|
||||||
|
:style="{ width: Math.ceil(course.membership.progress) + '%' }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="user && course.membership" class="text-sm mb-4">
|
||||||
|
{{ Math.ceil(course.membership.progress) }}% completed
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mt-auto">
|
||||||
|
<div class="flex avatar-group overlap">
|
||||||
|
<div
|
||||||
|
class="mr-1"
|
||||||
|
:class="{ 'avatar-group overlap': course.instructors.length > 1 }"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
v-for="instructor in course.instructors"
|
||||||
|
:user="instructor"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span v-if="course.instructors.length == 1">
|
||||||
|
{{ course.instructors[0].full_name }}
|
||||||
|
</span>
|
||||||
|
<span v-if="course.instructors.length == 2">
|
||||||
|
{{ course.instructors[0].first_name }} and
|
||||||
|
{{ course.instructors[1].first_name }}
|
||||||
|
</span>
|
||||||
|
<span v-if="course.instructors.length > 2">
|
||||||
|
{{ course.instructors[0].first_name }} and
|
||||||
|
{{ course.instructors.length - 1 }} others
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="font-semibold">
|
||||||
|
{{ course.price }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
||||||
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import { Badge, Tooltip } from 'frappe-ui'
|
||||||
|
|
||||||
|
const { user } = sessionStore()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
course: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.course-image {
|
||||||
|
height: 168px;
|
||||||
|
width: 100%;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-card-pills {
|
||||||
|
background: #ffffff;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
padding: 3.5px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 0.011em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-image {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
background-color: theme('colors.orange.100');
|
||||||
|
color: theme('colors.orange.600');
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-group .avatar {
|
||||||
|
transition: margin 0.1s ease-in-out;
|
||||||
|
}
|
||||||
|
.image-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
font-size: 5rem;
|
||||||
|
color: theme('colors.gray.700');
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.avatar-group.overlap .avatar + .avatar {
|
||||||
|
margin-left: calc(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.short-introduction {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0.25rem 0 1.25rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
164
frontend/src/components/CourseCardOverlay.vue
Normal file
164
frontend/src/components/CourseCardOverlay.vue
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<template>
|
||||||
|
<div class="shadow rounded-md min-w-80">
|
||||||
|
<iframe
|
||||||
|
v-if="course.data.video_link"
|
||||||
|
:src="video_link"
|
||||||
|
class="rounded-t-md min-h-56 w-full"
|
||||||
|
/>
|
||||||
|
<div class="p-5">
|
||||||
|
<div v-if="course.data.price" class="text-2xl font-semibold mb-3">
|
||||||
|
{{ course.data.price }}
|
||||||
|
</div>
|
||||||
|
<router-link
|
||||||
|
v-if="course.data.membership"
|
||||||
|
:to="{
|
||||||
|
name: 'Lesson',
|
||||||
|
params: {
|
||||||
|
courseName: course.name,
|
||||||
|
chapterNumber: course.data.current_lesson
|
||||||
|
? course.data.current_lesson.split('.')[0]
|
||||||
|
: 1,
|
||||||
|
lessonNumber: course.data.current_lesson
|
||||||
|
? course.data.current_lesson.split('.')[1]
|
||||||
|
: 1,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button variant="solid" size="md" class="w-full">
|
||||||
|
<span>
|
||||||
|
{{ __('Continue Learning') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
v-else-if="course.data.paid_course"
|
||||||
|
:to="{
|
||||||
|
name: 'Billing',
|
||||||
|
params: {
|
||||||
|
type: 'course',
|
||||||
|
name: course.data.name,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button variant="solid" size="md" class="w-full">
|
||||||
|
<span>
|
||||||
|
{{ __('Buy this course') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<Button
|
||||||
|
v-else
|
||||||
|
@click="enrollStudent()"
|
||||||
|
variant="solid"
|
||||||
|
class="w-full"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{ __('Start Learning') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<router-link
|
||||||
|
v-if="user?.data?.is_moderator || is_instructor()"
|
||||||
|
:to="{
|
||||||
|
name: 'CreateCourse',
|
||||||
|
params: {
|
||||||
|
courseName: course.data.name,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button variant="subtle" class="w-full mt-2" size="md">
|
||||||
|
<span>
|
||||||
|
{{ __('Edit') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<div class="mt-8 mb-4 font-medium">
|
||||||
|
{{ __('This course has:') }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
|
<BookOpen class="h-5 w-5 stroke-1.5 text-gray-600" />
|
||||||
|
<span class="ml-2">
|
||||||
|
{{ course.data.lesson_count }} {{ __('Lessons') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
|
<Users class="h-5 w-5 stroke-1.5 text-gray-600" />
|
||||||
|
<span class="ml-2">
|
||||||
|
{{ course.data.enrollment_count_formatted }}
|
||||||
|
{{ __('Enrolled Students') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Star class="h-5 w-5 stroke-1.5 fill-orange-500 text-gray-50" />
|
||||||
|
<span class="ml-2">
|
||||||
|
{{ course.data.avg_rating }} {{ __('Rating') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
||||||
|
import { computed, inject } from 'vue'
|
||||||
|
import { Button, createResource } from 'frappe-ui'
|
||||||
|
import { createToast } from '@/utils/'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const user = inject('$user')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
course: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const video_link = computed(() => {
|
||||||
|
if (props.course.data.video_link) {
|
||||||
|
return 'https://www.youtube.com/embed/' + props.course.data.video_link
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
function enrollStudent() {
|
||||||
|
if (!user.data) {
|
||||||
|
createToast({
|
||||||
|
title: 'Please Login',
|
||||||
|
icon: 'alert-circle',
|
||||||
|
iconClasses: 'text-yellow-600 bg-yellow-100',
|
||||||
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
||||||
|
}, 3000)
|
||||||
|
} else {
|
||||||
|
const enrollStudentResource = createResource({
|
||||||
|
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
|
||||||
|
})
|
||||||
|
console.log(props.course)
|
||||||
|
enrollStudentResource
|
||||||
|
.submit({
|
||||||
|
course: props.course.data.name,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
createToast({
|
||||||
|
title: 'Enrolled Successfully',
|
||||||
|
icon: 'check',
|
||||||
|
iconClasses: 'text-green-600 bg-green-100',
|
||||||
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push({
|
||||||
|
name: 'Lesson',
|
||||||
|
params: {
|
||||||
|
courseName: props.course.data.name,
|
||||||
|
chapterNumber: 1,
|
||||||
|
lessonNumber: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, 3000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const is_instructor = () => {}
|
||||||
|
</script>
|
||||||
170
frontend/src/components/CourseOutline.vue
Normal file
170
frontend/src/components/CourseOutline.vue
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-base">
|
||||||
|
<div
|
||||||
|
v-if="title && (outline.data?.length || allowEdit)"
|
||||||
|
class="flex items-center justify-between mb-4"
|
||||||
|
>
|
||||||
|
<div class="font-semibold" :class="allowEdit ? 'text-base' : 'text-lg'">
|
||||||
|
{{ __(title) }}
|
||||||
|
</div>
|
||||||
|
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
|
||||||
|
{{ __('Add Chapter') }}
|
||||||
|
</Button>
|
||||||
|
<!-- <span class="font-medium cursor-pointer" @click="expandAllChapters()">
|
||||||
|
{{ expandAll ? __("Collapse all chapters") : __("Expand all chapters") }}
|
||||||
|
</span> -->
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="{
|
||||||
|
'shadow rounded-md pt-2 px-2': showOutline && outline.data?.length,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Disclosure
|
||||||
|
v-slot="{ open }"
|
||||||
|
v-for="(chapter, index) in outline.data"
|
||||||
|
:key="chapter.name"
|
||||||
|
:defaultOpen="openChapterDetail(chapter.idx)"
|
||||||
|
>
|
||||||
|
<DisclosureButton ref="" class="flex w-full px-2 py-3">
|
||||||
|
<ChevronRight
|
||||||
|
:class="{
|
||||||
|
'rotate-90 transform duration-200': open,
|
||||||
|
'duration-200': !open,
|
||||||
|
open: index == 1,
|
||||||
|
}"
|
||||||
|
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||||
|
/>
|
||||||
|
<div class="text-base text-left font-medium leading-5">
|
||||||
|
{{ chapter.title }}
|
||||||
|
</div>
|
||||||
|
</DisclosureButton>
|
||||||
|
<DisclosurePanel class="pb-2">
|
||||||
|
<div v-for="lesson in chapter.lessons" :key="lesson.name">
|
||||||
|
<div class="outline-lesson pl-8 py-2">
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: allowEdit ? 'CreateLesson' : 'Lesson',
|
||||||
|
params: {
|
||||||
|
courseName: courseName,
|
||||||
|
chapterNumber: lesson.number.split('.')[0],
|
||||||
|
lessonNumber: lesson.number.split('.')[1],
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="flex items-center text-sm leading-5">
|
||||||
|
<MonitorPlay
|
||||||
|
v-if="lesson.icon === 'icon-youtube'"
|
||||||
|
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||||
|
/>
|
||||||
|
<HelpCircle
|
||||||
|
v-else-if="lesson.icon === 'icon-quiz'"
|
||||||
|
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||||
|
/>
|
||||||
|
<FileText
|
||||||
|
v-else-if="lesson.icon === 'icon-list'"
|
||||||
|
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||||
|
/>
|
||||||
|
{{ lesson.title }}
|
||||||
|
<Check
|
||||||
|
v-if="lesson.is_complete"
|
||||||
|
class="h-4 w-4 text-green-500 stroke-1.5 ml-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="allowEdit" class="flex mt-2 pl-8">
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'CreateLesson',
|
||||||
|
params: {
|
||||||
|
courseName: courseName,
|
||||||
|
chapterNumber: chapter.idx,
|
||||||
|
lessonNumber: chapter.lessons.length + 1,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button>
|
||||||
|
{{ __('Add Lesson') }}
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<Button class="ml-2" @click="openChapterModal(chapter)">
|
||||||
|
{{ __('Edit Chapter') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DisclosurePanel>
|
||||||
|
</Disclosure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChapterModal
|
||||||
|
v-model="showChapterModal"
|
||||||
|
v-model:outline="outline"
|
||||||
|
:course="courseName"
|
||||||
|
:chapterDetail="getCurrentChapter()"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Button, createResource } from 'frappe-ui'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
||||||
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
MonitorPlay,
|
||||||
|
HelpCircle,
|
||||||
|
FileText,
|
||||||
|
Check,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import ChapterModal from '@/components/Modals/ChapterModal.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const expandAll = ref(true)
|
||||||
|
const showChapterModal = ref(false)
|
||||||
|
const currentChapter = ref(null)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
courseName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
showOutline: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
allowEdit: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const outline = createResource({
|
||||||
|
url: 'lms.lms.utils.get_course_outline',
|
||||||
|
cache: ['course_outline', props.courseName],
|
||||||
|
params: {
|
||||||
|
course: props.courseName,
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const openChapterDetail = (index) => {
|
||||||
|
return index == route.params.chapterNumber || index == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const openChapterModal = (chapter = null) => {
|
||||||
|
currentChapter.value = chapter
|
||||||
|
showChapterModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentChapter = () => {
|
||||||
|
return currentChapter.value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.outline-lesson:has(.router-link-active) {
|
||||||
|
background-color: theme('colors.gray.100');
|
||||||
|
}
|
||||||
|
</style>
|
||||||
103
frontend/src/components/CourseReviews.vue
Normal file
103
frontend/src/components/CourseReviews.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="reviews.data" class="mt-20 mb-10">
|
||||||
|
<Button
|
||||||
|
v-if="membership && !hasReviewed.data"
|
||||||
|
@click="openReviewModal()"
|
||||||
|
class="float-right"
|
||||||
|
>
|
||||||
|
{{ __('Write a Review') }}
|
||||||
|
</Button>
|
||||||
|
<div class="flex items-center font-semibold text-2xl">
|
||||||
|
<Star class="h-6 w-6 stroke-1 text-gray-50 fill-orange-500 mr-1" />
|
||||||
|
{{ avg_rating }} {{ __('ratings and ') }} {{ reviews.data.length }}
|
||||||
|
{{ __('reviews') }}
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-8 mt-10">
|
||||||
|
<div v-for="(review, index) in reviews.data">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<UserAvatar :user="review.owner_details" :size="'2xl'" />
|
||||||
|
<div class="mx-4">
|
||||||
|
<span class="text-lg font-medium mr-4">
|
||||||
|
{{ review.owner_details.full_name }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{{ review.creation }}
|
||||||
|
</span>
|
||||||
|
<div class="flex mt-2">
|
||||||
|
<Star
|
||||||
|
v-for="index in 5"
|
||||||
|
class="h-5 w-5 text-gray-100 bg-gray-200 rounded-sm mr-2"
|
||||||
|
:class="
|
||||||
|
index <= Math.ceil(review.rating)
|
||||||
|
? 'fill-orange-500'
|
||||||
|
: 'fill-gray-600'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="review.review" class="mt-4 leading-5">
|
||||||
|
{{ review.review }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ReviewModal
|
||||||
|
v-model="showReviewModal"
|
||||||
|
v-model:reloadReviews="reviews"
|
||||||
|
v-model:hasReviewed="hasReviewed"
|
||||||
|
:courseName="courseName"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Star } from 'lucide-vue-next'
|
||||||
|
import { createResource, Button } from 'frappe-ui'
|
||||||
|
import { computed, ref, inject } from 'vue'
|
||||||
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
import ReviewModal from '@/components/Modals/ReviewModal.vue'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
courseName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
avg_rating: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
membership: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasReviewed = createResource({
|
||||||
|
url: 'frappe.client.get_count',
|
||||||
|
cache: ['eligible_to_review', props.courseName, props.membership?.member],
|
||||||
|
params: {
|
||||||
|
doctype: 'LMS Course Review',
|
||||||
|
filters: {
|
||||||
|
course: props.courseName,
|
||||||
|
owner: props.membership?.member,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auto: user.data?.name ? true : false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const reviews = createResource({
|
||||||
|
url: 'lms.lms.utils.get_reviews',
|
||||||
|
cache: ['course_reviews', props.courseName],
|
||||||
|
params: {
|
||||||
|
course: props.courseName,
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const showReviewModal = ref(false)
|
||||||
|
|
||||||
|
function openReviewModal() {
|
||||||
|
showReviewModal.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
30
frontend/src/components/CreateOutline.vue
Normal file
30
frontend/src/components/CreateOutline.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="course">
|
||||||
|
<div class="text-xl font-semibold">
|
||||||
|
{{ course.title }}
|
||||||
|
</div>
|
||||||
|
<div v-if="course.chapters.length">
|
||||||
|
{{ course.chapters }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="border bg-white rounded-md p-5 text-center mt-4">
|
||||||
|
<div>
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'There are no chapters in this course. Create and manage chapters from here.'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<Button class="mt-4">
|
||||||
|
{{ __('Add Chapter') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
course: {
|
||||||
|
type: Object,
|
||||||
|
default: {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
20
frontend/src/components/DesktopLayout.vue
Normal file
20
frontend/src/components/DesktopLayout.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative flex h-full flex-col">
|
||||||
|
<div class="h-full flex-1">
|
||||||
|
<div class="flex h-screen text-base">
|
||||||
|
<div
|
||||||
|
class="relative block min-h-0 flex-shrink-0 overflow-hidden hover:overflow-auto"
|
||||||
|
>
|
||||||
|
<slot name="sidebar" />
|
||||||
|
<AppSidebar />
|
||||||
|
</div>
|
||||||
|
<div class="w-full overflow-auto" id="scrollContainer">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import AppSidebar from './AppSidebar.vue'
|
||||||
|
</script>
|
||||||
231
frontend/src/components/DiscussionReplies.vue
Normal file
231
frontend/src/components/DiscussionReplies.vue
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mt-6">
|
||||||
|
<div v-if="!singleThread" class="flex items-center mb-5">
|
||||||
|
<Button variant="outline" @click="showTopics = true">
|
||||||
|
<template #icon>
|
||||||
|
<ChevronLeft class="w-5 h-5 stroke-1.5 text-gray-700" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<span class="text-lg font-semibold ml-2">
|
||||||
|
{{ topic.title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="(reply, index) in replies.data">
|
||||||
|
<div
|
||||||
|
class="py-3"
|
||||||
|
:class="{ 'border-b': index + 1 != replies.data.length }"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<UserAvatar :user="reply.user" class="mr-2" />
|
||||||
|
<span>
|
||||||
|
{{ reply.user.full_name }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm ml-2">
|
||||||
|
{{ timeAgo(reply.creation) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Dropdown
|
||||||
|
v-if="user.data.name == reply.owner && !reply.editable"
|
||||||
|
:options="[
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
onClick() {
|
||||||
|
reply.editable = true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
onClick() {
|
||||||
|
deleteReply(reply)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<template v-slot="{ open }">
|
||||||
|
<MoreHorizontal class="w-4 h-4 stroke-1.5 cursor-pointer" />
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
<div v-if="reply.editable">
|
||||||
|
<Button variant="ghost" @click="postEdited(reply)">
|
||||||
|
{{ __('Post') }}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" @click="reply.editable = false">
|
||||||
|
{{ __('Discard') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TextEditor
|
||||||
|
:content="reply.reply"
|
||||||
|
@change="(val) => (reply.reply = val)"
|
||||||
|
:editable="reply.editable || false"
|
||||||
|
:fixedMenu="reply.editable || false"
|
||||||
|
:editorClass="
|
||||||
|
reply.editable
|
||||||
|
? 'ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none'
|
||||||
|
: 'prose-sm'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TextEditor
|
||||||
|
class="mt-5"
|
||||||
|
:content="newReply"
|
||||||
|
@change="(val) => (newReply = val)"
|
||||||
|
placeholder="Type your reply here..."
|
||||||
|
:fixedMenu="true"
|
||||||
|
editorClass="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none border border-gray-300 rounded-b-md min-h-[7rem] py-1 px-2"
|
||||||
|
/>
|
||||||
|
<div class="flex justify-between mt-2">
|
||||||
|
<span> </span>
|
||||||
|
<Button @click="postReply()">
|
||||||
|
<span>
|
||||||
|
{{ __('Post') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { createResource, TextEditor, Button, Dropdown } from 'frappe-ui'
|
||||||
|
import { timeAgo } from '../utils'
|
||||||
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
||||||
|
import { ref, inject, onMounted } from 'vue'
|
||||||
|
import { createToast } from '../utils'
|
||||||
|
|
||||||
|
const showTopics = defineModel('showTopics')
|
||||||
|
const newReply = ref('')
|
||||||
|
const socket = inject('$socket')
|
||||||
|
const user = inject('$user')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
topic: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
singleThread: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
socket.on('publish_message', (data) => {
|
||||||
|
replies.reload()
|
||||||
|
})
|
||||||
|
socket.on('update_message', (data) => {
|
||||||
|
replies.reload()
|
||||||
|
})
|
||||||
|
socket.on('delete_message', (data) => {
|
||||||
|
replies.reload()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const replies = createResource({
|
||||||
|
url: 'lms.lms.utils.get_discussion_replies',
|
||||||
|
cache: ['replies', props.topic],
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
topic: props.topic.name,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const newReplyResource = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
doctype: 'Discussion Reply',
|
||||||
|
reply: newReply.value,
|
||||||
|
topic: props.topic.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const postReply = () => {
|
||||||
|
newReplyResource.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
validate() {
|
||||||
|
if (!newReply.value) {
|
||||||
|
return 'Reply cannot be empty'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
newReply.value = ''
|
||||||
|
replies.reload()
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
createToast({
|
||||||
|
title: 'Error',
|
||||||
|
text: err.messages?.[0] || err,
|
||||||
|
icon: 'x',
|
||||||
|
iconClasses: 'bg-red-600 text-white rounded-md p-px',
|
||||||
|
position: 'top-center',
|
||||||
|
timeout: 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const editReplyResource = createResource({
|
||||||
|
url: 'frappe.client.set_value',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'Discussion Reply',
|
||||||
|
name: values.name,
|
||||||
|
fieldname: 'reply',
|
||||||
|
value: values.reply,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const postEdited = (reply) => {
|
||||||
|
editReplyResource.submit(
|
||||||
|
{
|
||||||
|
name: reply.name,
|
||||||
|
reply: reply.reply,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
validate() {
|
||||||
|
if (!reply.reply) {
|
||||||
|
return 'Reply cannot be empty'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
reply.editable = false
|
||||||
|
replies.reload()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteReplyResource = createResource({
|
||||||
|
url: 'frappe.client.delete',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'Discussion Reply',
|
||||||
|
name: values.name,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteReply = (reply) => {
|
||||||
|
deleteReplyResource.submit(
|
||||||
|
{
|
||||||
|
name: reply.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
replies.reload()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
132
frontend/src/components/Discussions.vue
Normal file
132
frontend/src/components/Discussions.vue
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<Button v-if="!singleThread" class="float-right" @click="openTopicModal()">
|
||||||
|
{{ __('New {0}').format(title) }}
|
||||||
|
</Button>
|
||||||
|
<div class="text-xl font-semibold">
|
||||||
|
{{ __(title) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="topics.data?.length && !singleThread">
|
||||||
|
<div v-if="showTopics" v-for="(topic, index) in topics.data">
|
||||||
|
<div
|
||||||
|
@click="showReplies(topic)"
|
||||||
|
class="flex items-center cursor-pointer py-5 w-full"
|
||||||
|
:class="{ 'border-b': index + 1 != topics.data.length }"
|
||||||
|
>
|
||||||
|
<UserAvatar :user="topic.user" size="2xl" class="mr-4" />
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-semibold mb-1">
|
||||||
|
{{ topic.title }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span>
|
||||||
|
{{ topic.user.full_name }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm ml-3">
|
||||||
|
{{ timeAgo(topic.creation) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<DiscussionReplies
|
||||||
|
:topic="currentTopic"
|
||||||
|
v-model:showTopics="showTopics"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="singleThread && topics.data">
|
||||||
|
<DiscussionReplies :topic="topics.data" :singleThread="singleThread" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex justify-center border mt-5 p-5 rounded-md">
|
||||||
|
<MessageSquareIcon class="w-10 h-10 stroke-1.5 text-gray-800 mr-2" />
|
||||||
|
<div>
|
||||||
|
<div class="text-xl font-semibold mb-2">
|
||||||
|
{{ __(emptyStateTitle) }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ __(emptyStateText) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DiscussionModal
|
||||||
|
v-model="showTopicModal"
|
||||||
|
:title="__('New {0}').format(title)"
|
||||||
|
:doctype="props.doctype"
|
||||||
|
:docname="props.docname"
|
||||||
|
v-model:reloadTopics="topics"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { createResource, Button, TextEditor } from 'frappe-ui'
|
||||||
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
import { timeAgo } from '../utils'
|
||||||
|
import { ref, onMounted, inject } from 'vue'
|
||||||
|
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
||||||
|
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
||||||
|
import { MessageSquareIcon } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const showTopics = ref(true)
|
||||||
|
const currentTopic = ref(null)
|
||||||
|
const socket = inject('$socket')
|
||||||
|
const user = inject('$user')
|
||||||
|
const showTopicModal = ref(false)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
doctype: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
docname: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
emptyStateTitle: {
|
||||||
|
type: String,
|
||||||
|
default: 'No topics yet',
|
||||||
|
},
|
||||||
|
emptyStateText: {
|
||||||
|
type: String,
|
||||||
|
default: 'Be the first to start a discussion',
|
||||||
|
},
|
||||||
|
singleThread: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (user.data) topics.reload()
|
||||||
|
|
||||||
|
socket.on('new_discussion_topic', (data) => {
|
||||||
|
topics.refresh()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const topics = createResource({
|
||||||
|
url: 'lms.lms.utils.get_discussion_topics',
|
||||||
|
cache: ['topics', props.doctype, props.docname],
|
||||||
|
makeParams() {
|
||||||
|
return {
|
||||||
|
doctype: props.doctype,
|
||||||
|
docname: props.docname,
|
||||||
|
single_thread: props.singleThread,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const showReplies = (topic) => {
|
||||||
|
showTopics.value = false
|
||||||
|
currentTopic.value = topic
|
||||||
|
}
|
||||||
|
|
||||||
|
const openTopicModal = () => {
|
||||||
|
showTopicModal.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
23
frontend/src/components/Icons/BatchIcon.vue
Normal file
23
frontend/src/components/Icons/BatchIcon.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 18 18"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<g clip-path="url(#clip0_1584_1676)">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M3.17474 0.625C2.34632 0.625 1.67474 1.29657 1.67474 2.125V7.475C1.67474 8.30343 2.34632 8.975 3.17474 8.975H14.8247C15.6532 8.975 16.3247 8.30343 16.3247 7.475V2.125C16.3247 1.29657 15.6532 0.625 14.8247 0.625H3.17474ZM2.67474 2.125C2.67474 1.84886 2.8986 1.625 3.17474 1.625H14.8247C15.1009 1.625 15.3247 1.84886 15.3247 2.125V7.475C15.3247 7.75114 15.1009 7.975 14.8247 7.975H3.17474C2.8986 7.975 2.67474 7.75114 2.67474 7.475V2.125ZM4.27478 10.0749C3.99864 10.0749 3.77478 10.2987 3.77478 10.5749V12.6749C3.77478 12.951 3.99864 13.1749 4.27478 13.1749C4.55092 13.1749 4.77478 12.951 4.77478 12.6749V11.0749H6.92478V12.6749C6.92478 12.951 7.14864 13.1749 7.42478 13.1749C7.70092 13.1749 7.92478 12.951 7.92478 12.6749V10.5749C7.92478 10.2987 7.70092 10.0749 7.42478 10.0749H4.27478ZM10.0749 10.5749C10.0749 10.2987 10.2987 10.0749 10.5749 10.0749H13.7249C14.001 10.0749 14.2249 10.2987 14.2249 10.5749V12.6749C14.2249 12.951 14.001 13.1749 13.7249 13.1749C13.4487 13.1749 13.2249 12.951 13.2249 12.6749V11.0749H11.0749V12.6749C11.0749 12.951 10.851 13.1749 10.5749 13.1749C10.2987 13.1749 10.0749 12.951 10.0749 12.6749V10.5749ZM1.125 14.275C0.848858 14.275 0.625 14.4988 0.625 14.775V16.875C0.625 17.1511 0.848858 17.375 1.125 17.375C1.40114 17.375 1.625 17.1511 1.625 16.875V15.275H3.775V16.875C3.775 17.1511 3.99886 17.375 4.275 17.375C4.55114 17.375 4.775 17.1511 4.775 16.875V14.775C4.775 14.4988 4.55114 14.275 4.275 14.275H1.125ZM13.2252 14.775C13.2252 14.4988 13.4491 14.275 13.7252 14.275H16.8752C17.1514 14.275 17.3752 14.4988 17.3752 14.775V16.875C17.3752 17.1511 17.1514 17.375 16.8752 17.375C16.5991 17.375 16.3752 17.1511 16.3752 16.875V15.275H14.2252V16.875C14.2252 17.1511 14.0014 17.375 13.7252 17.375C13.4491 17.375 13.2252 17.1511 13.2252 16.875V14.775ZM7.42511 14.275C7.14897 14.275 6.92511 14.4988 6.92511 14.775V16.875C6.92511 17.1511 7.14897 17.375 7.42511 17.375C7.70125 17.375 7.92511 17.1511 7.92511 16.875V15.275H10.0751V16.875C10.0751 17.1511 10.299 17.375 10.5751 17.375C10.8513 17.375 11.0751 17.1511 11.0751 16.875V14.775C11.0751 14.4988 10.8513 14.275 10.5751 14.275H7.42511Z"
|
||||||
|
fill="#525252"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_1584_1676">
|
||||||
|
<rect width="18" height="18" fill="white" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
27
frontend/src/components/Icons/CollapseSidebar.vue
Normal file
27
frontend/src/components/Icons/CollapseSidebar.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 18 18"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M10.875 9.06223L3 9.06232"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M6.74537 5.31699L3 9.06236L6.74527 12.8076"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M14.1423 4L14.1423 14.125"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
18
frontend/src/components/Icons/IndicatorIcon.vue
Normal file
18
frontend/src/components/Icons/IndicatorIcon.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="8"
|
||||||
|
cy="8"
|
||||||
|
r="4.5"
|
||||||
|
fill="transparent"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
36
frontend/src/components/Icons/LMSLogo.vue
Normal file
36
frontend/src/components/Icons/LMSLogo.vue
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
width="118"
|
||||||
|
height="118"
|
||||||
|
viewBox="0 0 118 118"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M93.9278 0H23.1013C10.3428 0 0 10.3428 0 23.1013V93.9278C0 106.686 10.3428 117.029 23.1013 117.029H93.9278C106.686 117.029 117.029 106.686 117.029 93.9278V23.1013C117.029 10.3428 106.686 0 93.9278 0Z"
|
||||||
|
fill="url(#paint0_radial_174_336)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M93.9278 0H23.1013C10.3428 0 0 10.3428 0 23.1013V93.9278C0 106.686 10.3428 117.029 23.1013 117.029H93.9278C106.686 117.029 117.029 106.686 117.029 93.9278V23.1013C117.029 10.3428 106.686 0 93.9278 0Z"
|
||||||
|
fill="#0B3D3D"
|
||||||
|
fill-opacity="0.8"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M95.1879 33.1294L91.4077 32.0268C80.1721 28.7716 67.9389 30.9242 58.5409 37.7496C52.083 33.0769 43.9975 30.5042 36.1746 30.5042H21.8938V41.0048H36.2796C42.2649 41.0048 48.1978 42.9999 52.923 46.6226L58.5934 50.9279L64.2637 46.6226C70.144 42.1599 77.5469 40.2698 84.7923 41.2673V76.1818C75.5518 75.2367 66.2063 77.7044 58.6459 83.2172C51.0854 77.7044 41.6349 75.2367 32.4994 76.1818V52.8705H21.9988V86.4724H95.3454V33.1294H95.1879Z"
|
||||||
|
fill="#58FF9B"
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<radialGradient
|
||||||
|
id="paint0_radial_174_336"
|
||||||
|
cx="0"
|
||||||
|
cy="0"
|
||||||
|
r="1"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="translate(117.24 -101.5) rotate(105.042) scale(226.282)"
|
||||||
|
>
|
||||||
|
<stop offset="0.445162" stop-color="#1F7676" />
|
||||||
|
<stop offset="1" stop-color="#0A4B4B" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
81
frontend/src/components/JobCard.vue
Normal file
81
frontend/src/components/JobCard.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex shadow rounded-md p-4 h-full">
|
||||||
|
<img
|
||||||
|
:src="job.company_logo"
|
||||||
|
class="w-12 h-12 rounded-lg object-contain mr-4"
|
||||||
|
:alt="job.company_name"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="text-xl font-semibold mb-2">
|
||||||
|
{{ job.job_title }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ __('posted by') }}
|
||||||
|
<span class="font-medium">
|
||||||
|
{{ job.company_name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center my-4">
|
||||||
|
<Badge :label="job.type" theme="green" size="lg" class="mr-4" />
|
||||||
|
<Badge :label="job.location.split(' ')[0]" theme="gray" size="lg">
|
||||||
|
<template #prefix>
|
||||||
|
<MapPin class="h-4 w-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ __('posted on') }}
|
||||||
|
<span class="font-medium">
|
||||||
|
{{ dayjs(job.creation).format('DD MMM YYYY') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- <div class="flex flex-col shadow rounded-md p-4 h-full">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="text-xl font-semibold mb-2">
|
||||||
|
{{ job.job_title }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ __("posted by") }}
|
||||||
|
<span class="font-medium">
|
||||||
|
{{ job.company_name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
:src="job.company_logo"
|
||||||
|
class="w-12 h-12 rounded-lg object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between mt-8">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Badge :label="job.type" theme="green" size="lg" class="mr-4"/>
|
||||||
|
<Badge :label="job.location" theme="gray" size="lg">
|
||||||
|
<template #prefix>
|
||||||
|
<MapPin class="h-4 w-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-medium">
|
||||||
|
{{ dayjs(job.creation).format('DD MMM YYYY') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div> -->
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { MapPin } from 'lucide-vue-next'
|
||||||
|
import { Badge } from 'frappe-ui'
|
||||||
|
import { inject } from 'vue'
|
||||||
|
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
|
const props = defineProps({
|
||||||
|
job: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
99
frontend/src/components/LessonContent.vue
Normal file
99
frontend/src/components/LessonContent.vue
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="youtube">
|
||||||
|
<iframe
|
||||||
|
class="youtube-video"
|
||||||
|
:src="getYouTubeVideoSource(youtube.split('/').pop())"
|
||||||
|
width="100%"
|
||||||
|
height="400"
|
||||||
|
frameborder="0"
|
||||||
|
allowfullscreen
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
<div v-for="block in content.split('\n\n')">
|
||||||
|
<div v-if="block.includes('{{ YouTubeVideo')">
|
||||||
|
<iframe
|
||||||
|
class="youtube-video"
|
||||||
|
:src="getYouTubeVideoSource(block)"
|
||||||
|
width="100%"
|
||||||
|
height="400"
|
||||||
|
frameborder="0"
|
||||||
|
allowfullscreen
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="block.includes('{{ Quiz')">
|
||||||
|
<Quiz :quiz="getId(block)" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="block.includes('{{ Video')">
|
||||||
|
<video controls width="100%" controlsList="nodownload">
|
||||||
|
<source :src="getId(block)" type="video/mp4" />
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="block.includes('{{ PDF')">
|
||||||
|
<iframe
|
||||||
|
:src="getPDFSource(block)"
|
||||||
|
width="100%"
|
||||||
|
height="400"
|
||||||
|
frameborder="0"
|
||||||
|
allowfullscreen
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="block.includes('{{ Audio')">
|
||||||
|
<audio width="100%" controls controlsList="nodownload">
|
||||||
|
<source :src="getId(block)" type="audio/mp3" />
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="block.includes('{{ Embed')">
|
||||||
|
<iframe
|
||||||
|
width="100%"
|
||||||
|
height="400"
|
||||||
|
:src="getId(block)"
|
||||||
|
frameborder="0"
|
||||||
|
allowfullscreen
|
||||||
|
>
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
<div v-else v-html="markdown.render(block)"></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="quizId">
|
||||||
|
<Quiz :quiz="quizId" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import Quiz from '@/components/QuizBlock.vue'
|
||||||
|
import MarkdownIt from 'markdown-it'
|
||||||
|
|
||||||
|
const markdown = new MarkdownIt({
|
||||||
|
html: true,
|
||||||
|
linkify: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
content: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
youtube: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
quizId: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const getYouTubeVideoSource = (block) => {
|
||||||
|
if (block.includes('{{')) {
|
||||||
|
block = getId(block)
|
||||||
|
}
|
||||||
|
return `https://www.youtube.com/embed/${block}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPDFSource = (block) => {
|
||||||
|
return `${getId(block)}#toolbar=0`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getId = (block) => {
|
||||||
|
return block.match(/\(["']([^"']+?)["']\)/)[1]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
142
frontend/src/components/LessonPlugins.vue
Normal file
142
frontend/src/components/LessonPlugins.vue
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-lg font-semibold">
|
||||||
|
{{ __('Components') }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-5">
|
||||||
|
<Tooltip
|
||||||
|
:text="
|
||||||
|
__(
|
||||||
|
'Content such as quiz, video and image will be added in the editor you select.'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
placement="bottom"
|
||||||
|
>
|
||||||
|
<div class="">
|
||||||
|
<div class="text-xs text-gray-600 mb-1">
|
||||||
|
{{ __('Select an Editor') }}
|
||||||
|
</div>
|
||||||
|
<Select v-model="currentEditor" :options="getEditorOptions()" />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<div class="flex mt-4">
|
||||||
|
<Link
|
||||||
|
v-model="quiz"
|
||||||
|
class="flex-1"
|
||||||
|
doctype="LMS Quiz"
|
||||||
|
:label="__('Select a Quiz')"
|
||||||
|
/>
|
||||||
|
<Button @click="addQuiz()" class="self-end ml-2">
|
||||||
|
<template #icon>
|
||||||
|
<Plus class="h-4 w-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="text-xs text-gray-600 mb-1">
|
||||||
|
{{ __('Add an image, video, pdf or audio.') }}
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<FileUploader
|
||||||
|
v-if="!file"
|
||||||
|
:fileTypes="['image/*', 'video/*', 'audio/*', '.pdf']"
|
||||||
|
:validateFile="validateFile"
|
||||||
|
@success="(data) => addFile(data)"
|
||||||
|
>
|
||||||
|
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||||
|
<div class="">
|
||||||
|
<Button @click="openFileSelector" :loading="uploading">
|
||||||
|
{{
|
||||||
|
uploading
|
||||||
|
? __('Uploading {0}%').format(progress)
|
||||||
|
: __('Upload an File')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FileUploader>
|
||||||
|
<div v-else class="">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="border rounded-md p-2 mr-2">
|
||||||
|
<FileText class="h-4 w-4 stroke-1.5 text-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-xs">
|
||||||
|
{{ file.file_name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
import { FileUploader, Button, Select, Tooltip } from 'frappe-ui'
|
||||||
|
import { Plus, FileText } from 'lucide-vue-next'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const quiz = ref(null)
|
||||||
|
const file = ref(null)
|
||||||
|
const lessonEditor = ref(null)
|
||||||
|
const instructorEditor = ref(null)
|
||||||
|
const currentEditor = ref('Lesson Content')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
editor: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
notesEditor: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const addQuiz = () => {
|
||||||
|
getCurrentEditor().caret.setToLastBlock('end', 0)
|
||||||
|
if (quiz.value) {
|
||||||
|
getCurrentEditor().blocks.insert('quiz', {
|
||||||
|
quiz: quiz.value,
|
||||||
|
})
|
||||||
|
quiz.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addFile = (data) => {
|
||||||
|
getCurrentEditor().caret.setToLastBlock('end', 0)
|
||||||
|
getCurrentEditor().blocks.insert('upload', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateFile = (file) => {
|
||||||
|
let extension = file.name.split('.').pop().toLowerCase()
|
||||||
|
if (!['jpg', 'jpeg', 'png', 'mp4', 'mov', 'mp3'].includes(extension)) {
|
||||||
|
return 'Only image and video files are allowed.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEditorOptions = () => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Lesson Content',
|
||||||
|
value: 'Lesson Content',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Instructor Content',
|
||||||
|
value: 'Instructor Content',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentEditor = () => {
|
||||||
|
return currentEditor.value == 'Lesson Content'
|
||||||
|
? lessonEditor.value
|
||||||
|
: instructorEditor.value
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.editor, props.notesEditor],
|
||||||
|
([newEditor, newNotesEditor], [oldEditor, oldNotesEditor]) => {
|
||||||
|
lessonEditor.value = newEditor
|
||||||
|
instructorEditor.value = newNotesEditor
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
110
frontend/src/components/LiveClass.vue
Normal file
110
frontend/src/components/LiveClass.vue
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<Button
|
||||||
|
v-if="user.data.is_moderator"
|
||||||
|
variant="solid"
|
||||||
|
class="float-right mb-3"
|
||||||
|
@click="openLiveClassModal"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
<span>
|
||||||
|
{{ __('Add Live Class') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<div class="text-lg font-semibold mb-4">
|
||||||
|
{{ __('Live Class') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
|
||||||
|
<div v-for="cls in liveClasses.data">
|
||||||
|
<div class="border rounded-md p-3">
|
||||||
|
<div class="font-semibold text-lg mb-4">
|
||||||
|
{{ cls.title }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||||
|
<span class="ml-2">
|
||||||
|
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mb-5">
|
||||||
|
<Clock class="w-4 h-4 stroke-1.5" />
|
||||||
|
<span class="ml-2">
|
||||||
|
{{ formatTime(cls.time) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-5">
|
||||||
|
{{ cls.description }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
:href="cls.start_url"
|
||||||
|
target="_blank"
|
||||||
|
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
|
||||||
|
>
|
||||||
|
<Monitor class="h-4 w-4 stroke-1.5" />
|
||||||
|
{{ __('Start') }}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
:href="cls.join_url"
|
||||||
|
target="_blank"
|
||||||
|
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
|
||||||
|
>
|
||||||
|
<Video class="h-4 w-4 stroke-1.5" />
|
||||||
|
{{ __('Join') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm italic text-gray-600">
|
||||||
|
{{ __('No live classes scheduled') }}
|
||||||
|
</div>
|
||||||
|
<LiveClassModal
|
||||||
|
:batch="props.batch"
|
||||||
|
v-model="showLiveClassModal"
|
||||||
|
v-model:reloadLiveClasses="liveClasses"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { createListResource, Button } from 'frappe-ui'
|
||||||
|
import { Plus, Clock, Calendar, Video, Monitor } from 'lucide-vue-next'
|
||||||
|
import { inject } from 'vue'
|
||||||
|
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { formatTime } from '@/utils/'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
const showLiveClassModal = ref(false)
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
batch: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const liveClasses = createListResource({
|
||||||
|
doctype: 'LMS Live Class',
|
||||||
|
filters: {
|
||||||
|
batch_name: props.batch,
|
||||||
|
date: ['>=', new Date()],
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'time',
|
||||||
|
'date',
|
||||||
|
'start_url',
|
||||||
|
'join_url',
|
||||||
|
'owner',
|
||||||
|
],
|
||||||
|
orderBy: 'date',
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const openLiveClassModal = () => {
|
||||||
|
showLiveClassModal.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
61
frontend/src/components/MobileLayout.vue
Normal file
61
frontend/src/components/MobileLayout.vue
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<div class="h-full pb-10" id="scrollContainer">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="tabs"
|
||||||
|
class="fixed flex justify-around border-t border-gray-300 bottom-0 z-10 w-full bg-white standalone:pb-4"
|
||||||
|
:style="{
|
||||||
|
gridTemplateColumns: `repeat(${tabs.length}, minmax(0, 1fr))`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.label"
|
||||||
|
:class="isVisible(tab) ? 'block' : 'hidden'"
|
||||||
|
class="flex flex-col items-center justify-center py-3 transition active:scale-95"
|
||||||
|
@click="handleClick(tab)"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="tab.icon"
|
||||||
|
class="h-6 w-6 stroke-1.5"
|
||||||
|
:class="[isActive(tab) ? 'text-gray-900' : 'text-gray-600']"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { getSidebarLinks } from '../utils'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { computed, inject } from 'vue'
|
||||||
|
import { sessionStore } from '@/stores/session'
|
||||||
|
|
||||||
|
const { logout, user } = sessionStore()
|
||||||
|
let { isLoggedIn } = sessionStore()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const tabs = computed(() => {
|
||||||
|
return getSidebarLinks()
|
||||||
|
})
|
||||||
|
|
||||||
|
let isActive = (tab) => {
|
||||||
|
return tab.activeFor?.includes(router.currentRoute.value.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = (tab) => {
|
||||||
|
if (tab.label == 'Log in') window.location.href = '/login'
|
||||||
|
else if (tab.label == 'Log out')
|
||||||
|
logout.submit().then(() => {
|
||||||
|
isLoggedIn = false
|
||||||
|
})
|
||||||
|
else router.push({ name: tab.to })
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVisible = (tab) => {
|
||||||
|
if (tab.label == 'Log in') return !isLoggedIn
|
||||||
|
else if (tab.label == 'Log out') return isLoggedIn
|
||||||
|
else return true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
117
frontend/src/components/Modals/AnnouncementModal.vue
Normal file
117
frontend/src/components/Modals/AnnouncementModal.vue
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title: __('Make an Announcement'),
|
||||||
|
size: 'xl',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Submit',
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: (close) => makeAnnouncement(close),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="">
|
||||||
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
|
{{ __('Subject') }}
|
||||||
|
</div>
|
||||||
|
<Input type="text" v-model="announcement.subject" />
|
||||||
|
</div>
|
||||||
|
<div class="">
|
||||||
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
|
{{ __('Reply To') }}
|
||||||
|
</div>
|
||||||
|
<Input type="text" v-model="announcement.replyTo" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
|
{{ __('Announcement') }}
|
||||||
|
</div>
|
||||||
|
<TextEditor
|
||||||
|
:bubbleMenu="true"
|
||||||
|
@change="(val) => (announcement.announcement = val)"
|
||||||
|
editorClass="prose-sm py-2 px-2 min-h-[200px] border-gray-300 hover:border-gray-400 rounded-md bg-gray-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, Input, TextEditor, createResource } from 'frappe-ui'
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
import { createToast } from '@/utils/'
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
batch: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
students: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const announcement = reactive({
|
||||||
|
subject: '',
|
||||||
|
replyTo: '',
|
||||||
|
announcement: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const announcementResource = createResource({
|
||||||
|
url: 'frappe.core.doctype.communication.email.make',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
recipients: props.students.join(', '),
|
||||||
|
cc: announcement.replyTo,
|
||||||
|
subject: announcement.subject,
|
||||||
|
content: announcement.announcement,
|
||||||
|
doctype: 'LMS Batch',
|
||||||
|
name: props.batch,
|
||||||
|
send_email: 1,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const makeAnnouncement = (close) => {
|
||||||
|
announcementResource.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
validate() {
|
||||||
|
if (!props.students.length) {
|
||||||
|
return 'No students in this batch'
|
||||||
|
}
|
||||||
|
if (!announcement.subject) {
|
||||||
|
return 'Subject is required'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
close()
|
||||||
|
createToast({
|
||||||
|
title: 'Success',
|
||||||
|
text: 'Announcement has been sent successfully',
|
||||||
|
icon: 'Check',
|
||||||
|
iconClasses: 'bg-green-600 text-white rounded-md p-px',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
createToast({
|
||||||
|
title: 'Error',
|
||||||
|
text: err.messages?.[0] || err,
|
||||||
|
icon: 'x',
|
||||||
|
iconClasses: 'bg-red-600 text-white rounded-md p-px',
|
||||||
|
position: 'top-center',
|
||||||
|
timeout: 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
68
frontend/src/components/Modals/BatchCourseModal.vue
Normal file
68
frontend/src/components/Modals/BatchCourseModal.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title: __('Add a course'),
|
||||||
|
size: 'sm',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Submit'),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: (close) => addCourse(close),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<Link doctype="LMS Course" v-model="course" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, createResource } from 'frappe-ui'
|
||||||
|
import { ref, defineModel } from 'vue'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
import { showToast } from '@/utils'
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const course = ref(null)
|
||||||
|
const courses = defineModel('courses')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
batch: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const createBatchCourse = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
doctype: 'Batch Course',
|
||||||
|
parent: props.batch,
|
||||||
|
parenttype: 'LMS Batch',
|
||||||
|
parentfield: 'courses',
|
||||||
|
course: course.value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const addCourse = (close) => {
|
||||||
|
createBatchCourse.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
courses.value.reload()
|
||||||
|
close()
|
||||||
|
course.value = null
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showToast('Error', err.message[0] || err, 'x')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
161
frontend/src/components/Modals/ChapterModal.vue
Normal file
161
frontend/src/components/Modals/ChapterModal.vue
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title: __('Add Chapter'),
|
||||||
|
size: 'lg',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: chapterDetail ? __('Edit Chapter') : __('Add Chapter'),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: (close) =>
|
||||||
|
chapterDetail ? editChapter(close) : addChapter(close),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<FormControl label="Title" v-model="chapter.title" class="mb-4" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, FormControl, createResource } from 'frappe-ui'
|
||||||
|
import { defineModel, reactive, watch, inject } from 'vue'
|
||||||
|
import { createToast, formatTime } from '@/utils/'
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const outline = defineModel('outline')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
course: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
chapterDetail: {
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const chapter = reactive({
|
||||||
|
title: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const chapterResource = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
doctype: 'Course Chapter',
|
||||||
|
title: chapter.title,
|
||||||
|
description: chapter.description,
|
||||||
|
course: props.course,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const chapterEditResource = createResource({
|
||||||
|
url: 'frappe.client.set_value',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'Course Chapter',
|
||||||
|
name: props.chapterDetail?.name,
|
||||||
|
fieldname: 'title',
|
||||||
|
value: chapter.title,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const chapterReference = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
doctype: 'Chapter Reference',
|
||||||
|
chapter: values.name,
|
||||||
|
parent: props.course,
|
||||||
|
parenttype: 'LMS Course',
|
||||||
|
parentfield: 'chapters',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const addChapter = (close) => {
|
||||||
|
chapterResource.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
validate() {
|
||||||
|
if (!chapter.title) {
|
||||||
|
return 'Title is required'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
chapterReference.submit(
|
||||||
|
{ name: data.name },
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
outline.value.reload()
|
||||||
|
createToast({
|
||||||
|
text: 'Chapter added successfully',
|
||||||
|
icon: 'check',
|
||||||
|
iconClasses: 'bg-green-600 text-white rounded-md p-px',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showError(err)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showError(err)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const editChapter = (close) => {
|
||||||
|
chapterEditResource.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
validate() {
|
||||||
|
if (!chapter.title) {
|
||||||
|
return 'Title is required'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
outline.value.reload()
|
||||||
|
createToast({
|
||||||
|
text: 'Chapter updated successfully',
|
||||||
|
icon: 'check',
|
||||||
|
iconClasses: 'bg-green-600 text-white rounded-md p-px',
|
||||||
|
})
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showError(err)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showError = (err) => {
|
||||||
|
createToast({
|
||||||
|
title: 'Error',
|
||||||
|
text: err.messages?.[0] || err,
|
||||||
|
icon: 'x',
|
||||||
|
iconClasses: 'bg-red-600 text-white rounded-md p-px',
|
||||||
|
position: 'top-center',
|
||||||
|
timeout: 10,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.chapterDetail,
|
||||||
|
(newChapter) => {
|
||||||
|
chapter.title = newChapter?.title
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
114
frontend/src/components/Modals/DiscussionModal.vue
Normal file
114
frontend/src/components/Modals/DiscussionModal.vue
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
:options="{
|
||||||
|
title: props.title,
|
||||||
|
size: '2xl',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Submit',
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: (close) => submitTopic(close),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
|
{{ __('Title') }}
|
||||||
|
</div>
|
||||||
|
<Input type="text" v-model="topic.title" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
|
{{ __('Details') }}
|
||||||
|
</div>
|
||||||
|
<TextEditor
|
||||||
|
:content="topic.reply"
|
||||||
|
@change="(val) => (topic.reply = val)"
|
||||||
|
:editable="true"
|
||||||
|
:fixedMenu="true"
|
||||||
|
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, Input, TextEditor, createResource } from 'frappe-ui'
|
||||||
|
import { reactive, defineModel } from 'vue'
|
||||||
|
|
||||||
|
const topics = defineModel('reloadTopics')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
doctype: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
docname: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const topic = reactive({
|
||||||
|
title: '',
|
||||||
|
reply: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const topicResource = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
doctype: 'Discussion Topic',
|
||||||
|
reference_doctype: props.doctype,
|
||||||
|
reference_docname: props.docname,
|
||||||
|
title: topic.title,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const replyResource = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
doctype: 'Discussion Reply',
|
||||||
|
topic: values.topic,
|
||||||
|
reply: topic.reply,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitTopic = (close) => {
|
||||||
|
topicResource.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
replyResource.submit(
|
||||||
|
{
|
||||||
|
topic: data.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
topic.title = ''
|
||||||
|
topic.reply = ''
|
||||||
|
topics.value.reload()
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
188
frontend/src/components/Modals/EvaluationModal.vue
Normal file
188
frontend/src/components/Modals/EvaluationModal.vue
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title: __('Schedule Evaluation'),
|
||||||
|
size: 'xl',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Submit'),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: (close) => submitEvaluation(close),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
|
{{ __('Course') }}
|
||||||
|
</div>
|
||||||
|
<Select v-model="evaluation.course" :options="getCourses()" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
|
{{ __('Date') }}
|
||||||
|
</div>
|
||||||
|
<DatePicker v-model="evaluation.date" />
|
||||||
|
</div>
|
||||||
|
<div v-if="slots.data?.length">
|
||||||
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
|
{{ __('Select a slot') }}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div v-for="slot in slots.data">
|
||||||
|
<div
|
||||||
|
class="text-base text-center border rounded-md bg-gray-200 p-2 cursor-pointer"
|
||||||
|
@click="saveSlot(slot)"
|
||||||
|
:class="{
|
||||||
|
'border-gray-900': evaluation.start_time == slot.start_time,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ formatTime(slot.start_time) }} -
|
||||||
|
{{ formatTime(slot.end_time) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm italic text-red-600">
|
||||||
|
{{ __('No slots available for this date.') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, createResource, Select, DatePicker } from 'frappe-ui'
|
||||||
|
import { defineModel, reactive, watch, inject } from 'vue'
|
||||||
|
import { createToast, formatTime } from '@/utils/'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
|
const show = defineModel()
|
||||||
|
const evaluations = defineModel('reloadEvals')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
courses: {
|
||||||
|
type: Array,
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
batch: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
let evaluation = reactive({
|
||||||
|
course: '',
|
||||||
|
date: '',
|
||||||
|
start_time: '',
|
||||||
|
end_time: '',
|
||||||
|
day: '',
|
||||||
|
batch: props.batch,
|
||||||
|
member: user.data.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createEvaluation = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
doctype: 'LMS Certificate Request',
|
||||||
|
batch_name: values.batch,
|
||||||
|
...values,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function submitEvaluation(close) {
|
||||||
|
createEvaluation.submit(evaluation, {
|
||||||
|
validate() {
|
||||||
|
if (!evaluation.course) {
|
||||||
|
return 'Please select a course.'
|
||||||
|
}
|
||||||
|
if (!evaluation.date) {
|
||||||
|
return 'Please select a date.'
|
||||||
|
}
|
||||||
|
if (!evaluation.start_time) {
|
||||||
|
return 'Please select a slot.'
|
||||||
|
}
|
||||||
|
if (dayjs(evaluation.date).isSameOrBefore(dayjs(), 'day')) {
|
||||||
|
return 'Please select a future date.'
|
||||||
|
}
|
||||||
|
if (dayjs(evaluation.date).isAfter(dayjs(props.endDate), 'day')) {
|
||||||
|
return `Please select a date before the end date ${dayjs(
|
||||||
|
props.endDate
|
||||||
|
).format('DD MMMM YYYY')}.`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
evaluations.value.reload()
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
createToast({
|
||||||
|
title: 'Error',
|
||||||
|
text: err.messages?.[0] || err,
|
||||||
|
icon: 'x',
|
||||||
|
iconClasses: 'bg-red-600 text-white rounded-md p-px',
|
||||||
|
position: 'top-center',
|
||||||
|
timeout: 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCourses = () => {
|
||||||
|
let courses = []
|
||||||
|
for (const course of props.courses) {
|
||||||
|
courses.push({
|
||||||
|
label: course.title,
|
||||||
|
value: course.course,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return courses
|
||||||
|
}
|
||||||
|
|
||||||
|
const slots = createResource({
|
||||||
|
url: 'lms.lms.doctype.course_evaluator.course_evaluator.get_schedule',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
course: values.course,
|
||||||
|
date: values.date,
|
||||||
|
batch: props.batch,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => evaluation.date,
|
||||||
|
(date) => {
|
||||||
|
evaluation.start_time = ''
|
||||||
|
if (date) {
|
||||||
|
slots.submit(evaluation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => evaluation.course,
|
||||||
|
(course) => {
|
||||||
|
evaluation.date = ''
|
||||||
|
evaluation.start_time = ''
|
||||||
|
slots.reset()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const saveSlot = (slot) => {
|
||||||
|
evaluation.start_time = slot.start_time
|
||||||
|
evaluation.end_time = slot.end_time
|
||||||
|
evaluation.day = slot.day
|
||||||
|
}
|
||||||
|
</script>
|
||||||
137
frontend/src/components/Modals/JobApplicationModal.vue
Normal file
137
frontend/src/components/Modals/JobApplicationModal.vue
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
class="text-base"
|
||||||
|
:options="{
|
||||||
|
title: __('Apply for this job'),
|
||||||
|
size: 'lg',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Submit',
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: (close) => {
|
||||||
|
submitResume(close)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<p>
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'Submit your resume to proceed with your application for this position. Upon submission, it will be shared with the job poster.'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<div v-if="!resume">
|
||||||
|
<FileUploader
|
||||||
|
:fileTypes="['.pdf']"
|
||||||
|
:validateFile="validateFile"
|
||||||
|
@success="
|
||||||
|
(file) => {
|
||||||
|
resume = file
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||||
|
<div class="">
|
||||||
|
<Button @click="openFileSelector" :loading="uploading">
|
||||||
|
{{
|
||||||
|
uploading ? `Uploading ${progress}%` : 'Upload your resume'
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FileUploader>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex items-center">
|
||||||
|
<div class="border rounded-md p-2 mr-2">
|
||||||
|
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span>
|
||||||
|
{{ resume.file_name }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-500 mt-1">
|
||||||
|
{{ getFileSize(resume.file_size) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, FileUploader, Button, createResource } from 'frappe-ui'
|
||||||
|
import { FileText } from 'lucide-vue-next'
|
||||||
|
import { ref, inject, defineModel } from 'vue'
|
||||||
|
import { createToast, getFileSize } from '@/utils/'
|
||||||
|
|
||||||
|
const resume = ref(null)
|
||||||
|
const show = defineModel()
|
||||||
|
const user = inject('$user')
|
||||||
|
const application = defineModel('application')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
job: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const validateFile = (file) => {
|
||||||
|
let extension = file.name.split('.').pop().toLowerCase()
|
||||||
|
if (extension != 'pdf') {
|
||||||
|
return 'Only PDF file is allowed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobApplication = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
doctype: 'LMS Job Application',
|
||||||
|
user: user.data?.name,
|
||||||
|
resume: resume.value?.file_name,
|
||||||
|
job: props.job,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitResume = (close) => {
|
||||||
|
jobApplication.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
validate() {
|
||||||
|
if (!resume.value) {
|
||||||
|
return 'Please upload your resume'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
createToast({
|
||||||
|
title: 'Success',
|
||||||
|
text: 'Your application has been submitted',
|
||||||
|
icon: 'check',
|
||||||
|
iconClasses: 'bg-green-600 text-white rounded-md p-px',
|
||||||
|
})
|
||||||
|
application.value.reload()
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
createToast({
|
||||||
|
title: 'Error',
|
||||||
|
text: err.messages?.[0] || err,
|
||||||
|
icon: 'x',
|
||||||
|
iconClasses: 'bg-red-600 text-white rounded-md p-px',
|
||||||
|
position: 'top-center',
|
||||||
|
timeout: 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
226
frontend/src/components/Modals/LiveClassModal.vue
Normal file
226
frontend/src/components/Modals/LiveClassModal.vue
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title: __('Create a Live Class'),
|
||||||
|
size: 'xl',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Submit',
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: (close) => submitLiveClass(close),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
|
{{ __('Title') }}
|
||||||
|
</div>
|
||||||
|
<Input type="text" v-model="liveClass.title" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
|
<Tooltip
|
||||||
|
class="flex items-center"
|
||||||
|
:text="
|
||||||
|
__(
|
||||||
|
'Time must be in 24 hour format (HH:mm). Example 11:30 or 22:00'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{ __('Time') }}
|
||||||
|
</span>
|
||||||
|
<Info class="stroke-2 w-3 h-3 ml-1" />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Input v-model="liveClass.time" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
|
{{ __('Timezone') }}
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
v-model="liveClass.timezone"
|
||||||
|
:options="getTimezoneOptions()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
|
{{ __('Date') }}
|
||||||
|
</div>
|
||||||
|
<DatePicker v-model="liveClass.date" inputClass="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
|
<Tooltip
|
||||||
|
class="flex items-center"
|
||||||
|
:text="__('Duration of the live class in minutes')"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{ __('Duration') }}
|
||||||
|
</span>
|
||||||
|
<Info class="stroke-2 w-3 h-3 ml-1" />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Input type="number" v-model="liveClass.duration" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
|
{{ __('Auto Recording') }}
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
v-model="liveClass.auto_recording"
|
||||||
|
:options="getRecordingOptions()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
|
{{ __('Description') }}
|
||||||
|
</div>
|
||||||
|
<Textarea v-model="liveClass.description" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Input,
|
||||||
|
DatePicker,
|
||||||
|
Select,
|
||||||
|
Textarea,
|
||||||
|
Dialog,
|
||||||
|
createResource,
|
||||||
|
Tooltip,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { reactive, inject } from 'vue'
|
||||||
|
import { getTimezones, createToast } from '@/utils/'
|
||||||
|
import { Info } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const liveClasses = defineModel('reloadLiveClasses')
|
||||||
|
const show = defineModel()
|
||||||
|
const user = inject('$user')
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
batch: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
let liveClass = reactive({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
date: '',
|
||||||
|
time: '',
|
||||||
|
duration: '',
|
||||||
|
timezone: '',
|
||||||
|
auto_recording: 'No Recording',
|
||||||
|
batch: props.batch,
|
||||||
|
host: user.data.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
const getTimezoneOptions = () => {
|
||||||
|
return getTimezones().map((timezone) => {
|
||||||
|
return {
|
||||||
|
label: timezone,
|
||||||
|
value: timezone,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRecordingOptions = () => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'No Recording',
|
||||||
|
value: 'No Recording',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Local',
|
||||||
|
value: 'Local',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Cloud',
|
||||||
|
value: 'Cloud',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const createLiveClass = createResource({
|
||||||
|
url: 'lms.lms.doctype.lms_batch.lms_batch.create_live_class',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'LMS Live Class',
|
||||||
|
batch_name: values.batch,
|
||||||
|
...values,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitLiveClass = (close) => {
|
||||||
|
createLiveClass.submit(liveClass, {
|
||||||
|
validate() {
|
||||||
|
if (!liveClass.title) {
|
||||||
|
return 'Please enter a title.'
|
||||||
|
}
|
||||||
|
if (!liveClass.date) {
|
||||||
|
return 'Please select a date.'
|
||||||
|
}
|
||||||
|
if (dayjs(liveClass.date).isSameOrBefore(dayjs(), 'day')) {
|
||||||
|
return 'Please select a future date.'
|
||||||
|
}
|
||||||
|
if (!liveClass.time) {
|
||||||
|
return 'Please select a time.'
|
||||||
|
}
|
||||||
|
if (!valideTime()) {
|
||||||
|
return 'Please enter a valid time in the format HH:mm.'
|
||||||
|
}
|
||||||
|
if (!liveClass.duration) {
|
||||||
|
return 'Please select a duration.'
|
||||||
|
}
|
||||||
|
if (!liveClass.timezone) {
|
||||||
|
return 'Please select a timezone.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
liveClasses.value.reload()
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
createToast({
|
||||||
|
title: 'Error',
|
||||||
|
text: err.messages?.[0] || err,
|
||||||
|
icon: 'x',
|
||||||
|
iconClasses: 'bg-red-600 text-white rounded-md p-px',
|
||||||
|
position: 'top-center',
|
||||||
|
timeout: 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const valideTime = () => {
|
||||||
|
let time = liveClass.time.split(':')
|
||||||
|
if (time.length != 2) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (time[0] < 0 || time[0] > 23) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (time[1] < 0 || time[1] > 59) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
90
frontend/src/components/Modals/ReviewModal.vue
Normal file
90
frontend/src/components/Modals/ReviewModal.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title: __('Write a Review'),
|
||||||
|
size: 'xl',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Submit',
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: (close) => submitReview(close),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
|
{{ __('Rating') }}
|
||||||
|
</div>
|
||||||
|
<Rating v-model="review.rating" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
|
{{ __('Review') }}
|
||||||
|
</div>
|
||||||
|
<Textarea type="text" size="md" rows="5" v-model="review.review" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, Textarea, createResource } from 'frappe-ui'
|
||||||
|
import { defineModel, reactive } from 'vue'
|
||||||
|
import Rating from '@/components/Controls/Rating.vue'
|
||||||
|
import { createToast } from '@/utils/'
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const reviews = defineModel('reloadReviews')
|
||||||
|
const hasReviewed = defineModel('hasReviewed')
|
||||||
|
|
||||||
|
let review = reactive({
|
||||||
|
review: '',
|
||||||
|
rating: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
courseName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const createReview = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
doctype: 'LMS Course Review',
|
||||||
|
course: props.courseName,
|
||||||
|
...values,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
function submitReview(close) {
|
||||||
|
review.rating = review.rating / 5
|
||||||
|
createReview.submit(review, {
|
||||||
|
validate() {
|
||||||
|
if (!review.rating) {
|
||||||
|
return 'Please enter a rating.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
reviews.value.reload()
|
||||||
|
hasReviewed.value.reload()
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
createToast({
|
||||||
|
text: err.messages?.[0] || err,
|
||||||
|
icon: 'x',
|
||||||
|
iconClasses: 'text-red-600 bg-red-300',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
70
frontend/src/components/Modals/StudentModal.vue
Normal file
70
frontend/src/components/Modals/StudentModal.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title: __('Add a Student'),
|
||||||
|
size: 'sm',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Submit',
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: (close) => addStudent(close),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<Link
|
||||||
|
doctype="User"
|
||||||
|
v-model="student"
|
||||||
|
:filters="{ ignore_user_type: 1 }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, createResource } from 'frappe-ui'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
|
const students = defineModel('reloadStudents')
|
||||||
|
const student = ref()
|
||||||
|
const show = defineModel()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
batch: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const studentResource = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
doctype: 'Batch Student',
|
||||||
|
parent: props.batch,
|
||||||
|
parenttype: 'LMS Batch',
|
||||||
|
parentfield: 'students',
|
||||||
|
student: student.value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const addStudent = (close) => {
|
||||||
|
studentResource.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
students.value.reload()
|
||||||
|
close()
|
||||||
|
student.value = null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
44
frontend/src/components/NotPermitted.vue
Normal file
44
frontend/src/components/NotPermitted.vue
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-base border rounded-md w-1/3 mx-auto my-32">
|
||||||
|
<div class="border-b px-5 py-3 font-medium">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center before:bg-red-600 before:w-2 before:h-2 before:rounded-md before:mr-2"
|
||||||
|
></span>
|
||||||
|
{{ __(title) }}
|
||||||
|
</div>
|
||||||
|
<div class="px-5 py-3">
|
||||||
|
<div class="mb-4 leading-6">
|
||||||
|
{{ __(text) }}
|
||||||
|
</div>
|
||||||
|
<Button variant="solid" class="w-full" @click="redirect()">
|
||||||
|
{{ __(buttonLabel) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Button } from 'frappe-ui'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: 'Not Permitted',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
default: 'You are not permitted to access this page.',
|
||||||
|
},
|
||||||
|
buttonLabel: {
|
||||||
|
type: String,
|
||||||
|
default: 'Login',
|
||||||
|
},
|
||||||
|
buttonLink: {
|
||||||
|
type: String,
|
||||||
|
default: '/login',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const redirect = () => {
|
||||||
|
window.location.href = props.buttonLink
|
||||||
|
}
|
||||||
|
</script>
|
||||||
454
frontend/src/components/Quiz.vue
Normal file
454
frontend/src/components/Quiz.vue
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="quiz.data">
|
||||||
|
<div class="bg-blue-100 py-2 px-2 mb-4 rounded-md text-sm text-blue-800">
|
||||||
|
<div class="leading-relaxed">
|
||||||
|
{{
|
||||||
|
__('This quiz consists of {0} questions.').format(
|
||||||
|
quiz.data.questions.length
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div v-if="quiz.data.passing_percentage" class="leading-relaxed">
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'You will have to get {0}% correct answers in order to pass the quiz.'
|
||||||
|
).format(quiz.data.passing_percentage)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div v-if="quiz.data.max_attempts" class="leading-relaxed">
|
||||||
|
{{
|
||||||
|
__('You can attempt this quiz {0}.').format(
|
||||||
|
quiz.data.max_attempts == 1
|
||||||
|
? '1 time'
|
||||||
|
: `${quiz.data.max_attempts} times`
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div v-if="quiz.data.time" class="leading-relaxed">
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'The quiz has a time limit. For each question you will be given {0} seconds.'
|
||||||
|
).format(quiz.data.time)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="activeQuestion == 0">
|
||||||
|
<div class="border text-center p-20 rounded-md">
|
||||||
|
<div class="font-semibold text-lg">
|
||||||
|
{{ quiz.data.title }}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="
|
||||||
|
!quiz.data.max_attempts ||
|
||||||
|
attempts.data?.length < quiz.data.max_attempts
|
||||||
|
"
|
||||||
|
@click="startQuiz"
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{ __('Start') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<div v-else>
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'You have already exceeded the maximum number of attempts allowed for this quiz.'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!quizSubmission.data">
|
||||||
|
<div v-for="(question, qtidx) in quiz.data.questions">
|
||||||
|
<div
|
||||||
|
v-if="qtidx == activeQuestion - 1 && questionDetails.data"
|
||||||
|
class="border rounded-md p-5"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="mr-2">
|
||||||
|
{{ __('Question {0}').format(activeQuestion) }}:
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{{
|
||||||
|
questionDetails.data.multiple
|
||||||
|
? __('Choose all answers that apply')
|
||||||
|
: __('Choose one answer')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-900 text-sm font-semibold item-left">
|
||||||
|
{{ question.marks }}
|
||||||
|
{{ question.marks == 1 ? __('Mark') : __('Marks') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-900 font-semibold mt-2">
|
||||||
|
{{ questionDetails.data.question }}
|
||||||
|
</div>
|
||||||
|
<div v-if="questionDetails.data.type == 'Choices'" v-for="index in 4">
|
||||||
|
<label
|
||||||
|
v-if="questionDetails.data[`option_${index}`]"
|
||||||
|
class="flex items-center bg-gray-200 rounded-md p-3 mt-4 w-full cursor-pointer focus:border-blue-600"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-if="!showAnswers.length && !questionDetails.data.multiple"
|
||||||
|
type="radio"
|
||||||
|
:name="encodeURIComponent(questionDetails.data.question)"
|
||||||
|
class="w-3.5 h-3.5 text-gray-900 focus:ring-gray-200"
|
||||||
|
@change="markAnswer(index)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-else-if="!showAnswers.length && questionDetails.data.multiple"
|
||||||
|
type="checkbox"
|
||||||
|
:name="encodeURIComponent(questionDetails.data.question)"
|
||||||
|
class="w-3.5 h-3.5 text-gray-900 rounded-sm focus:ring-gray-200"
|
||||||
|
@change="markAnswer(index)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="quiz.data.show_answers"
|
||||||
|
v-for="(answer, idx) in showAnswers"
|
||||||
|
>
|
||||||
|
<div v-if="index - 1 == idx">
|
||||||
|
<CheckCircle v-if="answer" class="w-4 h-4 text-green-500" />
|
||||||
|
<MinusCircle
|
||||||
|
v-else-if="questionDetails.data[`is_correct_${index}`]"
|
||||||
|
class="w-4 h-4 text-green-500"
|
||||||
|
/>
|
||||||
|
<XCircle
|
||||||
|
v-else-if="answer == 0"
|
||||||
|
class="w-4 h-4 text-red-500"
|
||||||
|
/>
|
||||||
|
<MinusCircle v-else class="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="ml-2">
|
||||||
|
{{ questionDetails.data[`option_${index}`] }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
v-if="questionDetails.data[`explanation_${index}`]"
|
||||||
|
class="mt-2 text-sm hidden"
|
||||||
|
>
|
||||||
|
{{ questionDetails.data[`explanation_${index}`] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between mt-8">
|
||||||
|
<div>
|
||||||
|
{{
|
||||||
|
__('Question {0} of {1}').format(
|
||||||
|
activeQuestion,
|
||||||
|
quiz.data.questions.length
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="quiz.data.show_answers && !showAnswers.length"
|
||||||
|
@click="checkAnswer()"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{ __('Check') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-else-if="activeQuestion != quiz.data.questions.length"
|
||||||
|
@click="nextQuetion()"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{ __('Next') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Button v-else @click="submitQuiz()">
|
||||||
|
<span>
|
||||||
|
{{ __('Submit') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="border rounded-md p-20 text-center">
|
||||||
|
<div class="text-lg font-semibold">
|
||||||
|
{{ __('Quiz Summary') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'You got {0}% correct answers with a score of {1} out of {2}'
|
||||||
|
).format(
|
||||||
|
Math.ceil(quizSubmission.data.percentage),
|
||||||
|
quizSubmission.data.score,
|
||||||
|
quizSubmission.data.score_out_of
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
@click="resetQuiz()"
|
||||||
|
class="mt-2"
|
||||||
|
v-if="
|
||||||
|
!quiz.data.max_attempts ||
|
||||||
|
attempts?.data.length < quiz.data.max_attempts
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{ __('Try Again') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="quiz.data.show_submission_history && attempts?.data"
|
||||||
|
class="mt-10"
|
||||||
|
>
|
||||||
|
<ListView
|
||||||
|
:columns="getSubmissionColumns()"
|
||||||
|
:rows="attempts?.data"
|
||||||
|
row-key="name"
|
||||||
|
:options="{ selectable: false, showTooltip: false }"
|
||||||
|
>
|
||||||
|
</ListView>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
createDocumentResource,
|
||||||
|
Button,
|
||||||
|
createResource,
|
||||||
|
ListView,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { ref, watch, reactive, inject } from 'vue'
|
||||||
|
import { createToast } from '@/utils/'
|
||||||
|
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
|
||||||
|
import { timeAgo } from '@/utils'
|
||||||
|
const user = inject('$user')
|
||||||
|
|
||||||
|
const activeQuestion = ref(0)
|
||||||
|
const currentQuestion = ref('')
|
||||||
|
const selectedOptions = reactive([0, 0, 0, 0])
|
||||||
|
const showAnswers = reactive([])
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
quizName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const quiz = createResource({
|
||||||
|
url: 'frappe.client.get',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'LMS Quiz',
|
||||||
|
name: props.quizName,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cache: ['quiz', props.quizName],
|
||||||
|
auto: true,
|
||||||
|
onSuccess(data) {
|
||||||
|
attempts.reload()
|
||||||
|
resetQuiz()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const attempts = createResource({
|
||||||
|
url: 'frappe.client.get_list',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'LMS Quiz Submission',
|
||||||
|
filters: {
|
||||||
|
member: user.data?.name,
|
||||||
|
quiz: quiz.data?.name,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
'name',
|
||||||
|
'creation',
|
||||||
|
'score',
|
||||||
|
'score_out_of',
|
||||||
|
'percentage',
|
||||||
|
'passing_percentage',
|
||||||
|
],
|
||||||
|
order_by: 'creation desc',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
transform(data) {
|
||||||
|
data.forEach((submission, index) => {
|
||||||
|
submission.creation = timeAgo(submission.creation)
|
||||||
|
submission.idx = index + 1
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const quizSubmission = createResource({
|
||||||
|
url: 'lms.lms.doctype.lms_quiz.lms_quiz.quiz_summary',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
quiz: quiz.data.name,
|
||||||
|
results: localStorage.getItem(quiz.data.title),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const questionDetails = createResource({
|
||||||
|
url: 'lms.lms.utils.get_question_details',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
question: currentQuestion.value,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(activeQuestion, (value) => {
|
||||||
|
if (value > 0) {
|
||||||
|
currentQuestion.value = quiz.data.questions[value - 1].question
|
||||||
|
questionDetails.reload()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.quizName,
|
||||||
|
(newName) => {
|
||||||
|
console.log(newName)
|
||||||
|
if (newName) {
|
||||||
|
quiz.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const startQuiz = () => {
|
||||||
|
activeQuestion.value = 1
|
||||||
|
localStorage.removeItem(quiz.data.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
const markAnswer = (index) => {
|
||||||
|
if (!questionDetails.data.multiple)
|
||||||
|
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
|
||||||
|
selectedOptions[index - 1] = selectedOptions[index - 1] ? 0 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAnswers = () => {
|
||||||
|
let answers = []
|
||||||
|
selectedOptions.forEach((value, index) => {
|
||||||
|
if (selectedOptions[index])
|
||||||
|
answers.push(questionDetails.data[`option_${index + 1}`])
|
||||||
|
})
|
||||||
|
return answers
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkAnswer = () => {
|
||||||
|
let answers = getAnswers()
|
||||||
|
if (!answers.length) {
|
||||||
|
createToast({
|
||||||
|
title: 'Please select an option',
|
||||||
|
icon: 'alert-circle',
|
||||||
|
iconClasses: 'text-yellow-600 bg-yellow-100',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
createResource({
|
||||||
|
url: 'lms.lms.doctype.lms_quiz.lms_quiz.check_answer',
|
||||||
|
params: {
|
||||||
|
question: currentQuestion.value,
|
||||||
|
type: questionDetails.data.type,
|
||||||
|
answers: JSON.stringify(answers),
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
onSuccess(data) {
|
||||||
|
selectedOptions.forEach((option, index) => {
|
||||||
|
if (option) {
|
||||||
|
showAnswers[index] = option && data[index]
|
||||||
|
} else if (questionDetails.data[`is_correct_${index + 1}`]) {
|
||||||
|
showAnswers[index] = 0
|
||||||
|
} else {
|
||||||
|
showAnswers[index] = undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addToLocalStorage()
|
||||||
|
if (!quiz.data.show_answers) {
|
||||||
|
resetQuestion()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addToLocalStorage = () => {
|
||||||
|
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
|
||||||
|
let questionData = {
|
||||||
|
question_index: activeQuestion.value,
|
||||||
|
answers: getAnswers().join(),
|
||||||
|
is_correct: showAnswers.filter((answer) => {
|
||||||
|
return answer != undefined
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
quizData ? quizData.push(questionData) : (quizData = [questionData])
|
||||||
|
localStorage.setItem(quiz.data.title, JSON.stringify(quizData))
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextQuetion = () => {
|
||||||
|
if (!quiz.data.show_answers) {
|
||||||
|
checkAnswer()
|
||||||
|
} else {
|
||||||
|
resetQuestion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetQuestion = () => {
|
||||||
|
if (activeQuestion.value == quiz.data.questions.length) return
|
||||||
|
activeQuestion.value = activeQuestion.value + 1
|
||||||
|
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
|
||||||
|
showAnswers.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitQuiz = () => {
|
||||||
|
if (!quiz.data.show_answers) {
|
||||||
|
checkAnswer()
|
||||||
|
setTimeout(() => {
|
||||||
|
createSubmission()
|
||||||
|
}, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
createSubmission()
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSubmission = () => {
|
||||||
|
quizSubmission.reload().then(() => {
|
||||||
|
attempts.reload()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetQuiz = () => {
|
||||||
|
activeQuestion.value = 0
|
||||||
|
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
|
||||||
|
showAnswers.length = 0
|
||||||
|
quizSubmission.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSubmissionColumns = () => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'No.',
|
||||||
|
key: 'idx',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Date',
|
||||||
|
key: 'creation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Score',
|
||||||
|
key: 'score',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Score out of',
|
||||||
|
key: 'score_out_of',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Percentage',
|
||||||
|
key: 'percentage',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
26
frontend/src/components/QuizBlock.vue
Normal file
26
frontend/src/components/QuizBlock.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<Quiz v-if="user.data" :quizName="quiz"></Quiz>
|
||||||
|
<div v-else class="border rounded-md text-center py-20">
|
||||||
|
<div>
|
||||||
|
{{ __('Please login to access the quiz.') }}
|
||||||
|
</div>
|
||||||
|
<Button @click="redirectToLogin()" class="mt-2">
|
||||||
|
<span>
|
||||||
|
{{ __('Login') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { inject } from 'vue'
|
||||||
|
import { Button } from 'frappe-ui'
|
||||||
|
import Quiz from '@/components/Quiz.vue'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
const props = defineProps({
|
||||||
|
quiz: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
61
frontend/src/components/SidebarLink.vue
Normal file
61
frontend/src/components/SidebarLink.vue
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<button
|
||||||
|
v-if="link && !link.onlyMobile"
|
||||||
|
class="flex h-7 cursor-pointer items-center rounded text-gray-800 duration-300 ease-in-out focus:outline-none focus:transition-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-gray-400"
|
||||||
|
:class="isActive ? 'bg-white shadow-sm' : 'hover:bg-gray-100'"
|
||||||
|
@click="handleClick"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center duration-300 ease-in-out"
|
||||||
|
:class="isCollapsed ? 'p-1' : 'px-2 py-1'"
|
||||||
|
>
|
||||||
|
<Tooltip :text="link.label" placement="right">
|
||||||
|
<slot name="icon">
|
||||||
|
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
||||||
|
<component
|
||||||
|
:is="link.icon"
|
||||||
|
class="h-5 w-5 stroke-1.5 text-gray-800"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</slot>
|
||||||
|
</Tooltip>
|
||||||
|
<span
|
||||||
|
class="flex-shrink-0 text-base duration-300 ease-in-out"
|
||||||
|
:class="
|
||||||
|
isCollapsed
|
||||||
|
? 'ml-0 w-0 overflow-hidden opacity-0'
|
||||||
|
: 'ml-2 w-auto opacity-100'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ link.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Tooltip } from 'frappe-ui'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
link: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isCollapsed: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
router.push({ name: props.link.to })
|
||||||
|
}
|
||||||
|
|
||||||
|
let isActive = computed(() => {
|
||||||
|
return props.link?.activeFor?.includes(router.currentRoute.value.name)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
59
frontend/src/components/Tags.vue
Normal file
59
frontend/src/components/Tags.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="mb-1.5 text-sm text-gray-700">
|
||||||
|
{{ __(label) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
{{ tags }}
|
||||||
|
<div
|
||||||
|
v-for="tag in tags?.split(', ')"
|
||||||
|
class="flex items-center bg-gray-100 p-2 rounded-md mr-2"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
<X
|
||||||
|
class="stroke-1.5 w-3 h-3 ml-2 cursor-pointer"
|
||||||
|
@click="removeTag(tag)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormControl v-model="newTag" @keyup.enter="updateTags()" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { FormControl } from 'frappe-ui'
|
||||||
|
import { X } from 'lucide-vue-next'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: 'Tags',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log(props.modelValue)
|
||||||
|
let tags = ref(props.modelValue)
|
||||||
|
console.log(tags.value)
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
let newTag = ref('')
|
||||||
|
|
||||||
|
let emitChange = (value) => {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTags = () => {
|
||||||
|
if (newTag) {
|
||||||
|
tags.value = tags.value ? `${tags.value}, ${newTag}` : newTag
|
||||||
|
newTag.value = ''
|
||||||
|
emitChange(tags.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeTag = (tag) => {
|
||||||
|
tags.value = tags.value.replace(tag, '').replace(', ,', ',')
|
||||||
|
emitChange(tags.value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
93
frontend/src/components/UpcomingEvaluations.vue
Normal file
93
frontend/src/components/UpcomingEvaluations.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mb-10">
|
||||||
|
<Button v-if="isStudent" @click="openEvalModal" class="float-right">
|
||||||
|
{{ __('Schedule Evaluation') }}
|
||||||
|
</Button>
|
||||||
|
<div class="text-lg font-semibold mb-4">
|
||||||
|
{{ __('Upcoming Evaluations') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="upcoming_evals.data?.length">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div v-for="evl in upcoming_evals.data">
|
||||||
|
<div class="border rounded-md p-3">
|
||||||
|
<div class="font-semibold mb-3">
|
||||||
|
{{ evl.course_title }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||||
|
<span class="ml-2">
|
||||||
|
{{ dayjs(evl.date).format('DD MMMM YYYY') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<Clock class="w-4 h-4 stroke-1.5" />
|
||||||
|
<span class="ml-2">
|
||||||
|
{{ formatTime(evl.start_time) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<UserCog2 class="w-4 h-4 stroke-1.5" />
|
||||||
|
<span class="ml-2 font-medium">
|
||||||
|
{{ evl.evaluator_name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm italic text-gray-600">
|
||||||
|
{{ __('No upcoming evaluations.') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<EvaluationModal
|
||||||
|
:batch="batch"
|
||||||
|
:endDate="endDate"
|
||||||
|
:courses="courses"
|
||||||
|
v-model="showEvalModal"
|
||||||
|
v-model:reloadEvals="upcoming_evals"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Calendar, Clock, UserCog2 } from 'lucide-vue-next'
|
||||||
|
import { inject, ref } from 'vue'
|
||||||
|
import { formatTime } from '../utils'
|
||||||
|
import { Button, createResource } from 'frappe-ui'
|
||||||
|
import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
|
||||||
|
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
|
const user = inject('$user')
|
||||||
|
const showEvalModal = ref(false)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
batch: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
courses: {
|
||||||
|
type: Array,
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
isStudent: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const upcoming_evals = createResource({
|
||||||
|
url: 'lms.lms.utils.get_upcoming_evals',
|
||||||
|
cache: ['upcoming_evals', user.data.name],
|
||||||
|
params: {
|
||||||
|
student: user.data.name,
|
||||||
|
courses: props.courses.map((course) => course.course),
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
function openEvalModal() {
|
||||||
|
showEvalModal.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
22
frontend/src/components/UserAvatar.vue
Normal file
22
frontend/src/components/UserAvatar.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<template>
|
||||||
|
<Avatar
|
||||||
|
class="avatar border border-gray-300"
|
||||||
|
v-if="user"
|
||||||
|
:label="user.full_name"
|
||||||
|
:image="user.user_image"
|
||||||
|
:size="size"
|
||||||
|
v-bind="$attrs"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Avatar } from 'frappe-ui'
|
||||||
|
const props = defineProps({
|
||||||
|
user: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
115
frontend/src/components/UserDropdown.vue
Normal file
115
frontend/src/components/UserDropdown.vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<Dropdown :options="userDropdownOptions">
|
||||||
|
<template v-slot="{ open }">
|
||||||
|
<button
|
||||||
|
class="flex h-12 py-2 items-center rounded-md duration-300 ease-in-out"
|
||||||
|
:class="
|
||||||
|
isCollapsed
|
||||||
|
? 'px-0 w-auto'
|
||||||
|
: open
|
||||||
|
? 'bg-white shadow-sm px-2 w-52'
|
||||||
|
: 'hover:bg-gray-200 px-2 w-52'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="branding.data?.brand_html"
|
||||||
|
v-html="branding.data?.brand_html"
|
||||||
|
class="w-8 h-8 rounded flex-shrink-0"
|
||||||
|
></span>
|
||||||
|
<LMSLogo v-else class="w-8 h-8 rounded flex-shrink-0" />
|
||||||
|
<div
|
||||||
|
class="flex flex-1 flex-col text-left duration-300 ease-in-out"
|
||||||
|
:class="
|
||||||
|
isCollapsed
|
||||||
|
? 'opacity-0 ml-0 w-0 overflow-hidden'
|
||||||
|
: 'opacity-100 ml-2 w-auto'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="text-base font-medium text-gray-900 leading-none">
|
||||||
|
<span v-if="branding.data?.brand_name">
|
||||||
|
{{ branding.data?.brand_name }}
|
||||||
|
</span>
|
||||||
|
<span v-else> Learning </span>
|
||||||
|
</div>
|
||||||
|
<div v-if="user" class="mt-1 text-sm text-gray-700 leading-none">
|
||||||
|
{{ convertToTitleCase(user.split('@')[0]) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="duration-300 ease-in-out"
|
||||||
|
:class="
|
||||||
|
isCollapsed
|
||||||
|
? 'opacity-0 ml-0 w-0 overflow-hidden'
|
||||||
|
: 'opacity-100 ml-2 w-auto'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<ChevronDown class="h-4 w-4 text-gray-700" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
||||||
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import { Dropdown, createResource } from 'frappe-ui'
|
||||||
|
import { ChevronDown, LogIn, LogOut, User } from 'lucide-vue-next'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { convertToTitleCase } from '../utils'
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const props = defineProps({
|
||||||
|
isCollapsed: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const branding = createResource({
|
||||||
|
url: 'lms.lms.api.get_branding',
|
||||||
|
cache: true,
|
||||||
|
auto: true,
|
||||||
|
onSuccess(data) {
|
||||||
|
document.querySelector("link[rel='icon']").href = data.favicon
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { logout, user } = sessionStore()
|
||||||
|
let { isLoggedIn } = sessionStore()
|
||||||
|
const userDropdownOptions = [
|
||||||
|
/* {
|
||||||
|
icon: User,
|
||||||
|
label: 'My Profile',
|
||||||
|
onClick: () => {
|
||||||
|
router.push(`/user/${user.data?.username}`)
|
||||||
|
},
|
||||||
|
condition: () => {
|
||||||
|
return isLoggedIn
|
||||||
|
},
|
||||||
|
}, */
|
||||||
|
{
|
||||||
|
icon: LogOut,
|
||||||
|
label: 'Log out',
|
||||||
|
onClick: () => {
|
||||||
|
logout.submit().then(() => {
|
||||||
|
isLoggedIn = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
condition: () => {
|
||||||
|
return isLoggedIn
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: LogIn,
|
||||||
|
label: 'Log in',
|
||||||
|
onClick: () => {
|
||||||
|
window.location.href = '/login'
|
||||||
|
},
|
||||||
|
condition: () => {
|
||||||
|
return !isLoggedIn
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
</script>
|
||||||
2
frontend/src/index.css
Normal file
2
frontend/src/index.css
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
@import './assets/Inter/inter.css';
|
||||||
|
@import 'frappe-ui/src/style.css';
|
||||||
37
frontend/src/main.js
Normal file
37
frontend/src/main.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import './index.css'
|
||||||
|
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import router from './router'
|
||||||
|
import App from './App.vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import dayjs from '@/utils/dayjs'
|
||||||
|
import translationPlugin from './translation'
|
||||||
|
import { usersStore } from './stores/user'
|
||||||
|
import { sessionStore } from './stores/session'
|
||||||
|
import { initSocket } from './socket'
|
||||||
|
import {
|
||||||
|
FrappeUI,
|
||||||
|
setConfig,
|
||||||
|
frappeRequest,
|
||||||
|
resourcesPlugin,
|
||||||
|
pageMetaPlugin,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
|
||||||
|
let pinia = createPinia()
|
||||||
|
let app = createApp(App)
|
||||||
|
setConfig('resourceFetcher', frappeRequest)
|
||||||
|
|
||||||
|
app.use(FrappeUI)
|
||||||
|
app.use(pinia)
|
||||||
|
app.use(router)
|
||||||
|
app.use(translationPlugin)
|
||||||
|
app.use(pageMetaPlugin)
|
||||||
|
app.provide('$dayjs', dayjs)
|
||||||
|
app.provide('$socket', initSocket())
|
||||||
|
app.mount('#app')
|
||||||
|
|
||||||
|
const { userResource } = usersStore()
|
||||||
|
let { isLoggedIn } = sessionStore()
|
||||||
|
|
||||||
|
app.provide('$user', userResource)
|
||||||
|
app.config.globalProperties.$user = userResource
|
||||||
328
frontend/src/pages/AssignmentSubmission.vue
Normal file
328
frontend/src/pages/AssignmentSubmission.vue
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class="flex justify-between sticky top-0 z-10 border-b bg-white px-3 py-2.5 sm:px-5"
|
||||||
|
>
|
||||||
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
|
<Button variant="solid" @click="submitAssignment()">
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<div class="container py-5">
|
||||||
|
<div
|
||||||
|
v-if="submissionResource.data"
|
||||||
|
class="bg-blue-100 p-2 rounded-md leading-5 text-sm italic"
|
||||||
|
>
|
||||||
|
{{ __("You've successfully submitted the assignment.") }}
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
"Once the moderator grades your submission, you'll find the details here."
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{{ __('Feel free to make edits to your submission if needed.') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="assignment.data">
|
||||||
|
<div>
|
||||||
|
<div class="text-xl font-semibold hidden">
|
||||||
|
{{ __('Question') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm mt-1 hidden">
|
||||||
|
{{
|
||||||
|
__('Read the question carefully before attempting the assignment.')
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-html="assignment.data.question"
|
||||||
|
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="">
|
||||||
|
<div class="text-xl font-semibold mt-10">
|
||||||
|
{{ __('Submission') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="showUploader()">
|
||||||
|
<div class="text-sm mt-1 mb-4">
|
||||||
|
{{ __('Add your assignment as {0}').format(assignment.data.type) }}
|
||||||
|
</div>
|
||||||
|
<FileUploader
|
||||||
|
v-if="!submissionFile"
|
||||||
|
:fileTypes="getType()"
|
||||||
|
:validateFile="validateFile"
|
||||||
|
@success="(file) => saveSubmission(file)"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
#default="{
|
||||||
|
file,
|
||||||
|
uploading,
|
||||||
|
progress,
|
||||||
|
uploaded,
|
||||||
|
message,
|
||||||
|
error,
|
||||||
|
total,
|
||||||
|
success,
|
||||||
|
openFileSelector,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button @click="openFileSelector" :loading="uploading">
|
||||||
|
{{
|
||||||
|
uploading
|
||||||
|
? __('Uploading {0}%').format(progress)
|
||||||
|
: __('Upload File')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</FileUploader>
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="border rounded-md p-2 mr-2">
|
||||||
|
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span>
|
||||||
|
{{ submissionFile.file_name }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-500 mt-1">
|
||||||
|
{{ getFileSize(submissionFile.file_size) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<X
|
||||||
|
@click="removeSubmission()"
|
||||||
|
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="assignment.data.type == 'URL'">
|
||||||
|
<div class="text-sm mb-4">
|
||||||
|
{{ __('Enter a URL') }}
|
||||||
|
</div>
|
||||||
|
<FormControl v-model="answer" />
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="text-sm mb-4">
|
||||||
|
{{ __('Write your answer here') }}
|
||||||
|
</div>
|
||||||
|
<TextEditor
|
||||||
|
:content="answer"
|
||||||
|
@change="(val) => (answer = val)"
|
||||||
|
:editable="true"
|
||||||
|
:fixedMenu="true"
|
||||||
|
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Breadcrumbs,
|
||||||
|
createResource,
|
||||||
|
FileUploader,
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
TextEditor,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { FileText, X } from 'lucide-vue-next'
|
||||||
|
import { computed, inject, onMounted, ref } from 'vue'
|
||||||
|
import { showToast, getFileSize } from '../utils'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
const submissionFile = ref(null)
|
||||||
|
const answer = ref(null)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
assignmentName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
submissionName: {
|
||||||
|
type: String,
|
||||||
|
default: 'new',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const assignment = createResource({
|
||||||
|
url: 'frappe.client.get',
|
||||||
|
params: {
|
||||||
|
doctype: 'LMS Assignment',
|
||||||
|
name: props.assignmentName,
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const showUploader = () => {
|
||||||
|
return ['PDF', 'Image', 'Document'].includes(assignment.data?.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSubmission = createResource({
|
||||||
|
url: 'frappe.client.set_value',
|
||||||
|
makeParams(values) {
|
||||||
|
let fieldname = {}
|
||||||
|
if (showUploader()) {
|
||||||
|
fieldname.assignment_attachment = submissionFile.value.file_url
|
||||||
|
} else {
|
||||||
|
fieldname.answer = answer.value
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
doctype: 'LMS Assignment Submission',
|
||||||
|
name: props.submissionName,
|
||||||
|
fieldname: fieldname,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const imageResource = createResource({
|
||||||
|
url: 'lms.lms.api.get_file_info',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
file_url: values.image,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: false,
|
||||||
|
onSuccess(data) {
|
||||||
|
submissionFile.value = data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const newSubmission = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
makeParams(values) {
|
||||||
|
let doc = {
|
||||||
|
doctype: 'LMS Assignment Submission',
|
||||||
|
assignment: props.assignmentName,
|
||||||
|
member: user.data?.name,
|
||||||
|
}
|
||||||
|
if (showUploader()) {
|
||||||
|
doc.assignment_attachment = submissionFile.value.file_url
|
||||||
|
} else {
|
||||||
|
doc.answer = answer.value
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
doc: doc,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const submissionResource = createResource({
|
||||||
|
url: 'frappe.client.get_value',
|
||||||
|
params: {
|
||||||
|
doctype: 'LMS Assignment Submission',
|
||||||
|
fieldname: showUploader() ? 'assignment_attachment' : 'answer',
|
||||||
|
filters: {
|
||||||
|
name: props.submissionName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onSuccess(data) {
|
||||||
|
if (data.assignment_attachment)
|
||||||
|
imageResource.reload({ image: data.assignment_attachment })
|
||||||
|
if (data.answer) answer.value = data.answer
|
||||||
|
},
|
||||||
|
})
|
||||||
|
onMounted(() => {
|
||||||
|
if (!user.data) {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
if (props.submissionName != 'new') {
|
||||||
|
submissionResource.reload()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitAssignment = () => {
|
||||||
|
if (props.submissionName != 'new') {
|
||||||
|
updateSubmission.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
showToast('Success', 'Submission updated successfully.', 'check')
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showToast('Error', err.messages?.[0] || err, 'x')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
addNewSubmission()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addNewSubmission = () => {
|
||||||
|
newSubmission.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
showToast('Success', 'Assignment submitted successfully.', 'check')
|
||||||
|
router.push({
|
||||||
|
name: 'AssignmentSubmission',
|
||||||
|
params: {
|
||||||
|
assignmentName: props.assignmentName,
|
||||||
|
submissionName: data.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showToast('Error', err.messages?.[0] || err, 'x')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
let crumbs = [
|
||||||
|
{
|
||||||
|
label: 'Assignment',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: assignment.data?.title,
|
||||||
|
route: {
|
||||||
|
name: 'AssignmentSubmission',
|
||||||
|
params: {
|
||||||
|
assignmentName: assignment.data?.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return crumbs
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveSubmission = (file) => {
|
||||||
|
submissionFile.value = file
|
||||||
|
}
|
||||||
|
|
||||||
|
const getType = () => {
|
||||||
|
const type = assignment.data?.type
|
||||||
|
if (type == 'Image') {
|
||||||
|
return ['image/*']
|
||||||
|
} else if (type == 'Document') {
|
||||||
|
return [
|
||||||
|
'.doc',
|
||||||
|
'.docx',
|
||||||
|
'.xml',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
]
|
||||||
|
} else if (type == 'PDF') {
|
||||||
|
return ['.pdf']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateFile = (file) => {
|
||||||
|
let type = assignment.data?.type
|
||||||
|
let extension = file.name.split('.').pop().toLowerCase()
|
||||||
|
if (type == 'Image' && !['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||||
|
return 'Only image file is allowed.'
|
||||||
|
} else if (
|
||||||
|
type == 'Document' &&
|
||||||
|
!['doc', 'docx', 'xml'].includes(extension)
|
||||||
|
) {
|
||||||
|
return 'Only document file is allowed.'
|
||||||
|
} else if (type == 'PDF' && !['pdf'].includes(extension)) {
|
||||||
|
return 'Only PDF file is allowed.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeSubmission = () => {
|
||||||
|
submissionFile.value = null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
267
frontend/src/pages/Batch.vue
Normal file
267
frontend/src/pages/Batch.vue
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="user.data?.is_moderator || isStudent" class="">
|
||||||
|
<header
|
||||||
|
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||||
|
>
|
||||||
|
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||||
|
<Button v-if="user.data?.is_moderator" @click="openAnnouncementModal()">
|
||||||
|
<span>
|
||||||
|
{{ __('Make an Announcement') }}
|
||||||
|
</span>
|
||||||
|
<template #suffix>
|
||||||
|
<SendIcon class="h-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<div v-if="batch.data" class="grid grid-cols-[70%,30%] h-full">
|
||||||
|
<div class="border-r-2">
|
||||||
|
<Tabs v-model="tabIndex" :tabs="tabs" tablistClass="overflow-x-visible">
|
||||||
|
<template #tab="{ tab, selected }" class="overflow-x-hidden">
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="group -mb-px flex items-center gap-1 border-b border-transparent py-2.5 text-base text-gray-600 duration-300 ease-in-out hover:border-gray-400 hover:text-gray-900"
|
||||||
|
:class="{ 'text-gray-900': selected }"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
v-if="tab.icon"
|
||||||
|
:is="tab.icon"
|
||||||
|
class="h-4 stroke-1.5"
|
||||||
|
/>
|
||||||
|
{{ __(tab.label) }}
|
||||||
|
<Badge
|
||||||
|
v-if="tab.count"
|
||||||
|
:class="{
|
||||||
|
'text-gray-900 border border-gray-900': selected,
|
||||||
|
}"
|
||||||
|
variant="subtle"
|
||||||
|
theme="gray"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{{ tab.count }}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #default="{ tab }">
|
||||||
|
<div class="pt-5 px-10 pb-10">
|
||||||
|
<div v-if="tab.label == 'Courses'">
|
||||||
|
<BatchCourses :batch="batch.data.name" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="tab.label == 'Dashboard'">
|
||||||
|
<BatchDashboard :batch="batch" :isStudent="isStudent" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="tab.label == 'Live Class'">
|
||||||
|
<LiveClass :batch="batch.data.name" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="tab.label == 'Students'">
|
||||||
|
<BatchStudents :batch="batch.data.name" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="tab.label == 'Assessments'">
|
||||||
|
<Assessments :batch="batch.data.name" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="tab.label == 'Announcements'">
|
||||||
|
<Announcements :batch="batch.data.name" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="tab.label == 'Discussions'">
|
||||||
|
<Discussions
|
||||||
|
doctype="LMS Batch"
|
||||||
|
:docname="batch.data.name"
|
||||||
|
title="Discussions"
|
||||||
|
:key="batch.data.name"
|
||||||
|
:singleThread="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="text-2xl font-semibold mb-2">
|
||||||
|
{{ batch.data.title }}
|
||||||
|
</div>
|
||||||
|
<div v-html="batch.data.description" class="leading-5 mb-4"></div>
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
|
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
|
<span>
|
||||||
|
{{ dayjs(batch.data.start_date).format('DD MMMM YYYY') }} -
|
||||||
|
{{ dayjs(batch.data.end_date).format('DD MMMM YYYY') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mb-6">
|
||||||
|
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
|
<span>
|
||||||
|
{{ formatTime(batch.data.start_time) }} -
|
||||||
|
{{ formatTime(batch.data.end_time) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AnnouncementModal
|
||||||
|
v-model="showAnnouncementModal"
|
||||||
|
:batch="batch.data.name"
|
||||||
|
:students="batch.data.students"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!user.data?.name" class="">
|
||||||
|
<div class="text-base border rounded-md w-1/3 mx-auto my-32">
|
||||||
|
<div class="border-b px-5 py-3 font-medium">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center before:bg-red-600 before:w-2 before:h-2 before:rounded-md before:mr-2"
|
||||||
|
></span>
|
||||||
|
{{ __('Not Permitted') }}
|
||||||
|
</div>
|
||||||
|
<div class="px-5 py-3">
|
||||||
|
<div v-if="user.data" class="mb-4 leading-6">
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'You are not a member of this batch. Please checkout our upcoming batches.'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div v-else class="mb-4 leading-6">
|
||||||
|
{{ __('Please login to access this page.') }}
|
||||||
|
</div>
|
||||||
|
<router-link
|
||||||
|
v-if="user.data"
|
||||||
|
:to="{
|
||||||
|
name: 'Batches',
|
||||||
|
params: {
|
||||||
|
batchName: batch.data?.name,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button variant="solid" class="w-full">
|
||||||
|
{{ __('Upcoming Batches') }}
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<Button
|
||||||
|
v-else
|
||||||
|
variant="solid"
|
||||||
|
class="w-full"
|
||||||
|
@click="redirectToLogin()"
|
||||||
|
>
|
||||||
|
{{ __('Login') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Breadcrumbs, Button, createResource, Tabs, Badge } from 'frappe-ui'
|
||||||
|
import { computed, inject, ref } from 'vue'
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
LayoutDashboard,
|
||||||
|
BookOpen,
|
||||||
|
Laptop,
|
||||||
|
BookOpenCheck,
|
||||||
|
Contact2,
|
||||||
|
Mail,
|
||||||
|
SendIcon,
|
||||||
|
MessageCircle,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { formatTime } from '@/utils'
|
||||||
|
import BatchDashboard from '@/components/BatchDashboard.vue'
|
||||||
|
import BatchCourses from '@/components/BatchCourses.vue'
|
||||||
|
import LiveClass from '@/components/LiveClass.vue'
|
||||||
|
import BatchStudents from '@/components/BatchStudents.vue'
|
||||||
|
import Assessments from '@/components/Assessments.vue'
|
||||||
|
import Announcements from '@/components/Annoucements.vue'
|
||||||
|
import AnnouncementModal from '@/components/Modals/AnnouncementModal.vue'
|
||||||
|
import Discussions from '@/components/Discussions.vue'
|
||||||
|
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
|
const user = inject('$user')
|
||||||
|
const showAnnouncementModal = ref(false)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
batchName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const batch = createResource({
|
||||||
|
url: 'lms.lms.utils.get_batch_details',
|
||||||
|
cache: ['batch', props.batchName],
|
||||||
|
params: {
|
||||||
|
batch: props.batchName,
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
let crumbs = [{ label: 'All Batches', route: { name: 'Batches' } }]
|
||||||
|
if (!isStudent.value) {
|
||||||
|
crumbs.push({
|
||||||
|
label: 'Details',
|
||||||
|
route: {
|
||||||
|
name: 'BatchDetail',
|
||||||
|
params: {
|
||||||
|
batchName: batch.data?.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
crumbs.push({
|
||||||
|
label: batch?.data?.title,
|
||||||
|
route: { name: 'Batch', params: { batchName: props.batchName } },
|
||||||
|
})
|
||||||
|
return crumbs
|
||||||
|
})
|
||||||
|
|
||||||
|
const isStudent = computed(() => {
|
||||||
|
return (
|
||||||
|
user?.data &&
|
||||||
|
batch.data?.students.length &&
|
||||||
|
batch.data?.students.includes(user.data.name)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const tabIndex = ref(0)
|
||||||
|
const tabs = computed(() => {
|
||||||
|
let batchTabs = []
|
||||||
|
if (isStudent.value) {
|
||||||
|
batchTabs.push({
|
||||||
|
label: 'Dashboard',
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (user.data?.is_moderator) {
|
||||||
|
batchTabs.push({
|
||||||
|
label: 'Students',
|
||||||
|
icon: Contact2,
|
||||||
|
})
|
||||||
|
batchTabs.push({
|
||||||
|
label: 'Assessments',
|
||||||
|
icon: BookOpenCheck,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
batchTabs.push({
|
||||||
|
label: 'Live Class',
|
||||||
|
icon: Laptop,
|
||||||
|
})
|
||||||
|
batchTabs.push({
|
||||||
|
label: 'Courses',
|
||||||
|
icon: BookOpen,
|
||||||
|
})
|
||||||
|
batchTabs.push({
|
||||||
|
label: 'Announcements',
|
||||||
|
icon: Mail,
|
||||||
|
})
|
||||||
|
batchTabs.push({
|
||||||
|
label: 'Discussions',
|
||||||
|
icon: MessageCircle,
|
||||||
|
})
|
||||||
|
return batchTabs
|
||||||
|
})
|
||||||
|
|
||||||
|
const redirectToLogin = () => {
|
||||||
|
window.location.href = `/login?redirect-to=/batches`
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAnnouncementModal = () => {
|
||||||
|
showAnnouncementModal.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user