Compare commits

..

1 Commits

Author SHA1 Message Date
Hussain Nagaria
d9bf4e2c58 fix: title and tab copy 2024-04-16 14:42:17 +05:30
320 changed files with 7890 additions and 102746 deletions

BIN
.github/batch.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 912 KiB

View File

@@ -5,7 +5,7 @@ echo "Setting Up System Dependencies..."
sudo apt update sudo apt update
sudo apt remove mysql-server mysql-client sudo apt remove mysql-server mysql-client
sudo apt-get install libcups2-dev redis-server mariadb-client sudo apt install libcups2-dev redis-server mariadb-client-10.6
install_wkhtmltopdf() { install_wkhtmltopdf() {
wget -q https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.focal_amd64.deb wget -q https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.focal_amd64.deb

View File

@@ -1,40 +0,0 @@
#!/bin/bash
set -e
cd ~ || exit
echo "Setting Up Bench..."
pip install frappe-bench
bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)" --frappe-branch "${BASE_BRANCH}"
cd ./frappe-bench || exit
echo "Get LMS..."
bench get-app --skip-assets lms "${GITHUB_WORKSPACE}"
echo "Generating POT file..."
bench generate-pot-file --app lms
cd ./apps/lms || exit
echo "Configuring git user..."
git config user.email "developers@erpnext.com"
git config user.name "frappe-pr-bot"
echo "Setting the correct git remote..."
# Here, the git remote is a local file path by default. Let's change it to the upstream repo.
git remote set-url upstream https://github.com/frappe/lms.git
echo "Creating a new branch..."
isodate=$(date -u +"%Y-%m-%d")
branch_name="pot_${BASE_BRANCH}_${isodate}"
git checkout -b "${branch_name}"
echo "Commiting changes..."
git add lms/locale/main.pot
git commit -m "chore: update POT file"
gh auth setup-git
git push -u upstream "${branch_name}"
echo "Creating a PR..."
gh pr create --fill --base "${BASE_BRANCH}" --head "${branch_name}" -R frappe/lms

BIN
.github/hero.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

BIN
.github/lms-logo.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

BIN
.github/quiz.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -1,64 +0,0 @@
name: Build Container Image
on:
workflow_dispatch:
push:
branches:
- main
tags:
- "*"
jobs:
build:
name: Build
runs-on: ubuntu-latest
strategy:
matrix:
arch: [amd64, arm64]
permissions:
packages: write
steps:
- name: Checkout Entire Repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/${{ matrix.arch }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set Branch
run: |
export APPS_JSON='[{"url": "https://github.com/frappe/lms","branch": "main"}]'
echo "APPS_JSON_BASE64=$(echo $APPS_JSON | base64 -w 0)" >> $GITHUB_ENV
echo "FRAPPE_BRANCH=version-15" >> $GITHUB_ENV
- name: Set Image Tag
run: |
echo "IMAGE_TAG=stable" >> $GITHUB_ENV
- uses: actions/checkout@v4
with:
repository: frappe/frappe_docker
path: builds
- name: Build and push
uses: docker/build-push-action@v6
with:
push: true
context: builds
file: builds/images/layered/Containerfile
tags: >
ghcr.io/${{ github.repository }}:${{ github.ref_name }},
ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }}
build-args: |
"FRAPPE_BRANCH=${{ env.FRAPPE_BRANCH }}"
"APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}"

View File

@@ -1,34 +0,0 @@
name: Regenerate POT file (translatable strings)
on:
schedule:
- cron: "00 16 * * 5"
workflow_dispatch:
jobs:
regenerate-pot-file:
name: Release
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
branch: ["develop"]
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ matrix.branch }}
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Run script to update POT file
run: |
bash ${GITHUB_WORKSPACE}/.github/helper/update_pot_file.sh
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
BASE_BRANCH: ${{ matrix.branch }}

View File

@@ -30,4 +30,4 @@ jobs:
run: pip install semgrep run: pip install semgrep
- name: Run Semgrep rules - name: Run Semgrep rules
run: semgrep ci --config ./frappe-semgrep-rules/rules run: semgrep ci --config ./frappe-semgrep-rules/rules

View File

@@ -1,27 +0,0 @@
name: Create weekly release
on:
schedule:
# 13:00 UTC -> 7pm IST on every Wednesday
- cron: '30 4 * * 3'
workflow_dispatch:
jobs:
release:
name: Release
runs-on: ubuntu-latest
strategy:
fail-fast: false
steps:
- uses: octokit/request-action@v2.x
with:
route: POST /repos/{owner}/{repo}/pulls
owner: frappe
repo: lms
title: |-
"chore: merge 'develop' into 'main'"
body: "Automated weekly release"
base: main
head: develop
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}

View File

@@ -1,32 +0,0 @@
name: Generate Semantic Release
on:
workflow_dispatch:
push:
branches:
- main
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout Entire Repository
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup dependencies
run: |
npm install @semantic-release/git @semantic-release/exec --no-save
- name: Create Release
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GIT_AUTHOR_NAME: "Frappe PR Bot"
GIT_AUTHOR_EMAIL: "developers@frappe.io"
GIT_COMMITTER_NAME: "Frappe PR Bot"
GIT_COMMITTER_EMAIL: "developers@frappe.io"
run: npx semantic-release

View File

@@ -1,39 +0,0 @@
# This action:
#
# 1. Generates release notes using github API.
# 2. Strips unnecessary info like chore/style etc from notes.
# 3. Updates release info.
name: 'Release Notes'
on:
workflow_dispatch:
inputs:
tag_name:
description: 'Tag of release like v2.0.0'
required: true
type: string
release:
types: [released]
permissions:
contents: read
jobs:
regen-notes:
name: 'Regenerate release notes'
runs-on: ubuntu-latest
steps:
- name: Update notes
run: |
NEW_NOTES=$(gh api --method POST -H "Accept: application/vnd.github+json" /repos/frappe/lms/releases/generate-notes -f tag_name=$RELEASE_TAG \
| jq -r '.body' \
| sed -E '/^\* (chore|ci|test|docs|style)/d' \
| sed -E 's/by @mergify //'
)
RELEASE_ID=$(gh api -H "Accept: application/vnd.github+json" /repos/frappe/lms/releases/tags/$RELEASE_TAG | jq -r '.id')
gh api --method PATCH -H "Accept: application/vnd.github+json" /repos/frappe/lms/releases/$RELEASE_ID -f body="$NEW_NOTES"
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
RELEASE_TAG: ${{ github.event.inputs.tag_name || github.event.release.tag_name }}

View File

@@ -24,7 +24,7 @@ jobs:
services: services:
mariadb: mariadb:
image: mariadb:10.8 image: mariadb:10.6
env: env:
MARIADB_ROOT_PASSWORD: 123 MARIADB_ROOT_PASSWORD: 123
ports: ports:
@@ -99,7 +99,6 @@ jobs:
cd ~/frappe-bench/ cd ~/frappe-bench/
bench --site lms.test execute frappe.utils.install.complete_setup_wizard bench --site lms.test execute frappe.utils.install.complete_setup_wizard
bench --site lms.test execute frappe.tests.ui_test_helpers.create_test_user bench --site lms.test execute frappe.tests.ui_test_helpers.create_test_user
bench --site lms.test set-password frappe@example.com admin
- name: cypress pre-requisites - name: cypress pre-requisites
run: | run: |

3
.gitignore vendored
View File

@@ -11,5 +11,4 @@ __pycache__/
node_modules node_modules
package-lock.json package-lock.json
lms/public/frontend lms/public/frontend
lms/www/lms.html lms/www/lms.html
frappe-ui

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "frappe-ui"]
path = frappe-ui
url = https://github.com/frappe/frappe-ui

View File

@@ -1,21 +0,0 @@
{
"branches": ["develop"],
"plugins": [
"@semantic-release/commit-analyzer", {
"preset": "angular"
},
"@semantic-release/release-notes-generator",
[
"@semantic-release/exec", {
"prepareCmd": 'sed -ir "s/[0-9]*\.[0-9]*\.[0-9]*/${nextRelease.version}/" lms/__init__.py'
}
],
[
"@semantic-release/git", {
"assets": ["lms/__init__.py"],
"message": "chore(release): Bumped to Version ${nextRelease.version}"
}
],
"@semantic-release/github"
]
}

223
README.md
View File

@@ -1,174 +1,109 @@
<div align="center" markdown="1"> <p align="center">
<a href="https://www.frappelms.com/">
<img src="https://frappe.io/files/lms.png" alt="Frappe LMS" width="50px" height="50px">
</a>
<p align="center">Easy to use, open source, learning management system.</p>
</p>
<img src=".github/lms-logo.png" alt="Frappe Learning logo" width="80" height="80"/>
<h1>Frappe Learning</h1>
**Easy to use, open source, Learning Management System** &nbsp;
![Tests](https://img.shields.io/endpoint?url=https://dashboard.cypress.io/badge/simple/vandxn/main&style=flat&logo=cypress) <p align="center">
<a href="https://www.producthunt.com/posts/frappe-lms?utm_source=badge-top-post-topic-badge&utm_medium=badge&utm_souce=badge-frappe&#0045;lms" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-topic-badge.svg?post_id=396079&theme=dark&period=weekly&topic_id=204" alt="Frappe&#0032;LMS - Easy&#0032;to&#0032;use&#0044;&#0032;100&#0037;&#0032;open&#0032;source&#0032;learning&#0032;management&#0032;system | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</p>
<div align="center" style="max-height: 40px;">
<a href="https://frappecloud.com/lms/signup">
<img src=".github/try-on-f-cloud.svg" height="40">
</a>
</div> </div>
&nbsp;
<div align="center"> <p align="center">
<img src=".github/hero.png?v=5" alt="Hero Image" width="72%" /> <a href="https://dashboard.cypress.io/projects/vandxn/runs">
</div> <img alt="cypress" src="https://img.shields.io/endpoint?url=https://dashboard.cypress.io/badge/simple/vandxn/main&style=flat&logo=cypress">
<br /> </a>
<div align="center"> <a href="https://github.com/frappe/lms/blob/main/LICENSE">
<a href="https://frappe.io/learning">Website</a> <img alt="license" src="https://img.shields.io/badge/license-AGPLv3-blue">
- </a>
<a href="https://docs.frappe.io/learning">Documentation</a> </p>
</div>
## Frappe Learning <img width="1402" alt="Lesson" src="https://frappelms.com/files/banner.png">
Frappe Learning is an easy-to-use learning system that helps you bring structure to your content.
### Motivation
In 2021, we were looking for a Learning Management System to launch [Mon.School](https://mon.school) for FOSS United. We checked out Moodle, but it didnt feel right. The forms were unnecessarily lengthy and the UI was confusing. It shouldn't be this hard to create a course right? So I started making a learning system for Mon.School which soon became a product in itself. The aim is to have a simple platform that anyone can use to launch a course of their own and make knowledge sharing easier.
### Key Features
- **Structured Learning**: Design a course with a 3-level hierarchy, where your courses have chapters and you can group your lessons within these chapters. This ensures that the context of the lesson is set by the chapter.
- **Live Classes**: Group learners into batches based on courses and duration. You can then create Zoom live class for these batches right from the app. Learners get to see the list of live classes they have to take as a part of this batch.
- **Quizzes and Assignments**: Create quizzes where questions can have single-choice, multiple-choice options, or can be open ended. Instructors can also add assignments which learners can submit as PDF's or Documents.
- **Getting Certified**: Once a learner has completed the course or batch, you can grant them a certificate. The app provides an inbuilt certificate template. You can use this or else create a template of your own and use that instead.
<details> <details>
<summary>View Screenshots</summary> <summary>Show more screenshots</summary>
<img width="1520" alt="ss1" src="https://user-images.githubusercontent.com/31363128/210056046-584bc8aa-d28c-4514-b031-73817012837d.png">
<img width="830" alt="ss2" src="https://user-images.githubusercontent.com/31363128/210056097-36849182-6db0-43a2-8c62-5333cd2aedf4.png">
![Batch](.github/batch.png) <img width="941" alt="ss3" src="https://user-images.githubusercontent.com/31363128/210056134-01a7c429-1ef4-434e-9d43-128dda35d7e5.png">
<div align="center">
<sub>
Create batches to group your learners
</sub>
</div>
<br>
![Quiz](.github/quiz.png)
<div align="center">
<sub>
Evaluate their knowledge by quizzes
</sub>
</div>
<br>
![Cerficicate](.github/certificate.png)
<div align="center">
<sub>
Autenticate their work with certification
</sub>
</div>
</details> </details>
Frappe LMS is an easy-to-use, open-source learning management system. You can use it to create and share online courses. The app has a clear UI that helps students focus only on what's important and assists in distraction-free learning.
### Under the Hood You can create courses and lessons through simple forms. Lessons can be in the form of text, videos, quizzes or a combination of all these. You can keep your students engaged with quizzes to help revise and test the concepts learned. Course Instructors and Students can reach out to each other through the discussions section available for each lesson and get queries resolved.
- [**Frappe Framework**](https://github.com/frappe/frappe): A full-stack web application framework. ## Features
- Create online courses. 📚
- Add detailed descriptions and preview videos to the course. 🎬
- Add videos, quizzes, and assignments to your lessons and make them interesting and interactive 📝
- Discussions section below each lesson where instructors and students can interact with each other. 💬
- Create batches to group your students based on courses and track their progress 🏛
- Statistics dashboard that provides all important numbers at a glimpse. 📈
- Job Board where users can post and look for jobs. 💼
- People directory with each person's profile page 👨‍👩‍👧‍👦
- Set cover image, profile photo, short bio, and other professional information. 🦹🏼‍♀️
- Simple layout that optimizes readability 🤓
- Delightful user experience in overall usage ✨
- [**Frappe UI**](https://github.com/frappe/frappe-ui): A Vue-based UI library, to provide a modern user interface. ## Tech Stack
## Production Setup Frappe LMS is built on [Frappe Framework](https://frappeframework.com) which is a batteries-included python web framework.
These are some of the tools it's built on:
- [Python](https://www.python.org)
- [Redis](https://redis.io/)
- [MariaDB](https://mariadb.org/)
- [Socket.io](https://socket.io/)
### Managed Hosting ## Local Setup
You can try [Frappe Cloud](https://frappecloud.com), a simple, user-friendly and sophisticated [open-source](https://github.com/frappe/press) platform to host Frappe applications with peace of mind.
It takes care of installation, setup, upgrades, monitoring, maintenance and support of your Frappe deployments. It is a fully featured developer platform with an ability to manage and control multiple Frappe deployments.
<div>
<a href="https://frappecloud.com/lms/signup" target="_blank">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://frappe.io/files/try-on-fc-white.png">
<img src="https://frappe.io/files/try-on-fc-black.png" alt="Try on Frappe Cloud" height="28" />
</picture>
</a>
</div>
### Self Hosting
Follow these steps to set up Frappe Learning in production:
**Step 1**: Download the easy install script
```bash
wget https://frappe.io/easy-install.py
```
**Step 2**: Run the deployment command
```bash
python3 ./easy-install.py deploy \
--project=learning_prod_setup \
--email=your_email.example.com \
--image=ghcr.io/frappe/lms \
--version=stable \
--app=lms \
--sitename subdomain.domain.tld
```
Replace the following parameters with your values:
- `your_email.example.com`: Your email address
- `subdomain.domain.tld`: Your domain name where Learning will be hosted
The script will set up a production-ready instance of Frappe Learning with all the necessary configurations in about 5 minutes.
## Development Setup
### Docker ### Docker
You need Docker, docker-compose, and git setup on your machine. Refer to [Docker documentation](https://docs.docker.com/). After that, run the following commands:
```
git clone https://github.com/frappe/lms
cd apps/lms/docker
docker-compose up
```
You need Docker, docker-compose and git setup on your machine. Refer [Docker documentation](https://docs.docker.com/). After that, follow below steps: Wait for some time until the setup script creates a site. After that, you can access `http://localhost:8000` in your browser and the app's login screen should show up.
**Step 1**: Setup folder and download the required files ### Frappe Bench
mkdir frappe-learning Currently, this app depends on the `develop` branch of [frappe](https://github.com/frappe/frappe).
cd frappe-learning
# Download the docker-compose file 1. Setup frappe-bench by following [this guide](https://frappeframework.com/docs/v14/user/en/installation)
wget -O docker-compose.yml https://raw.githubusercontent.com/frappe/lms/develop/docker/docker-compose.yml 1. In the frappe-bench directory, run `bench start` and keep it running. Open a new terminal session and cd into the `frappe-bench` directory.
1. Run the following commands:
```sh
bench new-site lms.test
bench get-app lms
bench --site lms.test install-app lms
bench --site lms.test add-to-hosts
# Download the setup script 1. Now, you can access the site at `http://lms.test:8000`
wget -O init.sh https://raw.githubusercontent.com/frappe/lms/develop/docker/init.sh
**Step 2**: Run the container and daemonize it
docker compose up -d ## Deployment
Frappe LMS is an app built on top of the Frappe Framework. So, you can follow any deployment guide for hosting a Frappe Framework-based site.
**Step 3**: The site [http://lms.localhost:8000/lms](http://lms.localhost:8000/lms) should now be available. The default credentials are: ### Managed Hosting
- Username: Administrator Frappe LMS can be deployed in a few clicks on [Frappe Cloud](https://frappecloud.com/marketplace/apps/lms).
- Password: admin
### Local ### Self-hosting
If you want to self-host, you can follow official [Frappe Bench Installation](https://github.com/frappe/bench#installation) instructions.
To setup the repository locally follow the steps mentioned below: ## Bugs and Feature Requests
If you find any bugs or have a feature idea for the app, feel free to report them here on [GitHub Issues](https://github.com/frappe/lms/issues). Make sure you share enough information (app screenshots, browser console screenshots, stack traces, etc) for project maintainers.
1. Install bench and setup a `frappe-bench` directory by following the [Installation Steps](https://frappeframework.com/docs/user/en/installation) ## License
1. Start the server by running `bench start` Distributed under [GNU AFFERO GENERAL PUBLIC LICENSE](license.txt)
1. In a separate terminal window, create a new site by running `bench new-site learning.test`
1. Map your site to localhost with the command `bench --site learning.test add-to-hosts`
1. Get the Learning app. Run `bench get-app https://github.com/frappe/lms`
1. Run `bench --site learning.test install-app lms`.
1. Now open the URL `http://learning.test:8000/lms` in your browser, you should see the app running
## Learn and connect
- [Telegram Public Group](https://t.me/frappelms)
- [Discuss Forum](https://discuss.frappe.io/c/lms/70)
- [Documentation](https://docs.frappe.io/learning)
- [YouTube](https://www.youtube.com/channel/UCn3bV5kx77HsVwtnlCeEi_A)
<br>
<br>
<div align="center" style="padding-top: 0.75rem;">
<a href="https://frappe.io" target="_blank">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://frappe.io/files/Frappe-white.png">
<img src="https://frappe.io/files/Frappe-black.png" alt="Frappe Technologies" height="28"/>
</picture>
</a>
</div>

View File

@@ -1,8 +0,0 @@
files:
- source: /lms/locale/main.pot
translation: /lms/locale/%two_letters_code%.po
pull_request_title: "chore: sync translations from crowdin"
pull_request_labels:
- translation
commit_message: "chore: %language% translations"
append_commit_message: false

View File

@@ -13,6 +13,6 @@ module.exports = defineConfig({
openMode: 0, openMode: 0,
}, },
e2e: { e2e: {
baseUrl: "http://lms1:8000", baseUrl: "http://pyp:8000",
}, },
}); });

View File

@@ -1,159 +1,133 @@
describe("Course Creation", () => { describe("Course Creation", () => {
it("creates a new course", () => { it("creates a new course", () => {
cy.login(); cy.login();
cy.wait(1000); cy.visit("/courses");
cy.visit("/lms/courses");
// Create a course // Create a course
cy.get("header").children().last().children().last().click(); cy.get("a.btn").contains("Create a Course").click();
cy.wait(1000);
cy.url().should("include", "/courses/new-course/edit");
cy.get("#title").type("Test Course");
cy.get("#intro").type("Test Course Short Introduction");
cy.get("#description").type("Test Course Description");
cy.get("#video-link").type("-LPmw2Znl2c");
cy.get("#tags-input").type("Test");
cy.get("#published").check();
cy.wait(1000); cy.wait(1000);
cy.url().should("include", "/courses/new/edit");
cy.get("label").contains("Title").type("Test Course");
cy.get("label")
.contains("Short Introduction")
.type("Test Course Short Introduction to test the UI");
cy.get("div[contenteditable=true").invoke(
"text",
"Test Course Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
);
cy.fixture("profile.png", "base64").then((fileContent) => {
cy.get('input[type="file"]').attachFile({
fileContent,
fileName: "profile.png",
mimeType: "image/png",
encoding: "base64",
});
});
cy.get("label")
.contains("Preview Video")
.type("https://www.youtube.com/embed/-LPmw2Znl2c");
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
cy.get("label")
.contains("Category")
.parent()
.within(() => {
cy.get("button").click();
});
cy.get("[id^=headlessui-combobox-option-")
.should("be.visible")
.first()
.click();
/* Instructor */
cy.get("label")
.contains("Instructors")
.parent()
.within(() => {
cy.get("input").click().type("frappe");
cy.get("input")
.invoke("attr", "aria-controls")
.as("instructor_list_id");
});
cy.get("@instructor_list_id").then((instructor_list_id) => {
cy.get(`[id^=${instructor_list_id}`)
.should("be.visible")
.within(() => {
cy.get("[id^=headlessui-combobox-option-").first().click();
});
});
cy.get("label").contains("Published").click();
cy.get("label").contains("Published On").type("2021-01-01");
cy.button("Save").click(); cy.button("Save").click();
// Add Chapter // Add Chapter
cy.wait(1000); cy.wait(1000);
cy.button("Add Chapter").click(); cy.link("Course Outline").click();
cy.wait(1000); cy.wait(1000);
cy.get("[id^=headlessui-dialog-panel-") cy.get(".edit-header .btn-add-chapter").click();
.should("be.visible") cy.wait(500);
.within(() => { cy.get("#chapter-title").type("Test Chapter");
cy.get("label").contains("Title").type("Test Chapter"); cy.get("#chapter-description").type("Test Chapter Description");
cy.button("Create").click(); cy.button("Save").click();
});
// Add Lesson // Add Lesson
cy.wait(1000); cy.wait(1000);
cy.button("Add Lesson").click(); cy.link("Add Lesson").click();
cy.wait(1000); cy.wait(1000);
cy.url().should("include", "/learn/1-1/edit"); cy.get("#lesson-title").type("Test Lesson");
// Content
cy.get(".collapse-section.collapsed:first").click();
cy.get("#lesson-content .ce-block")
.click()
.type(
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now. {enter}"
);
cy.get("#lesson-content .ce-toolbar__plus").click();
cy.get('#lesson-content [data-item-name="youtube"]').click();
cy.get('input[data-fieldname="youtube"]').type("GoDtyItReto");
cy.button("Insert").click();
cy.wait(1000); cy.wait(1000);
cy.get("label").contains("Title").type("Test Lesson");
cy.get("#content .ce-block").type(
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
);
cy.button("Save").click(); cy.button("Save").click();
// View Course // View Course
cy.wait(1000); cy.wait(1000);
cy.visit("/lms"); cy.visit("/courses");
cy.wait(500); cy.get(".course-card-title:first").contains("Test Course");
cy.url().should("include", "/lms/courses"); cy.get(".course-card:first").click();
cy.get(".grid a:first").within(() => { cy.url().should("include", "/courses/test-course");
cy.get("div").contains("Test Course"); cy.get("#title").contains("Test Course");
cy.get("div").contains( cy.get(".preview-video").should(
"Test Course Short Introduction to test the UI"
);
cy.get(".course-image")
.invoke("css", "background-image")
.should("include", "/files/profile");
});
cy.get(".grid a:first").click();
cy.url().should("include", "/lms/courses/test-course");
cy.get("div").contains("Test Course");
cy.get("div").contains("Test Course Short Introduction to test the UI");
cy.get("div").contains("Learning");
cy.get("div").contains("Frappe");
cy.get("div").contains("ERPNext");
cy.get("iframe").should(
"have.attr", "have.attr",
"src", "src",
"https://www.youtube.com/embed/-LPmw2Znl2c" "https://www.youtube.com/embed/-LPmw2Znl2c"
); );
cy.get("#intro").contains("Test Course Short Introduction");
// View Chapter // View Chapter
cy.get("div").contains("Test Chapter"); cy.get(".chapter-title-main:first").contains("Test Chapter");
cy.get("[id^=headlessui-disclosure-panel-").within(() => { cy.get(".chapter-description:first").contains(
cy.get("div").contains("Test Lesson").click(); "Test Chapter Description"
}); );
cy.wait(3000); cy.get(".lesson-info:first").contains("Test Lesson");
cy.get(".lesson-info:first").click();
// View Lesson // View Lesson
cy.url().should("include", "/learn/1-1"); cy.wait(1000);
cy.get("div").contains("Test Lesson"); cy.url().should("include", "learn/1.1");
cy.get("#title").contains("Test Lesson");
cy.get("div").contains( cy.get(".lesson-video iframe").should(
"have.attr",
"src",
"https://www.youtube.com/embed/GoDtyItReto"
);
cy.get(".lesson-content-card").contains(
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now." "This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
); );
// Add Discussion // Add Discussion
cy.button("New Question").click(); cy.get(".reply").click();
cy.wait(500); cy.wait(500);
cy.get("[id^=headlessui-dialog-panel-").within(() => { cy.get(".discussion-modal").should("be.visible");
cy.get("label").contains("Title").type("Test Discussion");
cy.get("div[contenteditable=true]").invoke( // Enter title
"text", cy.get(".modal .topic-title")
"This is a test discussion. This will check if the UI is working properly." .type("Discussion from tests")
.should("have.value", "Discussion from tests");
// Enter comment
cy.get(".modal .discussions-comment").type(
"This is a discussion from the cypress ui tests."
);
// Submit
cy.get(".modal .submit-discussion").click();
cy.wait(2000);
// Check if discussion is added to page and content is visible
cy.get(".sidebar-parent:first .discussion-topic-title").should(
"have.text",
"Discussion from tests"
);
cy.get(".sidebar-parent:first .discussion-topic-title").click();
cy.get(".discussion-on-page:visible").should("have.class", "show");
cy.get(
".discussion-on-page:visible .reply-card .reply-text .ql-editor p"
).should(
"have.text",
"This is a discussion from the cypress ui tests."
);
cy.get(".discussion-form:visible .discussions-comment").type(
"This is a discussion from the cypress ui tests. \n\nThis comment was entered through the commentbox on the page."
);
cy.get(".discussion-form:visible .submit-discussion").click();
cy.wait(3000);
cy.get(".discussion-on-page:visible").should("have.class", "show");
cy.get(".discussion-on-page:visible")
.children(".reply-card")
.eq(1)
.find(".reply-text")
.should(
"have.text",
"This is a discussion from the cypress ui tests. This comment was entered through the commentbox on the page.\n"
); );
cy.button("Post").click();
});
// View Discussion
cy.wait(500);
cy.get("div").contains("Test Discussion").click();
cy.get("div[contenteditable=true").invoke(
"text",
"This is a test comment. This will check if the UI is working properly."
);
cy.get("div").contains(
"This is a test comment. This will check if the UI is working properly."
);
}); });
}); });

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -24,8 +24,6 @@
// -- This will overwrite an existing command -- // -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
import "cypress-file-upload";
Cypress.Commands.add("login", (email, password) => { Cypress.Commands.add("login", (email, password) => {
if (!email) { if (!email) {
email = Cypress.config("testUser") || "Administrator"; email = Cypress.config("testUser") || "Administrator";
@@ -55,13 +53,3 @@ Cypress.Commands.add("iconButton", (text) => {
Cypress.Commands.add("dialog", (selector) => { Cypress.Commands.add("dialog", (selector) => {
return cy.get(`[role=dialog] ${selector}`); return cy.get(`[role=dialog] ${selector}`);
}); });
Cypress.Commands.add("paste", { prevSubject: true }, (subject, text) => {
cy.wrap(subject).then(($element) => {
const element = $element[0];
element.focus();
element.textContent = text;
const event = new Event("paste", { bubbles: true });
element.dispatchEvent(event);
});
});

View File

@@ -2,7 +2,7 @@ version: "3.7"
name: lms name: lms
services: services:
mariadb: mariadb:
image: mariadb:10.8 image: mariadb:10.6
command: command:
- --character-set-server=utf8mb4 - --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci - --collation-server=utf8mb4_unicode_ci

View File

@@ -35,6 +35,7 @@ bench new-site lms.localhost \
bench --site lms.localhost install-app lms bench --site lms.localhost install-app lms
bench --site lms.localhost set-config developer_mode 1 bench --site lms.localhost set-config developer_mode 1
bench --site lms.localhost clear-cache bench --site lms.localhost clear-cache
bench --site lms.localhost set-config mute_emails 1
bench use lms.localhost bench use lms.localhost
bench start bench start

Submodule frappe-ui updated: 8cd9b06a5e...c5faaae38e

View File

@@ -10,34 +10,24 @@
}, },
"dependencies": { "dependencies": {
"@editorjs/checklist": "^1.6.0", "@editorjs/checklist": "^1.6.0",
"@editorjs/code": "^2.9.0",
"@editorjs/editorjs": "^2.29.0", "@editorjs/editorjs": "^2.29.0",
"@editorjs/embed": "^2.7.0", "@editorjs/embed": "^2.7.0",
"@editorjs/header": "^2.8.1", "@editorjs/header": "^2.8.1",
"@editorjs/inline-code": "^1.5.0", "@editorjs/image": "^2.9.0",
"@editorjs/nested-list": "^1.4.2", "@editorjs/nested-list": "^1.4.2",
"@editorjs/paragraph": "^2.11.3", "@editorjs/paragraph": "^2.11.3",
"@editorjs/simple-image": "^1.6.0",
"@editorjs/table": "^2.4.2",
"ace-builds": "^1.36.2",
"apexcharts": "^4.3.0",
"chart.js": "^4.4.1", "chart.js": "^4.4.1",
"codemirror-editor-vue3": "^2.8.0",
"dayjs": "^1.11.6", "dayjs": "^1.11.6",
"feather-icons": "^4.28.0", "feather-icons": "^4.28.0",
"frappe-ui": "^0.1.89", "frappe-ui": "^0.1.50",
"lucide-vue-next": "^0.383.0", "lucide-vue-next": "^0.309.0",
"markdown-it": "^14.0.0", "markdown-it": "^14.0.0",
"pinia": "^2.0.33", "pinia": "^2.0.33",
"socket.io-client": "^4.7.2", "socket.io-client": "^4.7.2",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"typescript": "^5.7.2", "vue": "^3.2.25",
"vue": "^3.4.23",
"vue-chartjs": "^5.3.0", "vue-chartjs": "^5.3.0",
"vue-draggable-next": "^2.2.1", "vue-router": "^4.0.12"
"vue-router": "^4.0.12",
"vue3-apexcharts": "^1.8.0",
"vuedraggable": "4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.3", "@vitejs/plugin-vue": "^5.0.3",

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -8,16 +8,12 @@
<script setup> <script setup>
import { Toasts } from 'frappe-ui' import { Toasts } from 'frappe-ui'
import { Dialogs } from '@/utils/dialogs' import { Dialogs } from '@/utils/dialogs'
import { computed, onMounted, onUnmounted } from 'vue' import { computed, defineAsyncComponent } from 'vue'
import { useScreenSize } from './utils/composables' import { useScreenSize } from './utils/composables'
import DesktopLayout from './components/DesktopLayout.vue' import DesktopLayout from './components/DesktopLayout.vue'
import MobileLayout from './components/MobileLayout.vue' import MobileLayout from './components/MobileLayout.vue'
import { stopSession } from '@/telemetry'
import { init as initTelemetry } from '@/telemetry'
import { usersStore } from '@/stores/user'
const screenSize = useScreenSize() const screenSize = useScreenSize()
let { userResource } = usersStore()
const Layout = computed(() => { const Layout = computed(() => {
if (screenSize.width < 640) { if (screenSize.width < 640) {
@@ -26,13 +22,4 @@ const Layout = computed(() => {
return DesktopLayout return DesktopLayout
} }
}) })
onMounted(async () => {
if (!userResource.data) return
await initTelemetry()
})
onUnmounted(() => {
stopSession()
})
</script> </script>

View File

@@ -25,7 +25,7 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { createResource, Avatar } from 'frappe-ui' import { createListResource, Avatar } from 'frappe-ui'
import { timeAgo } from '@/utils' import { timeAgo } from '@/utils'
const props = defineProps({ const props = defineProps({
@@ -35,15 +35,24 @@ const props = defineProps({
}, },
}) })
const communications = createResource({ const communications = createListResource({
url: 'lms.lms.api.get_announcements', doctype: 'Communication',
makeParams(value) { fields: [
return { 'subject',
batch: props.batch, 'content',
} 'recipients',
'cc',
'communication_date',
'sender',
'sender_full_name',
],
filters: {
reference_doctype: 'LMS Batch',
reference_name: props.batch,
}, },
orderBy: 'communication_date desc',
auto: true, auto: true,
cache: ['announcement', props.batch], cache: ['batch', props.batch],
}) })
</script> </script>
<style> <style>

View File

@@ -1,92 +1,40 @@
<template> <template>
<div <div
class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out bg-gray-50" class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out bg-gray-50"
:class="sidebarStore.isSidebarCollapsed ? 'w-14' : 'w-56'" :class="isSidebarCollapsed ? 'w-14' : 'w-56'"
> >
<div <div
class="flex flex-col overflow-hidden" class="flex flex-col overflow-hidden"
:class="sidebarStore.isSidebarCollapsed ? 'items-center' : ''" :class="isSidebarCollapsed ? 'items-center' : ''"
> >
<UserDropdown :isCollapsed="sidebarStore.isSidebarCollapsed" /> <UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" />
<div class="flex flex-col" v-if="sidebarSettings.data"> <div class="flex flex-col overflow-y-auto">
<SidebarLink <SidebarLink
v-for="link in sidebarLinks" v-for="link in links"
:link="link" :link="link"
:isCollapsed="sidebarStore.isSidebarCollapsed" :isCollapsed="isSidebarCollapsed"
class="mx-2 my-0.5" class="mx-2 my-0.5"
/> />
</div> </div>
<div
v-if="sidebarSettings.data?.web_pages?.length || isModerator"
class="mt-4"
>
<div
class="flex items-center justify-between pr-2 cursor-pointer"
:class="sidebarStore.isSidebarCollapsed ? 'pl-3' : 'pl-4'"
@click="showWebPages = !showWebPages"
>
<div
v-if="!sidebarStore.isSidebarCollapsed"
class="flex items-center text-sm text-gray-600 my-1"
>
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<ChevronRight
class="h-4 w-4 stroke-1.5 text-gray-900 transition-all duration-300 ease-in-out"
:class="{ 'rotate-90': showWebPages }"
/>
</span>
<span class="ml-2">
{{ __('More') }}
</span>
</div>
<Button v-if="isModerator" variant="ghost" @click="openPageModal()">
<template #icon>
<Plus class="h-4 w-4 text-gray-700 stroke-1.5" />
</template>
</Button>
</div>
<div
v-if="sidebarSettings.data?.web_pages?.length"
class="flex flex-col transition-all duration-300 ease-in-out"
:class="showWebPages ? 'block' : 'hidden'"
>
<SidebarLink
v-for="link in sidebarSettings.data.web_pages"
:link="link"
:isCollapsed="sidebarStore.isSidebarCollapsed"
class="mx-2 my-0.5"
:showControls="isModerator ? true : false"
@openModal="openPageModal"
@deletePage="deletePage"
/>
</div>
</div>
</div> </div>
<SidebarLink <SidebarLink
:link="{ :link="{
label: sidebarStore.isSidebarCollapsed ? 'Expand' : 'Collapse', label: isSidebarCollapsed ? 'Expand' : 'Collapse',
}" }"
:isCollapsed="sidebarStore.isSidebarCollapsed" :isCollapsed="isSidebarCollapsed"
@click="toggleSidebar()" @click="isSidebarCollapsed = !isSidebarCollapsed"
class="m-2" class="m-2"
> >
<template #icon> <template #icon>
<span class="grid h-5 w-6 flex-shrink-0 place-items-center"> <span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<CollapseSidebar <CollapseSidebar
class="h-4.5 w-4.5 text-gray-700 duration-300 ease-in-out" class="h-4.5 w-4.5 text-gray-700 duration-300 ease-in-out"
:class="{ :class="{ '[transform:rotateY(180deg)]': isSidebarCollapsed }"
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed,
}"
/> />
</span> </span>
</template> </template>
</SidebarLink> </SidebarLink>
</div> </div>
<PageModal
v-model="showPageModal"
v-model:reloadSidebar="sidebarSettings"
:page="pageToEdit"
/>
</template> </template>
<script setup> <script setup>
@@ -94,177 +42,14 @@ import UserDropdown from '@/components/UserDropdown.vue'
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue' import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
import SidebarLink from '@/components/SidebarLink.vue' import SidebarLink from '@/components/SidebarLink.vue'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { ref, onMounted, inject, watch } from 'vue' import { ref } from 'vue'
import { getSidebarLinks } from '../utils' import { getSidebarLinks } from '../utils'
import { usersStore } from '@/stores/user'
import { sessionStore } from '@/stores/session'
import { useSidebar } from '@/stores/sidebar'
import { useSettings } from '@/stores/settings'
import { ChevronRight, Plus } from 'lucide-vue-next'
import { createResource, Button } from 'frappe-ui'
import PageModal from '@/components/Modals/PageModal.vue'
const { user, sidebarSettings } = sessionStore() const links = getSidebarLinks()
const { userResource } = usersStore()
let sidebarStore = useSidebar()
const socket = inject('$socket')
const unreadCount = ref(0)
const sidebarLinks = ref(getSidebarLinks())
const showPageModal = ref(false)
const isModerator = ref(false)
const isInstructor = ref(false)
const pageToEdit = ref(null)
const showWebPages = ref(false)
const settingsStore = useSettings()
onMounted(() => {
socket.on('publish_lms_notifications', (data) => {
unreadNotifications.reload()
})
addNotifications()
sidebarSettings.reload(
{},
{
onSuccess(data) {
Object.keys(data).forEach((key) => {
if (!parseInt(data[key])) {
sidebarLinks.value = sidebarLinks.value.filter(
(link) => link.label.toLowerCase().split(' ').join('_') !== key
)
}
})
},
}
)
})
const unreadNotifications = createResource({
cache: 'Unread Notifications Count',
url: 'frappe.client.get_count',
makeParams(values) {
return {
doctype: 'Notification Log',
filters: {
for_user: user,
read: 0,
},
}
},
onSuccess(data) {
unreadCount.value = data
sidebarLinks.value = sidebarLinks.value.map((link) => {
if (link.label === 'Notifications') {
link.count = data
}
return link
})
},
auto: user ? true : false,
})
const addNotifications = () => {
if (user) {
sidebarLinks.value.push({
label: 'Notifications',
icon: 'Bell',
to: 'Notifications',
activeFor: ['Notifications'],
count: unreadCount.value,
})
}
}
const addQuizzes = () => {
if (isInstructor.value || isModerator.value) {
sidebarLinks.value.push({
label: 'Quizzes',
icon: 'CircleHelp',
to: 'Quizzes',
activeFor: ['Quizzes', 'QuizForm'],
})
}
}
const addAssignments = () => {
if (isInstructor.value || isModerator.value) {
sidebarLinks.value.push({
label: 'Assignments',
icon: 'Pencil',
to: 'Assignments',
activeFor: ['Assignments', 'AssignmentForm'],
})
}
}
const addPrograms = () => {
let activeFor = ['Programs', 'ProgramForm']
let index = 1
let canAddProgram = false
if (
!isInstructor.value &&
!isModerator.value &&
settingsStore.learningPaths.data
) {
sidebarLinks.value = sidebarLinks.value.filter(
(link) => link.label !== 'Courses'
)
activeFor.push('CourseDetail')
activeFor.push('Lesson')
index = 0
canAddProgram = true
} else if (isInstructor.value || isModerator.value) {
canAddProgram = true
}
if (canAddProgram) {
sidebarLinks.value.splice(index, 0, {
label: 'Programs',
icon: 'Route',
to: 'Programs',
activeFor: activeFor,
})
}
}
const openPageModal = (link) => {
showPageModal.value = true
pageToEdit.value = link
}
const deletePage = (link) => {
createResource({
url: 'lms.lms.api.delete_sidebar_item',
makeParams(values) {
return {
webpage: link.web_page,
}
},
}).submit(
{},
{
onSuccess() {
sidebarSettings.reload()
},
}
)
}
const getSidebarFromStorage = () => { const getSidebarFromStorage = () => {
return useStorage('sidebar_is_collapsed', false) return useStorage('sidebar_is_collapsed', false)
} }
watch(userResource, () => { let isSidebarCollapsed = ref(getSidebarFromStorage())
if (userResource.data) {
isModerator.value = userResource.data.is_moderator
isInstructor.value = userResource.data.is_instructor
addPrograms()
addQuizzes()
addAssignments()
}
})
const toggleSidebar = () => {
sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed
}
</script> </script>

View File

@@ -1,67 +0,0 @@
<template>
<Popover placement="right-start" class="flex w-full">
<template #target="{ togglePopover }">
<button
:class="[
'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-gray-800 hover:bg-gray-100',
]"
@click.prevent="togglePopover()"
>
<div class="flex gap-2">
<LayoutGrid class="size-4 stroke-1.5" />
<span class="whitespace-nowrap">
{{ __('Apps') }}
</span>
</div>
<ChevronRight class="h-4 w-4 stroke-1.5" />
</button>
</template>
<template #body>
<div
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg border border-gray-100 bg-white shadow-xl"
>
<div v-for="app in apps.data" key="name">
<a
:href="app.route"
class="flex flex-col gap-1.5 rounded justify-center items-center py-2 px-3 hover:bg-gray-100"
>
<img class="size-8" :src="app.logo" />
<div class="text-sm" @click="app.onClick">
{{ app.title }}
</div>
</a>
</div>
</div>
</template>
</Popover>
</template>
<script setup>
import { Popover, createResource } from 'frappe-ui'
import { LayoutGrid, ChevronRight } from 'lucide-vue-next'
const apps = createResource({
url: 'frappe.apps.get_apps',
cache: 'apps',
auto: true,
transform: (data) => {
let _apps = [
{
name: 'frappe',
logo: '/assets/lms/images/desk.png',
title: __('Desk'),
route: '/app',
},
]
data.map((app) => {
if (app.name === 'lms') return
_apps.push({
name: app.name,
logo: app.logo,
title: __(app.title),
route: app.route,
})
})
return _apps
},
})
</script>

View File

@@ -1,75 +0,0 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'xl',
}"
>
<template #body>
<div class="p-5 space-y-4">
<div v-if="type == 'quiz'" class="text-lg font-semibold">
{{ __('Add a quiz to your lesson') }}
</div>
<div v-else class="text-lg font-semibold">
{{ __('Add an assignment to your lesson') }}
</div>
<div>
<Link
v-if="type == 'quiz'"
v-model="quiz"
doctype="LMS Quiz"
:label="__('Select a quiz')"
:onCreate="(value, close) => redirectToForm()"
/>
<Link
v-else
v-model="assignment"
doctype="LMS Assignment"
:label="__('Select an assignment')"
:onCreate="(value, close) => redirectToForm()"
/>
</div>
<div class="flex justify-end space-x-2">
<Button variant="solid" @click="addAssessment()">
{{ __('Save') }}
</Button>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, Button } from 'frappe-ui'
import { onMounted, ref, nextTick } from 'vue'
import Link from '@/components/Controls/Link.vue'
const show = ref(false)
const quiz = ref(null)
const assignment = ref(null)
const props = defineProps({
type: {
type: String,
required: true,
},
onAddition: {
type: Function,
required: true,
},
})
onMounted(async () => {
await nextTick()
show.value = true
})
const addAssessment = () => {
props.onAddition(props.type == 'quiz' ? quiz.value : assignment.value)
show.value = false
}
const redirectToForm = () => {
if (props.type == 'quiz') window.open('/lms/quizzes/new', '_blank')
else window.open('/lms/assignments/new', '_blank')
}
</script>

View File

@@ -1,15 +1,7 @@
<template> <template>
<div> <div>
<div class="flex items-center justify-between mb-4"> <div class="text-lg font-semibold mb-4">
<div class="text-lg font-semibold"> {{ __('Assessments') }}
{{ __('Assessments') }}
</div>
<Button v-if="canSeeAddButton()" @click="showModal = true">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('Add') }}
</Button>
</div> </div>
<div v-if="assessments.data?.length"> <div v-if="assessments.data?.length">
<ListView <ListView
@@ -17,89 +9,41 @@
:rows="assessments.data" :rows="assessments.data"
row-key="name" row-key="name"
:options="{ :options="{
selectable: false,
showTooltip: false, showTooltip: false,
getRowRoute: (row) => getRowRoute(row), getRowRoute: (row) => {
selectable: user.data?.is_student ? false : true, 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',
},
}
}
},
}" }"
> >
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
>
<ListHeaderItem :item="item" v-for="item in getAssessmentColumns()">
<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 assessments.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key == 'assessment_type'">
{{ row[column.key] == 'LMS Quiz' ? 'Quiz' : 'Assignment' }}
</div>
<div v-else-if="column.key == 'title'">
{{ row[column.key] }}
</div>
<div v-else-if="isNaN(row[column.key])">
<Badge :theme="getStatusTheme(row[column.key])">
{{ row[column.key] }}
</Badge>
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeAssessments(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView> </ListView>
</div> </div>
<div v-else class="text-sm italic text-gray-600"> <div v-else class="text-sm italic text-gray-600">
{{ __('No Assessments') }} {{ __('No Assessments') }}
</div> </div>
</div> </div>
<AssessmentModal
v-model="showModal"
v-model:assessments="assessments"
:batch="props.batch"
/>
</template> </template>
<script setup> <script setup>
import { import { ListView, createResource } from 'frappe-ui'
ListView, import { inject } from 'vue'
ListRow,
ListRows,
ListHeader,
ListHeaderItem,
ListRowItem,
ListSelectBanner,
createResource,
Button,
Badge,
} from 'frappe-ui'
import { inject, ref } from 'vue'
import AssessmentModal from '@/components/Modals/AssessmentModal.vue'
import { Plus, Trash2 } from 'lucide-vue-next'
const user = inject('$user') const user = inject('$user')
const showModal = ref(false)
const props = defineProps({ const props = defineProps({
batch: { batch: {
@@ -130,93 +74,25 @@ const assessments = createResource({
auto: true, auto: true,
}) })
const deleteAssessments = createResource({
url: 'lms.lms.api.delete_documents',
makeParams(values) {
return {
doctype: 'LMS Assessment',
documents: values.assessments,
}
},
})
const removeAssessments = (selections, unselectAll) => {
deleteAssessments.submit(
{ assessments: Array.from(selections) },
{
onSuccess(data) {
assessments.reload()
unselectAll()
},
}
)
}
const getRowRoute = (row) => {
if (row.assessment_type == 'LMS Assignment') {
if (row.submission) {
return {
name: 'AssignmentSubmission',
params: {
assignmentID: row.assessment_name,
submissionName: row.submission.name,
},
}
} else {
return {
name: 'AssignmentSubmission',
params: {
assignmentID: row.assessment_name,
submissionName: 'new',
},
}
}
} else {
return {
name: 'QuizPage',
params: {
quizID: row.assessment_name,
},
}
}
}
const canSeeAddButton = () => {
return user.data?.is_moderator || user.data?.is_evaluator
}
const getAssessmentColumns = () => { const getAssessmentColumns = () => {
let columns = [ let columns = [
{ {
label: 'Assessment', label: 'Assessment',
key: 'title', key: 'title',
width: '25rem',
}, },
{ {
label: 'Type', label: 'Type',
key: 'assessment_type', key: 'assessment_type',
width: '15rem',
}, },
] ]
if (!user.data?.is_moderator) { if (!user.data?.is_moderator) {
columns.push({ columns.push({
label: 'Status/Percentage', label: 'Status/Score',
key: 'status', key: 'status',
align: 'left', align: 'center',
width: '10rem',
}) })
} }
return columns return columns
} }
const getStatusTheme = (status) => {
if (status === 'Pass') {
return 'green'
} else if (status === 'Not Graded') {
return 'orange'
} else {
return 'red'
}
}
</script> </script>

View File

@@ -1,448 +0,0 @@
<template>
<div
v-if="assignment.data"
class="grid grid-cols-[68%,32%] h-full"
:class="{ 'border rounded-lg': !showTitle }"
>
<div class="border-r p-5 overflow-y-auto h-[calc(100vh-3.2rem)]">
<div v-if="showTitle" class="text-lg font-semibold mb-5">
<div v-if="submissionName === 'new'">
{{ __('Submission by') }} {{ user.data?.full_name }}
</div>
<div v-else>
{{ __('Submission by') }} {{ submissionResource.doc?.member_name }}
</div>
</div>
<div class="text-sm text-gray-600 font-medium mb-2">
{{ __('Question') }}:
</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="flex flex-col">
<div class="p-5">
<div class="flex items-center justify-between mb-4">
<div class="font-semibold">
{{ __('Submission') }}
</div>
<div class="flex items-center space-x-2">
<Badge v-if="isDirty" theme="orange">
{{ __('Not Saved') }}
</Badge>
<Badge
v-else-if="submissionResource.doc?.status"
:theme="statusTheme"
size="lg"
>
{{ submissionResource.doc?.status }}
</Badge>
<Button variant="solid" @click="submitAssignment()">
{{ __('Save') }}
</Button>
</div>
</div>
<div
v-if="
submissionName != 'new' &&
!['Pass', 'Fail'].includes(submissionResource.doc?.status) &&
submissionResource.doc?.owner == user.data?.name
"
class="bg-blue-100 p-3 rounded-md leading-5 text-sm mb-4"
>
{{ __("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="showUploader()">
<div class="text-xs text-gray-600 mt-1 mb-2">
{{ __('Add your assignment as {0}').format(assignment.data.type) }}
</div>
<FileUploader
v-if="!submissionFile"
:fileTypes="getType()"
:validateFile="validateFile"
@success="(file) => saveSubmission(file)"
>
<template #default="{ uploading, progress, 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>
<a
:href="submissionFile.file_url"
target="_blank"
class="flex flex-col cursor-pointer !no-underline"
>
<span>
{{ submissionFile.file_name }}
</span>
<span class="text-sm text-gray-500 mt-1">
{{ getFileSize(submissionFile.file_size) }}
</span>
</a>
<X
v-if="canModifyAssignment"
@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-xs text-gray-600 mb-1">
{{ __('Enter a URL') }}
</div>
<FormControl
v-model="answer"
type="text"
:readonly="!canModifyAssignment"
/>
</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
v-if="
user.data?.name == submissionResource.doc?.owner &&
submissionResource.doc?.comments
"
class="mt-8 p-3 bg-blue-100 rounded-md"
>
<div class="text-sm text-gray-600 font-medium mb-2">
{{ __('Comments by Evaluator') }}:
</div>
<div class="leading-5">
{{ submissionResource.doc.comments }}
</div>
</div>
<!-- Grading -->
<div v-if="canGradeSubmission" class="mt-8 space-y-4">
<div class="font-semibold mb-2">
{{ __('Grading') }}
</div>
<FormControl
v-if="submissionResource.doc"
v-model="submissionResource.doc.status"
:label="__('Grade')"
type="select"
:options="submissionStatusOptions"
/>
<FormControl
v-if="submissionResource.doc"
v-model="submissionResource.doc.comments"
:label="__('Comments')"
type="textarea"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
Badge,
Button,
call,
createResource,
createDocumentResource,
FileUploader,
FormControl,
TextEditor,
} from 'frappe-ui'
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { FileText, X } from 'lucide-vue-next'
import { showToast, getFileSize } from '@/utils'
import { useRouter } from 'vue-router'
const submissionFile = ref(null)
const answer = ref(null)
const router = useRouter()
const user = inject('$user')
const showTitle = router.currentRoute.value.name == 'AssignmentSubmission'
const isDirty = ref(false)
const props = defineProps({
assignmentID: {
type: String,
required: true,
},
submissionName: {
type: String,
default: 'new',
},
})
onMounted(() => {
window.addEventListener('keydown', keyboardShortcut)
})
const keyboardShortcut = (e) => {
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
submitAssignment()
e.preventDefault()
}
}
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
const assignment = createResource({
url: 'frappe.client.get',
params: {
doctype: 'LMS Assignment',
name: props.assignmentID,
},
auto: true,
onSuccess(data) {
if (props.submissionName != 'new') {
submissionResource.reload()
}
},
})
const newSubmission = createResource({
url: 'frappe.client.insert',
makeParams(values) {
let doc = {
doctype: 'LMS Assignment Submission',
assignment: props.assignmentID,
member: user.data?.name,
}
if (showUploader()) {
doc.assignment_attachment = submissionFile.value.file_url
} else {
doc.answer = answer.value
}
return {
doc: doc,
}
},
})
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 submissionResource = createDocumentResource({
doctype: 'LMS Assignment Submission',
name: props.submissionName,
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
auto: false,
cache: [user.data?.name, props.assignmentID],
})
watch(submissionResource, () => {
if (submissionResource.doc) {
if (submissionResource.doc.assignment_attachment) {
imageResource.reload({
image: submissionResource.doc.assignment_attachment,
})
}
if (submissionResource.doc.answer) {
answer.value = submissionResource.doc.answer
}
if (submissionResource.isDirty) {
isDirty.value = true
} else if (showUploader() && !submissionFile.value) {
isDirty.value = true
} else if (!showUploader() && !answer.value) {
isDirty.value = true
} else {
isDirty.value = false
}
}
})
watch(submissionFile, () => {
if (props.submissionName == 'new' && submissionFile.value) {
isDirty.value = true
}
})
const submitAssignment = () => {
if (props.submissionName != 'new') {
let evaluator =
submissionResource.doc && submissionResource.doc.owner != user.data?.name
? user.data?.name
: null
submissionResource.setValue.submit(
{
...submissionResource.doc,
evaluator: evaluator,
},
{
onSuccess(data) {
showToast(__('Success'), __('Changes saved successfully'), 'check')
},
}
)
} else {
addNewSubmission()
}
}
const addNewSubmission = () => {
newSubmission.submit(
{},
{
onSuccess(data) {
showToast('Success', 'Assignment submitted successfully.', 'check')
if (router.currentRoute.value.name == 'AssignmentSubmission') {
router.push({
name: 'AssignmentSubmission',
params: {
assignmentID: props.assignmentID,
submissionName: data.name,
},
})
} else {
markLessonProgress()
router.go()
}
submissionResource.name = data.name
submissionResource.reload()
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
},
}
)
}
const saveSubmission = (file) => {
submissionFile.value = file
}
const markLessonProgress = () => {
if (router.currentRoute.value.name == 'Lesson') {
let courseName = router.currentRoute.value.params.courseName
let chapterNumber = router.currentRoute.value.params.chapterNumber
let lessonNumber = router.currentRoute.value.params.lessonNumber
call('lms.lms.api.mark_lesson_progress', {
course: courseName,
chapter_number: chapterNumber,
lesson_number: lessonNumber,
})
}
}
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
}
const canGradeSubmission = computed(() => {
return (
(user.data?.is_moderator ||
user.data?.is_evaluator ||
user.data?.is_instructor) &&
props.submissionName != 'new' &&
router.currentRoute.value.name == 'AssignmentSubmission'
)
})
const canModifyAssignment = computed(() => {
return (
!submissionResource.doc ||
(submissionResource.doc?.owner == user.data?.name &&
submissionResource.doc?.status == 'Not Graded')
)
})
const submissionStatusOptions = computed(() => {
return [
{ label: 'Not Graded', value: 'Not Graded' },
{ label: 'Pass', value: 'Pass' },
{ label: 'Fail', value: 'Fail' },
]
})
const statusTheme = computed(() => {
if (!submissionResource.doc) {
return 'orange'
} else if (submissionResource.doc.status == 'Pass') {
return 'green'
} else if (submissionResource.doc.status == 'Not Graded') {
return 'blue'
} else {
return 'red'
}
})
const showUploader = () => {
return ['PDF', 'Image', 'Document'].includes(assignment.data?.type)
}
</script>

View File

@@ -1,46 +0,0 @@
<template>
<Assignment
v-if="user.data && submission.data"
:assignmentID="assignmentID"
:submissionName="submission.data?.name || 'new'"
/>
<div v-else class="border rounded-md text-center py-20">
<div>
{{ __('Please login to access the assignment.') }}
</div>
<Button @click="redirectToLogin()" class="mt-2">
<span>
{{ __('Login') }}
</span>
</Button>
</div>
</template>
<script setup>
import { inject, watch } from 'vue'
import { Button, createResource } from 'frappe-ui'
import Assignment from '@/components/Assignment.vue'
const user = inject('$user')
const props = defineProps({
assignmentID: {
type: String,
required: true,
},
})
const submission = createResource({
url: 'frappe.client.get_value',
makeParams(values) {
return {
doctype: 'LMS Assignment Submission',
fieldname: 'name',
filters: {
assignment: props.assignmentID,
member: user.data?.name,
},
}
},
auto: true,
})
</script>

View File

@@ -1,134 +0,0 @@
<template>
<div>
<!-- <audio width="100%" controls controlsList="nodownload" class="mb-4">
<source :src="encodeURI(file)" type="audio/mp3" />
</audio> -->
<audio @ended="handleAudioEnd" controlsList="nodownload" class="mb-4">
<source :src="encodeURI(file)" type="audio/mp3" />
</audio>
<div class="flex items-center space-x-2 shadow rounded-lg p-1 w-1/2">
<Button variant="ghost" @click="togglePlay">
<template #icon>
<Play v-if="!isPlaying" class="w-4 h-4 text-gray-900" />
<Pause v-else class="w-4 h-4 text-gray-900" />
</template>
</Button>
<input
type="range"
min="0"
:max="duration"
step="0.1"
v-model="currentTime"
@input="changeCurrentTime"
class="duration-slider w-full h-1"
/>
<span class="text-xs text-gray-900 font-medium">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</span>
<Button variant="ghost" @click="toggleMute">
<template #icon>
<Volume2 v-if="!isMuted" class="w-4 h-4 text-gray-900" />
<VolumeX v-else class="w-4 h-4 text-gray-900" />
</template>
</Button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { Play, Pause, Volume2, VolumeX } from 'lucide-vue-next'
import { Button } from 'frappe-ui'
const isPlaying = ref(false)
const audio = ref(null)
let isMuted = ref(false)
let currentTime = ref(0)
let duration = ref(0)
const props = defineProps({
file: {
type: String,
required: true,
},
})
onMounted(() => {
setTimeout(() => {
audio.value = document.querySelector('audio')
audio.value.onloadedmetadata = () => {
duration.value = audio.value.duration
}
audio.value.ontimeupdate = () => {
currentTime.value = audio.value.currentTime
}
}, 0)
})
const togglePlay = () => {
if (audio.value.paused) {
audio.value.play()
isPlaying.value = true
} else {
audio.value.pause()
isPlaying.value = false
}
}
const toggleMute = () => {
audio.value.muted = !audio.value.muted
isMuted.value = audio.value.muted
}
const changeCurrentTime = () => {
audio.value.currentTime = currentTime.value
}
const handleAudioEnd = () => {
isPlaying.value = false
}
const formatTime = (time) => {
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
}
watch(isPlaying, (newVal) => {
if (newVal) {
audio.value.play()
} else {
audio.value.pause()
}
})
</script>
<style>
.duration-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
background-color: theme('colors.gray.400');
cursor: pointer;
}
.duration-slider::-webkit-slider-thumb {
height: 10px;
width: 10px;
-webkit-appearance: none;
background-color: theme('colors.gray.900');
}
@media screen and (-webkit-min-device-pixel-ratio: 0) {
input[type='range'] {
overflow: hidden;
width: 150px;
-webkit-appearance: none;
}
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
cursor: pointer;
box-shadow: -150px 0 0 150px theme('colors.gray.900');
}
}
</style>

View File

@@ -1,19 +1,14 @@
<template> <template>
<div <div
class="flex flex-col shadow hover:bg-gray-100 rounded-md p-4 h-full" class="flex flex-col border border-gray-200 rounded-md p-4 h-full"
style="min-height: 150px" style="min-height: 150px"
> >
<div class="text-lg leading-5 font-semibold mb-2">
{{ batch.title }}
</div>
<Badge <Badge
v-if="batch.seat_count && batch.seats_left > 0" v-if="batch.seat_count && batch.seats_left > 0"
theme="green" theme="green"
class="self-start mb-2" class="self-start mb-2"
> >
{{ batch.seats_left }} {{ batch.seats_left }} {{ __('Seat Left') }}
<span v-if="batch.seats_left > 1">{{ __('Seats Left') }}</span
><span v-else-if="batch.seats_left == 1">{{ __('Seat Left') }}</span>
</Badge> </Badge>
<Badge <Badge
v-else-if="batch.seat_count && batch.seats_left <= 0" v-else-if="batch.seat_count && batch.seats_left <= 0"
@@ -22,59 +17,43 @@
> >
{{ __('Sold Out') }} {{ __('Sold Out') }}
</Badge> </Badge>
<div class="short-introduction text-sm text-gray-700"> <div class="text-xl font-semibold mb-1">
{{ batch.title }}
</div>
<div class="short-introduction">
{{ batch.description }} {{ batch.description }}
</div> </div>
<div v-if="batch.amount" class="font-semibold mb-4"> <div class="mt-auto">
{{ batch.price }} <div v-if="batch.amount" class="font-semibold text-lg mb-4">
</div> {{ batch.price }}
<div class="flex flex-col space-y-2 mt-auto"> </div>
<DateRange <div class="flex items-center mb-3">
:startDate="batch.start_date" <BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
:endDate="batch.end_date" <span> {{ batch.courses.length }} {{ __('Courses') }} </span>
class="text-sm text-gray-700" </div>
/> <div class="flex items-center mb-3">
<div class="flex items-center text-sm text-gray-700"> <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" /> <Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span> <span>
{{ formatTime(batch.start_time) }} - {{ formatTime(batch.end_time) }} {{ formatTime(batch.start_time) }} - {{ formatTime(batch.end_time) }}
</span> </span>
</div> </div>
<div
v-if="batch.timezone"
class="flex items-center text-sm text-gray-700"
>
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-gray-600" />
<span>
{{ batch.timezone }}
</span>
</div>
</div>
<div
v-if="batch.instructors?.length"
class="flex avatar-group overlap mt-4"
>
<div
class="h-6 mr-1"
:class="{ 'avatar-group overlap': batch.instructors.length > 1 }"
>
<UserAvatar
v-for="instructor in batch.instructors"
:user="instructor"
/>
</div>
<CourseInstructors :instructors="batch.instructors" />
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { Calendar, Clock, BookOpen } from 'lucide-vue-next'
import { inject } from 'vue'
import { Badge } from 'frappe-ui' import { Badge } from 'frappe-ui'
import { formatTime } from '../utils' import { formatTime } from '../utils'
import { Clock, BookOpen, Globe } from 'lucide-vue-next'
import DateRange from '@/components/Common/DateRange.vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue'
const dayjs = inject('$dayjs')
const props = defineProps({ const props = defineProps({
batch: { batch: {
type: Object, type: Object,
@@ -90,20 +69,7 @@ const props = defineProps({
text-overflow: ellipsis; text-overflow: ellipsis;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
margin: 0.25rem 0 1rem; margin: 0.25rem 0 1.25rem;
line-height: 1.5; line-height: 1.5;
} }
.avatar-group {
display: inline-flex;
align-items: center;
}
.avatar-group .avatar {
transition: margin 0.1s ease-in-out;
}
.avatar-group.overlap .avatar + .avatar {
margin-left: calc(-8px);
}
</style> </style>

View File

@@ -1,28 +1,26 @@
<template> <template>
<div> <div>
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div class="text-lg font-semibold"> <div class="text-xl font-semibold">
{{ __('Courses') }} {{ __('Courses') }}
</div> </div>
<Button v-if="canSeeAddButton()" @click="openCourseModal()"> <Button
v-if="user.data?.is_moderator"
variant="solid"
@click="openCourseModal()"
>
<template #prefix> <template #prefix>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
</template> </template>
{{ __('Add') }} {{ __('Add Course') }}
</Button> </Button>
</div> </div>
<div v-if="courses.data?.length"> <div v-if="courses.data?.length">
<ListView <ListView
:columns="getCoursesColumns()" :columns="getCoursesColumns()"
:rows="courses.data" :rows="courses.data"
row-key="batch_course" row-key="name"
:options="{ :options="{ showTooltip: false }"
showTooltip: false,
getRowRoute: (row) => ({
name: 'CourseDetail',
params: { courseName: row.name },
}),
}"
> >
<ListHeader <ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2" class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
@@ -51,10 +49,7 @@
<ListSelectBanner> <ListSelectBanner>
<template #actions="{ unselectAll, selections }"> <template #actions="{ unselectAll, selections }">
<div class="flex gap-2"> <div class="flex gap-2">
<Button <Button variant="ghost" @click="removeCourses(selections)">
variant="ghost"
@click="removeCourses(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" /> <Trash2 class="h-4 w-4 stroke-1.5" />
</Button> </Button>
</div> </div>
@@ -84,7 +79,6 @@ import {
ListRowItem, ListRowItem,
} from 'frappe-ui' } from 'frappe-ui'
import { Plus, Trash2 } from 'lucide-vue-next' import { Plus, Trash2 } from 'lucide-vue-next'
import { showToast } from '@/utils'
const showCourseModal = ref(false) const showCourseModal = ref(false)
const user = inject('$user') const user = inject('$user')
@@ -114,47 +108,34 @@ const getCoursesColumns = () => {
{ {
label: 'Title', label: 'Title',
key: 'title', key: 'title',
width: 2,
}, },
{ {
label: 'Lessons', label: 'Lessons',
key: 'lessons', key: 'lesson_count',
align: 'right',
}, },
{ {
label: 'Enrollments', label: 'Enrollments',
align: 'right', key: 'enrollment_count',
key: 'enrollments',
}, },
] ]
} }
const deleteCourses = createResource({ const removeCourse = createResource({
url: 'lms.lms.api.delete_documents', url: 'frappe.client.delete',
makeParams(values) { makeParams(values) {
return { return {
doctype: 'Batch Course', doctype: 'Batch Course',
documents: values.courses, name: values.course,
} }
}, },
}) })
const removeCourses = (selections, unselectAll) => { const removeCourses = (selections) => {
deleteCourses.submit( console.log(selections)
{ selections.forEach(async (course) => {
courses: Array.from(selections), removeCourse.submit({ course })
}, await setTimeout(1000)
{ })
onSuccess(data) { courses.reload()
courses.reload()
showToast(__('Success'), __('Courses deleted successfully'), 'check')
unselectAll()
},
}
)
}
const canSeeAddButton = () => {
return user.data?.is_moderator || user.data?.is_evaluator
} }
</script> </script>

View File

@@ -1,12 +1,11 @@
<template> <template>
<div v-if="batch.data" class="shadow rounded-md p-5 lg:w-72"> <div v-if="batch.data" class="shadow rounded-md p-5" style="width: 300px">
<Badge <Badge
v-if="batch.data.seat_count && seats_left > 0" v-if="batch.data.seat_count && seats_left > 0"
theme="green" theme="green"
class="self-start mb-2 float-right" class="self-start mb-2 float-right"
> >
{{ seats_left }} <span v-if="seats_left > 1">{{ __('Seats Left') }}</span {{ seats_left }} {{ __('Seat Left') }}
><span v-else-if="seats_left == 1">{{ __('Seat Left') }}</span>
</Badge> </Badge>
<Badge <Badge
v-else-if="batch.data.seat_count && seats_left <= 0" v-else-if="batch.data.seat_count && seats_left <= 0"
@@ -22,26 +21,22 @@
<BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" /> <BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span> <span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
</div> </div>
<DateRange
:startDate="batch.data.start_date"
:endDate="batch.data.end_date"
class="mb-3"
/>
<div class="flex items-center mb-3"> <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" /> <Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span> <span>
{{ formatTime(batch.data.start_time) }} - {{ formatTime(batch.data.start_time) }} -
{{ formatTime(batch.data.end_time) }} {{ formatTime(batch.data.end_time) }}
</span> </span>
</div> </div>
<div v-if="batch.data.timezone" class="flex items-center">
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span>
{{ batch.data.timezone }}
</span>
</div>
<router-link <router-link
v-if="isModerator || isStudent" v-if="user?.data?.is_moderator"
:to="{ :to="{
name: 'Batch', name: 'Batch',
params: { params: {
@@ -51,7 +46,7 @@
> >
<Button variant="solid" class="w-full mt-4"> <Button variant="solid" class="w-full mt-4">
<span> <span>
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }} {{ __('Manage Batch') }}
</span> </span>
</Button> </Button>
</router-link> </router-link>
@@ -63,9 +58,9 @@
name: batch.data.name, name: batch.data.name,
}, },
}" }"
v-else-if="batch.data.paid_batch && batch.data.seats_left" v-else-if="batch.data.paid_batch"
> >
<Button v-if="!isStudent" class="w-full mt-4" variant="solid"> <Button class="w-full mt-4" variant="solid">
<span> <span>
{{ __('Register Now') }} {{ __('Register Now') }}
</span> </span>
@@ -74,15 +69,14 @@
<Button <Button
variant="solid" variant="solid"
class="w-full mt-2" class="w-full mt-2"
v-else-if="batch.data.allow_self_enrollment && batch.data.seats_left" v-else-if="batch.data.allow_self_enrollment"
@click="enrollInBatch()"
> >
{{ __('Enroll Now') }} {{ __('Enroll Now') }}
</Button> </Button>
<router-link <router-link
v-if="isModerator" v-if="user?.data?.is_moderator"
:to="{ :to="{
name: 'BatchForm', name: 'BatchCreation',
params: { params: {
batchName: batch.data.name, batchName: batch.data.name,
}, },
@@ -97,14 +91,12 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { formatNumberIntoCurrency, formatTime } from '@/utils'
import { BookOpen, Calendar, Clock } from 'lucide-vue-next'
import { inject, computed } from 'vue' import { inject, computed } from 'vue'
import { Badge, Button, createResource } from 'frappe-ui' import { Badge, Button } from 'frappe-ui'
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
import { formatNumberIntoCurrency, formatTime, showToast } from '@/utils'
import DateRange from '@/components/Common/DateRange.vue'
import { useRouter } from 'vue-router'
const router = useRouter() const dayjs = inject('$dayjs')
const user = inject('$user') const user = inject('$user')
const props = defineProps({ const props = defineProps({
@@ -114,51 +106,10 @@ const props = defineProps({
}, },
}) })
const enroll = createResource({
url: 'lms.lms.utils.enroll_in_batch',
makeParams(values) {
return {
batch: props.batch.data.name,
}
},
})
const enrollInBatch = () => {
if (!user.data) {
window.location.href = `/login?redirect-to=/batches/details/${props.batch.data.name}`
}
enroll.submit(
{},
{
onSuccess(data) {
showToast(
__('Success'),
__('You have been enrolled in this batch'),
'check'
)
router.push({
name: 'Batch',
params: {
batchName: props.batch.data.name,
},
})
},
}
)
}
const seats_left = computed(() => { const seats_left = computed(() => {
if (props.batch.data?.seat_count) { if (props.batch.data?.seat_count) {
return props.batch.data?.seat_count - props.batch.data?.students?.length return props.batch.data?.seat_count - props.batch.data?.students?.length
} }
return null return null
}) })
const isStudent = computed(() => {
return props.batch.data?.students?.includes(user.data?.name)
})
const isModerator = computed(() => {
return user.data?.is_moderator
})
</script> </script>

View File

@@ -1,199 +1,77 @@
<template> <template>
<div class=""> <Button class="float-right mb-3" variant="solid" @click="openStudentModal()">
<div class="w-full flex items-center justify-between pb-4"> <template #prefix>
<div class="font-medium text-gray-600"> <Plus class="h-4 w-4" />
{{ __('Statistics') }} </template>
</div> {{ __('Add Student') }}
</div> </Button>
<div class="grid grid-cols-3 gap-5 mb-8"> <div class="text-lg font-semibold mb-4">
<div class="flex items-center shadow py-2 px-3 rounded-md"> {{ __('Students') }}
<div class="p-2 rounded-md bg-gray-100 mr-3">
<User class="w-18 h-18 stroke-1.5 text-gray-700" />
</div>
<div class="flex flex-col">
<span class="text-xl font-semibold mb-1">
{{ students.data?.length }}
</span>
<span class="text-gray-700">
{{ __('Students') }}
</span>
</div>
</div>
<div class="flex items-center shadow py-2 px-3 rounded-md">
<div class="p-2 rounded-md bg-gray-100 mr-3">
<BookOpen class="w-18 h-18 stroke-1.5 text-gray-700" />
</div>
<div class="flex flex-col">
<span class="text-xl font-semibold mb-1">
{{ batch.courses?.length }}
</span>
<span class="text-gray-700">
{{ __('Courses') }}
</span>
</div>
</div>
<div class="flex items-center shadow py-2 px-3 rounded-md">
<div class="p-2 rounded-md bg-gray-100 mr-3">
<ShieldCheck class="w-18 h-18 stroke-1.5 text-gray-700" />
</div>
<div class="flex flex-col">
<span class="text-xl font-semibold mb-1">
{{ assessmentCount }}
</span>
<span class="text-gray-700">
{{ __('Assessments') }}
</span>
</div>
</div>
</div>
<div class="mb-8">
<div class="text-gray-600 font-medium">
{{ __('Progress') }}
</div>
<ApexChart
v-if="showProgressChart"
:options="chartOptions"
:series="chartData"
type="bar"
height="350"
/>
<div
class="flex items-center justify-center text-sm text-gray-700 space-x-4"
>
<div class="flex items-center space-x-2">
<div class="w-3 h-3" style="background-color: #0f736b"></div>
<div>
{{ __('Courses') }}
</div>
</div>
<div class="flex items-center space-x-2">
<div class="w-3 h-3" style="background-color: #0070cc"></div>
<div>
{{ __('Assessments') }}
</div>
</div>
</div>
</div>
</div> </div>
<div v-if="students.data?.length">
<div> <ListView
<div class="flex items-center justify-between mb-4"> :columns="getStudentColumns()"
<div class="text-gray-600 font-medium"> :rows="students.data"
{{ __('Students') }} row-key="name"
</div> :options="{ showTooltip: false }"
<Button @click="openStudentModal()"> >
<template #prefix> <ListHeader
<Plus class="h-4 w-4" /> class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
</template>
{{ __('Add') }}
</Button>
</div>
<div v-if="students.data?.length">
<ListView
:columns="getStudentColumns()"
:rows="students.data"
row-key="name"
:options="{
showTooltip: false,
}"
> >
<ListHeader <ListHeaderItem :item="item" v-for="item in getStudentColumns()">
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2" <template #prefix="{ item }">
> <component
<ListHeaderItem v-if="item.icon"
:item="item" :is="item.icon"
v-for="item in getStudentColumns()" class="h-4 w-4 stroke-1.5 ml-4"
:title="item.label" />
>
<template #prefix="{ item }">
<FeatherIcon
v-if="item.icon"
:name="item.icon"
class="h-4 w-4 stroke-1.5"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow
:row="row"
v-for="row in students.data"
class="group cursor-pointer"
@click="openStudentProgressModal(row)"
>
<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
v-if="column.key == 'progress'"
class="flex items-center space-x-4 w-full"
>
<ProgressBar :progress="row[column.key]" size="sm" />
</div>
<div
v-else-if="column.key == 'copy'"
class="invisible group-hover:visible"
>
<Button variant="ghost" @click="copyEmail(row)">
<template #icon>
<Clipboard class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeStudents(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template> </template>
</ListSelectBanner> </ListHeaderItem>
</ListView> </ListHeader>
</div> <ListRows>
<div v-else class="text-sm italic text-gray-600"> <ListRow :row="row" v-for="row in students.data">
{{ __('There are no students in this batch.') }} <template #default="{ column, item }">
</div> <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> </div>
<StudentModal <StudentModal
:batch="props.batch.name" :batch="props.batch"
v-model="showStudentModal" v-model="showStudentModal"
v-model:reloadStudents="students" v-model:reloadStudents="students"
/> />
<BatchStudentProgress
:student="selectedStudent"
v-model="showStudentProgressModal"
/>
</template> </template>
<script setup> <script setup>
import { import {
Avatar,
Button,
createResource, createResource,
FeatherIcon,
ListHeader, ListHeader,
ListHeaderItem, ListHeaderItem,
ListSelectBanner, ListSelectBanner,
@@ -201,217 +79,73 @@ import {
ListRows, ListRows,
ListView, ListView,
ListRowItem, ListRowItem,
Avatar,
Button,
} from 'frappe-ui' } from 'frappe-ui'
import { import { Trash2, Plus } from 'lucide-vue-next'
BookOpen, import { ref } from 'vue'
Clipboard,
Plus,
ShieldCheck,
Trash2,
User,
} from 'lucide-vue-next'
import { ref, watch } from 'vue'
import StudentModal from '@/components/Modals/StudentModal.vue' import StudentModal from '@/components/Modals/StudentModal.vue'
import { showToast } from '@/utils'
import ProgressBar from '@/components/ProgressBar.vue'
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
import ApexChart from 'vue3-apexcharts'
const showStudentModal = ref(false) const showStudentModal = ref(false)
const showStudentProgressModal = ref(false)
const selectedStudent = ref(null)
const chartData = ref(null)
const chartOptions = ref(null)
const showProgressChart = ref(false)
const assessmentCount = ref(0)
const props = defineProps({ const props = defineProps({
batch: { batch: {
type: Object, type: String,
default: null, default: null,
}, },
}) })
const students = createResource({ const students = createResource({
url: 'lms.lms.utils.get_batch_students', url: 'lms.lms.utils.get_batch_students',
cache: ['students', props.batch.name], cache: ['students', props.batch],
params: { params: {
batch: props.batch?.name, batch: props.batch,
}, },
auto: true, auto: true,
onSuccess(data) {
chartData.value = getChartData()
showProgressChart.value = true
},
}) })
const getStudentColumns = () => { const getStudentColumns = () => {
let columns = [ return [
{ {
label: 'Full Name', label: 'Full Name',
key: 'full_name', key: 'full_name',
width: '20rem',
icon: 'user',
}, },
{ {
label: 'Progress', label: 'Courses Done',
key: 'progress', key: 'courses_completed',
width: '10rem', align: 'center',
icon: 'activity', },
{
label: 'Assessments Done',
key: 'assessments_completed',
align: 'center',
}, },
{ {
label: 'Last Active', label: 'Last Active',
key: 'last_active', key: 'last_active',
width: '15rem',
align: 'center',
icon: 'clock',
},
{
label: '',
key: 'copy',
}, },
] ]
return columns
} }
const openStudentModal = () => { const openStudentModal = () => {
showStudentModal.value = true showStudentModal.value = true
} }
const openStudentProgressModal = (row) => { const removeStudent = createResource({
showStudentProgressModal.value = true url: 'frappe.client.delete',
selectedStudent.value = row
}
const deleteStudents = createResource({
url: 'lms.lms.api.delete_documents',
makeParams(values) { makeParams(values) {
return { return {
doctype: 'Batch Student', doctype: 'Batch Student',
documents: values.students, name: values.student,
} }
}, },
}) })
const removeStudents = (selections, unselectAll) => { const removeStudents = (selections) => {
deleteStudents.submit( selections.forEach(async (student) => {
{ removeStudent.submit({ student })
students: Array.from(selections), await setTimeout(1000)
},
{
onSuccess(data) {
students.reload()
showToast(__('Success'), __('Students deleted successfully'), 'check')
unselectAll()
},
}
)
}
const getChartData = () => {
let categories = {}
Object.keys(students.data?.[0].courses).forEach((course) => {
categories[course] = {
value: 0,
type: 'course',
label: course,
}
}) })
students.reload()
Object.keys(students.data?.[0].assessments).forEach((assessment) => {
categories[assessment] = {
value: 0,
type: 'assessment',
label: assessment,
}
})
students.data.forEach((student) => {
Object.keys(student.courses).forEach((course) => {
if (student.courses[course] === 100) {
categories[course].value += 1
}
})
Object.keys(student.assessments).forEach((assessment) => {
if (student.assessments[assessment] === 100) {
categories[assessment].value += 1
}
})
})
chartOptions.value = getChartOptions(categories)
return [
{
name: __('Completed by Students'),
data: Object.values(categories).map((item) => item.value),
},
]
} }
const getChartOptions = (categories) => {
const courseColor = '#0F736B'
const assessmentColor = '#0070CC'
const maxY =
students.data?.length % 5
? students.data?.length + (5 - (students.data?.length % 5))
: students.data?.length
return {
chart: {
type: 'bar',
height: 50,
toolbar: {
show: false,
},
},
plotOptions: {
bar: {
distributed: true,
borderRadius: 0,
horizontal: true,
barHeight: '30%',
},
},
colors: Object.values(categories).map((item) =>
item.type === 'course' ? courseColor : assessmentColor
),
xaxis: {
categories: Object.values(categories).map((item) => item.label),
labels: {
style: {
fontSize: '10px',
},
rotate: 0,
formatter: function (value) {
return value.length > 20 ? `${value.substring(0, 20)}...` : value // Trim long labels
},
},
},
yaxis: {
max: maxY,
min: 0,
stepSize: 10,
tickAmount: maxY / 5,
},
}
}
const copyEmail = (row) => {
navigator.clipboard.writeText(row.email)
showToast(__('Success'), __('Email copied to clipboard'), 'check')
}
watch(students, () => {
if (students.data?.length) {
assessmentCount.value = Object.keys(students.data?.[0].assessments).length
}
})
</script> </script>
<style>
.apexcharts-legend {
display: none !important;
}
</style>

View File

@@ -1,92 +0,0 @@
<template>
<div class="flex flex-col justify-between min-h-0">
<div>
<div class="flex items-center justify-between">
<div class="font-semibold mb-1">
{{ __(label) }}
</div>
<Badge
v-if="isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
</div>
<div class="text-xs text-gray-600">
{{ __(description) }}
</div>
</div>
<div class="overflow-y-auto">
<SettingFields :fields="fields" :data="data.data" />
<div class="flex flex-row-reverse mt-auto">
<Button variant="solid" :loading="saveSettings.loading" @click="update">
{{ __('Update') }}
</Button>
</div>
</div>
</div>
</template>
<script setup>
import { createResource, Button, Badge } from 'frappe-ui'
import SettingFields from '@/components/SettingFields.vue'
import { watch, ref } from 'vue'
const isDirty = ref(false)
const props = defineProps({
fields: {
type: Array,
required: true,
},
data: {
type: Object,
required: true,
},
label: {
type: String,
required: true,
},
description: {
type: String,
},
})
const saveSettings = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
doctype: 'Website Settings',
name: 'Website Settings',
fieldname: values.fields,
}
},
})
const update = () => {
let fieldsToSave = {}
let imageFields = ['favicon', 'banner_image', 'footer_logo']
props.fields.forEach((f) => {
if (imageFields.includes(f.name)) {
fieldsToSave[f.name] = f.value ? f.value.file_url : null
} else {
fieldsToSave[f.name] = f.value
}
})
saveSettings.submit(
{
fields: fieldsToSave,
},
{
onSuccess(data) {
isDirty.value = false
},
}
)
}
watch(props.data, (newData) => {
if (newData && !isDirty.value) {
isDirty.value = true
}
})
</script>

View File

@@ -1,151 +0,0 @@
<template>
<div class="flex flex-col min-h-0">
<div class="flex items-center justify-between">
<div class="text-xl font-semibold mb-1">
{{ label }}
</div>
<Button @click="() => showCategoryForm()">
<template #icon>
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
<X v-else class="h-3 w-3 stroke-1.5" />
</template>
</Button>
</div>
<div
v-if="showForm"
class="flex items-center justify-between my-4 space-x-2"
>
<FormControl
ref="categoryInput"
v-model="category"
:placeholder="__('Category Name')"
class="flex-1"
/>
<Button @click="addCategory()" variant="subtle">
{{ __('Add') }}
</Button>
</div>
<div class="overflow-y-scroll">
<div class="text-base divide-y">
<FormControl
:value="cat.category"
type="text"
v-for="cat in categories.data"
class="form-control"
@change.stop="(e) => update(cat.name, e.target.value)"
/>
</div>
</div>
</div>
</template>
<script setup>
import {
Button,
FormControl,
createListResource,
createResource,
debounce,
} from 'frappe-ui'
import { Plus, X } from 'lucide-vue-next'
import { ref } from 'vue'
const showForm = ref(false)
const category = ref(null)
const categoryInput = ref(null)
const props = defineProps({
label: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
})
const categories = createListResource({
doctype: 'LMS Category',
fields: ['name', 'category'],
auto: true,
})
const newCategory = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Category',
category: category.value,
},
}
},
})
const addCategory = () => {
newCategory.submit(
{},
{
onSuccess(data) {
categories.reload()
category.value = null
},
}
)
}
const showCategoryForm = () => {
showForm.value = !showForm.value
setTimeout(() => {
categoryInput.value.$el.querySelector('input').focus()
}, 0)
}
const updateCategory = createResource({
url: 'frappe.client.rename_doc',
makeParams(values) {
return {
doctype: 'LMS Category',
old_name: values.name,
new_name: values.category,
}
},
})
const update = (name, value) => {
updateCategory.submit(
{
name: name,
category: value,
},
{
onSuccess() {
categories.reload()
},
}
)
}
</script>
<style>
.form-control input {
padding: 1.25rem 0;
border-color: transparent;
background: white;
}
.form-control input:focus {
outline: transparent;
background: white;
box-shadow: none;
border-color: transparent;
}
.form-control input:hover {
outline: transparent;
background: white;
box-shadow: none;
border-color: transparent;
}
</style>

View File

@@ -1,22 +0,0 @@
<template>
<div class="flex items-center">
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span>
{{ getFormattedDateRange(props.startDate, props.endDate) }}
</span>
</div>
</template>
<script setup>
import { Calendar } from 'lucide-vue-next'
import { getFormattedDateRange } from '@/utils'
const props = defineProps({
startDate: {
type: String,
},
endDate: {
type: String,
},
})
</script>

View File

@@ -75,7 +75,7 @@
> >
<li <li
:class="[ :class="[
'flex items-center rounded px-2.5 py-2 text-base', 'flex items-center rounded px-2.5 py-1.5 text-base',
{ 'bg-gray-100': active }, { 'bg-gray-100': active },
]" ]"
> >
@@ -87,16 +87,7 @@
name="item-label" name="item-label"
v-bind="{ active, selected, option }" v-bind="{ active, selected, option }"
> >
<div class="flex flex-col space-y-1"> {{ option.label }}
<div>
{{ option.label }}
</div>
<div
v-if="option.description"
class="text-xs text-gray-700"
v-html="option.description"
></div>
</div>
</slot> </slot>
</li> </li>
</ComboboxOption> </ComboboxOption>

View File

@@ -1,204 +0,0 @@
<template>
<div
class="editor flex flex-col gap-1"
:style="{
height: height,
}"
>
<span class="text-xs" v-if="label">
{{ label }}
</span>
<div
ref="editor"
class="h-auto flex-1 overflow-hidden overscroll-none !rounded border border-outline-gray-2 bg-surface-gray-2 dark:bg-gray-900"
/>
<span
class="mt-1 text-xs text-gray-600"
v-show="description"
v-html="description"
></span>
<Button
v-if="showSaveButton"
@click="emit('save', aceEditor?.getValue())"
class="mt-3"
>
{{ __('Save') }}
</Button>
</div>
</template>
<script setup lang="ts">
import { useDark } from '@vueuse/core'
import ace from 'ace-builds'
import 'ace-builds/src-min-noconflict/ext-searchbox'
import 'ace-builds/src-min-noconflict/theme-chrome'
import 'ace-builds/src-min-noconflict/theme-twilight'
import { PropType, onMounted, ref, watch } from 'vue'
import { Button } from 'frappe-ui'
const isDark = useDark({
attribute: 'data-theme',
})
const props = defineProps({
modelValue: {
type: [Object, String, Array],
},
type: {
type: String as PropType<'JSON' | 'HTML' | 'Python' | 'JavaScript' | 'CSS'>,
default: 'JSON',
},
label: {
type: String,
default: '',
},
readonly: {
type: Boolean,
default: false,
},
height: {
type: String,
default: '250px',
},
showLineNumbers: {
type: Boolean,
default: false,
},
autofocus: {
type: Boolean,
default: true,
},
showSaveButton: {
type: Boolean,
default: false,
},
description: {
type: String,
default: '',
},
})
const emit = defineEmits(['save', 'update:modelValue'])
const editor = ref<HTMLElement | null>(null)
let aceEditor = null as ace.Ace.Editor | null
onMounted(() => {
setupEditor()
})
const setupEditor = () => {
aceEditor = ace.edit(editor.value as HTMLElement)
resetEditor(props.modelValue as string, true)
aceEditor.setReadOnly(props.readonly)
aceEditor.setOptions({
fontSize: '12px',
useWorker: false,
showGutter: props.showLineNumbers,
wrap: props.showLineNumbers,
})
if (props.type === 'CSS') {
import('ace-builds/src-noconflict/mode-css').then(() => {
aceEditor?.session.setMode('ace/mode/css')
})
} else if (props.type === 'JavaScript') {
import('ace-builds/src-noconflict/mode-javascript').then(() => {
aceEditor?.session.setMode('ace/mode/javascript')
})
} else if (props.type === 'Python') {
import('ace-builds/src-noconflict/mode-python').then(() => {
aceEditor?.session.setMode('ace/mode/python')
})
} else if (props.type === 'JSON') {
import('ace-builds/src-noconflict/mode-json').then(() => {
aceEditor?.session.setMode('ace/mode/json')
})
} else {
import('ace-builds/src-noconflict/mode-html').then(() => {
aceEditor?.session.setMode('ace/mode/html')
})
}
aceEditor.on('blur', () => {
try {
let value = aceEditor?.getValue() || ''
if (props.type === 'JSON') {
value = JSON.parse(value)
}
if (value === props.modelValue) return
if (!props.showSaveButton && !props.readonly) {
emit('update:modelValue', value)
}
} catch (e) {
// do nothing
}
})
}
const getModelValue = () => {
let value = props.modelValue || ''
try {
if (props.type === 'JSON' || typeof value === 'object') {
value = JSON.stringify(value, null, 2)
}
} catch (e) {
// do nothing
}
return value as string
}
function resetEditor(value: string, resetHistory = false) {
value = getModelValue()
aceEditor?.setValue(value)
aceEditor?.clearSelection()
aceEditor?.setTheme(isDark.value ? 'ace/theme/twilight' : 'ace/theme/chrome')
props.autofocus && aceEditor?.focus()
if (resetHistory) {
aceEditor?.session.getUndoManager().reset()
}
}
watch(isDark, () => {
aceEditor?.setTheme(isDark.value ? 'ace/theme/twilight' : 'ace/theme/chrome')
})
watch(
() => props.type,
() => {
setupEditor()
}
)
watch(
() => props.modelValue,
() => {
resetEditor(props.modelValue as string)
}
)
defineExpose({ resetEditor })
</script>
<style scoped>
.editor .ace_editor {
height: 100%;
width: 100%;
border-radius: 5px;
overscroll-behavior: none;
}
.editor :deep(.ace_scrollbar-h) {
display: none;
}
.editor :deep(.ace_search) {
@apply dark:bg-gray-800 dark:text-gray-200;
@apply dark:border-gray-800;
}
.editor :deep(.ace_searchbtn) {
@apply dark:bg-gray-800 dark:text-gray-200;
@apply dark:border-gray-800;
}
.editor :deep(.ace_button) {
@apply dark:bg-gray-800 dark:text-gray-200;
}
.editor :deep(.ace_search_field) {
@apply dark:bg-gray-900 dark:text-gray-200;
@apply dark:border-gray-800;
}
</style>

View File

@@ -1,114 +0,0 @@
<template>
<div class="space-y-1.5">
<label class="block text-xs text-gray-600">
{{ label }}
</label>
<div class="w-full">
<Popover>
<template #target="{ togglePopover }">
<button
@click="openPopover(togglePopover)"
class="flex w-full items-center space-x-2 focus:outline-none bg-gray-100 rounded h-7 py-1.5 px-2 hover:bg-gray-200 focus:bg-white border border-gray-100 hover:border-gray-200 focus:border-gray-500"
>
<component
v-if="selectedIcon"
class="w-4 h-4 text-gray-700 stroke-1.5"
:is="icons[selectedIcon]"
/>
<component
v-else
class="w-4 h-4 text-gray-700 stroke-1.5"
:is="icons.Folder"
/>
<span v-if="selectedIcon">
{{ selectedIcon }}
</span>
<span v-else class="text-gray-600">
{{ __('Choose an icon') }}
</span>
</button>
</template>
<template #body-main="{ close, isOpen }" class="w-full">
<div class="p-3 max-h-56 overflow-auto w-full">
<FormControl
ref="search"
v-model="iconQuery"
:placeholder="__('Search for an icon')"
autocomplete="off"
/>
<div class="grid grid-cols-10 gap-4 mt-4">
<div v-for="(iconComponent, iconName) in filteredIcons">
<component
:is="iconComponent"
class="h-4 w-4 stroke-1.5 text-gray-700 cursor-pointer"
@click="setIcon(iconName, close)"
/>
</div>
</div>
</div>
</template>
</Popover>
</div>
</div>
</template>
<script setup>
import { FormControl, Popover } from 'frappe-ui'
import * as icons from 'lucide-vue-next'
import { ref, computed, onMounted, nextTick } from 'vue'
const iconQuery = ref('')
const selectedIcon = ref('')
const search = ref(null)
const emit = defineEmits(['update:modelValue', 'change'])
const iconArray = ref(
Object.keys(icons)
.sort(() => 0.5 - Math.random())
.slice(0, 100)
.reduce((result, key) => {
result[key] = icons[key]
return result
}, {})
)
const props = defineProps({
label: {
type: String,
default: 'Icon',
},
modelValue: {
type: String,
default: '',
},
})
onMounted(() => {
selectedIcon.value = props.modelValue
})
const setIcon = (icon, close) => {
emit('update:modelValue', icon)
selectedIcon.value = icon
iconQuery.value = ''
close()
}
const filteredIcons = computed(() => {
if (!iconQuery.value) {
return iconArray.value
}
return Object.keys(icons)
.filter((icon) =>
icon.toLowerCase().includes(iconQuery.value.toLowerCase())
)
.reduce((result, key) => {
result[key] = icons[key]
return result
}, {})
})
const openPopover = (togglePopover) => {
togglePopover()
}
</script>

View File

@@ -2,7 +2,6 @@
<div class="space-y-1.5"> <div class="space-y-1.5">
<label class="block" :class="labelClasses" v-if="attrs.label"> <label class="block" :class="labelClasses" v-if="attrs.label">
{{ attrs.label }} {{ attrs.label }}
<span class="text-red-500" v-if="attrs.required">*</span>
</label> </label>
<Autocomplete <Autocomplete
ref="autocomplete" ref="autocomplete"
@@ -29,8 +28,8 @@
<slot name="item-label" v-bind="{ active, selected, option }" /> <slot name="item-label" v-bind="{ active, selected, option }" />
</template> </template>
<template #footer="{ value, close }"> <template v-if="attrs.onCreate" #footer="{ value, close }">
<div v-if="attrs.onCreate"> <div>
<Button <Button
variant="ghost" variant="ghost"
class="w-full !justify-start" class="w-full !justify-start"
@@ -42,21 +41,8 @@
</template> </template>
</Button> </Button>
</div> </div>
<div>
<Button
variant="ghost"
class="w-full !justify-start"
:label="__('Clear')"
@click="() => clearValue(close)"
>
<template #prefix>
<X class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div>
</template> </template>
</Autocomplete> </Autocomplete>
<p v-if="description" class="text-sm text-gray-600">{{ description }}</p>
</div> </div>
</template> </template>
@@ -64,7 +50,7 @@
import Autocomplete from '@/components/Controls/Autocomplete.vue' import Autocomplete from '@/components/Controls/Autocomplete.vue'
import { watchDebounced } from '@vueuse/core' import { watchDebounced } from '@vueuse/core'
import { createResource, Button } from 'frappe-ui' import { createResource, Button } from 'frappe-ui'
import { Plus, X } from 'lucide-vue-next' import { Plus } from 'lucide-vue-next'
import { useAttrs, computed, ref } from 'vue' import { useAttrs, computed, ref } from 'vue'
const props = defineProps({ const props = defineProps({
@@ -80,14 +66,12 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
description: {
type: String,
default: '',
},
}) })
const emit = defineEmits(['update:modelValue', 'change']) const emit = defineEmits(['update:modelValue', 'change'])
const attrs = useAttrs() const attrs = useAttrs()
const valuePropPassed = computed(() => 'value' in attrs) const valuePropPassed = computed(() => 'value' in attrs)
const value = computed({ const value = computed({
@@ -124,7 +108,6 @@ const options = createResource({
url: 'frappe.desk.search.search_link', url: 'frappe.desk.search.search_link',
cache: [props.doctype, text.value], cache: [props.doctype, text.value],
method: 'POST', method: 'POST',
auto: true,
params: { params: {
txt: text.value, txt: text.value,
doctype: props.doctype, doctype: props.doctype,
@@ -133,15 +116,14 @@ const options = createResource({
transform: (data) => { transform: (data) => {
return data.map((option) => { return data.map((option) => {
return { return {
label: option.label || option.value, label: option.value,
value: option.value, value: option.value,
description: option.description,
} }
}) })
}, },
}) })
const reload = (val) => { function reload(val) {
options.update({ options.update({
params: { params: {
txt: val, txt: val,
@@ -152,11 +134,6 @@ const reload = (val) => {
options.reload() options.reload()
} }
const clearValue = (close) => {
emit(valuePropPassed.value ? 'change' : 'update:modelValue', '')
close()
}
const labelClasses = computed(() => { const labelClasses = computed(() => {
return [ return [
{ {

View File

@@ -1,246 +0,0 @@
<template>
<div>
<label class="block mb-1" :class="labelClasses" v-if="label">
{{ label }}
<span class="text-red-500" v-if="required">*</span>
</label>
<div class="grid grid-cols-3 gap-1">
<Button
ref="emails"
v-for="value in values"
:key="value"
:label="value"
theme="gray"
variant="subtle"
class="rounded-md"
@keydown.delete.capture.stop="removeLastValue"
>
<template #suffix>
<X @click="removeValue(value)" class="h-4 w-4 stroke-1.5" />
</template>
</Button>
<div class="">
<Combobox v-model="selectedValue" nullable>
<Popover class="w-full" v-model:show="showOptions">
<template #target="{ togglePopover }">
<ComboboxInput
ref="search"
class="search-input form-input w-full focus-visible:!ring-0"
type="text"
:value="query"
@change="
(e) => {
query = e.target.value
showOptions = true
}
"
autocomplete="off"
@focus="() => togglePopover()"
@keydown.delete.capture.stop="removeLastValue"
/>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl">
<ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
static
>
<ComboboxOption
v-for="option in options"
:key="option.value"
:value="option"
v-slot="{ active }"
>
<li
:class="[
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
{ 'bg-gray-100': active },
]"
>
<div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium">
{{ option.description }}
</div>
<div class="text-sm text-gray-600">
{{ option.value }}
</div>
</div>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</div>
</template>
</Popover>
</Combobox>
</div>
</div>
<!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> -->
</div>
</template>
<script setup>
import {
Combobox,
ComboboxInput,
ComboboxOptions,
ComboboxOption,
} from '@headlessui/vue'
import { createResource, Popover, Button } from 'frappe-ui'
import { ref, computed, nextTick } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { X } from 'lucide-vue-next'
const props = defineProps({
label: {
type: String,
},
size: {
type: String,
default: 'sm',
},
doctype: {
type: String,
required: true,
},
filters: {
type: Object,
default: () => ({}),
},
validate: {
type: Function,
default: null,
},
errorMessage: {
type: Function,
default: (value) => `${value} is an Invalid value`,
},
required: {
type: Boolean,
},
})
const values = defineModel()
const emails = ref([])
const search = ref(null)
const error = ref(null)
const query = ref('')
const text = ref('')
const showOptions = ref(false)
const selectedValue = computed({
get: () => query.value || '',
set: (val) => {
query.value = ''
if (val) {
showOptions.value = false
}
val?.value && addValue(val.value)
},
})
watchDebounced(
query,
(val) => {
val = val || ''
if (text.value === val) return
text.value = val
reload(val)
},
{ debounce: 300, immediate: true }
)
const filterOptions = createResource({
url: 'frappe.desk.search.search_link',
method: 'POST',
cache: [text.value, props.doctype],
auto: true,
params: {
txt: text.value,
doctype: props.doctype,
},
})
const options = computed(() => {
return filterOptions.data || []
})
function reload(val) {
filterOptions.update({
params: {
txt: val,
doctype: props.doctype,
},
})
filterOptions.reload()
}
const addValue = (value) => {
error.value = null
if (value) {
const splitValues = value.split(',')
splitValues.forEach((value) => {
value = value.trim()
if (value) {
// check if value is not already in the values array
if (!values.value?.includes(value)) {
// check if value is valid
if (value && props.validate && !props.validate(value)) {
error.value = props.errorMessage(value)
return
}
// add value to values array
if (!values.value) {
values.value = [value]
} else {
values.value.push(value)
}
value = value.replace(value, '')
}
}
})
!error.value && (value = '')
}
}
const removeValue = (value) => {
values.value = values.value.filter((v) => v !== value)
}
const removeLastValue = () => {
if (query.value) return
let emailRef = emails.value[emails.value.length - 1]?.$el
if (document.activeElement === emailRef) {
values.value.pop()
nextTick(() => {
if (values.value.length) {
emailRef = emails.value[emails.value.length - 1].$el
emailRef?.focus()
} else {
setFocus()
}
})
} else {
emailRef?.focus()
}
}
function setFocus() {
search.value.$el.focus()
}
defineExpose({ setFocus })
const labelClasses = computed(() => {
return [
{
sm: 'text-xs',
md: 'text-base',
}[props.size || 'sm'],
'text-gray-600',
]
})
</script>

View File

@@ -1,28 +1,18 @@
<template> <template>
<div class="space-y-1"> <div class="flex text-center">
<label class="block text-xs text-gray-600" v-if="props.label"> <div v-for="index in 5">
{{ props.label }} <Star
</label> :class="index <= rating ? 'fill-orange-500' : ''"
<div class="flex text-center"> class="h-6 w-6 fill-gray-400 text-gray-50 mr-1 cursor-pointer"
<div @click="markRating(index)"
v-for="index in 5" />
@mouseover="hoveredRating = index"
@mouseleave="hoveredRating = 0"
>
<Star
class="fill-gray-400 text-gray-50 stroke-1 mr-1 cursor-pointer"
:class="iconClasses(index)"
@click="markRating(index)"
/>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { Star } from 'lucide-vue-next' import { Star } from 'lucide-vue-next'
import { ref, watch } from 'vue' import { ref } from 'vue'
const props = defineProps({ const props = defineProps({
id: { id: {
type: String, type: String,
@@ -32,36 +22,10 @@ const props = defineProps({
type: Number, type: Number,
default: 0, default: 0,
}, },
label: {
type: String,
default: '',
},
size: {
type: String,
default: 'md',
},
}) })
const iconClasses = (index) => {
let classes = [
{
sm: 'size-4',
md: 'size-5',
lg: 'size-6',
xl: 'size-7',
}[props.size],
]
if (index <= hoveredRating.value && index > rating.value) {
classes.push('fill-yellow-200')
} else if (index <= rating.value) {
classes.push('fill-yellow-500')
}
return classes.join(' ')
}
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const rating = ref(props.modelValue) let rating = ref(props.modelValue)
const hoveredRating = ref(0)
let emitChange = (value) => { let emitChange = (value) => {
emit('update:modelValue', value) emit('update:modelValue', value)
@@ -71,11 +35,4 @@ function markRating(index) {
emitChange(index) emitChange(index)
rating.value = index rating.value = index
} }
watch(
() => props.modelValue,
(newVal) => {
rating.value = newVal
}
)
</script> </script>

View File

@@ -2,25 +2,15 @@
<div <div
v-if="course.title" v-if="course.title"
class="flex flex-col h-full rounded-md shadow-md text-base overflow-auto" class="flex flex-col h-full rounded-md shadow-md text-base overflow-auto"
style="min-height: 350px" style="min-height: 320px"
> >
<div <div
class="course-image" class="course-image"
:class="{ 'default-image': !course.image }" :class="{ 'default-image': !course.image }"
:style="{ backgroundImage: 'url(\'' + encodeURI(course.image) + '\')' }" :style="{ backgroundImage: 'url(' + encodeURI(course.image) + ')' }"
> >
<div <div class="flex relative top-4 left-4 w-fit flex-wrap">
class="flex items-center flex-wrap space-x-1 relative top-4 px-2 w-fit" <Badge theme="gray" size="md" class="mr-2" v-for="tag in course.tags">
>
<Badge v-if="course.featured" variant="subtle" theme="green" size="md">
{{ __('Featured') }}
</Badge>
<Badge
variant="subtle"
theme="gray"
size="md"
v-for="tag in course.tags"
>
{{ tag }} {{ tag }}
</Badge> </Badge>
</div> </div>
@@ -30,36 +20,36 @@
</div> </div>
<div class="flex flex-col flex-auto p-4"> <div class="flex flex-col flex-auto p-4">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<div v-if="course.lessons"> <div v-if="course.lesson_count">
<Tooltip :text="__('Lessons')"> <Tooltip :text="__('Lessons')">
<span class="flex items-center"> <span class="flex items-center">
<BookOpen class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" /> <BookOpen class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
{{ course.lessons }} {{ course.lesson_count }}
</span> </span>
</Tooltip> </Tooltip>
</div> </div>
<div v-if="course.enrollments"> <div v-if="course.enrollment_count">
<Tooltip :text="__('Enrolled Students')"> <Tooltip :text="__('Enrolled Students')">
<span class="flex items-center"> <span class="flex items-center">
<Users class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" /> <Users class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
{{ course.enrollments }} {{ course.enrollment_count }}
</span> </span>
</Tooltip> </Tooltip>
</div> </div>
<div v-if="course.rating"> <div v-if="course.avg_rating">
<Tooltip :text="__('Average Rating')"> <Tooltip :text="__('Average Rating')">
<span class="flex items-center"> <span class="flex items-center">
<Star class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" /> <Star class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
{{ course.rating }} {{ course.avg_rating }}
</span> </span>
</Tooltip> </Tooltip>
</div> </div>
<div v-if="course.status != 'Approved'"> <div v-if="course.status != 'Approved'">
<Badge <Badge
variant="subtle" variant="solid"
:theme="course.status === 'Under Review' ? 'orange' : 'blue'" :theme="course.status === 'Under Review' ? 'orange' : 'blue'"
size="sm" size="sm"
> >
@@ -72,15 +62,18 @@
{{ course.title }} {{ course.title }}
</div> </div>
<div class="short-introduction text-gray-700 text-sm"> <div class="short-introduction">
{{ course.short_introduction }} {{ course.short_introduction }}
</div> </div>
<div
<ProgressBar
v-if="user && course.membership" v-if="user && course.membership"
:progress="course.membership.progress" 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"> <div v-if="user && course.membership" class="text-sm mb-4">
{{ Math.ceil(course.membership.progress) }}% completed {{ Math.ceil(course.membership.progress) }}% completed
</div> </div>
@@ -88,7 +81,7 @@
<div class="flex items-center justify-between mt-auto"> <div class="flex items-center justify-between mt-auto">
<div class="flex avatar-group overlap"> <div class="flex avatar-group overlap">
<div <div
class="h-6 mr-1" class="mr-1"
:class="{ 'avatar-group overlap': course.instructors.length > 1 }" :class="{ 'avatar-group overlap': course.instructors.length > 1 }"
> >
<UserAvatar <UserAvatar
@@ -96,7 +89,17 @@
:user="instructor" :user="instructor"
/> />
</div> </div>
<CourseInstructors :instructors="course.instructors" /> <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>
<div class="font-semibold"> <div class="font-semibold">
@@ -111,8 +114,6 @@ import { BookOpen, Users, Star } from 'lucide-vue-next'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { Badge, Tooltip } from 'frappe-ui' import { Badge, Tooltip } from 'frappe-ui'
import CourseInstructors from '@/components/CourseInstructors.vue'
import ProgressBar from '@/components/ProgressBar.vue'
const { user } = sessionStore() const { user } = sessionStore()
@@ -149,8 +150,8 @@ const props = defineProps({
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
background-color: theme('colors.green.100'); background-color: theme('colors.orange.100');
color: theme('colors.green.600'); color: theme('colors.orange.600');
} }
.avatar-group { .avatar-group {

View File

@@ -16,10 +16,10 @@
params: { params: {
courseName: course.name, courseName: course.name,
chapterNumber: course.data.current_lesson chapterNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[0] ? course.data.current_lesson.split('.')[0]
: 1, : 1,
lessonNumber: course.data.current_lesson lessonNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[1] ? course.data.current_lesson.split('.')[1]
: 1, : 1,
}, },
}" }"
@@ -46,12 +46,6 @@
</span> </span>
</Button> </Button>
</router-link> </router-link>
<div
v-else-if="course.data.disable_self_learning"
class="bg-blue-100 text-blue-900 text-sm rounded-md py-1 px-3"
>
{{ __('Contact the Administrator to enroll for this course.') }}
</div>
<Button <Button
v-else v-else
@click="enrollStudent()" @click="enrollStudent()"
@@ -63,19 +57,10 @@
{{ __('Start Learning') }} {{ __('Start Learning') }}
</span> </span>
</Button> </Button>
<Button
v-if="canGetCertificate"
@click="fetchCertificate()"
variant="subtle"
class="w-full mt-2"
size="md"
>
{{ __('Get Certificate') }}
</Button>
<router-link <router-link
v-if="user?.data?.is_moderator || is_instructor()" v-if="user?.data?.is_moderator || is_instructor()"
:to="{ :to="{
name: 'CourseForm', name: 'CreateCourse',
params: { params: {
courseName: course.data.name, courseName: course.data.name,
}, },
@@ -87,29 +72,27 @@
</span> </span>
</Button> </Button>
</router-link> </router-link>
<div class="space-y-4"> <div class="mt-8 mb-4 font-medium">
<div class="mt-8 font-medium"> {{ __('This course has:') }}
{{ __('This course has:') }} </div>
</div> <div class="flex items-center mb-3">
<div class="flex items-center"> <BookOpen class="h-5 w-5 stroke-1.5 text-gray-600" />
<BookOpen class="h-4 w-4 stroke-1.5 text-gray-600" /> <span class="ml-2">
<span class="ml-2"> {{ course.data.lesson_count }} {{ __('Lessons') }}
{{ course.data.lessons }} {{ __('Lessons') }} </span>
</span> </div>
</div> <div class="flex items-center mb-3">
<div class="flex items-center"> <Users class="h-5 w-5 stroke-1.5 text-gray-600" />
<Users class="h-4 w-4 stroke-1.5 text-gray-600" /> <span class="ml-2">
<span class="ml-2"> {{ course.data.enrollment_count_formatted }}
{{ formatAmount(course.data.enrollments) }} {{ __('Enrolled Students') }}
{{ __('Enrolled Students') }} </span>
</span> </div>
</div> <div class="flex items-center">
<div v-if="parseInt(course.data.rating) > 0" class="flex items-center"> <Star class="h-5 w-5 stroke-1.5 fill-orange-500 text-gray-50" />
<Star class="h-4 w-4 stroke-1.5 fill-orange-500 text-gray-50" /> <span class="ml-2">
<span class="ml-2"> {{ course.data.avg_rating }} {{ __('Rating') }}
{{ course.data.rating }} {{ __('Rating') }} </span>
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -118,8 +101,7 @@
import { BookOpen, Users, Star } from 'lucide-vue-next' import { BookOpen, Users, Star } from 'lucide-vue-next'
import { computed, inject } from 'vue' import { computed, inject } from 'vue'
import { Button, createResource } from 'frappe-ui' import { Button, createResource } from 'frappe-ui'
import { showToast, formatAmount } from '@/utils/' import { createToast } from '@/utils/'
import { capture } from '@/telemetry'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const router = useRouter() const router = useRouter()
@@ -141,31 +123,29 @@ const video_link = computed(() => {
function enrollStudent() { function enrollStudent() {
if (!user.data) { if (!user.data) {
showToast( createToast({
__('Please Login'), title: 'Please Login',
__('You need to login first to enroll for this course'), icon: 'alert-circle',
'alert-circle' iconClasses: 'text-yellow-600 bg-yellow-100',
) })
setTimeout(() => { setTimeout(() => {
window.location.href = `/login?redirect-to=${window.location.pathname}` window.location.href = `/login?redirect-to=${window.location.pathname}`
}, 2000) }, 3000)
} else { } else {
const enrollStudentResource = createResource({ const enrollStudentResource = createResource({
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership', url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
}) })
console.log(props.course)
enrollStudentResource enrollStudentResource
.submit({ .submit({
course: props.course.data.name, course: props.course.data.name,
}) })
.then(() => { .then(() => {
capture('enrolled_in_course', { createToast({
course: props.course.data.name, title: 'Enrolled Successfully',
icon: 'check',
iconClasses: 'text-green-600 bg-green-100',
}) })
showToast(
__('Success'),
__('You have been enrolled in this course'),
'check'
)
setTimeout(() => { setTimeout(() => {
router.push({ router.push({
name: 'Lesson', name: 'Lesson',
@@ -175,52 +155,10 @@ function enrollStudent() {
lessonNumber: 1, lessonNumber: 1,
}, },
}) })
}, 2000) }, 3000)
}) })
} }
} }
const is_instructor = () => { const is_instructor = () => {}
let user_is_instructor = false
props.course.data.instructors.forEach((instructor) => {
if (!user_is_instructor && instructor.name == user.data?.name) {
user_is_instructor = true
}
})
return user_is_instructor
}
const canGetCertificate = computed(() => {
if (
props.course.data?.enable_certification &&
props.course.data?.membership?.progress == 100
) {
return true
}
return false
})
const certificate = createResource({
url: 'lms.lms.doctype.lms_certificate.lms_certificate.create_certificate',
makeParams(values) {
return {
course: values.course,
}
},
onSuccess(data) {
window.open(
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
data.name
}&format=${encodeURIComponent(data.template)}`,
'_blank'
)
},
})
const fetchCertificate = () => {
certificate.submit({
course: props.course.data?.name,
member: user.data?.name,
})
}
</script> </script>

View File

@@ -1,50 +0,0 @@
<template>
<span v-if="instructors?.length == 1">
<router-link
:to="{
name: 'Profile',
params: { username: instructors[0].username },
}"
>
{{ instructors[0].full_name }}
</router-link>
</span>
<span v-if="instructors?.length == 2">
<router-link
:to="{
name: 'Profile',
params: { username: instructors[0].username },
}"
>
{{ instructors[0].first_name }}
</router-link>
and
<router-link
:to="{
name: 'Profile',
params: { username: instructors[1].username },
}"
>
{{ instructors[1].first_name }}
</router-link>
</span>
<span v-if="instructors?.length > 2">
<router-link
:to="{
name: 'Profile',
params: { username: instructors[0].username },
}"
>
{{ instructors[0].first_name }}
</router-link>
and {{ instructors?.length - 1 }} others
</span>
</template>
<script setup>
const props = defineProps({
instructors: {
type: Array,
required: true,
},
})
</script>

View File

@@ -2,9 +2,9 @@
<div class="text-base"> <div class="text-base">
<div <div
v-if="title && (outline.data?.length || allowEdit)" v-if="title && (outline.data?.length || allowEdit)"
class="grid grid-cols-[70%,30%] mb-4 px-2" class="flex items-center justify-between mb-4"
> >
<div class="font-semibold text-lg leading-5"> <div class="font-semibold" :class="allowEdit ? 'text-base' : 'text-lg'">
{{ __(title) }} {{ __(title) }}
</div> </div>
<Button size="sm" v-if="allowEdit" @click="openChapterModal()"> <Button size="sm" v-if="allowEdit" @click="openChapterModal()">
@@ -16,7 +16,7 @@
</div> </div>
<div <div
:class="{ :class="{
'shadow rounded-md py-2 px-2': showOutline && outline.data?.length, 'shadow rounded-md pt-2 px-2': showOutline && outline.data?.length,
}" }"
> >
<Disclosure <Disclosure
@@ -25,94 +25,58 @@
:key="chapter.name" :key="chapter.name"
:defaultOpen="openChapterDetail(chapter.idx)" :defaultOpen="openChapterDetail(chapter.idx)"
> >
<DisclosureButton ref="" class="flex items-center w-full p-2 group"> <DisclosureButton ref="" class="flex w-full px-2 py-3">
<ChevronRight <ChevronRight
:class="{ :class="{
'rotate-90 transform duration-200': open, 'rotate-90 transform duration-200': open,
'duration-200': !open, 'duration-200': !open,
hidden: chapter.is_scorm_package,
open: index == 1, open: index == 1,
}" }"
class="h-4 w-4 text-gray-900 stroke-1" class="h-4 w-4 text-gray-900 stroke-1 mr-2"
/> />
<div <div class="text-base text-left font-medium leading-5">
class="text-base text-left font-medium leading-5 ml-2"
@click="redirectToChapter(chapter)"
>
{{ chapter.title }} {{ chapter.title }}
</div> </div>
<div class="flex ml-auto space-x-4">
<Tooltip :text="__('Edit Chapter')" placement="bottom">
<FilePenLine
v-if="allowEdit"
@click.prevent="openChapterModal(chapter)"
class="h-4 w-4 text-gray-900 invisible group-hover:visible"
/>
</Tooltip>
<Tooltip :text="__('Delete Chapter')" placement="bottom">
<Trash2
v-if="allowEdit"
@click.prevent="trashChapter(chapter.name)"
class="h-4 w-4 text-red-500 invisible group-hover:visible"
/>
</Tooltip>
</div>
</DisclosureButton> </DisclosureButton>
<DisclosurePanel v-if="!chapter.is_scorm_package"> <DisclosurePanel class="pb-2">
<Draggable <div v-for="lesson in chapter.lessons" :key="lesson.name">
v-if="!chapter.is_scorm_package" <div class="outline-lesson pl-8 py-2">
:list="chapter.lessons" <router-link
:disabled="!allowEdit" :to="{
item-key="name" name: allowEdit ? 'CreateLesson' : 'Lesson',
group="items" params: {
@end="updateOutline" courseName: courseName,
:data-chapter="chapter.name" chapterNumber: lesson.number.split('.')[0],
> lessonNumber: lesson.number.split('.')[1],
<template #item="{ element: lesson }"> },
<div class="outline-lesson pl-8 py-2 pr-4"> }"
<router-link >
:to="{ <div class="flex items-center text-sm leading-5">
name: allowEdit ? 'LessonForm' : 'Lesson', <MonitorPlay
params: { v-if="lesson.icon === 'icon-youtube'"
courseName: courseName, class="h-4 w-4 text-gray-900 stroke-1 mr-2"
chapterNumber: lesson.number.split('.')[0], />
lessonNumber: lesson.number.split('.')[1], <HelpCircle
}, v-else-if="lesson.icon === 'icon-quiz'"
}" class="h-4 w-4 text-gray-900 stroke-1 mr-2"
> />
<div class="flex items-center text-sm leading-5 group"> <FileText
<MonitorPlay v-else-if="lesson.icon === 'icon-list'"
v-if="lesson.icon === 'icon-youtube'" class="h-4 w-4 text-gray-900 stroke-1 mr-2"
class="h-4 w-4 text-gray-900 stroke-1 mr-2" />
/> {{ lesson.title }}
<HelpCircle <Check
v-else-if="lesson.icon === 'icon-quiz'" v-if="lesson.is_complete"
class="h-4 w-4 text-gray-900 stroke-1 mr-2" class="h-4 w-4 text-green-500 stroke-1.5 ml-2"
/> />
<FileText </div>
v-else-if="lesson.icon === 'icon-list'" </router-link>
class="h-4 w-4 text-gray-900 stroke-1 mr-2" </div>
/> </div>
{{ lesson.title }} <div v-if="allowEdit" class="flex mt-2 pl-8">
<Trash2
v-if="allowEdit"
@click.prevent="trashLesson(lesson.name, chapter.name)"
class="h-4 w-4 text-red-500 ml-auto invisible group-hover:visible"
/>
<Check
v-if="lesson.is_complete"
class="h-4 w-4 text-green-700 ml-2"
/>
</div>
</router-link>
</div>
</template>
</Draggable>
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
<router-link <router-link
v-if="!chapter.is_scorm_package"
:to="{ :to="{
name: 'LessonForm', name: 'CreateLesson',
params: { params: {
courseName: courseName, courseName: courseName,
chapterNumber: chapter.idx, chapterNumber: chapter.idx,
@@ -124,6 +88,9 @@
{{ __('Add Lesson') }} {{ __('Add Lesson') }}
</Button> </Button>
</router-link> </router-link>
<Button class="ml-2" @click="openChapterModal(chapter)">
{{ __('Edit Chapter') }}
</Button>
</div> </div>
</DisclosurePanel> </DisclosurePanel>
</Disclosure> </Disclosure>
@@ -137,30 +104,23 @@
/> />
</template> </template>
<script setup> <script setup>
import { Button, createResource, Tooltip } from 'frappe-ui' import { Button, createResource } from 'frappe-ui'
import { getCurrentInstance, inject, ref } from 'vue' import { ref } from 'vue'
import Draggable from 'vuedraggable'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue' import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
import { import {
Check,
ChevronRight, ChevronRight,
FileText,
FilePenLine,
HelpCircle,
MonitorPlay, MonitorPlay,
Trash2, HelpCircle,
FileText,
Check,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useRoute, useRouter } from 'vue-router' import { useRoute } from 'vue-router'
import ChapterModal from '@/components/Modals/ChapterModal.vue' import ChapterModal from '@/components/Modals/ChapterModal.vue'
import { showToast } from '@/utils'
const route = useRoute() const route = useRoute()
const router = useRouter() const expandAll = ref(true)
const user = inject('$user')
const showChapterModal = ref(false) const showChapterModal = ref(false)
const currentChapter = ref(null) const currentChapter = ref(null)
const app = getCurrentInstance()
const { $dialog } = app.appContext.config.globalProperties
const props = defineProps({ const props = defineProps({
courseName: { courseName: {
@@ -179,10 +139,6 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
getProgress: {
type: Boolean,
default: false,
},
}) })
const outline = createResource({ const outline = createResource({
@@ -190,63 +146,10 @@ const outline = createResource({
cache: ['course_outline', props.courseName], cache: ['course_outline', props.courseName],
params: { params: {
course: props.courseName, course: props.courseName,
progress: props.getProgress,
}, },
auto: true, auto: true,
}) })
const deleteLesson = createResource({
url: 'lms.lms.api.delete_lesson',
makeParams(values) {
return {
lesson: values.lesson,
chapter: values.chapter,
}
},
onSuccess() {
outline.reload()
showToast('Success', 'Lesson deleted successfully', 'check')
},
})
const updateLessonIndex = createResource({
url: 'lms.lms.api.update_lesson_index',
makeParams(values) {
return {
lesson: values.lesson,
sourceChapter: values.sourceChapter,
targetChapter: values.targetChapter,
idx: values.idx,
}
},
onSuccess() {
showToast('Success', 'Lesson moved successfully', 'check')
},
})
const trashLesson = (lessonName, chapterName) => {
$dialog({
title: __('Delete this lesson?'),
message: __(
'Deleting this lesson will permanently remove it from the course. This action cannot be undone. Are you sure you want to continue?'
),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
onClick(close) {
deleteLesson.submit({
lesson: lessonName,
chapter: chapterName,
})
close()
},
},
],
})
}
const openChapterDetail = (index) => { const openChapterDetail = (index) => {
return index == route.params.chapterNumber || index == 1 return index == route.params.chapterNumber || index == 1
} }
@@ -259,70 +162,6 @@ const openChapterModal = (chapter = null) => {
const getCurrentChapter = () => { const getCurrentChapter = () => {
return currentChapter.value return currentChapter.value
} }
const updateOutline = (e) => {
updateLessonIndex.submit({
lesson: e.item.__draggable_context.element.name,
sourceChapter: e.from.dataset.chapter,
targetChapter: e.to.dataset.chapter,
idx: e.newIndex,
})
}
const deleteChapter = createResource({
url: 'lms.lms.api.delete_chapter',
makeParams(values) {
return {
chapter: values.chapter,
}
},
onSuccess() {
outline.reload()
showToast('Success', 'Chapter deleted successfully', 'check')
},
})
const trashChapter = (chapterName) => {
$dialog({
title: __('Delete this chapter?'),
message: __(
'Deleting this chapter will also delete all its lessons and permanently remove it from the course. This action cannot be undone. Are you sure you want to continue?'
),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
onClick(close) {
deleteChapter.submit({ chapter: chapterName })
close()
},
},
],
})
}
const redirectToChapter = (chapter) => {
if (!chapter.is_scorm_package) return
event.preventDefault()
if (props.allowEdit) return
if (!user.data) {
showToast(
__('You are not enrolled'),
__('Please enroll for this course to view this lesson'),
'alert-circle'
)
return
}
router.push({
name: 'SCORMChapter',
params: {
courseName: props.courseName,
chapterName: chapter.name,
},
})
}
</script> </script>
<style> <style>
.outline-lesson:has(.router-link-active) { .outline-lesson:has(.router-link-active) {

View File

@@ -1,5 +1,5 @@
<template> <template>
<div v-if="reviews.data?.length || membership" class="mt-20 mb-10"> <div v-if="reviews.data" class="mt-20 mb-10">
<Button <Button
v-if="membership && !hasReviewed.data" v-if="membership && !hasReviewed.data"
@click="openReviewModal()" @click="openReviewModal()"
@@ -8,30 +8,18 @@
{{ __('Write a Review') }} {{ __('Write a Review') }}
</Button> </Button>
<div class="flex items-center font-semibold text-2xl"> <div class="flex items-center font-semibold text-2xl">
{{ __('Student Reviews') }} <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>
<div class="grid gap-8 mt-10"> <div class="grid gap-8 mt-10">
<div v-for="(review, index) in reviews.data"> <div v-for="(review, index) in reviews.data">
<div class="flex items-center"> <div class="flex items-center">
<router-link <UserAvatar :user="review.owner_details" :size="'2xl'" />
:to="{
name: 'Profile',
params: { username: review.owner_details.username },
}"
>
<UserAvatar :user="review.owner_details" :size="'2xl'" />
</router-link>
<div class="mx-4"> <div class="mx-4">
<router-link <span class="text-lg font-medium mr-4">
:to="{ {{ review.owner_details.full_name }}
name: 'Profile', </span>
params: { username: review.owner_details.username },
}"
>
<span class="text-lg font-medium mr-4">
{{ review.owner_details.full_name }}
</span>
</router-link>
<span> <span>
{{ review.creation }} {{ review.creation }}
</span> </span>
@@ -76,7 +64,7 @@ const props = defineProps({
required: true, required: true,
}, },
avg_rating: { avg_rating: {
type: String, type: Number,
required: true, required: true,
}, },
membership: { membership: {

View File

@@ -5,6 +5,7 @@
<div <div
class="relative block min-h-0 flex-shrink-0 overflow-hidden hover:overflow-auto" class="relative block min-h-0 flex-shrink-0 overflow-hidden hover:overflow-auto"
> >
<slot name="sidebar" />
<AppSidebar /> <AppSidebar />
</div> </div>
<div class="w-full overflow-auto" id="scrollContainer"> <div class="w-full overflow-auto" id="scrollContainer">

View File

@@ -69,11 +69,9 @@
/> />
</div> </div>
</div> </div>
<TextEditor <TextEditor
class="mt-5" class="mt-5"
:content="newReply" :content="newReply"
:mentions="mentionUsers"
@change="(val) => (newReply = val)" @change="(val) => (newReply = val)"
placeholder="Type your reply here..." placeholder="Type your reply here..."
:fixedMenu="true" :fixedMenu="true"
@@ -94,14 +92,13 @@ import { createResource, TextEditor, Button, Dropdown } from 'frappe-ui'
import { timeAgo } from '../utils' import { timeAgo } from '../utils'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next' import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
import { ref, inject, onMounted, computed } from 'vue' import { ref, inject, onMounted } from 'vue'
import { createToast } from '../utils' import { createToast } from '../utils'
const showTopics = defineModel('showTopics') const showTopics = defineModel('showTopics')
const newReply = ref('') const newReply = ref('')
const socket = inject('$socket') const socket = inject('$socket')
const user = inject('$user') const user = inject('$user')
const allUsers = inject('$allUsers')
const props = defineProps({ const props = defineProps({
topic: { topic: {
@@ -150,16 +147,6 @@ const newReplyResource = createResource({
}, },
}) })
const mentionUsers = computed(() => {
let users = Object.values(allUsers.data).map((user) => {
return {
value: user.name,
label: user.full_name,
}
})
return users
})
const postReply = () => { const postReply = () => {
newReplyResource.submit( newReplyResource.submit(
{}, {},

View File

@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<Button v-if="!singleThread" class="float-right" @click="openTopicModal()"> <Button v-if="!singleThread" class="float-right" @click="openTopicModal()">
{{ __('New {0}').format(singularize(title)) }} {{ __('New {0}').format(title) }}
</Button> </Button>
<div class="text-xl font-semibold"> <div class="text-xl font-semibold">
{{ __(title) }} {{ __(title) }}
@@ -40,16 +40,13 @@
<div v-else-if="singleThread && topics.data"> <div v-else-if="singleThread && topics.data">
<DiscussionReplies :topic="topics.data" :singleThread="singleThread" /> <DiscussionReplies :topic="topics.data" :singleThread="singleThread" />
</div> </div>
<div <div v-else class="flex justify-center border mt-5 p-5 rounded-md">
v-else <MessageSquareIcon class="w-10 h-10 stroke-1.5 text-gray-800 mr-2" />
class="flex flex-col items-center justify-center border-2 border-dashed mt-5 py-8 rounded-md" <div>
> <div class="text-xl font-semibold mb-2">
<MessageSquareText class="w-7 h-7 text-gray-500 stroke-1.5 mr-2" />
<div class="">
<div v-if="emptyStateTitle" class="font-medium mb-2">
{{ __(emptyStateTitle) }} {{ __(emptyStateTitle) }}
</div> </div>
<div class="text-gray-600"> <div>
{{ __(emptyStateText) }} {{ __(emptyStateText) }}
</div> </div>
</div> </div>
@@ -63,14 +60,13 @@
/> />
</template> </template>
<script setup> <script setup>
import { createResource, Button } from 'frappe-ui' import { createResource, Button, TextEditor } from 'frappe-ui'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { singularize, timeAgo } from '../utils' import { timeAgo } from '../utils'
import { ref, onMounted, inject } from 'vue' import { ref, onMounted, inject } from 'vue'
import DiscussionReplies from '@/components/DiscussionReplies.vue' import DiscussionReplies from '@/components/DiscussionReplies.vue'
import DiscussionModal from '@/components/Modals/DiscussionModal.vue' import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
import { MessageSquareText } from 'lucide-vue-next' import { MessageSquareIcon } from 'lucide-vue-next'
import { getScrollContainer } from '@/utils/scrollContainer'
const showTopics = ref(true) const showTopics = ref(true)
const currentTopic = ref(null) const currentTopic = ref(null)
@@ -93,20 +89,16 @@ const props = defineProps({
}, },
emptyStateTitle: { emptyStateTitle: {
type: String, type: String,
default: '', default: 'No topics yet',
}, },
emptyStateText: { emptyStateText: {
type: String, type: String,
default: 'Start a discussion', default: 'Be the first to start a discussion',
}, },
singleThread: { singleThread: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
scrollToBottom: {
type: Boolean,
default: false,
},
}) })
onMounted(() => { onMounted(() => {
@@ -115,19 +107,8 @@ onMounted(() => {
socket.on('new_discussion_topic', (data) => { socket.on('new_discussion_topic', (data) => {
topics.refresh() topics.refresh()
}) })
if (props.scrollToBottom) {
setTimeout(() => {
scrollToEnd()
}, 100)
}
}) })
const scrollToEnd = () => {
let scrollContainer = getScrollContainer()
scrollContainer.scrollTop = scrollContainer.scrollHeight
}
const topics = createResource({ const topics = createResource({
url: 'lms.lms.utils.get_discussion_topics', url: 'lms.lms.utils.get_discussion_topics',
cache: ['topics', props.doctype, props.docname], cache: ['topics', props.doctype, props.docname],

View File

@@ -1,30 +1,34 @@
<template> <template>
<div class="flex rounded p-1 lg:px-2 lg:py-2.5 hover:bg-gray-100"> <div class="flex shadow rounded-md p-4 h-full">
<div class="flex w-3/5 md:w-2/5"> <img
<img :src="job.company_logo"
:src="job.company_logo" class="w-12 h-12 rounded-lg object-contain mr-4"
class="w-12 h-12 rounded-lg object-contain mr-4" :alt="job.company_name"
:alt="job.company_name" />
/> <div>
<div> <div class="text-xl font-semibold mb-2">
<div class="font-medium mb-1"> {{ job.job_title }}
{{ job.job_title }} </div>
</div> <div>
<div class="text-gray-700"> {{ __('posted by') }}
{{ job.company_name }} <span class="font-medium">
</div> {{ 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 justify-end w-1/5 text-gray-700">
{{ job.location.replace(',', '').split(' ')[0] }}
</div>
<div
class="flex justify-end w-1/5 text-gray-700 text-right hidden md:block"
>
{{ job.type }}
</div>
<div class="flex justify-end w-1/5 text-sm text-gray-700 text-right">
{{ dayjs(job.creation).format('DD MMM YYYY') }}
</div> </div>
</div> </div>
<!-- <div class="flex flex-col shadow rounded-md p-4 h-full"> <!-- <div class="flex flex-col shadow rounded-md p-4 h-full">

View File

@@ -9,7 +9,7 @@
allowfullscreen allowfullscreen
></iframe> ></iframe>
</div> </div>
<div v-for="block in content?.split('\n\n')"> <div v-for="block in content.split('\n\n')">
<div v-if="block.includes('{{ YouTubeVideo')"> <div v-if="block.includes('{{ YouTubeVideo')">
<iframe <iframe
class="youtube-video" class="youtube-video"
@@ -24,12 +24,7 @@
<Quiz :quiz="getId(block)" /> <Quiz :quiz="getId(block)" />
</div> </div>
<div v-else-if="block.includes('{{ Video')"> <div v-else-if="block.includes('{{ Video')">
<video <video controls width="100%" controlsList="nodownload">
controls
width="100%"
controlsList="nodownload"
oncontextmenu="return false;"
>
<source :src="getId(block)" type="video/mp4" /> <source :src="getId(block)" type="video/mp4" />
</video> </video>
</div> </div>
@@ -37,7 +32,7 @@
<iframe <iframe
:src="getPDFSource(block)" :src="getPDFSource(block)"
width="100%" width="100%"
height="700px" height="400"
frameborder="0" frameborder="0"
allowfullscreen allowfullscreen
></iframe> ></iframe>

View File

@@ -1,96 +0,0 @@
<template>
<div class="space-y-5">
<div class="space-y-2">
<div class="flex items-center text-sm font-medium space-x-2">
<span>
{{ __('What does include in preview mean?') }}
</span>
</div>
<div class="text-xs text-gray-600 mb-1 leading-5">
{{
__(
'If Include in Preview is enabled for a lesson then the lesson will also be accessible to non logged in users.'
)
}}
</div>
</div>
<div class="space-y-2">
<div
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
@click="openHelpDialog('quiz')"
>
<span>
{{ __('How to add a Quiz?') }}
</span>
<Info class="w-3 h-3 text-gray-700" />
</div>
<div class="text-xs text-gray-600 mb-1 leading-5">
{{
__(
'Click on the add icon in the editor and select Quiz from the menu. It opens up a dialog, where you can either select a quiz from the list or create a new quiz. When you select the Create New option it redirects you to the quiz creation page.'
)
}}
</div>
</div>
<div class="space-y-2">
<div
class="flex text-sm font-medium space-x-2 cursor-pointer"
@click="openHelpDialog('upload')"
>
<span class="leading-5">
{{ __(contentMap['upload']) }}
</span>
<Info class="w-3 h-3 text-gray-700" />
</div>
<div class="text-xs text-gray-600 mb-1 leading-5">
{{
__(
'To upload Image, Video, Audio or PDF from your system, click on the add icon and select upload from the menu. Then choose the file you want to add to the lesson and it gets added to your lesson.'
)
}}
</div>
</div>
<div class="space-y-2">
<div
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
@click="openHelpDialog('youtube')"
>
<span>
{{ __(contentMap['youtube']) }}
</span>
<Info class="w-3 h-3 text-gray-700" />
</div>
<div class="text-xs text-gray-600 mb-1 leading-5">
{{
__(
'Copy the URL of the video from YouTube and paste it in the editor.'
)
}}
</div>
</div>
</div>
<ExplanationVideos v-model="showExplanation" :title="title" :type="type" />
</template>
<script setup>
import { Info } from 'lucide-vue-next'
import { ref } from 'vue'
import ExplanationVideos from '@/components/Modals/ExplanationVideos.vue'
const showExplanation = ref(false)
const type = ref(null)
const title = ref(null)
const contentMap = {
quiz: 'How to add a Quiz?',
upload: 'How to upload content from your system?',
youtube: 'How to add a YouTube Video?',
}
const openHelpDialog = (contentType) => {
type.value = contentType
title.value = contentMap[contentType]
showExplanation.value = true
}
</script>

View 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>

View File

@@ -1,59 +1,59 @@
<template> <template>
<div class="flex items-center justify-between mb-5"> <Button
<div class="text-lg font-semibold"> v-if="user.data.is_moderator"
{{ __('Live Class') }} variant="solid"
</div> class="float-right mb-3"
<Button v-if="user.data.is_moderator" @click="openLiveClassModal"> @click="openLiveClassModal"
<template #prefix> >
<Plus class="h-4 w-4" /> <template #prefix>
</template> <Plus class="h-4 w-4" />
<span> </template>
{{ __('Add') }} <span>
</span> {{ __('Add Live Class') }}
</Button> </span>
</Button>
<div class="text-lg font-semibold mb-4">
{{ __('Live Class') }}
</div> </div>
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5"> <div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
<div <div v-for="cls in liveClasses.data">
v-for="cls in liveClasses.data" <div class="border rounded-md p-3">
class="flex flex-col border rounded-md h-full text-sm text-gray-700 p-3" <div class="font-semibold text-lg mb-4">
> {{ cls.title }}
<div class="font-semibold text-gray-900 text-lg mb-4"> </div>
{{ cls.title }} <div class="flex items-center mb-2">
</div> <Calendar class="w-4 h-4 stroke-1.5" />
<div class="leading-5 text-gray-700 text-sm mb-4"> <span class="ml-2">
{{ cls.description }} {{ dayjs(cls.date).format('DD MMMM YYYY') }}
</div> </span>
<div class="flex items-center mb-2"> </div>
<Calendar class="w-4 h-4 stroke-1.5 text-gray-700" /> <div class="flex items-center mb-5">
<span class="ml-2"> <Clock class="w-4 h-4 stroke-1.5" />
{{ dayjs(cls.date).format('DD MMMM YYYY') }} <span class="ml-2">
</span> {{ formatTime(cls.time) }}
</div> </span>
<div class="flex items-center mb-5"> </div>
<Clock class="w-4 h-4 stroke-1.5" /> <div class="mb-5">
<span class="ml-2"> {{ cls.description }}
{{ formatTime(cls.time) }} </div>
</span> <div class="flex items-center gap-2">
</div> <a
<div class="flex items-center space-x-2 text-gray-900 mt-auto"> :href="cls.start_url"
<a target="_blank"
v-if="user.data?.is_moderator || user.data?.is_evaluator" 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"
:href="cls.start_url" >
target="_blank" <Monitor class="h-4 w-4 stroke-1.5" />
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" {{ __('Start') }}
> </a>
<Monitor class="h-4 w-4 stroke-1.5" /> <a
{{ __('Start') }} :href="cls.join_url"
</a> target="_blank"
<a 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"
v-if="cls.date <= dayjs().format('YYYY-MM-DD')" >
:href="cls.join_url" <Video class="h-4 w-4 stroke-1.5" />
target="_blank" {{ __('Join') }}
class="w-full 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" </a>
> </div>
<Video class="h-4 w-4 stroke-1.5" />
{{ __('Join') }}
</a>
</div> </div>
</div> </div>
</div> </div>
@@ -89,6 +89,7 @@ const liveClasses = createListResource({
doctype: 'LMS Live Class', doctype: 'LMS Live Class',
filters: { filters: {
batch_name: props.batch, batch_name: props.batch,
date: ['>=', new Date()],
}, },
fields: [ fields: [
'title', 'title',

View File

@@ -1,214 +0,0 @@
<template>
<div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between">
<div>
<div class="text-xl font-semibold mb-1">
{{ __(label) }}
</div>
<!-- <div class="text-xs text-gray-600">
{{ __(description) }}
</div> -->
</div>
<div class="flex item-center space-x-2">
<FormControl
v-model="search"
:placeholder="__('Search')"
type="text"
:debounce="300"
/>
<Button @click="() => (showForm = !showForm)">
<template #icon>
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
<X v-else class="h-3 w-3 stroke-1.5" />
</template>
</Button>
</div>
</div>
<!-- Form to add new member -->
<div v-if="showForm" class="flex items-center space-x-2 my-4">
<FormControl
v-model="member.email"
:placeholder="__('Email')"
type="email"
class="w-full"
/>
<FormControl
v-model="member.first_name"
:placeholder="__('First Name')"
type="test"
class="w-full"
/>
<Button @click="addMember()" variant="subtle">
{{ __('Add') }}
</Button>
</div>
<div class="mt-2 pb-10 overflow-auto">
<!-- Member list -->
<div class="overflow-y-scroll">
<ul class="divide-y">
<li
v-for="member in memberList"
class="grid grid-cols-3 gap-10 py-2 cursor-pointer"
>
<div
@click="openProfile(member.username)"
class="flex items-center space-x-3 col-span-2"
>
<Avatar
:image="member.user_image"
:label="member.full_name"
size="lg"
/>
<div class="space-y-1">
<div class="flex">
<div class="text-gray-900">
{{ member.full_name }}
</div>
<div
class="px-1"
v-if="member.role && getRole(member.role) !== 'Student'"
>
<Badge
:variant="'subtle'"
:ref_for="true"
theme="blue"
size="sm"
label="Badge"
>
{{ getRole(member.role) }}
</Badge>
</div>
</div>
<div class="text-sm text-gray-700">
{{ member.name }}
</div>
</div>
</div>
<div class="flex items-center justify-center text-gray-700 text-sm">
<div v-if="member.last_active">
{{ dayjs(member.last_active).format('DD MMM, YYYY HH:mm a') }}
</div>
<div v-else>-</div>
</div>
</li>
</ul>
</div>
<div
v-if="memberList.length && hasNextPage"
class="flex justify-center mt-4"
>
<Button @click="members.reload()">
<template #prefix>
<RefreshCw class="h-3 w-3 stroke-1.5" />
</template>
{{ __('Load More') }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { createResource, Avatar, Button, FormControl, Badge } from 'frappe-ui'
import { useRouter } from 'vue-router'
import { ref, watch, reactive, inject } from 'vue'
import { RefreshCw, Plus, X } from 'lucide-vue-next'
const router = useRouter()
const show = defineModel('show')
const search = ref('')
const start = ref(0)
const memberList = ref([])
const hasNextPage = ref(false)
const showForm = ref(false)
const dayjs = inject('$dayjs')
const member = reactive({
email: '',
first_name: '',
})
const props = defineProps({
label: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
show: {
type: Boolean,
},
})
const members = createResource({
url: 'lms.lms.api.get_members',
makeParams: () => {
return {
search: search.value,
start: start.value,
}
},
onSuccess(data) {
memberList.value = memberList.value.concat(data)
start.value = start.value + 20
hasNextPage.value = data.length === 20
},
auto: true,
})
const openProfile = (username) => {
show.value = false
router.push({
name: 'Profile',
params: {
username: username,
},
})
}
const newMember = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'User',
first_name: member.first_name,
email: member.email,
},
}
},
auto: false,
onSuccess(data) {
show.value = false
router.push({
name: 'Profile',
params: {
username: data.username,
},
})
},
})
const addMember = () => {
newMember.reload()
}
watch(search, () => {
memberList.value = []
start.value = 0
members.reload()
})
const getRole = (role) => {
const map = {
'LMS Student': 'Student',
'Course Creator': 'Instructor',
Moderator: 'Moderator',
'Batch Evaluator': 'Evaluator',
}
return map[role]
}
</script>

View File

@@ -4,135 +4,42 @@
<slot /> <slot />
</div> </div>
<div <div
v-if="sidebarSettings.data" v-if="tabs"
class="fixed flex items-center justify-around border-t border-gray-300 bottom-0 z-10 w-full bg-white standalone:pb-4" class="fixed flex justify-around border-t border-gray-300 bottom-0 z-10 w-full bg-white standalone:pb-4"
:style="{ :style="{
gridTemplateColumns: `repeat(${ gridTemplateColumns: `repeat(${tabs.length}, minmax(0, 1fr))`,
sidebarLinks.length + 1
}, minmax(0, 1fr))`,
}" }"
> >
<button <button
v-for="tab in sidebarLinks" v-for="tab in tabs"
:key="tab.label" :key="tab.label"
:class="isVisible(tab) ? 'block' : 'hidden'" :class="isVisible(tab) ? 'block' : 'hidden'"
class="flex flex-col items-center justify-center py-3 transition active:scale-95" class="flex flex-col items-center justify-center py-3 transition active:scale-95"
@click="handleClick(tab)" @click="handleClick(tab)"
> >
<component <component
:is="icons[tab.icon]" :is="tab.icon"
class="h-6 w-6 stroke-1.5" class="h-6 w-6 stroke-1.5"
:class="[isActive(tab) ? 'text-gray-900' : 'text-gray-600']" :class="[isActive(tab) ? 'text-gray-900' : 'text-gray-600']"
/> />
</button> </button>
<Popover
trigger="hover"
popoverClass="bottom-28 mx-2"
placement="top-start"
>
<template #target>
<component
:is="icons['List']"
class="h-6 w-6 stroke-1.5 text-gray-600"
/>
</template>
<template #body-main>
<div class="text-base p-5 space-y-4">
<div
v-for="link in otherLinks"
:key="link.label"
class="flex items-center space-x-2"
@click="handleClick(link)"
>
<component
:is="icons[link.icon]"
class="h-4 w-4 stroke-1.5 text-gray-600"
/>
<div>
{{ link.label }}
</div>
</div>
</div>
</template>
</Popover>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { getSidebarLinks } from '../utils' import { getSidebarLinks } from '../utils'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { watch, ref, onMounted } from 'vue' import { computed, inject } from 'vue'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { usersStore } from '@/stores/user'
import { Popover } from 'frappe-ui'
import * as icons from 'lucide-vue-next'
const { logout, user, sidebarSettings } = sessionStore() const { logout, user } = sessionStore()
let { isLoggedIn } = sessionStore() let { isLoggedIn } = sessionStore()
const router = useRouter() const router = useRouter()
let { userResource } = usersStore() const tabs = computed(() => {
const sidebarLinks = ref(getSidebarLinks()) return getSidebarLinks()
const otherLinks = ref([])
onMounted(() => {
sidebarSettings.reload(
{},
{
onSuccess(data) {
Object.keys(data).forEach((key) => {
if (!parseInt(data[key])) {
sidebarLinks.value = sidebarLinks.value.filter(
(link) => link.label.toLowerCase().split(' ').join('_') !== key
)
}
})
addOtherLinks()
},
}
)
}) })
const addOtherLinks = () => {
if (user) {
otherLinks.value.push({
label: 'Notifications',
icon: 'Bell',
to: 'Notifications',
})
otherLinks.value.push({
label: 'Profile',
icon: 'UserRound',
})
otherLinks.value.push({
label: 'Log out',
icon: 'LogOut',
})
} else {
otherLinks.value.push({
label: 'Log in',
icon: 'LogIn',
})
}
}
watch(userResource, () => {
if (
userResource.data &&
(userResource.data.is_moderator || userResource.data.is_instructor)
) {
addQuizzes()
}
})
const addQuizzes = () => {
otherLinks.value.push({
label: 'Quizzes',
icon: 'CircleHelp',
to: 'Quizzes',
})
}
let isActive = (tab) => { let isActive = (tab) => {
return tab.activeFor?.includes(router.currentRoute.value.name) return tab.activeFor?.includes(router.currentRoute.value.name)
} }
@@ -143,13 +50,6 @@ const handleClick = (tab) => {
logout.submit().then(() => { logout.submit().then(() => {
isLoggedIn = false isLoggedIn = false
}) })
else if (tab.label == 'Profile')
router.push({
name: 'Profile',
params: {
username: userResource.data?.username,
},
})
else router.push({ name: tab.to }) else router.push({ name: tab.to })
} }

View File

@@ -18,7 +18,6 @@
<div class=""> <div class="">
<div class="mb-1.5 text-sm text-gray-600"> <div class="mb-1.5 text-sm text-gray-600">
{{ __('Subject') }} {{ __('Subject') }}
<span class="text-red-500">*</span>
</div> </div>
<Input type="text" v-model="announcement.subject" /> <Input type="text" v-model="announcement.subject" />
</div> </div>
@@ -45,7 +44,7 @@
<script setup> <script setup>
import { Dialog, Input, TextEditor, createResource } from 'frappe-ui' import { Dialog, Input, TextEditor, createResource } from 'frappe-ui'
import { reactive } from 'vue' import { reactive } from 'vue'
import { showToast } from '@/utils/' import { createToast } from '@/utils/'
const show = defineModel() const show = defineModel()
@@ -95,14 +94,22 @@ const makeAnnouncement = (close) => {
}, },
onSuccess() { onSuccess() {
close() close()
showToast( createToast({
__('Success'), title: 'Success',
__('Announcement has been sent successfully'), text: 'Announcement has been sent successfully',
'check' icon: 'Check',
) iconClasses: 'bg-green-600 text-white rounded-md p-px',
})
}, },
onError(err) { onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'check') 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,
})
}, },
} }
) )

View File

@@ -1,86 +0,0 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Add an assessment'),
size: 'sm',
actions: [
{
label: __('Submit'),
variant: 'solid',
onClick: (close) => addAssessment(close),
},
],
}"
>
<template #body-content>
<div class="space-y-4">
<FormControl
type="select"
:options="assessmentTypes"
v-model="assessmentType"
:label="__('Type')"
/>
<Link
v-model="assessment"
:doctype="assessmentType"
:label="__('Assessment')"
/>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, FormControl, createResource } from 'frappe-ui'
import Link from '@/components/Controls/Link.vue'
import { computed, ref } from 'vue'
import { showToast } from '@/utils'
const show = defineModel()
const assessmentType = ref(null)
const assessment = ref(null)
const assessments = defineModel('assessments')
const props = defineProps({
batch: {
type: String,
default: null,
},
})
const assessmentResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Assessment',
parent: props.batch,
parenttype: 'LMS Batch',
parentfield: 'assessment',
assessment_type: assessmentType.value,
assessment_name: assessment.value,
},
}
},
})
const addAssessment = (close) => {
assessmentResource.submit(
{},
{
onSuccess(data) {
assessments.value.reload()
showToast(__('Success'), __('Assessment added successfully'), 'check')
close()
},
}
)
}
const assessmentTypes = computed(() => {
return [
{ label: 'Quiz', value: 'LMS Quiz' },
{ label: 'Assignment', value: 'LMS Assignment' },
]
})
</script>

View File

@@ -14,18 +14,7 @@
}" }"
> >
<template #body-content> <template #body-content>
<Link <Link doctype="LMS Course" v-model="course" />
doctype="LMS Course"
v-model="course"
:label="__('Course')"
:required="true"
/>
<Link
doctype="Course Evaluator"
v-model="evaluator"
:label="__('Evaluator')"
class="mt-4"
/>
</template> </template>
</Dialog> </Dialog>
</template> </template>
@@ -37,7 +26,6 @@ import { showToast } from '@/utils'
const show = defineModel() const show = defineModel()
const course = ref(null) const course = ref(null)
const evaluator = ref(null)
const courses = defineModel('courses') const courses = defineModel('courses')
const props = defineProps({ const props = defineProps({
@@ -57,7 +45,6 @@ const createBatchCourse = createResource({
parenttype: 'LMS Batch', parenttype: 'LMS Batch',
parentfield: 'courses', parentfield: 'courses',
course: course.value, course: course.value,
evaluator: evaluator.value,
}, },
} }
}, },
@@ -71,7 +58,6 @@ const addCourse = (close) => {
courses.value.reload() courses.value.reload()
close() close()
course.value = null course.value = null
evaluator.value = null
}, },
onError(err) { onError(err) {
showToast('Error', err.message[0] || err, 'x') showToast('Error', err.message[0] || err, 'x')

View File

@@ -1,113 +0,0 @@
<template>
<Dialog v-model="show" :options="{}">
<template #body>
<div class="p-5 space-y-8 text-base">
<div class="flex items-center space-x-2">
<Avatar :image="student.user_image" size="3xl" />
<div class="space-y-1">
<div class="flex items-center space-x-2">
<div class="text-xl font-semibold">
{{ student.full_name }}
</div>
<Badge :theme="student.progress === 100 ? 'green' : 'red'">
{{ student.progress }}% {{ __('Complete') }}
</Badge>
</div>
<div class="text-sm text-gray-700">
{{ student.email }}
</div>
</div>
</div>
<!-- Assessments -->
<div>
<div>
<div
class="grid grid-cols-[70%,30%] border-b pl-2 pb-1 mb-2 text-xs text-gray-700 font-medium"
>
<span>
{{ __('Assessment') }}
</span>
<span>
{{ __('Progress') }}
</span>
</div>
<div
v-for="assessment in Object.keys(student.assessments)"
class="grid grid-cols-[70%,30%] pl-2 mb-2 text-gray-700 font-medium"
>
<span>
{{ assessment }}
</span>
<span v-if="isAssignment(student.assessments[assessment])">
<Badge :theme="getStatusTheme(student.assessments[assessment])">
{{ student.assessments[assessment] }}
</Badge>
</span>
<span v-else>
{{ student.assessments[assessment] }}
</span>
</div>
</div>
</div>
<!-- Courses -->
<div>
<div>
<div
class="grid grid-cols-[70%,30%] mb-2 text-xs text-gray-700 border-b pl-2 pb-1 font-medium"
>
<span>
{{ __('Courses') }}
</span>
<span>
{{ __('Progress') }}
</span>
</div>
<div
v-for="course in Object.keys(student.courses)"
class="grid grid-cols-[70%,30%] pl-2 mb-2 text-gray-700 font-medium"
>
<span>
{{ course }}
</span>
<span>
{{ Math.floor(student.courses[course]) }}
</span>
</div>
</div>
</div>
<!-- <span class="mt-4">
{{ student }}
</span> -->
</div>
</template>
</Dialog>
</template>
<script setup>
import { Avatar, Badge, Dialog } from 'frappe-ui'
import ProgressBar from '@/components/ProgressBar.vue'
const show = defineModel()
const props = defineProps({
student: {
type: Object,
default: null,
},
})
const isAssignment = (value) => {
return isNaN(value)
}
const getStatusTheme = (status) => {
if (status === 'Pass') {
return 'green'
} else if (status == 'Not Graded') {
return 'orange'
} else {
return 'red'
}
}
</script>

View File

@@ -1,132 +0,0 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Generate Certificates'),
size: 'lg',
actions: [
{
label: 'Create',
variant: 'solid',
onClick: ({ close }) => {
generateCertificates(close)
},
},
],
}"
>
<template #body-content>
<div class="space-y-4">
<FormControl
type="select"
v-model="details.course"
:label="__('Course')"
:options="getCourses()"
/>
<Link
v-model="details.evaluator"
:label="__('Evaluator')"
doctype="Course Evaluator"
/>
<FormControl
type="date"
v-model="details.issue_date"
:label="__('Issue Date')"
/>
<FormControl
type="date"
v-model="details.expiry_date"
:label="__('Expiry Date')"
/>
<Link
v-model="details.template"
:label="__('Template')"
doctype="Print Format"
:filters="{
doc_type: 'LMS Certificate',
}"
/>
<Switch
size="sm"
:label="__('Published')"
:description="
__(
'Enabling this will publish the certificate on the certified participants page.'
)
"
v-model="details.published"
/>
</div>
</template>
</Dialog>
</template>
<script setup>
import { inject, reactive } from 'vue'
import { createResource, Dialog, FormControl, Switch } from 'frappe-ui'
import Link from '@/components/Controls/Link.vue'
import { showToast } from '@/utils'
const show = defineModel()
const dayjs = inject('$dayjs')
const details = reactive({
issue_date: dayjs().format('YYYY-MM-DD'),
expiry_date: null,
template: null,
evaluator: null,
published: true,
})
const props = defineProps({
batch: {
type: [Object, null],
required: true,
},
})
const createCertificate = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Certificate',
issue_date: details.issue_date,
expiry_date: details.expiry_date,
template: details.template,
published: details.published,
course: values.course,
batch: values.batch,
member: values.member,
evaluator: details.evaluator,
},
}
},
})
const generateCertificates = (close) => {
props.batch?.students.forEach((student) => {
createCertificate.submit(
{
course: details.course,
batch: props.batch.name,
member: student,
},
{
onError(err) {
showToast(__('Error'), err.messages?.[0] || err, 'x')
},
}
)
})
close()
showToast(__('Success'), __('Certificates generated successfully'), 'check')
}
const getCourses = () => {
return props.batch?.courses.map((course) => {
return {
label: course.course,
value: course.course,
}
})
}
</script>

View File

@@ -2,11 +2,11 @@
<Dialog <Dialog
v-model="show" v-model="show"
:options="{ :options="{
title: chapterDetail ? __('Edit Chapter') : __('Add Chapter'), title: __('Add Chapter'),
size: 'lg', size: 'lg',
actions: [ actions: [
{ {
label: chapterDetail ? __('Edit') : __('Create'), label: chapterDetail ? __('Edit Chapter') : __('Add Chapter'),
variant: 'solid', variant: 'solid',
onClick: (close) => onClick: (close) =>
chapterDetail ? editChapter(close) : addChapter(close), chapterDetail ? editChapter(close) : addChapter(close),
@@ -15,77 +15,17 @@
}" }"
> >
<template #body-content> <template #body-content>
<div class="space-y-4 text-base"> <FormControl label="Title" v-model="chapter.title" class="mb-4" />
<FormControl label="Title" v-model="chapter.title" :required="true" />
<Switch
size="sm"
:label="__('SCORM Package')"
:description="
__(
'Enable this only if you want to upload a SCORM package as a chapter.'
)
"
v-model="chapter.is_scorm_package"
/>
<div v-if="chapter.is_scorm_package">
<FileUploader
v-if="!chapter.scorm_package"
:fileTypes="['.zip']"
:validateFile="validateFile"
@success="(file) => (chapter.scorm_package = file)"
>
<template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="mb-4">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading ? `Uploading ${progress}%` : 'Upload an zip 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-5 w-5 stroke-1.5 text-gray-700" />
</div>
<div class="flex flex-col">
<span>
{{ chapter.scorm_package.file_name }}
</span>
<span class="text-sm text-gray-500 mt-1">
{{ getFileSize(chapter.scorm_package.file_size) }}
</span>
</div>
<X
@click="() => (chapter.scorm_package = null)"
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
</div>
</div>
</div>
</template> </template>
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { import { Dialog, FormControl, createResource } from 'frappe-ui'
Button, import { defineModel, reactive, watch, inject } from 'vue'
createResource, import { createToast, formatTime } from '@/utils/'
Dialog,
FileUploader,
FormControl,
Switch,
} from 'frappe-ui'
import { defineModel, reactive, watch } from 'vue'
import { showToast, getFileSize } from '@/utils/'
import { capture } from '@/telemetry'
import { FileText, X } from 'lucide-vue-next'
import { useSettings } from '@/stores/settings'
const show = defineModel() const show = defineModel()
const outline = defineModel('outline') const outline = defineModel('outline')
const settingsStore = useSettings()
const props = defineProps({ const props = defineProps({
course: { course: {
@@ -96,22 +36,32 @@ const props = defineProps({
type: Object, type: Object,
}, },
}) })
const chapter = reactive({ const chapter = reactive({
title: '', title: '',
is_scorm_package: 0,
scorm_package: null,
}) })
const chapterResource = createResource({ const chapterResource = createResource({
url: 'lms.lms.api.upsert_chapter', url: 'frappe.client.insert',
makeParams(values) { makeParams(values) {
return { return {
title: chapter.title, doc: {
course: props.course, doctype: 'Course Chapter',
is_scorm_package: chapter.is_scorm_package, title: chapter.title,
scorm_package: chapter.scorm_package, description: chapter.description,
course: props.course,
},
}
},
})
const chapterEditResource = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
doctype: 'Course Chapter',
name: props.chapterDetail?.name, name: props.chapterDetail?.name,
fieldname: 'title',
value: chapter.title,
} }
}, },
}) })
@@ -131,61 +81,43 @@ const chapterReference = createResource({
}, },
}) })
const addChapter = async (close) => { const addChapter = (close) => {
chapterResource.submit( chapterResource.submit(
{}, {},
{ {
validate() { validate() {
return validateChapter() if (!chapter.title) {
return 'Title is required'
}
}, },
onSuccess: (data) => { onSuccess: (data) => {
capture('chapter_created')
chapterReference.submit( chapterReference.submit(
{ name: data.name }, { name: data.name },
{ {
onSuccess(data) { onSuccess(data) {
cleanChapter()
if (!settingsStore.onboardingDetails.data?.is_onboarded) {
settingsStore.onboardingDetails.reload()
}
outline.value.reload() outline.value.reload()
showToast( createToast({
__('Success'), text: 'Chapter added successfully',
__('Chapter added successfully'), icon: 'check',
'check' iconClasses: 'bg-green-600 text-white rounded-md p-px',
) })
}, },
onError(err) { onError(err) {
showToast(__('Error'), err.messages?.[0] || err, 'x') showError(err)
}, },
} }
) )
close() close()
}, },
onError(err) { onError(err) {
showToast(__('Error'), err.messages?.[0] || err, 'x') showError(err)
}, },
} }
) )
} }
const validateChapter = () => {
if (!chapter.title) {
return __('Title is required')
}
if (chapter.is_scorm_package && !chapter.scorm_package) {
return __('Please upload a SCORM package')
}
}
const cleanChapter = () => {
chapter.title = ''
chapter.is_scorm_package = 0
chapter.scorm_package = null
}
const editChapter = (close) => { const editChapter = (close) => {
chapterResource.submit( chapterEditResource.submit(
{}, {},
{ {
validate() { validate() {
@@ -195,29 +127,35 @@ const editChapter = (close) => {
}, },
onSuccess() { onSuccess() {
outline.value.reload() outline.value.reload()
showToast(__('Success'), __('Chapter updated successfully'), 'check') createToast({
text: 'Chapter updated successfully',
icon: 'check',
iconClasses: 'bg-green-600 text-white rounded-md p-px',
})
close() close()
}, },
onError(err) { onError(err) {
showToast(__('Error'), err.messages?.[0] || err, 'x') 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( watch(
() => props.chapterDetail, () => props.chapterDetail,
(newChapter) => { (newChapter) => {
chapter.title = newChapter?.title chapter.title = newChapter?.title
chapter.is_scorm_package = newChapter?.is_scorm_package
chapter.scorm_package = newChapter?.scorm_package
} }
) )
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (extension !== 'zip') {
return __('Only zip files are allowed')
}
}
</script> </script>

View File

@@ -1,11 +1,11 @@
<template> <template>
<Dialog <Dialog
:options="{ :options="{
title: singularize(props.title), title: props.title,
size: '2xl', size: '2xl',
actions: [ actions: [
{ {
label: 'Post', label: 'Submit',
variant: 'solid', variant: 'solid',
onClick: (close) => submitTopic(close), onClick: (close) => submitTopic(close),
}, },
@@ -15,7 +15,10 @@
<template #body-content> <template #body-content>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div> <div>
<FormControl v-model="topic.title" :label="__('Title')" type="text" /> <div class="mb-1.5 text-sm text-gray-600">
{{ __('Title') }}
</div>
<Input type="text" v-model="topic.title" />
</div> </div>
<div> <div>
<div class="mb-1.5 text-sm text-gray-600"> <div class="mb-1.5 text-sm text-gray-600">
@@ -34,9 +37,8 @@
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui' import { Dialog, Input, TextEditor, createResource } from 'frappe-ui'
import { reactive, defineModel } from 'vue' import { reactive, defineModel } from 'vue'
import { showToast, singularize } from '@/utils'
const topics = defineModel('reloadTopics') const topics = defineModel('reloadTopics')
@@ -91,14 +93,6 @@ const submitTopic = (close) => {
topicResource.submit( topicResource.submit(
{}, {},
{ {
validate() {
if (!topic.title) {
return 'Title cannot be empty.'
}
if (!topic.reply) {
return 'Reply cannot be empty.'
}
},
onSuccess(data) { onSuccess(data) {
replyResource.submit( replyResource.submit(
{ {
@@ -114,9 +108,6 @@ const submitTopic = (close) => {
} }
) )
}, },
onError(err) {
showToast('Error', err.message, 'x')
},
} }
) )
} }

View File

@@ -1,109 +0,0 @@
<template>
<Popover transition="default">
<template #target="{ isOpen, togglePopover }" class="flex w-full">
<slot v-bind="{ isOpen, togglePopover }"></slot>
</template>
<template #body>
<div
class="absolute left-1/2 mt-3 w-96 max-w-lg -translate-x-1/2 transform rounded-lg bg-white px-4 sm:px-0 lg:max-w-3xl"
>
<div
class="overflow-hidden rounded-lg p-3 shadow-2xl ring-1 ring-black ring-opacity-5"
>
<div class="flex items-center justify-center space-x-2">
<TextInput
type="text"
placeholder="search by keyword"
v-model="search"
:debounce="300"
class="flex-1"
/>
<FileUploader
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<template
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="">
<Button @click="openFileSelector" :loading="uploading">
{{ uploading ? `Uploading ${progress}%` : 'Upload Image' }}
</Button>
</div>
</template>
</FileUploader>
</div>
<div
class="relative mt-2 grid w-[25.5rem] gap-2 bg-white lg:grid-cols-2"
>
<button
v-for="image in images.data"
:key="image.id"
class="h-[50px] w-[200px] overflow-hidden rounded hover:opacity-80"
@click="$emit('select', image.urls.raw)"
>
<img
:src="
image.urls.raw +
'&w=200&h=50&fit=crop&crop=entropy,faces,focalpoint'
"
/>
</button>
</div>
<div
v-if="images.data"
class="mt-2 text-center text-sm text-gray-500"
>
{{ __('Image search powered by') }}
<a class="underline" target="_blank" href="https://unsplash.com">
{{ __('Unsplash') }}
</a>
</div>
</div>
</div>
</template>
</Popover>
</template>
<script setup>
import {
Popover,
TextInput,
FileUploader,
Button,
createResource,
} from 'frappe-ui'
import { ref, watch } from 'vue'
const search = ref(null)
const emit = defineEmits(['select'])
const images = createResource({
url: 'lms.lms.api.get_unsplash_photos',
makeParams: () => {
return {
keyword: search.value,
}
},
auto: true,
debounce: 500,
})
watch(
() => search.value,
() => {
images.reload()
}
)
const saveImage = (file) => {
emit('select', file.file_url)
}
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
return 'Only image file is allowed.'
}
}
</script>

View File

@@ -1,190 +0,0 @@
<template>
<Dialog
:options="{
title: 'Edit your profile',
size: 'xl',
actions: [
{
label: 'Save',
variant: 'solid',
onClick: (close) => saveProfile(close),
},
],
}"
>
<template #body-content>
<div>
<FileUploader
v-if="!profile.image"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="mb-4">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading
? `Uploading ${progress}%`
: 'Upload a profile image'
}}
</Button>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="text-xs text-gray-600 mb-1">
{{ __('Profile Image') }}
</div>
<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="text-base flex flex-col">
<span>
{{ profile.image.file_name }}
</span>
<span class="text-sm text-gray-500 mt-1">
{{ getFileSize(profile.image.file_size) }}
</span>
</div>
<X
@click="removeImage()"
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
</div>
<FormControl
v-model="profile.first_name"
:label="__('First Name')"
class="mb-4"
/>
<FormControl
v-model="profile.last_name"
:label="__('Last Name')"
class="mb-4"
/>
<FormControl
v-model="profile.headline"
:label="__('Headline')"
class="mb-4"
/>
<div class="mb-4">
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Bio') }}
</div>
<TextEditor
:fixedMenu="true"
@change="(val) => (profile.bio = val)"
:content="profile.bio"
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,
FormControl,
FileUploader,
Button,
createResource,
TextEditor,
} from 'frappe-ui'
import { reactive, watch, defineModel } from 'vue'
import { FileText, X } from 'lucide-vue-next'
import { getFileSize, showToast } from '@/utils'
const reloadProfile = defineModel('reloadProfile')
const props = defineProps({
profile: {
type: Object,
required: true,
},
})
const profile = reactive({
first_name: '',
last_name: '',
headline: '',
bio: '',
image: '',
})
const imageResource = createResource({
url: 'lms.lms.api.get_file_info',
makeParams(values) {
return {
file_url: values.image,
}
},
auto: false,
onSuccess(data) {
profile.image = data
},
})
const updateProfile = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
doctype: 'User',
name: props.profile.data.name,
fieldname: {
user_image: profile.image.file_url,
...profile,
},
}
},
onSuccess(data) {
props.profile.data = data
},
})
const saveProfile = (close) => {
updateProfile.submit(
{},
{
onSuccess() {
close()
reloadProfile.value.reload()
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
},
}
)
}
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
return 'Only image file is allowed.'
}
}
const saveImage = (file) => {
profile.image = file
}
const removeImage = () => {
profile.image = null
}
watch(
() => props.profile.data,
(newVal) => {
if (newVal) {
profile.first_name = newVal.first_name
profile.last_name = newVal.last_name
profile.headline = newVal.headline
profile.bio = newVal.bio
if (newVal.user_image) imageResource.submit({ image: newVal.user_image })
}
}
)
</script>

View File

@@ -25,7 +25,7 @@
<div class="mb-1.5 text-sm text-gray-600"> <div class="mb-1.5 text-sm text-gray-600">
{{ __('Date') }} {{ __('Date') }}
</div> </div>
<FormControl type="date" v-model="evaluation.date" /> <DatePicker v-model="evaluation.date" />
</div> </div>
<div v-if="slots.data?.length"> <div v-if="slots.data?.length">
<div class="mb-1.5 text-sm text-gray-600"> <div class="mb-1.5 text-sm text-gray-600">
@@ -46,10 +46,7 @@
</div> </div>
</div> </div>
</div> </div>
<div <div v-else class="text-sm italic text-red-600">
v-else-if="evaluation.course && evaluation.date"
class="text-sm italic text-red-600"
>
{{ __('No slots available for this date.') }} {{ __('No slots available for this date.') }}
</div> </div>
</div> </div>
@@ -57,7 +54,7 @@
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, createResource, Select, FormControl } from 'frappe-ui' import { Dialog, createResource, Select, DatePicker } from 'frappe-ui'
import { defineModel, reactive, watch, inject } from 'vue' import { defineModel, reactive, watch, inject } from 'vue'
import { createToast, formatTime } from '@/utils/' import { createToast, formatTime } from '@/utils/'
@@ -116,7 +113,7 @@ function submitEvaluation(close) {
if (!evaluation.start_time) { if (!evaluation.start_time) {
return 'Please select a slot.' return 'Please select a slot.'
} }
if (dayjs(evaluation.date).isBefore(dayjs(), 'day')) { if (dayjs(evaluation.date).isSameOrBefore(dayjs(), 'day')) {
return 'Please select a future date.' return 'Please select a future date.'
} }
if (dayjs(evaluation.date).isAfter(dayjs(props.endDate), 'day')) { if (dayjs(evaluation.date).isAfter(dayjs(props.endDate), 'day')) {
@@ -130,20 +127,11 @@ function submitEvaluation(close) {
close() close()
}, },
onError(err) { onError(err) {
let message = err.messages?.[0] || err
let unavailabilityMessage
if (typeof message === 'string') {
unavailabilityMessage = message?.includes('unavailable')
} else {
unavailabilityMessage = false
}
createToast({ createToast({
title: unavailabilityMessage ? __('Evaluator is Unavailable') : '', title: 'Error',
text: message, text: err.messages?.[0] || err,
icon: unavailabilityMessage ? 'alert-circle' : 'x', icon: 'x',
iconClasses: 'bg-yellow-600 text-white rounded-md p-px', iconClasses: 'bg-red-600 text-white rounded-md p-px',
position: 'top-center', position: 'top-center',
timeout: 10, timeout: 10,
}) })
@@ -154,12 +142,10 @@ function submitEvaluation(close) {
const getCourses = () => { const getCourses = () => {
let courses = [] let courses = []
for (const course of props.courses) { for (const course of props.courses) {
if (course.evaluator) { courses.push({
courses.push({ label: course.title,
label: course.title, value: course.course,
value: course.course, })
})
}
} }
return courses return courses
} }
@@ -179,7 +165,7 @@ watch(
() => evaluation.date, () => evaluation.date,
(date) => { (date) => {
evaluation.start_time = '' evaluation.start_time = ''
if (date && evaluation.course) { if (date) {
slots.submit(evaluation) slots.submit(evaluation)
} }
} }

View File

@@ -1,378 +0,0 @@
<template>
<Dialog
v-model="show"
:options="{
size: '2xl',
}"
>
<template #body>
<div class="flex text-base">
<div class="flex flex-col w-1/2 p-5">
<div class="text-lg font-semibold mb-4">
{{ event.title }}
</div>
<div class="flex flex-col space-y-4 text-sm text-gray-800">
<Tooltip :text="__('Email ID')">
<div class="flex items-center space-x-2 w-fit">
<User class="h-4 w-4 stroke-1.5" />
<span>
{{ event.member }}
</span>
</div>
</Tooltip>
<Tooltip :text="__('Course')">
<div class="flex items-center space-x-2 w-fit">
<BookOpen class="h-4 w-4 stroke-1.5" />
<span>
{{ event.course_title }}
</span>
</div>
</Tooltip>
<Tooltip v-if="event.batch_title" :text="__('Batch')">
<div class="flex items-center space-x-2 w-fit">
<Users class="h-4 w-4 stroke-1.5" />
<span>
{{ event.batch_title }}
</span>
</div>
</Tooltip>
<Tooltip :text="__('Date')">
<div class="flex items-center space-x-2 w-fit">
<Calendar class="h-4 w-4 stroke-1.5" />
<span>
{{ dayjs(event.date).format('DD MMM YYYY') }}
</span>
</div>
</Tooltip>
<Tooltip :text="__('Time')">
<div class="flex items-center space-x-2 w-fit">
<Clock class="h-4 w-4 stroke-1.5" />
<span>
{{ formatTime(event.start_time) }} -
{{ formatTime(event.end_time) }}
</span>
</div>
</Tooltip>
</div>
<div class="flex items-center space-x-2 mt-auto">
<Button
v-if="certificate.name"
@click="openCertificate(certificate)"
class="w-full"
>
<template #prefix>
<FileText class="h-4 w-4 stroke-1.5" />
</template>
{{ __('View Certificate') }}
</Button>
<Button v-else @click="openCallLink(event.venue)" class="w-full">
<template #prefix>
<Video class="h-4 w-4 stroke-1.5" />
</template>
<span>
{{ __('Join Meeting') }}
</span>
</Button>
</div>
</div>
<Tabs :tabs="tabs" v-model="tabIndex" class="border-l w-1/2">
<template #default="{ tab }">
<div
v-if="tab.label == 'Evaluation'"
class="flex flex-col space-y-4 p-5"
>
<div class="flex items-center justify-between">
<Rating v-model="evaluation.rating" :label="__('Rating')" />
<FormControl
type="select"
:options="statusOptions"
v-model="evaluation.status"
:label="__('Status')"
class="w-1/2"
/>
</div>
<Textarea
v-model="evaluation.summary"
:label="__('Summary')"
:rows="7"
/>
<Button variant="solid" @click="saveEvaluation()">
{{ __('Save') }}
</Button>
</div>
<div v-else class="flex flex-col space-y-4 p-5">
<FormControl
type="checkbox"
v-model="certificate.published"
:label="__('Published')"
/>
<Link
v-model="certificate.template"
:label="__('Template')"
doctype="Print Format"
:filters="{
doc_type: 'LMS Certificate',
}"
/>
<FormControl
type="date"
v-model="certificate.issue_date"
:label="__('Issue Date')"
/>
<FormControl
type="date"
v-model="certificate.expiry_date"
:label="__('Expiry Date')"
/>
<Button variant="solid" @click="saveCertificate()">
{{ __('Save') }}
</Button>
</div>
</template>
</Tabs>
</div>
</template>
</Dialog>
</template>
<script setup>
import {
Dialog,
Button,
FormControl,
createResource,
Tabs,
Tooltip,
Textarea,
} from 'frappe-ui'
import {
User,
Calendar,
Clock,
Video,
BookOpen,
FileText,
GraduationCap,
Users,
ClipboardList,
} from 'lucide-vue-next'
import { inject, reactive, watch, ref, computed } from 'vue'
import { formatTime, showToast } from '@/utils'
import Rating from '@/components/Controls/Rating.vue'
import Link from '@/components/Controls/Link.vue'
const show = defineModel()
const dayjs = inject('$dayjs')
const tabIndex = ref(0)
const showCertification = ref(false)
const props = defineProps({
event: {
type: [Object, null],
required: true,
},
})
const evaluation = reactive({})
const certificate = reactive({})
const defaultTemplate = createResource({
url: 'frappe.client.get_value',
makeParams(values) {
return {
doctype: 'Property Setter',
fieldname: 'value',
filters: {
doc_type: 'LMS Certificate',
property: 'default_print_format',
},
}
},
auto: true,
onSuccess(data) {
certificate.template = data.value
},
})
const openCallLink = (link) => {
window.open(link, '_blank')
}
const evaluationResource = createResource({
url: 'lms.lms.api.save_evaluation_details',
makeParams(values) {
return {
member: props.event.member,
course: props.event.course,
batch_name: props.event.batch_name,
date: props.event.date,
start_time: props.event.start_time,
end_time: props.event.end_time,
status: evaluation.status,
rating: evaluation.rating,
summary: evaluation.summary,
evaluator: props.event.evaluator,
}
},
auto: false,
onSuccess(data) {
evaluation.name = data.name
},
})
const evaluationDetails = createResource({
url: 'frappe.client.get',
makeParams(values) {
return {
doctype: 'LMS Certificate Evaluation',
filters: {
member: props.event.member,
course: props.event.course,
},
}
},
onSuccess(data) {
for (const key in data) {
if (key in evaluation) evaluation[key] = data[key]
if (key == 'rating') evaluation.rating = data.rating * 5
if (evaluation.status == 'Pass') showCertification.value = true
}
},
auto: false,
})
const saveEvaluation = () => {
evaluationResource.submit(
{},
{
onSuccess: () => {
if (evaluation.status == 'Pass') {
showCertification.value = true
} else {
show.value = false
}
showToast(__('Success'), __('Evaluation saved successfully'), 'check')
},
}
)
}
const certificateResource = createResource({
url: 'lms.lms.api.save_certificate_details',
makeParams(values) {
return {
member: props.event.member,
course: props.event.course,
batch_name: props.event.batch_name,
published: certificate.published,
issue_date: certificate.issue_date,
expiry_date: certificate.expiry_date,
template: certificate.template,
evaluator: props.event.evaluator,
}
},
auto: false,
onSuccess(data) {
certificate.name = data
},
})
const certificateDetails = createResource({
url: 'frappe.client.get',
makeParams(values) {
return {
doctype: 'LMS Certificate',
filters: {
member: props.event.member,
course: props.event.course,
},
}
},
onSuccess(data) {
for (const key in data) {
if (key in certificate) certificate[key] = data[key]
certificate.name = data.name
showCertification.value = true
}
},
onError(err) {
certificate.template = defaultTemplate.data.value
},
auto: false,
})
const saveCertificate = () => {
certificateResource.submit(
{},
{
onSuccess: () => {
showToast(__('Success'), __('Certificate saved successfully'), 'check')
},
}
)
}
watch(show, () => {
if (show.value) {
evaluation.rating = 0
evaluation.status = 'Pending'
evaluation.summary = ''
evaluationDetails.reload()
certificate.published = true
certificate.issue_date = dayjs().format('YYYY-MM-DD')
certificate.expiry_date = null
certificate.template = null
certificate.name = null
certificateDetails.reload()
}
})
const openCertificate = (certificate) => {
window.open(
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
certificate.name
}&format=${encodeURIComponent(certificate.template)}`
)
}
const statusOptions = computed(() => {
return [
{
value: 'Pending',
label: __('Pending'),
},
{
value: 'In Progress',
label: __('In Progress'),
},
{
value: 'Pass',
label: __('Pass'),
},
{
value: 'Fail',
label: __('Fail'),
},
]
})
const tabs = computed(() => {
const tabsArray = [
{
label: __('Evaluation'),
icon: ClipboardList,
},
]
if (showCertification.value) {
tabsArray.push({
label: __('Certification'),
icon: GraduationCap,
})
}
return tabsArray
})
</script>

View File

@@ -1,39 +0,0 @@
<template>
<Dialog
v-model="show"
:options="{
size: '4xl',
title: title,
}"
>
<template #body-content>
<div>
<VideoBlock :file="file" />
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog } from 'frappe-ui'
import { computed } from 'vue'
import VideoBlock from '@/components/VideoBlock.vue'
const show = defineModel()
const props = defineProps({
type: {
type: [String, null],
required: true,
},
title: {
type: [String, null],
required: true,
},
})
const file = computed(() => {
if (props.type == 'youtube') return '/assets/lms/frontend/Youtube.mp4'
if (props.type == 'quiz') return '/assets/lms/frontend/Quiz.mp4'
if (props.type == 'upload') return '/assets/lms/frontend/Upload.mp4'
})
</script>

View File

@@ -17,66 +17,78 @@
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<FormControl <div class="mb-4">
type="text" <div class="mb-1.5 text-sm text-gray-600">
v-model="liveClass.title" {{ __('Title') }}
:label="__('Title')" </div>
class="mb-4" <Input type="text" v-model="liveClass.title" />
:required="true" </div>
/> <div class="mb-4">
<Tooltip <div class="mb-1.5 text-sm text-gray-600">
:text=" <Tooltip
__( class="flex items-center"
'Time must be in 24 hour format (HH:mm). Example 11:30 or 22:00' :text="
) __(
" 'Time must be in 24 hour format (HH:mm). Example 11:30 or 22:00'
> )
<FormControl "
v-model="liveClass.time" >
type="time" <span>
:label="__('Time')" {{ __('Time') }}
class="mb-4" </span>
:required="true" <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()"
/> />
</Tooltip> </div>
<FormControl
v-model="liveClass.timezone"
type="select"
:options="getTimezoneOptions()"
:label="__('Timezone')"
:required="true"
/>
</div> </div>
<div> <div>
<FormControl <div class="mb-4">
v-model="liveClass.date" <div class="mb-1.5 text-sm text-gray-600">
type="date" {{ __('Date') }}
class="mb-4" </div>
:label="__('Date')" <DatePicker v-model="liveClass.date" inputClass="w-full" />
:required="true" </div>
/> <div class="mb-4">
<Tooltip :text="__('Duration of the live class in minutes')"> <div class="mb-1.5 text-sm text-gray-600">
<FormControl <Tooltip
type="number" class="flex items-center"
v-model="liveClass.duration" :text="__('Duration of the live class in minutes')"
:label="__('Duration')" >
class="mb-4" <span>
:required="true" {{ __('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()"
/> />
</Tooltip> </div>
<FormControl
v-model="liveClass.auto_recording"
type="select"
:options="getRecordingOptions()"
:label="__('Auto Recording')"
/>
</div> </div>
</div> </div>
<FormControl <div>
v-model="liveClass.description" <div class="mb-1.5 text-sm text-gray-600">
type="textarea" {{ __('Description') }}
:label="__('Description')" </div>
/> <Textarea v-model="liveClass.description" />
</div>
</div> </div>
</template> </template>
</Dialog> </Dialog>
@@ -90,7 +102,6 @@ import {
Dialog, Dialog,
createResource, createResource,
Tooltip, Tooltip,
FormControl,
} from 'frappe-ui' } from 'frappe-ui'
import { reactive, inject } from 'vue' import { reactive, inject } from 'vue'
import { getTimezones, createToast } from '@/utils/' import { getTimezones, createToast } from '@/utils/'
@@ -158,37 +169,28 @@ const createLiveClass = createResource({
}) })
const submitLiveClass = (close) => { const submitLiveClass = (close) => {
return createLiveClass.submit(liveClass, { createLiveClass.submit(liveClass, {
validate() { validate() {
if (!liveClass.title) { if (!liveClass.title) {
return __('Please enter a title.') return 'Please enter a title.'
} }
if (!liveClass.date) { if (!liveClass.date) {
return __('Please select a date.') return 'Please select a date.'
}
if (dayjs(liveClass.date).isSameOrBefore(dayjs(), 'day')) {
return 'Please select a future date.'
} }
if (!liveClass.time) { if (!liveClass.time) {
return __('Please select a time.') return 'Please select a time.'
}
if (!liveClass.timezone) {
return __('Please select a timezone.')
} }
if (!valideTime()) { if (!valideTime()) {
return __('Please enter a valid time in the format HH:mm.') return 'Please enter a valid time in the format HH:mm.'
}
const liveClassDateTime = dayjs(`${liveClass.date}T${liveClass.time}`).tz(
liveClass.timezone,
true
)
if (
liveClassDateTime.isSameOrBefore(
dayjs().tz(liveClass.timezone, false),
'minute'
)
) {
return __('Please select a future date and time.')
} }
if (!liveClass.duration) { if (!liveClass.duration) {
return __('Please select a duration.') return 'Please select a duration.'
}
if (!liveClass.timezone) {
return 'Please select a timezone.'
} }
}, },
onSuccess() { onSuccess() {

View File

@@ -1,90 +0,0 @@
<template>
<Dialog
v-model="show"
class="text-base"
:options="{
title: __('Add web page to sidebar'),
size: 'lg',
actions: [
{
label: 'Add',
variant: 'solid',
onClick: (close) => {
addWebPage(close)
},
},
],
}"
>
<template #body-content>
<Link
v-model="page.webpage"
doctype="Web Page"
:label="__('Web Page')"
:filters="{
published: 1,
}"
/>
<IconPicker v-model="page.icon" :label="__('Icon')" class="mt-4" />
</template>
</Dialog>
</template>
<script setup>
import { Dialog, createResource } from 'frappe-ui'
import Link from '@/components/Controls/Link.vue'
import { reactive, watch } from 'vue'
import IconPicker from '@/components/Controls/IconPicker.vue'
import { showToast } from '@/utils'
const sidebar = defineModel('reloadSidebar')
const show = defineModel()
const page = reactive({
icon: '',
webpage: '',
})
const props = defineProps({
page: {
type: Object,
default: null,
},
})
const webPage = createResource({
url: 'lms.lms.api.update_sidebar_item',
makeParams(values) {
return {
webpage: page.webpage,
icon: page.icon,
}
},
})
watch(
() => props.page,
(newPage) => {
if (newPage) {
page.icon = newPage.icon
page.webpage = newPage.web_page
}
},
{ immediate: true }
)
const addWebPage = (close) => {
webPage.submit(
{},
{
onSuccess() {
sidebar.value.reload()
close()
showToast('Success', 'Web page added to sidebar', 'check')
},
onError(err) {
showToast('Error', err.message[0] || err, 'x')
close()
},
}
)
}
</script>

View File

@@ -1,354 +0,0 @@
<template>
<Dialog v-model="show" :options="dialogOptions">
<template #body-content>
<div class="space-y-4">
<div
v-if="!editMode"
class="flex items-center text-xs text-gray-700 space-x-5"
>
<div class="flex items-center space-x-2">
<input
type="radio"
id="existing"
value="existing"
v-model="questionType"
class="w-3 h-3 cursor-pointer"
/>
<label for="existing" class="cursor-pointer">
{{ __('Add an existing question') }}
</label>
</div>
<div class="flex items-center space-x-2">
<input
type="radio"
id="new"
value="new"
v-model="questionType"
class="w-3 h-3 cursor-pointer"
/>
<label for="new" class="cursor-pointer">
{{ __('Create a new question') }}
</label>
</div>
</div>
<div v-if="questionType == 'new' || editMode" class="space-y-2">
<div>
<label class="block text-xs text-gray-600 mb-1">
{{ __('Question') }}
</label>
<TextEditor
:content="question.question"
@change="(val) => (question.question = 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>
<FormControl
v-model="question.marks"
:label="__('Marks')"
type="number"
/>
<FormControl
:label="__('Type')"
v-model="question.type"
type="select"
:options="['Choices', 'User Input', 'Open Ended']"
class="pb-2"
:required="true"
/>
<div v-if="question.type == 'Choices'" class="divide-y border-t">
<div v-for="n in 4" class="space-y-4 py-2">
<FormControl
:label="__('Option') + ' ' + n"
v-model="question[`option_${n}`]"
:required="n <= 2 ? true : false"
/>
<FormControl
:label="__('Explanation')"
v-model="question[`explanation_${n}`]"
/>
<FormControl
:label="__('Correct Answer')"
v-model="question[`is_correct_${n}`]"
type="checkbox"
/>
</div>
</div>
<div
v-else-if="question.type == 'User Input'"
v-for="n in 4"
class="space-y-2"
>
<FormControl
:label="__('Possibility') + ' ' + n"
v-model="question[`possibility_${n}`]"
:required="n == 1 ? true : false"
/>
</div>
</div>
<div v-else-if="questionType == 'existing'" class="space-y-2">
<Link
v-model="existingQuestion.question"
:label="__('Select a question')"
doctype="LMS Question"
/>
<FormControl
v-model="existingQuestion.marks"
:label="__('Marks')"
type="number"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui'
import { computed, watch, reactive, ref } from 'vue'
import Link from '@/components/Controls/Link.vue'
import { showToast } from '@/utils'
const show = defineModel()
const quiz = defineModel('quiz')
const questionType = ref(null)
const editMode = ref(false)
const existingQuestion = reactive({
question: '',
marks: 0,
})
const question = reactive({
question: '',
type: 'Choices',
marks: 0,
})
const populateFields = () => {
let fields = ['option', 'is_correct', 'explanation', 'possibility']
let counter = 1
fields.forEach((field) => {
while (counter <= 4) {
question[`${field}_${counter}`] = field === 'is_correct' ? false : null
counter++
}
})
}
populateFields()
const props = defineProps({
title: {
type: String,
default: __('Add a new question'),
},
questionDetail: {
type: [Object, null],
required: true,
},
})
const questionData = createResource({
url: 'frappe.client.get',
makeParams() {
return {
doctype: 'LMS Question',
name: props.questionDetail.question,
}
},
auto: false,
onSuccess(data) {
let counter = 1
editMode.value = true
Object.keys(data).forEach((key) => {
if (Object.hasOwn(question, key)) question[key] = data[key]
})
while (counter <= 4) {
question[`is_correct_${counter}`] = data[`is_correct_${counter}`]
? true
: false
counter++
}
question.marks = props.questionDetail.marks
},
})
watch(show, () => {
if (show.value) {
editMode.value = false
if (props.questionDetail.question) questionData.fetch()
else {
;(question.question = ''), (question.marks = 0)
question.type = 'Choices'
existingQuestion.question = ''
existingQuestion.marks = 0
questionType.value = null
populateFields()
}
if (props.questionDetail.marks) question.marks = props.questionDetail.marks
}
})
const questionRow = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Quiz Question',
parent: quiz.value.data.name,
parentfield: 'questions',
parenttype: 'LMS Quiz',
...values,
},
}
},
})
const questionCreation = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Question',
...question,
},
}
},
})
const submitQuestion = (close) => {
if (props.questionDetail?.question) updateQuestion(close)
else addQuestion(close)
}
const addQuestion = (close) => {
if (questionType.value == 'existing') {
addQuestionRow(
{
question: existingQuestion.question,
marks: existingQuestion.marks,
},
close
)
} else {
questionCreation.submit(
{},
{
onSuccess(data) {
addQuestionRow(
{
question: data.name,
marks: question.marks,
},
close
)
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
}
)
}
}
const addQuestionRow = (question, close) => {
questionRow.submit(
{
...question,
},
{
onSuccess() {
show.value = false
showToast(__('Success'), __('Question added successfully'), 'check')
quiz.value.reload()
close()
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
close()
},
}
)
}
const questionUpdate = createResource({
url: 'frappe.client.set_value',
auto: false,
makeParams(values) {
return {
doctype: 'LMS Question',
name: questionData.data?.name,
fieldname: {
...question,
},
}
},
})
const marksUpdate = createResource({
url: 'frappe.client.set_value',
auto: false,
makeParams(values) {
return {
doctype: 'LMS Quiz Question',
name: props.questionDetail.name,
fieldname: {
marks: question.marks,
},
}
},
})
const updateQuestion = (close) => {
questionUpdate.submit(
{},
{
onSuccess() {
marksUpdate.submit(
{},
{
onSuccess() {
show.value = false
showToast(
__('Success'),
__('Question updated successfully'),
'check'
)
quiz.value.reload()
close()
},
}
)
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
}
)
}
const dialogOptions = computed(() => {
return {
title: __(props.title),
size: 'xl',
actions: [
{
label: __('Submit'),
variant: 'solid',
onClick: (close) => {
submitQuestion(close)
},
},
],
}
})
</script>
<style>
input[type='radio']:checked {
background-color: theme('colors.gray.900') !important;
border-color: theme('colors.gray.900') !important;
--tw-ring-color: theme('colors.gray.900') !important;
}
</style>

View File

@@ -1,354 +0,0 @@
<template>
<Dialog v-model="show" :options="{ size: '4xl' }">
<template #body>
<div class="flex h-[calc(100vh_-_8rem)]">
<div class="flex w-52 shrink-0 flex-col bg-gray-50 p-2">
<h1 class="mb-3 px-2 pt-2 text-lg font-semibold">
{{ __('Settings') }}
</h1>
<div v-for="tab in tabs" :key="tab.label">
<div
v-if="!tab.hideLabel"
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base font-medium text-gray-600 transition-all duration-300 ease-in-out"
>
<span>{{ __(tab.label) }}</span>
</div>
<nav class="space-y-1">
<SidebarLink
v-for="item in tab.items"
:link="item"
:key="item.label"
class="w-full"
:class="
activeTab?.label == item.label
? 'bg-white shadow-sm'
: 'hover:bg-gray-100'
"
@click="activeTab = item"
/>
</nav>
</div>
</div>
<div
v-if="activeTab && data.doc"
:key="activeTab.label"
class="flex flex-1 flex-col px-10 py-8"
>
<Members
v-if="activeTab.label === 'Members'"
:label="activeTab.label"
:description="activeTab.description"
v-model:show="show"
/>
<Categories
v-else-if="activeTab.label === 'Categories'"
:label="activeTab.label"
:description="activeTab.description"
/>
<PaymentSettings
v-else-if="activeTab.label === 'Payment Gateway'"
:label="activeTab.label"
:description="activeTab.description"
:data="data"
:fields="activeTab.fields"
/>
<BrandSettings
v-else-if="activeTab.label === 'Branding'"
:label="activeTab.label"
:description="activeTab.description"
:fields="activeTab.fields"
:data="branding"
/>
<SettingDetails
v-else
:fields="activeTab.fields"
:label="activeTab.label"
:description="activeTab.description"
:data="data"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, createDocumentResource, createResource } from 'frappe-ui'
import { ref, computed, watch } from 'vue'
import { useSettings } from '@/stores/settings'
import SettingDetails from '../SettingDetails.vue'
import SidebarLink from '@/components/SidebarLink.vue'
import Members from '@/components/Members.vue'
import Categories from '@/components/Categories.vue'
import BrandSettings from '@/components/BrandSettings.vue'
import PaymentSettings from '@/components/PaymentSettings.vue'
const show = defineModel()
const doctype = ref('LMS Settings')
const activeTab = ref(null)
const settingsStore = useSettings()
const data = createDocumentResource({
doctype: doctype.value,
name: doctype.value,
fields: ['*'],
cache: doctype.value,
auto: true,
})
const branding = createResource({
url: 'lms.lms.api.get_branding',
auto: true,
cache: 'brand',
})
const tabsStructure = computed(() => {
return [
{
label: 'Settings',
hideLabel: true,
items: [
{
label: 'General',
icon: 'Wrench',
fields: [
{
label: 'Enable Learning Paths',
name: 'enable_learning_paths',
description:
'This will enforce students to go through programs assigned to them in the correct order.',
type: 'checkbox',
},
{
label: 'Send calendar invite for evaluations',
name: 'send_calendar_invite_for_evaluations',
description:
'If enabled, it sends google calendar invite to the student for evaluations.',
type: 'checkbox',
},
{
label: 'Unsplash Access Key',
name: 'unsplash_access_key',
description:
'Optional. If this is set, students can pick a cover image from the unsplash library for their profile page. https://unsplash.com/documentation#getting-started.',
type: 'text',
},
],
},
],
},
{
label: 'Settings',
hideLabel: true,
items: [
{
label: 'Payment Gateway',
icon: 'DollarSign',
description:
'Configure the payment gateway and other payment related settings',
fields: [
{
label: 'Payment Gateway',
name: 'payment_gateway',
type: 'Link',
doctype: 'Payment Gateway',
},
{
label: 'Default Currency',
name: 'default_currency',
type: 'Link',
doctype: 'Currency',
},
{
label: 'Apply GST for India',
name: 'apply_gst',
type: 'checkbox',
},
{
label: 'Show USD equivalent amount',
name: 'show_usd_equivalent',
type: 'checkbox',
},
{
label: 'Apply rounding on equivalent',
name: 'apply_rounding',
type: 'checkbox',
},
],
},
],
},
{
label: 'Lists',
hideLabel: false,
items: [
{
label: 'Members',
description: 'Manage the members of your learning system',
icon: 'UserRoundPlus',
},
{
label: 'Categories',
description: 'Manage the members of your learning system',
icon: 'Network',
},
],
},
{
label: 'Customise',
hideLabel: false,
items: [
{
label: 'Branding',
icon: 'Blocks',
fields: [
{
label: 'Brand Name',
name: 'app_name',
type: 'text',
},
{
label: 'Logo',
name: 'banner_image',
type: 'Upload',
},
{
label: 'Favicon',
name: 'favicon',
type: 'Upload',
},
{
label: 'Footer Logo',
name: 'footer_logo',
type: 'Upload',
},
{
label: 'Address',
name: 'address',
type: 'textarea',
rows: 2,
},
{
label: 'Footer "Powered By"',
name: 'footer_powered',
type: 'textarea',
rows: 4,
},
{
label: 'Copyright',
name: 'copyright',
type: 'text',
},
],
},
{
label: 'Sidebar',
icon: 'PanelLeftIcon',
description: 'Choose the items you want to show in the sidebar',
fields: [
{
label: 'Courses',
name: 'courses',
type: 'checkbox',
},
{
label: 'Batches',
name: 'batches',
type: 'checkbox',
},
{
label: 'Certified Participants',
name: 'certified_participants',
type: 'checkbox',
},
{
type: 'Column Break',
},
{
label: 'Jobs',
name: 'jobs',
type: 'checkbox',
},
{
label: 'Statistics',
name: 'statistics',
type: 'checkbox',
},
{
label: 'Notifications',
name: 'notifications',
type: 'checkbox',
},
],
},
{
label: 'Email Templates',
icon: 'MailPlus',
fields: [
{
label: 'Batch Confirmation Template',
name: 'batch_confirmation_template',
doctype: 'Email Template',
type: 'Link',
},
{
label: 'Certification Template',
name: 'certification_template',
doctype: 'Email Template',
type: 'Link',
},
{
label: 'Assignment Submission Template',
name: 'assignment_submission_template',
doctype: 'Email Template',
type: 'Link',
},
],
},
{
label: 'Signup',
icon: 'LogIn',
fields: [
{
label: 'Custom Content',
name: 'custom_signup_content',
type: 'Code',
mode: 'htmlmixed',
rows: 10,
},
{
label: 'Ask for Occupation',
name: 'user_category',
type: 'checkbox',
description:
'Enable this option to ask users to select their occupation during the signup process.',
},
],
},
],
},
]
})
const tabs = computed(() => {
return tabsStructure.value.map((tab) => {
return {
...tab,
items: tab.items.filter((item) => {
return !item.condition || item.condition()
}),
}
})
})
watch(show, async () => {
if (show.value) {
const currentTab = await tabs.value
.flatMap((tab) => tab.items)
.find((item) => item.label === settingsStore.activeTab)
activeTab.value = currentTab || tabs.value[0].items[0]
} else {
activeTab.value = null
settingsStore.isSettingsOpen = false
}
})
</script>

View File

@@ -28,7 +28,6 @@
import { Dialog, createResource } from 'frappe-ui' import { Dialog, createResource } from 'frappe-ui'
import { ref } from 'vue' import { ref } from 'vue'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import { showToast } from '@/utils'
const students = defineModel('reloadStudents') const students = defineModel('reloadStudents')
const student = ref() const student = ref()
@@ -62,11 +61,8 @@ const addStudent = (close) => {
{ {
onSuccess() { onSuccess() {
students.value.reload() students.value.reload()
student.value = null
close() close()
}, student.value = null
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
}, },
} }
) )

View File

@@ -1,42 +0,0 @@
<template>
<div class="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 v-if="user.data" class="px-5 py-3">
<div>
{{ __('You do not have permission to access this page.') }}
</div>
<router-link
:to="{
name: 'Courses',
}"
>
<Button variant="solid" class="mt-2">
{{ __('Checkout Courses') }}
</Button>
</router-link>
</div>
<div class="px-5 py-3">
<div>
{{ __('Please login to access this page.') }}
</div>
<Button @click="redirectToLogin()" class="mt-4">
{{ __('Login') }}
</Button>
</div>
</div>
</template>
<script setup>
import { inject } from 'vue'
import { Button } from 'frappe-ui'
const user = inject('$user')
const redirectToLogin = () => {
window.location.href = '/login'
}
</script>

View File

@@ -1,151 +0,0 @@
<template>
<div v-if="showOnboardingBanner && onboardingDetails.data">
<Tooltip :text="__('Skip Onboarding')" placement="left">
<X
class="w-4 h-4 stroke-1 absolute top-2 right-2 cursor-pointer mr-1"
@click="skipOnboarding.reload()"
/>
</Tooltip>
<div class="flex items-center justify-evenly bg-gray-100 p-10">
<div
@click="redirectToCourseForm()"
class="flex items-center space-x-2"
:class="{
'cursor-pointer': !onboardingDetails.data.course_created?.length,
}"
>
<span
v-if="onboardingDetails.data.course_created?.length"
class="py-1 px-1 bg-white rounded-full"
>
<Check class="h-4 w-4 stroke-2 text-green-600" />
</span>
<span v-else class="font-semibold bg-white px-2 py-1 rounded-full">
1
</span>
<span class="text-lg font-semibold">
{{ __('Create a course') }}
</span>
</div>
<div
@click="redirectToChapterForm()"
class="flex items-center space-x-2"
:class="{
'cursor-pointer':
onboardingDetails.data.course_created?.length &&
!onboardingDetails.data.chapter_created?.length,
'text-gray-400': !onboardingDetails.data.course_created?.length,
}"
>
<span
v-if="onboardingDetails.data.chapter_created?.length"
class="py-1 px-1 bg-white rounded-full"
>
<Check class="h-4 w-4 stroke-2 text-green-600" />
</span>
<span v-else class="font-semibold bg-white px-2 py-1 rounded-full">
2
</span>
<span class="text-lg font-semibold">
{{ __('Add a chapter') }}
</span>
</div>
<div
@click="redirectToLessonForm()"
class="flex items-center space-x-2"
:class="{
'cursor-pointer':
onboardingDetails.data.course_created?.length &&
onboardingDetails.data.chapter_created?.length,
'text-gray-400':
!onboardingDetails.data.course_created?.length ||
!onboardingDetails.data.chapter_created?.length,
}"
>
<span
v-if="onboardingDetails.data.lesson_created?.length"
class="py-1 px-1 bg-white rounded-full"
>
<Check class="h-4 w-4 stroke-2 text-green-600" />
</span>
<span class="font-semibold bg-white px-2 py-1 rounded-full"> 3 </span>
<span class="text-lg font-semibold">
{{ __('Add a lesson') }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Check, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { useSettings } from '@/stores/settings'
import { createResource, Tooltip } from 'frappe-ui'
const showOnboardingBanner = ref(false)
const settings = useSettings()
const onboardingDetails = settings.onboardingDetails
const router = useRouter()
watch(onboardingDetails, () => {
if (!onboardingDetails.data?.is_onboarded) {
showOnboardingBanner.value = true
} else {
showOnboardingBanner.value = false
}
})
const redirectToCourseForm = () => {
if (onboardingDetails.data?.course_created.length) {
return
} else {
router.push({ name: 'CourseForm', params: { courseName: 'new' } })
}
}
const redirectToChapterForm = () => {
if (!onboardingDetails.data?.course_created.length) {
return
} else {
router.push({
name: 'CourseForm',
params: {
courseName: onboardingDetails.data?.first_course,
},
})
}
}
const redirectToLessonForm = () => {
if (!onboardingDetails.data?.course_created.length) {
return
} else if (!onboardingDetails.data?.chapter_created.length) {
return
} else {
router.push({
name: 'LessonForm',
params: {
courseName: onboardingDetails.data?.first_course,
chapterNumber: 1,
lessonNumber: 1,
},
})
}
}
const skipOnboarding = createResource({
url: 'frappe.client.set_value',
makeParams() {
return {
doctype: 'LMS Settings',
name: 'LMS Settings',
fieldname: 'is_onboarding_complete',
value: 1,
}
},
onSuccess(data) {
onboardingDetails.reload()
},
})
</script>

View File

@@ -1,109 +0,0 @@
<template>
<div class="flex flex-col h-full">
<div class="flex items-center justify-between">
<div class="text-xl font-semibold mb-1">
{{ label }}
</div>
<!-- <Badge
v-if="isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/> -->
</div>
<div class="overflow-y-scroll">
<div class="flex space-x-4">
<SettingFields :fields="fields" :data="data.doc" class="w-1/2" />
<SettingFields
v-if="paymentGateway.data"
:fields="paymentGateway.data.fields"
:data="paymentGateway.data.data"
class="w-1/2"
/>
</div>
</div>
<div class="flex flex-row-reverse mt-auto">
<Button variant="solid" @click="update">
{{ __('Update') }}
</Button>
</div>
</div>
</template>
<script setup>
import SettingFields from '@/components/SettingFields.vue'
import { createResource, Badge, Button } from 'frappe-ui'
import { watch, ref } from 'vue'
const props = defineProps({
label: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
data: {
type: Object,
required: true,
},
fields: {
type: Array,
required: true,
},
})
const paymentGateway = createResource({
url: 'lms.lms.api.get_payment_gateway_details',
makeParams(values) {
return {
payment_gateway: props.data.doc.payment_gateway,
}
},
auto: true,
})
const saveSettings = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
let fields = {}
Object.keys(paymentGateway.data.data).forEach((key) => {
if (
paymentGateway.data.data[key] &&
typeof paymentGateway.data.data[key] === 'object'
) {
fields[key] = paymentGateway.data.data[key].file_url
} else {
fields[key] = paymentGateway.data.data[key]
}
})
return {
doctype: paymentGateway.data.doctype,
name: paymentGateway.data.docname,
fieldname: fields,
}
},
auto: false,
onSuccess(data) {
paymentGateway.reload()
},
})
const update = () => {
props.fields.forEach((f) => {
if (f.type != 'Column Break') {
props.data.doc[f.name] = f.value
}
})
props.data.save.submit()
saveSettings.submit()
}
watch(
() => props.data.doc.payment_gateway,
() => {
paymentGateway.reload()
}
)
</script>

View File

@@ -1,44 +0,0 @@
<template>
<Tooltip :text="`${props.progress}%`">
<div class="w-full bg-gray-200 rounded-full h-1 my-2">
<div
class="bg-gray-900 rounded-full"
:class="progressBarHeight"
:style="{ width: progressBarWidth }"
></div>
</div>
</Tooltip>
</template>
<script setup>
import { computed } from 'vue'
import { Tooltip } from 'frappe-ui'
const props = defineProps({
progress: {
type: Number,
default: 0,
},
size: {
type: String,
default: 'sm',
},
})
const progressBarWidth = computed(() => {
const formattedPercentage = Math.min(Math.ceil(props.progress), 100)
return `${formattedPercentage}%`
})
const progressBarHeight = computed(() => {
if (props.size === 'sm') {
return 'h-1'
}
if (props.size === 'md') {
return 'h-2'
}
if (props.size === 'lg') {
return 'h-3'
}
})
</script>

View File

@@ -1,24 +1,10 @@
<template> <template>
<div v-if="quiz.data"> <div v-if="quiz.data">
<div <div class="bg-blue-100 py-2 px-2 mb-4 rounded-md text-sm text-blue-800">
class="bg-blue-100 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-blue-800" <div class="leading-relaxed">
>
<div class="leading-5">
{{ {{
__('This quiz consists of {0} questions.').format(questions.length) __('This quiz consists of {0} questions.').format(
}} quiz.data.questions.length
</div>
<div v-if="quiz.data?.duration" class="leading-5">
{{
__(
'Please ensure that you complete all the questions in {0} minutes.'
).format(quiz.data.duration)
}}
</div>
<div v-if="quiz.data?.duration" class="leading-5">
{{
__(
'If you fail to do so, the quiz will be automatically submitted when the timer ends.'
) )
}} }}
</div> </div>
@@ -38,16 +24,14 @@
) )
}} }}
</div> </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>
<div v-if="quiz.data.duration" class="flex items-center space-x-2 my-4">
<span class="text-gray-600 text-xs"> {{ __('Time') }}: </span>
<ProgressBar :progress="timerProgress" />
<span class="font-semibold">
{{ formatTimer(timer) }}
</span>
</div>
<div v-if="activeQuestion == 0"> <div v-if="activeQuestion == 0">
<div class="border text-center p-20 rounded-md"> <div class="border text-center p-20 rounded-md">
<div class="font-semibold text-lg"> <div class="font-semibold text-lg">
@@ -75,18 +59,22 @@
</div> </div>
</div> </div>
<div v-else-if="!quizSubmission.data"> <div v-else-if="!quizSubmission.data">
<div v-for="(question, qtidx) in questions"> <div v-for="(question, qtidx) in quiz.data.questions">
<div <div
v-if="qtidx == activeQuestion - 1 && questionDetails.data" v-if="qtidx == activeQuestion - 1 && questionDetails.data"
class="border rounded-md p-5" class="border rounded-md p-5"
> >
<div class="flex justify-between"> <div class="flex justify-between">
<div class="text-sm text-gray-600"> <div class="text-sm">
<span class="mr-2"> <span class="mr-2">
{{ __('Question {0}').format(activeQuestion) }}: {{ __('Question {0}').format(activeQuestion) }}:
</span> </span>
<span> <span>
{{ getInstructions(questionDetails.data) }} {{
questionDetails.data.multiple
? __('Choose all answers that apply')
: __('Choose one answer')
}}
</span> </span>
</div> </div>
<div class="text-gray-900 text-sm font-semibold item-left"> <div class="text-gray-900 text-sm font-semibold item-left">
@@ -94,10 +82,9 @@
{{ question.marks == 1 ? __('Mark') : __('Marks') }} {{ question.marks == 1 ? __('Mark') : __('Marks') }}
</div> </div>
</div> </div>
<div <div class="text-gray-900 font-semibold mt-2">
class="text-gray-900 font-semibold mt-2 leading-5" {{ questionDetails.data.question }}
v-html="questionDetails.data.question" </div>
></div>
<div v-if="questionDetails.data.type == 'Choices'" v-for="index in 4"> <div v-if="questionDetails.data.type == 'Choices'" v-for="index in 4">
<label <label
v-if="questionDetails.data[`option_${index}`]" v-if="questionDetails.data[`option_${index}`]"
@@ -118,17 +105,15 @@
class="w-3.5 h-3.5 text-gray-900 rounded-sm focus:ring-gray-200" class="w-3.5 h-3.5 text-gray-900 rounded-sm focus:ring-gray-200"
@change="markAnswer(index)" @change="markAnswer(index)"
/> />
<div <div
v-else-if="quiz.data.show_answers" v-else-if="quiz.data.show_answers"
v-for="(answer, idx) in showAnswers" v-for="(answer, idx) in showAnswers"
> >
<div v-if="index - 1 == idx"> <div v-if="index - 1 == idx">
<CheckCircle <CheckCircle v-if="answer" class="w-4 h-4 text-green-500" />
v-if="answer == 1"
class="w-4 h-4 text-green-500"
/>
<MinusCircle <MinusCircle
v-else-if="answer == 2" v-else-if="questionDetails.data[`is_correct_${index}`]"
class="w-4 h-4 text-green-500" class="w-4 h-4 text-green-500"
/> />
<XCircle <XCircle
@@ -138,65 +123,28 @@
<MinusCircle v-else class="w-4 h-4" /> <MinusCircle v-else class="w-4 h-4" />
</div> </div>
</div> </div>
<span <span class="ml-2">
class="ml-2" {{ questionDetails.data[`option_${index}`] }}
v-html="questionDetails.data[`option_${index}`]"
>
</span> </span>
</label> </label>
<div <div
v-if="questionDetails.data[`explanation_${index}`]" v-if="questionDetails.data[`explanation_${index}`]"
class="mt-2 text-xs" class="mt-2 text-sm hidden"
v-show="showAnswers.length"
> >
{{ questionDetails.data[`explanation_${index}`] }} {{ questionDetails.data[`explanation_${index}`] }}
</div> </div>
</div> </div>
<div v-else-if="questionDetails.data.type == 'User Input'"> <div class="flex items-center justify-between mt-8">
<FormControl <div>
v-model="possibleAnswer"
type="textarea"
:disabled="showAnswers.length ? true : false"
class="my-2"
/>
<div v-if="showAnswers.length">
<Badge v-if="showAnswers[0]" :label="__('Correct')" theme="green">
<template #prefix>
<CheckCircle class="w-4 h-4 text-green-500 mr-1" />
</template>
</Badge>
<Badge v-else theme="red" :label="__('Incorrect')">
<template #prefix>
<XCircle class="w-4 h-4 text-red-500 mr-1" />
</template>
</Badge>
</div>
</div>
<div v-else>
<TextEditor
class="mt-4"
:content="possibleAnswer"
@change="(val) => (possibleAnswer = 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 class="flex items-center justify-between mt-4">
<div class="text-sm text-gray-600">
{{ {{
__('Question {0} of {1}').format( __('Question {0} of {1}').format(
activeQuestion, activeQuestion,
questions.length quiz.data.questions.length
) )
}} }}
</div> </div>
<Button <Button
v-if=" v-if="quiz.data.show_answers && !showAnswers.length"
quiz.data.show_answers &&
!showAnswers.length &&
questionDetails.data.type != 'Open Ended'
"
@click="checkAnswer()" @click="checkAnswer()"
> >
<span> <span>
@@ -204,7 +152,7 @@
</span> </span>
</Button> </Button>
<Button <Button
v-else-if="activeQuestion != questions.length" v-else-if="activeQuestion != quiz.data.questions.length"
@click="nextQuetion()" @click="nextQuetion()"
> >
<span> <span>
@@ -220,18 +168,11 @@
</div> </div>
</div> </div>
</div> </div>
<div v-else class="border rounded-md p-20 text-center space-y-4"> <div v-else class="border rounded-md p-20 text-center">
<div class="text-lg font-semibold"> <div class="text-lg font-semibold">
{{ __('Quiz Summary') }} {{ __('Quiz Summary') }}
</div> </div>
<div v-if="quizSubmission.data.is_open_ended"> <div>
{{
__(
"Your submission has been successfully saved. The instructor will review and grade it shortly, and you'll be notified of your final result."
)
}}
</div>
<div v-else>
{{ {{
__( __(
'You got {0}% correct answers with a score of {1} out of {2}' 'You got {0}% correct answers with a score of {1} out of {2}'
@@ -271,31 +212,21 @@
</template> </template>
<script setup> <script setup>
import { import {
Badge, createDocumentResource,
Button, Button,
call,
createResource, createResource,
ListView, ListView,
TextEditor,
FormControl,
} from 'frappe-ui' } from 'frappe-ui'
import { ref, watch, reactive, inject, computed } from 'vue' import { ref, watch, reactive, inject } from 'vue'
import { createToast } from '@/utils/' import { createToast } from '@/utils/'
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next' import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
import { timeAgo } from '@/utils' import { timeAgo } from '@/utils'
import { useRouter } from 'vue-router'
import ProgressBar from '@/components/ProgressBar.vue'
const user = inject('$user') const user = inject('$user')
const activeQuestion = ref(0) const activeQuestion = ref(0)
const currentQuestion = ref('') const currentQuestion = ref('')
const selectedOptions = reactive([0, 0, 0, 0]) const selectedOptions = reactive([0, 0, 0, 0])
const showAnswers = reactive([]) const showAnswers = reactive([])
let questions = reactive([])
const possibleAnswer = ref(null)
const timer = ref(0)
let timerInterval = null
const router = useRouter()
const props = defineProps({ const props = defineProps({
quizName: { quizName: {
@@ -315,62 +246,11 @@ const quiz = createResource({
cache: ['quiz', props.quizName], cache: ['quiz', props.quizName],
auto: true, auto: true,
onSuccess(data) { onSuccess(data) {
populateQuestions() attempts.reload()
setupTimer() resetQuiz()
}, },
}) })
const populateQuestions = () => {
let data = quiz.data
if (data.shuffle_questions) {
questions = shuffleArray(data.questions)
if (data.limit_questions_to) {
questions = questions.slice(0, data.limit_questions_to)
}
} else {
questions = data.questions
}
}
const setupTimer = () => {
if (quiz.data.duration) {
timer.value = quiz.data.duration * 60
}
}
const startTimer = () => {
timerInterval = setInterval(() => {
timer.value--
if (timer.value == 0) {
clearInterval(timerInterval)
submitQuiz()
}
}, 1000)
}
const formatTimer = (seconds) => {
const hrs = Math.floor(seconds / 3600)
.toString()
.padStart(2, '0')
const mins = Math.floor((seconds % 3600) / 60)
.toString()
.padStart(2, '0')
const secs = (seconds % 60).toString().padStart(2, '0')
return hrs != '00' ? `${hrs}:${mins}:${secs}` : `${mins}:${secs}`
}
const timerProgress = computed(() => {
return (timer.value / (quiz.data.duration * 60)) * 100
})
const shuffleArray = (array) => {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[array[i], array[j]] = [array[j], array[i]]
}
return array
}
const attempts = createResource({ const attempts = createResource({
url: 'frappe.client.get_list', url: 'frappe.client.get_list',
makeParams(values) { makeParams(values) {
@@ -399,19 +279,6 @@ const attempts = createResource({
}, },
}) })
watch(
() => quiz.data,
() => {
if (quiz.data) {
populateQuestions()
}
if (quiz.data && quiz.data.max_attempts) {
attempts.reload()
resetQuiz()
}
}
)
const quizSubmission = createResource({ const quizSubmission = createResource({
url: 'lms.lms.doctype.lms_quiz.lms_quiz.quiz_summary', url: 'lms.lms.doctype.lms_quiz.lms_quiz.quiz_summary',
makeParams(values) { makeParams(values) {
@@ -441,6 +308,7 @@ watch(activeQuestion, (value) => {
watch( watch(
() => props.quizName, () => props.quizName,
(newName) => { (newName) => {
console.log(newName)
if (newName) { if (newName) {
quiz.reload() quiz.reload()
} }
@@ -450,7 +318,6 @@ watch(
const startQuiz = () => { const startQuiz = () => {
activeQuestion.value = 1 activeQuestion.value = 1
localStorage.removeItem(quiz.data.title) localStorage.removeItem(quiz.data.title)
if (quiz.data.duration) startTimer()
} }
const markAnswer = (index) => { const markAnswer = (index) => {
@@ -461,17 +328,10 @@ const markAnswer = (index) => {
const getAnswers = () => { const getAnswers = () => {
let answers = [] let answers = []
const type = questionDetails.data.type selectedOptions.forEach((value, index) => {
if (selectedOptions[index])
if (type == 'Choices') { answers.push(questionDetails.data[`option_${index + 1}`])
selectedOptions.forEach((value, index) => { })
if (selectedOptions[index])
answers.push(questionDetails.data[`option_${index + 1}`])
})
} else {
answers.push(possibleAnswer.value)
}
return answers return answers
} }
@@ -481,8 +341,7 @@ const checkAnswer = () => {
createToast({ createToast({
title: 'Please select an option', title: 'Please select an option',
icon: 'alert-circle', icon: 'alert-circle',
iconClasses: 'text-yellow-600 bg-yellow-100 rounded-full', iconClasses: 'text-yellow-600 bg-yellow-100',
position: 'top-center',
}) })
return return
} }
@@ -496,20 +355,15 @@ const checkAnswer = () => {
}, },
auto: true, auto: true,
onSuccess(data) { onSuccess(data) {
let type = questionDetails.data.type selectedOptions.forEach((option, index) => {
if (type == 'Choices') { if (option) {
selectedOptions.forEach((option, index) => { showAnswers[index] = option && data[index]
if (option) { } else if (questionDetails.data[`is_correct_${index + 1}`]) {
showAnswers[index] = option && data[index] showAnswers[index] = 0
} else if (data[index] == 2) { } else {
showAnswers[index] = 2 showAnswers[index] = undefined
} else { }
showAnswers[index] = undefined })
}
})
} else {
showAnswers.push(data)
}
addToLocalStorage() addToLocalStorage()
if (!quiz.data.show_answers) { if (!quiz.data.show_answers) {
resetQuestion() resetQuestion()
@@ -521,8 +375,8 @@ const checkAnswer = () => {
const addToLocalStorage = () => { const addToLocalStorage = () => {
let quizData = JSON.parse(localStorage.getItem(quiz.data.title)) let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
let questionData = { let questionData = {
question_name: currentQuestion.value, question_index: activeQuestion.value,
answer: getAnswers().join(), answers: getAnswers().join(),
is_correct: showAnswers.filter((answer) => { is_correct: showAnswers.filter((answer) => {
return answer != undefined return answer != undefined
}), }),
@@ -532,10 +386,9 @@ const addToLocalStorage = () => {
} }
const nextQuetion = () => { const nextQuetion = () => {
if (!quiz.data.show_answers && questionDetails.data?.type != 'Open Ended') { if (!quiz.data.show_answers) {
checkAnswer() checkAnswer()
} else { } else {
if (questionDetails.data?.type == 'Open Ended') addToLocalStorage()
resetQuestion() resetQuestion()
} }
} }
@@ -545,13 +398,11 @@ const resetQuestion = () => {
activeQuestion.value = activeQuestion.value + 1 activeQuestion.value = activeQuestion.value + 1
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0]) selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
showAnswers.length = 0 showAnswers.length = 0
possibleAnswer.value = null
} }
const submitQuiz = () => { const submitQuiz = () => {
if (!quiz.data.show_answers) { if (!quiz.data.show_answers) {
if (questionDetails.data.type == 'Open Ended') addToLocalStorage() checkAnswer()
else checkAnswer()
setTimeout(() => { setTimeout(() => {
createSubmission() createSubmission()
}, 500) }, 500)
@@ -561,16 +412,9 @@ const submitQuiz = () => {
} }
const createSubmission = () => { const createSubmission = () => {
quizSubmission.submit( quizSubmission.reload().then(() => {
{}, attempts.reload()
{ })
onSuccess(data) {
markLessonProgress()
if (quiz.data && quiz.data.max_attempts) attempts.reload()
if (quiz.data.duration) clearInterval(timerInterval)
},
}
)
} }
const resetQuiz = () => { const resetQuiz = () => {
@@ -578,25 +422,6 @@ const resetQuiz = () => {
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0]) selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
showAnswers.length = 0 showAnswers.length = 0
quizSubmission.reset() quizSubmission.reset()
populateQuestions()
setupTimer()
}
const getInstructions = (question) => {
if (question.type == 'Choices')
if (question.multiple) return __('Choose all answers that apply')
else return __('Choose one answer')
else return __('Type your answer')
}
const markLessonProgress = () => {
if (router.currentRoute.value.name == 'Lesson') {
call('lms.lms.api.mark_lesson_progress', {
course: router.currentRoute.value.params.courseName,
chapter_number: router.currentRoute.value.params.chapterNumber,
lesson_number: router.currentRoute.value.params.lessonNumber,
})
}
} }
const getSubmissionColumns = () => { const getSubmissionColumns = () => {

View File

@@ -23,8 +23,4 @@ const props = defineProps({
required: true, required: true,
}, },
}) })
const redirectToLogin = () => {
window.location.href = `/login`
}
</script> </script>

View File

@@ -1,78 +0,0 @@
<template>
<div class="flex flex-col justify-between h-full">
<div>
<div class="flex itemsc-center justify-between">
<div class="text-xl font-semibold leading-none mb-1">
{{ __(label) }}
</div>
<Badge
v-if="data.isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
</div>
<div class="text-xs text-gray-600">
{{ __(description) }}
</div>
</div>
<SettingFields :fields="fields" :data="data.doc" />
<div class="flex flex-row-reverse mt-auto">
<Button variant="solid" :loading="data.save.loading" @click="update">
{{ __('Update') }}
</Button>
</div>
</div>
</template>
<script setup>
import { Button, Badge } from 'frappe-ui'
import SettingFields from '@/components/SettingFields.vue'
import { showToast } from '@/utils'
const props = defineProps({
fields: {
type: Array,
required: true,
},
data: {
type: Object,
required: true,
},
label: {
type: String,
required: true,
},
description: {
type: String,
},
})
const update = () => {
props.fields.forEach((f) => {
if (f.type != 'Column Break') {
props.data.doc[f.name] = f.value
}
})
props.data.save.submit(
{},
{
onError(err) {
showToast(__('Error'), err.messages?.[0] || err, 'x')
},
}
)
}
</script>
<style>
.CodeMirror pre.CodeMirror-line,
.CodeMirror pre.CodeMirror-line-like {
font-family: revert;
}
.CodeMirror {
border-radius: 12px;
}
</style>

View File

@@ -1,145 +0,0 @@
<template>
<div
class="my-5"
:class="{ 'flex justify-between w-full': columns.length > 1 }"
>
<div v-for="(column, index) in columns" :key="index">
<div
class="flex flex-col space-y-5"
:class="columns.length > 1 ? 'w-72' : 'w-full'"
>
<div v-for="field in column">
<Link
v-if="field.type == 'Link'"
v-model="data[field.name]"
:doctype="field.doctype"
:label="__(field.label)"
/>
<div v-else-if="field.type == 'Code'">
<CodeEditor
:label="__(field.label)"
type="HTML"
description="The HTML you add here will be shown on your sign up page."
v-model="data[field.name]"
height="250px"
class="shrink-0"
:showLineNumbers="true"
>
</CodeEditor>
</div>
<div v-else-if="field.type == 'Upload'">
<div class="text-sm text-gray-600 mb-1">
{{ __(field.label) }}
</div>
<FileUploader
v-if="!data[field.name]"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => (data[field.name] = file)"
>
<template
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading ? `Uploading ${progress}%` : 'Upload an image'
}}
</Button>
</div>
</template>
</FileUploader>
<div v-else>
<div class="flex items-center text-sm space-x-2">
<div
class="flex items-center justify-center rounded border border-outline-gray-1 w-[15rem] py-5"
>
<img :src="data[field.name]?.file_url" class="h-6 rounded" />
</div>
<div class="flex flex-col flex-wrap">
<span class="break-all">
{{ data[field.name]?.file_name }}
</span>
<span class="text-sm text-gray-500 mt-1">
{{ getFileSize(data[field.name]?.file_size) }}
</span>
</div>
<X
@click="data[field.name] = null"
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
</div>
</div>
<Switch
v-else-if="field.type == 'checkbox'"
size="sm"
:label="__(field.label)"
:description="__(field.description)"
v-model="data[field.name]"
/>
<FormControl
v-else
:key="field.name"
v-model="data[field.name]"
:label="__(field.label)"
:type="field.type"
:rows="field.rows"
:options="field.options"
:description="field.description"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { FormControl, FileUploader, Button, Switch } from 'frappe-ui'
import { computed } from 'vue'
import { getFileSize, validateFile } from '@/utils'
import { X } from 'lucide-vue-next'
import Link from '@/components/Controls/Link.vue'
import CodeEditor from '@/components/Controls/CodeEditor.vue'
const props = defineProps({
fields: {
type: Array,
required: true,
},
data: {
type: Object,
required: true,
},
})
const columns = computed(() => {
const cols = []
let currentColumn = []
props.fields.forEach((field) => {
if (field.type === 'Column Break') {
if (currentColumn.length > 0) {
cols.push(currentColumn)
currentColumn = []
}
} else {
if (field.type == 'checkbox') {
field.value = props.data[field.name] ? true : false
} else {
field.value = props.data[field.name]
}
currentColumn.push(field)
}
})
if (currentColumn.length > 0) {
cols.push(currentColumn)
}
return cols
})
</script>

View File

@@ -6,66 +6,39 @@
@click="handleClick" @click="handleClick"
> >
<div <div
class="flex items-center w-full duration-300 ease-in-out group" class="flex items-center duration-300 ease-in-out"
:class="isCollapsed ? 'p-1 relative' : 'px-2 py-1'" :class="isCollapsed ? 'p-1' : 'px-2 py-1'"
> >
<Tooltip :text="link.label" placement="right"> <Tooltip :text="link.label" placement="right">
<slot name="icon"> <slot name="icon">
<span class="grid h-5 w-6 flex-shrink-0 place-items-center"> <span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<component <component
:is="icons[link.icon]" :is="link.icon"
class="h-4 w-4 stroke-1.5 text-gray-800" class="h-5 w-5 stroke-1.5 text-gray-800"
/> />
</span> </span>
</slot> </slot>
</Tooltip> </Tooltip>
<span <span
class="flex-shrink-0 text-sm duration-300 ease-in-out" class="flex-shrink-0 text-base duration-300 ease-in-out"
:class=" :class="
isCollapsed isCollapsed
? 'ml-0 w-0 overflow-hidden opacity-0' ? 'ml-0 w-0 overflow-hidden opacity-0'
: 'ml-2 w-auto opacity-100' : 'ml-2 w-auto opacity-100'
" "
> >
{{ __(link.label) }} {{ link.label }}
</span> </span>
<span
v-if="link.count"
class="!ml-auto block text-xs text-gray-600"
:class="
isCollapsed && link.count > 9
? 'absolute top-[2px] right-0 bg-white'
: ''
"
>
{{ link.count }}
</span>
<div
v-if="showControls && !isCollapsed"
class="flex items-center space-x-2 !ml-auto block text-xs text-gray-600 group-hover:visible invisible"
>
<component
:is="icons['Edit']"
class="h-3 w-3 stroke-1.5 text-gray-700"
@click.stop="openModal(link)"
/>
<component
:is="icons['X']"
class="h-3 w-3 stroke-1.5 text-gray-700"
@click.stop="deletePage(link)"
/>
</div>
</div> </div>
</button> </button>
</template> </template>
<script setup> <script setup>
import { Tooltip, Button } from 'frappe-ui' import { Tooltip } from 'frappe-ui'
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import * as icons from 'lucide-vue-next'
const router = useRouter() const router = useRouter()
const emit = defineEmits(['openModal', 'deletePage'])
const props = defineProps({ const props = defineProps({
link: { link: {
@@ -76,29 +49,13 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
showControls: {
type: Boolean,
default: false,
},
}) })
function handleClick() { function handleClick() {
if (router.hasRoute(props.link.to)) { router.push({ name: props.link.to })
router.push({ name: props.link.to })
} else if (props.link.to) {
window.location.href = `/${props.link.to}`
}
} }
const isActive = computed(() => { let isActive = computed(() => {
return props.link?.activeFor?.includes(router.currentRoute.value.name) return props.link?.activeFor?.includes(router.currentRoute.value.name)
}) })
const openModal = (link) => {
emit('openModal', link)
}
const deletePage = (link) => {
emit('deletePage', link)
}
</script> </script>

View File

@@ -34,7 +34,9 @@ const props = defineProps({
default: 'Tags', default: 'Tags',
}, },
}) })
console.log(props.modelValue)
let tags = ref(props.modelValue) let tags = ref(props.modelValue)
console.log(tags.value)
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
let newTag = ref('') let newTag = ref('')

View File

@@ -1,90 +0,0 @@
<template>
<Popover transition="default">
<template #target="{ isOpen, togglePopover }" class="flex w-full">
<slot v-bind="{ isOpen, togglePopover }"></slot>
</template>
<template #body>
<div
class="absolute left-1/2 mt-3 max-w-sm -translate-x-1/2 transform rounded-lg bg-white px-4 sm:px-0 lg:max-w-3xl"
>
<div
class="overflow-hidden rounded-lg p-3 shadow-2xl ring-1 ring-black ring-opacity-5"
>
<div class="flex items-center space-x-2">
<div class="flex-1">
<TextInput
type="text"
placeholder="search by keyword"
v-model="search"
:debounce="300"
/>
</div>
<FileUploader @success="(file) => $emit('select', file.file_url)">
<template
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="w-full text-center">
<Button @click="openFileSelector" :loading="uploading">
{{ uploading ? `Uploading ${progress}%` : 'Upload Image' }}
</Button>
</div>
</template>
</FileUploader>
</div>
<div
class="relative mt-2 grid w-[25.5rem] gap-2 bg-white lg:grid-cols-2"
>
<Button
v-for="image in $resources.images.data"
:key="image.id"
class="h-[50px] w-[200px] overflow-hidden rounded hover:opacity-80"
@click="$emit('select', image.urls.raw)"
>
<img
:src="
image.urls.raw +
'&w=200&h=50&fit=crop&crop=entropy,faces,focalpoint'
"
/>
</Button>
</div>
<div class="mt-2 text-center text-sm text-gray-500">
{{ __('Image search powered by') }}
<a class="underline" target="_blank" href="https://unsplash.com">
{{ __('Unsplash') }}
</a>
</div>
</div>
</div>
</template>
</Popover>
</template>
<script>
// import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
import { Popover, FileUploader, Button } from 'frappe-ui'
export default {
name: 'UnsplashImageBrowser',
components: {
Popover,
FileUploader,
},
emits: ['select'],
resources: {
images() {
return {
url: 'gameplan.api.get_unsplash_photos',
params: { keyword: this.search },
auto: true,
debounce: 500,
}
},
},
data() {
return {
search: '',
}
},
}
</script>

View File

@@ -1,53 +0,0 @@
<template>
<FileUploader
:fileTypes="['image/*', 'video/*', 'audio/*', '.pdf']"
:validateFile="validateFile"
@success="(data) => addFile(data)"
ref="fileUploader"
class="hide"
/>
</template>
<script setup>
import { FileUploader } from 'frappe-ui'
import { onMounted, ref, nextTick } from 'vue'
const fileUploader = ref(null)
const emit = defineEmits(['fileUploaded'])
const props = defineProps({
onFileUploaded: {
type: Function,
required: true,
},
})
onMounted(async () => {
await nextTick()
const fileInput = fileUploader.value.$el.querySelector('input[type="file"]')
if (fileInput) {
fileInput.click()
}
})
const addFile = (file) => {
props.onFileUploaded({
file_url: file.file_url,
file_type: file.file_type,
})
}
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png', 'mp4', 'mov', 'mp3', 'pdf'].includes(extension)) {
return 'Only image and video files are allowed.'
}
}
const isVideo = (type) => {
return ['mov', 'mp4', 'avi', 'mkv', 'webm'].includes(type.toLowerCase())
}
const isAudio = (type) => {
return ['mp3', 'wav', 'ogg'].includes(type.toLowerCase())
}
</script>

View File

@@ -1,17 +1,15 @@
<template> <template>
<Tooltip :text="user.full_name"> <Avatar
<Avatar class="avatar border border-gray-300"
class="avatar border border-gray-300 cursor-auto" v-if="user"
v-if="user" :label="user.full_name"
:label="user.full_name" :image="user.user_image"
:image="user.user_image" :size="size"
:size="size" v-bind="$attrs"
v-bind="$attrs" />
/>
</Tooltip>
</template> </template>
<script setup> <script setup>
import { Avatar, Tooltip } from 'frappe-ui' import { Avatar } from 'frappe-ui'
const props = defineProps({ const props = defineProps({
user: { user: {
type: Object, type: Object,

View File

@@ -1,5 +1,5 @@
<template> <template>
<Dropdown class="p-2" :options="userDropdownOptions"> <Dropdown :options="userDropdownOptions">
<template v-slot="{ open }"> <template v-slot="{ open }">
<button <button
class="flex h-12 py-2 items-center rounded-md duration-300 ease-in-out" class="flex h-12 py-2 items-center rounded-md duration-300 ease-in-out"
@@ -11,11 +11,11 @@
: 'hover:bg-gray-200 px-2 w-52' : 'hover:bg-gray-200 px-2 w-52'
" "
> >
<img <span
v-if="branding.data?.banner_image" v-if="branding.data?.brand_html"
:src="branding.data?.banner_image.file_url" v-html="branding.data?.brand_html"
class="w-8 h-8 rounded flex-shrink-0" class="w-8 h-8 rounded flex-shrink-0"
/> ></span>
<LMSLogo v-else class="w-8 h-8 rounded flex-shrink-0" /> <LMSLogo v-else class="w-8 h-8 rounded flex-shrink-0" />
<div <div
class="flex flex-1 flex-col text-left duration-300 ease-in-out" class="flex flex-1 flex-col text-left duration-300 ease-in-out"
@@ -26,20 +26,13 @@
" "
> >
<div class="text-base font-medium text-gray-900 leading-none"> <div class="text-base font-medium text-gray-900 leading-none">
<span <span v-if="branding.data?.brand_name">
v-if=" {{ branding.data?.brand_name }}
branding.data?.app_name && branding.data?.app_name != 'Frappe'
"
>
{{ branding.data?.app_name }}
</span> </span>
<span v-else> Learning </span> <span v-else> Learning </span>
</div> </div>
<div <div v-if="user" class="mt-1 text-sm text-gray-700 leading-none">
v-if="userResource" {{ convertToTitleCase(user.split('@')[0]) }}
class="mt-1 text-sm text-gray-700 leading-none"
>
{{ convertToTitleCase(userResource.data?.full_name) }}
</div> </div>
</div> </div>
<div <div
@@ -55,32 +48,18 @@
</button> </button>
</template> </template>
</Dropdown> </Dropdown>
<SettingsModal
v-if="userResource.data?.is_moderator"
v-model="showSettingsModal"
/>
</template> </template>
<script setup> <script setup>
import LMSLogo from '@/components/Icons/LMSLogo.vue' import LMSLogo from '@/components/Icons/LMSLogo.vue'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { Dropdown } from 'frappe-ui' import { Dropdown, createResource } from 'frappe-ui'
import Apps from '@/components/Apps.vue' import { ChevronDown, LogIn, LogOut, User } from 'lucide-vue-next'
import { ChevronDown, LogIn, LogOut, User, Settings } from 'lucide-vue-next'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { convertToTitleCase } from '../utils' import { convertToTitleCase } from '../utils'
import { usersStore } from '@/stores/user' import { onMounted } from 'vue'
import { useSettings } from '@/stores/settings'
import { markRaw, watch, ref } from 'vue'
import SettingsModal from '@/components/Modals/Settings.vue'
const router = useRouter() const router = useRouter()
const { logout, branding } = sessionStore()
let { userResource } = usersStore()
const settingsStore = useSettings()
let { isLoggedIn } = sessionStore()
const showSettingsModal = ref(false)
const props = defineProps({ const props = defineProps({
isCollapsed: { isCollapsed: {
type: Boolean, type: Boolean,
@@ -88,43 +67,28 @@ const props = defineProps({
}, },
}) })
watch( const branding = createResource({
() => settingsStore.isSettingsOpen, url: 'lms.lms.api.get_branding',
(value) => { cache: true,
showSettingsModal.value = value auto: true,
} onSuccess(data) {
) document.querySelector("link[rel='icon']").href = data.favicon
},
})
const { logout, user } = sessionStore()
let { isLoggedIn } = sessionStore()
const userDropdownOptions = [ const userDropdownOptions = [
{ /* {
icon: User, icon: User,
label: 'My Profile', label: 'My Profile',
onClick: () => { onClick: () => {
router.push(`/user/${userResource.data?.username}`) router.push(`/user/${user.data?.username}`)
}, },
condition: () => { condition: () => {
return isLoggedIn return isLoggedIn
}, },
}, }, */
{
component: markRaw(Apps),
condition: () => {
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
let system_user = cookies.get('system_user')
if (system_user === 'yes') return true
else return false
},
},
{
icon: Settings,
label: 'Settings',
onClick: () => {
settingsStore.isSettingsOpen = true
},
condition: () => {
return userResource.data?.is_moderator
},
},
{ {
icon: LogOut, icon: LogOut,
label: 'Log out', label: 'Log out',

View File

@@ -1,192 +0,0 @@
<template>
<div ref="videoContainer" class="video-block group relative">
<video
@timeupdate="updateTime"
@ended="videoEnded"
@click="togglePlay"
oncontextmenu="return false"
class="rounded-lg border border-gray-100 group cursor-pointer"
ref="videoRef"
>
<source :src="fileURL" :type="type" />
</video>
<div
class="flex items-center space-x-2 bg-gray-200 rounded-md p-0.5 absolute bottom-3 w-[98%] left-0 right-0 mx-auto invisible group-hover:visible"
>
<Button variant="ghost">
<template #icon>
<Play
v-if="!playing"
@click="playVideo"
class="w-4 h-4 text-gray-900"
/>
<Pause v-else @click="pauseVideo" class="w-4 h-4 text-gray-900" />
</template>
</Button>
<Button variant="ghost" @click="toggleMute">
<template #icon>
<Volume2 v-if="!muted" class="w-4 h-4 text-gray-900" />
<VolumeX v-else class="w-4 h-4 text-gray-900" />
</template>
</Button>
<input
type="range"
min="0"
:max="duration"
step="0.1"
v-model="currentTime"
@input="changeCurrentTime"
class="duration-slider w-full h-1"
/>
<span class="text-xs font-medium">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</span>
<Button variant="ghost" @click="toggleFullscreen">
<template #icon>
<Maximize class="w-4 h-4 text-gray-900" />
</template>
</Button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { Play, Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
import { Button } from 'frappe-ui'
const videoRef = ref(null)
const videoContainer = ref(null)
let playing = ref(false)
let currentTime = ref(0)
let duration = ref(0)
let muted = ref(false)
const props = defineProps({
file: {
type: String,
required: true,
},
type: {
type: String,
default: 'video/mp4',
},
})
onMounted(() => {
setTimeout(() => {
videoRef.value.onloadedmetadata = () => {
duration.value = videoRef.value.duration
}
videoRef.value.ontimeupdate = () => {
currentTime.value = videoRef.value.currentTime
}
}, 0)
})
const fileURL = computed(() => {
if (isYoutube) {
let url = props.file
if (url.includes('watch?v=')) {
url = url.replace('watch?v=', 'embed/')
}
return `${url}?autoplay=0&controls=0&disablekb=1&playsinline=1&cc_load_policy=1&cc_lang_pref=auto`
}
return props.file
})
const isYoutube = computed(() => {
return props.type == 'video/youtube'
})
const playVideo = () => {
videoRef.value.play()
playing.value = true
}
const pauseVideo = () => {
videoRef.value.pause()
playing.value = false
}
const togglePlay = () => {
if (playing.value) {
pauseVideo()
} else {
playVideo()
}
}
const videoEnded = () => {
playing.value = false
}
const toggleMute = () => {
videoRef.value.muted = !videoRef.value.muted
muted.value = videoRef.value.muted
}
const changeCurrentTime = () => {
videoRef.value.currentTime = currentTime.value
}
const formatTime = (time) => {
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
}
const toggleFullscreen = () => {
if (document.fullscreenElement) {
document.exitFullscreen()
} else {
videoContainer.value.requestFullscreen()
}
}
</script>
<style scoped>
.video-block {
width: 100%;
max-width: 900px;
margin: 0 auto;
}
.video-block video {
width: 100%;
height: auto;
}
iframe {
width: 100%;
min-height: 500px;
}
.duration-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
background-color: theme('colors.gray.400');
cursor: pointer;
}
.duration-slider::-webkit-slider-thumb {
height: 10px;
width: 10px;
-webkit-appearance: none;
background-color: theme('colors.gray.900');
}
@media screen and (-webkit-min-device-pixel-ratio: 0) {
input[type='range'] {
overflow: hidden;
width: 150px;
-webkit-appearance: none;
}
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
cursor: pointer;
box-shadow: -500px 0 0 500px theme('colors.gray.900');
}
}
</style>

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