Compare commits
1 Commits
l10n_devel
...
copy-minor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9bf4e2c58 |
40
.github/helper/update_pot_file.sh
vendored
40
.github/helper/update_pot_file.sh
vendored
@@ -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
|
|
||||||
34
.github/workflows/generate-pot-file.yml
vendored
34
.github/workflows/generate-pot-file.yml
vendored
@@ -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 }}
|
|
||||||
27
.github/workflows/make_release_pr.yml
vendored
27
.github/workflows/make_release_pr.yml
vendored
@@ -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 }}
|
|
||||||
32
.github/workflows/on_release.yml
vendored
32
.github/workflows/on_release.yml
vendored
@@ -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
|
|
||||||
39
.github/workflows/release_notes.yml
vendored
39
.github/workflows/release_notes.yml
vendored
@@ -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 }}
|
|
||||||
1
.github/workflows/ui-tests.yml
vendored
1
.github/workflows/ui-tests.yml
vendored
@@ -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: |
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,4 +12,3 @@ 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
3
.gitmodules
vendored
@@ -1,3 +0,0 @@
|
|||||||
[submodule "frappe-ui"]
|
|
||||||
path = frappe-ui
|
|
||||||
url = https://github.com/frappe/frappe-ui
|
|
||||||
21
.releaserc
21
.releaserc
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -75,13 +75,7 @@ cd apps/lms/docker
|
|||||||
docker-compose up
|
docker-compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
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 appear.
|
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.
|
||||||
You'll have to go through the setup wizard to set up the website the first time you access it. Log in using the following credentials to complete the setup wizard.
|
|
||||||
|
|
||||||
```
|
|
||||||
Username: Administrator
|
|
||||||
password: admin
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frappe Bench
|
### Frappe Bench
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -13,6 +13,6 @@ module.exports = defineConfig({
|
|||||||
openMode: 0,
|
openMode: 0,
|
||||||
},
|
},
|
||||||
e2e: {
|
e2e: {
|
||||||
baseUrl: "http://test_site_ui:8000",
|
baseUrl: "http://pyp:8000",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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("a").contains("New").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("Add Chapter").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.url().should("include", "/learn/1-1/edit");
|
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
|
cy.get("#lesson-title").type("Test Lesson");
|
||||||
|
|
||||||
cy.get("label").contains("Title").type("Test Lesson");
|
// Content
|
||||||
|
cy.get(".collapse-section.collapsed:first").click();
|
||||||
cy.get("#content .ce-block").type(
|
cy.get("#lesson-content .ce-block")
|
||||||
"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."
|
.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.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(
|
|
||||||
"text",
|
|
||||||
"This is a test discussion. This will check if the UI is working properly."
|
|
||||||
);
|
|
||||||
cy.button("Post").click();
|
|
||||||
});
|
|
||||||
|
|
||||||
// View Discussion
|
// Enter title
|
||||||
cy.wait(500);
|
cy.get(".modal .topic-title")
|
||||||
cy.get("div").contains("Test Discussion").click();
|
.type("Discussion from tests")
|
||||||
cy.get("div[contenteditable=true").invoke(
|
.should("have.value", "Discussion from tests");
|
||||||
"text",
|
|
||||||
"This is a test comment. This will check if the UI is working properly."
|
// Enter comment
|
||||||
|
cy.get(".modal .discussions-comment").type(
|
||||||
|
"This is a discussion from the cypress ui tests."
|
||||||
);
|
);
|
||||||
|
|
||||||
cy.get("div").contains(
|
// Submit
|
||||||
"This is a test comment. This will check if the UI is working properly."
|
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"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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
@@ -10,30 +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",
|
|
||||||
"ace-builds": "^1.36.2",
|
|
||||||
"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.69",
|
"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",
|
||||||
"vue": "^3.4.23",
|
"vue": "^3.2.25",
|
||||||
"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",
|
|
||||||
"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.
@@ -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>
|
||||||
|
|||||||
@@ -7,60 +7,15 @@
|
|||||||
class="flex flex-col overflow-hidden"
|
class="flex flex-col overflow-hidden"
|
||||||
:class="isSidebarCollapsed ? 'items-center' : ''"
|
:class="isSidebarCollapsed ? 'items-center' : ''"
|
||||||
>
|
>
|
||||||
<UserDropdown :isCollapsed="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="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="isSidebarCollapsed ? 'pl-3' : 'pl-4'"
|
|
||||||
@click="showWebPages = !showWebPages"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="!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="isSidebarCollapsed"
|
|
||||||
class="mx-2 my-0.5"
|
|
||||||
:showControls="isModerator ? true : false"
|
|
||||||
@openModal="openPageModal"
|
|
||||||
@deletePage="deletePage"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
:link="{
|
:link="{
|
||||||
@@ -80,11 +35,6 @@
|
|||||||
</template>
|
</template>
|
||||||
</SidebarLink>
|
</SidebarLink>
|
||||||
</div>
|
</div>
|
||||||
<PageModal
|
|
||||||
v-model="showPageModal"
|
|
||||||
v-model:reloadSidebar="sidebarSettings"
|
|
||||||
:page="pageToEdit"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -92,127 +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 { 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()
|
|
||||||
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)
|
|
||||||
|
|
||||||
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 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, () => {
|
|
||||||
if (userResource.data) {
|
|
||||||
isModerator.value = userResource.data.is_moderator
|
|
||||||
isInstructor.value = userResource.data.is_instructor
|
|
||||||
addQuizzes()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
let isSidebarCollapsed = ref(getSidebarFromStorage())
|
let isSidebarCollapsed = ref(getSidebarFromStorage())
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -1,92 +1,49 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="text-lg font-semibold mb-4">
|
<div class="text-lg font-semibold mb-4">
|
||||||
{{ __('Assessments') }}
|
{{ __('Assessments') }}
|
||||||
</div>
|
</div>
|
||||||
<Button v-if="canSeeAddButton()" @click="showModal = true">
|
|
||||||
<template #prefix>
|
|
||||||
<Plus class="h-4 w-4" />
|
|
||||||
</template>
|
|
||||||
{{ __('Add') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div v-if="assessments.data?.length">
|
<div v-if="assessments.data?.length">
|
||||||
<ListView
|
<ListView
|
||||||
:columns="getAssessmentColumns()"
|
:columns="getAssessmentColumns()"
|
||||||
: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) => {
|
||||||
|
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>
|
|
||||||
{{ 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,
|
|
||||||
} 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: {
|
||||||
@@ -117,61 +74,6 @@ 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: {
|
|
||||||
assignmentName: row.assessment_name,
|
|
||||||
submissionName: row.submission.name,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
name: 'AssignmentSubmission',
|
|
||||||
params: {
|
|
||||||
assignmentName: 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 = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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">
|
||||||
|
<div v-if="batch.amount" class="font-semibold text-lg mb-4">
|
||||||
{{ batch.price }}
|
{{ batch.price }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col space-y-2 mt-auto">
|
<div class="flex items-center mb-3">
|
||||||
<DateRange
|
<BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
:startDate="batch.start_date"
|
<span> {{ batch.courses.length }} {{ __('Courses') }} </span>
|
||||||
:endDate="batch.end_date"
|
</div>
|
||||||
class="text-sm text-gray-700"
|
<div class="flex items-center mb-3">
|
||||||
/>
|
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
<div class="flex items-center text-sm 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>
|
||||||
|
|||||||
@@ -4,25 +4,23 @@
|
|||||||
<div class="text-xl 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: 'lesson_count',
|
key: 'lesson_count',
|
||||||
align: 'right',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Enrollments',
|
label: 'Enrollments',
|
||||||
align: 'right',
|
|
||||||
key: 'enrollment_count',
|
key: 'enrollment_count',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<Button class="float-right mb-3" @click="openStudentModal()">
|
<Button class="float-right mb-3" variant="solid" @click="openStudentModal()">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus class="h-4 w-4" />
|
<Plus class="h-4 w-4" />
|
||||||
</template>
|
</template>
|
||||||
{{ __('Add') }}
|
{{ __('Add Student') }}
|
||||||
</Button>
|
</Button>
|
||||||
<div class="text-lg font-semibold mb-4">
|
<div class="text-lg font-semibold mb-4">
|
||||||
{{ __('Students') }}
|
{{ __('Students') }}
|
||||||
@@ -52,10 +52,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="removeStudents(selections)">
|
||||||
variant="ghost"
|
|
||||||
@click="removeStudents(selections, unselectAll)"
|
|
||||||
>
|
|
||||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,7 +85,6 @@ import {
|
|||||||
import { Trash2, Plus } from 'lucide-vue-next'
|
import { Trash2, Plus } from 'lucide-vue-next'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import StudentModal from '@/components/Modals/StudentModal.vue'
|
import StudentModal from '@/components/Modals/StudentModal.vue'
|
||||||
import { showToast } from '@/utils'
|
|
||||||
|
|
||||||
const showStudentModal = ref(false)
|
const showStudentModal = ref(false)
|
||||||
|
|
||||||
@@ -113,7 +109,6 @@ const getStudentColumns = () => {
|
|||||||
{
|
{
|
||||||
label: 'Full Name',
|
label: 'Full Name',
|
||||||
key: 'full_name',
|
key: 'full_name',
|
||||||
width: 2,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Courses Done',
|
label: 'Courses Done',
|
||||||
@@ -136,28 +131,21 @@ const openStudentModal = () => {
|
|||||||
showStudentModal.value = true
|
showStudentModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteStudents = createResource({
|
const removeStudent = createResource({
|
||||||
url: 'lms.lms.api.delete_documents',
|
url: 'frappe.client.delete',
|
||||||
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()
|
students.reload()
|
||||||
showToast(__('Success'), __('Students deleted successfully'), 'check')
|
|
||||||
unselectAll()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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">
|
|
||||||
<div>
|
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="option.label != option.description"
|
|
||||||
class="text-xs text-gray-700"
|
|
||||||
v-html="option.description"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</slot>
|
</slot>
|
||||||
</li>
|
</li>
|
||||||
</ComboboxOption>
|
</ComboboxOption>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -108,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,
|
||||||
@@ -119,7 +118,6 @@ const options = createResource({
|
|||||||
return {
|
return {
|
||||||
label: option.value,
|
label: option.value,
|
||||||
value: option.value,
|
value: option.value,
|
||||||
description: option.description,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,242 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<label class="block mb-1" :class="labelClasses" v-if="label">
|
|
||||||
{{ label }}
|
|
||||||
</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`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
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>
|
|
||||||
@@ -1,28 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-1">
|
|
||||||
<label class="block text-xs text-gray-600" v-if="props.label">
|
|
||||||
{{ props.label }}
|
|
||||||
</label>
|
|
||||||
<div class="flex text-center">
|
<div class="flex text-center">
|
||||||
<div
|
<div v-for="index in 5">
|
||||||
v-for="index in 5"
|
|
||||||
@mouseover="hoveredRating = index"
|
|
||||||
@mouseleave="hoveredRating = 0"
|
|
||||||
>
|
|
||||||
<Star
|
<Star
|
||||||
class="fill-gray-400 text-gray-50 stroke-1 mr-1 cursor-pointer"
|
:class="index <= rating ? 'fill-orange-500' : ''"
|
||||||
:class="iconClasses(index)"
|
class="h-6 w-6 fill-gray-400 text-gray-50 mr-1 cursor-pointer"
|
||||||
@click="markRating(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>
|
||||||
|
|||||||
@@ -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
|
|
||||||
class="flex items-center flex-wrap space-y-1 space-x-1 relative top-4 px-2 w-fit"
|
|
||||||
>
|
|
||||||
<Badge v-if="course.featured" variant="subtle" theme="green" size="md">
|
|
||||||
{{ __('Featured') }}
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
theme="gray"
|
|
||||||
size="md"
|
|
||||||
v-for="tag in course.tags"
|
|
||||||
>
|
>
|
||||||
|
<div class="flex relative top-4 left-4 w-fit flex-wrap">
|
||||||
|
<Badge theme="gray" size="md" class="mr-2" v-for="tag in course.tags">
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
@@ -116,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 } 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()
|
||||||
@@ -139,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',
|
||||||
'circle-warn'
|
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',
|
||||||
@@ -173,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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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()">
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
:key="chapter.name"
|
:key="chapter.name"
|
||||||
:defaultOpen="openChapterDetail(chapter.idx)"
|
:defaultOpen="openChapterDetail(chapter.idx)"
|
||||||
>
|
>
|
||||||
<DisclosureButton ref="" class="flex w-full p-2">
|
<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,
|
||||||
@@ -38,20 +38,12 @@
|
|||||||
{{ chapter.title }}
|
{{ chapter.title }}
|
||||||
</div>
|
</div>
|
||||||
</DisclosureButton>
|
</DisclosureButton>
|
||||||
<DisclosurePanel>
|
<DisclosurePanel class="pb-2">
|
||||||
<Draggable
|
<div v-for="lesson in chapter.lessons" :key="lesson.name">
|
||||||
:list="chapter.lessons"
|
<div class="outline-lesson pl-8 py-2">
|
||||||
:disabled="!allowEdit"
|
|
||||||
item-key="name"
|
|
||||||
group="items"
|
|
||||||
@end="updateOutline"
|
|
||||||
:data-chapter="chapter.name"
|
|
||||||
>
|
|
||||||
<template #item="{ element: lesson }">
|
|
||||||
<div class="outline-lesson pl-8 py-2 pr-4">
|
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: allowEdit ? 'LessonForm' : 'Lesson',
|
name: allowEdit ? 'CreateLesson' : 'Lesson',
|
||||||
params: {
|
params: {
|
||||||
courseName: courseName,
|
courseName: courseName,
|
||||||
chapterNumber: lesson.number.split('.')[0],
|
chapterNumber: lesson.number.split('.')[0],
|
||||||
@@ -59,7 +51,7 @@
|
|||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="flex items-center text-sm leading-5 group">
|
<div class="flex items-center text-sm leading-5">
|
||||||
<MonitorPlay
|
<MonitorPlay
|
||||||
v-if="lesson.icon === 'icon-youtube'"
|
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"
|
||||||
@@ -73,24 +65,18 @@
|
|||||||
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 }}
|
{{ lesson.title }}
|
||||||
<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
|
<Check
|
||||||
v-if="lesson.is_complete"
|
v-if="lesson.is_complete"
|
||||||
class="h-4 w-4 text-green-700 ml-2"
|
class="h-4 w-4 text-green-500 stroke-1.5 ml-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</Draggable>
|
<div v-if="allowEdit" class="flex mt-2 pl-8">
|
||||||
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
|
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: 'LessonForm',
|
name: 'CreateLesson',
|
||||||
params: {
|
params: {
|
||||||
courseName: courseName,
|
courseName: courseName,
|
||||||
chapterNumber: chapter.idx,
|
chapterNumber: chapter.idx,
|
||||||
@@ -119,8 +105,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Button, createResource } from 'frappe-ui'
|
import { Button, createResource } from 'frappe-ui'
|
||||||
import { ref, getCurrentInstance } 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 {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -128,18 +113,14 @@ import {
|
|||||||
HelpCircle,
|
HelpCircle,
|
||||||
FileText,
|
FileText,
|
||||||
Check,
|
Check,
|
||||||
Trash2,
|
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useRoute } 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 expandAll = ref(true)
|
const expandAll = ref(true)
|
||||||
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: {
|
||||||
@@ -158,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({
|
||||||
@@ -169,61 +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 Lesson'),
|
|
||||||
message: __('Are you sure you want to delete this lesson?'),
|
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -236,15 +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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.outline-lesson:has(.router-link-active) {
|
.outline-lesson:has(.router-link-active) {
|
||||||
|
|||||||
@@ -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
|
|
||||||
:to="{
|
|
||||||
name: 'Profile',
|
|
||||||
params: { username: review.owner_details.username },
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<UserAvatar :user="review.owner_details" :size="'2xl'" />
|
<UserAvatar :user="review.owner_details" :size="'2xl'" />
|
||||||
</router-link>
|
|
||||||
<div class="mx-4">
|
<div class="mx-4">
|
||||||
<router-link
|
|
||||||
:to="{
|
|
||||||
name: 'Profile',
|
|
||||||
params: { username: review.owner_details.username },
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<span class="text-lg font-medium mr-4">
|
<span class="text-lg font-medium mr-4">
|
||||||
{{ review.owner_details.full_name }}
|
{{ review.owner_details.full_name }}
|
||||||
</span>
|
</span>
|
||||||
</router-link>
|
|
||||||
<span>
|
<span>
|
||||||
{{ review.creation }}
|
{{ review.creation }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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(
|
||||||
{},
|
{},
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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="font-medium mb-1">
|
<div class="text-xl font-semibold mb-2">
|
||||||
{{ job.job_title }}
|
{{ job.job_title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-700">
|
<div>
|
||||||
|
{{ __('posted by') }}
|
||||||
|
<span class="font-medium">
|
||||||
{{ job.company_name }}
|
{{ job.company_name }}
|
||||||
|
</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
<div>
|
||||||
<div class="flex justify-end w-1/5 text-gray-700">
|
{{ __('posted on') }}
|
||||||
{{ job.location.replace(',', '').split(' ')[0] }}
|
<span class="font-medium">
|
||||||
</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') }}
|
{{ dayjs(job.creation).format('DD MMM YYYY') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,74 +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 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 items-center text-sm font-medium space-x-2 cursor-pointer"
|
|
||||||
@click="openHelpDialog('upload')"
|
|
||||||
>
|
|
||||||
<span class="leading-5">
|
|
||||||
{{ __('How to upload content from your system?') }}
|
|
||||||
</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>
|
|
||||||
{{ __('How to add a YouTube Video?') }}
|
|
||||||
</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" :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 openHelpDialog = (contentType) => {
|
|
||||||
type.value = contentType
|
|
||||||
showExplanation.value = true
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
142
frontend/src/components/LessonPlugins.vue
Normal file
142
frontend/src/components/LessonPlugins.vue
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-lg font-semibold">
|
||||||
|
{{ __('Components') }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-5">
|
||||||
|
<Tooltip
|
||||||
|
:text="
|
||||||
|
__(
|
||||||
|
'Content such as quiz, video and image will be added in the editor you select.'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
placement="bottom"
|
||||||
|
>
|
||||||
|
<div class="">
|
||||||
|
<div class="text-xs text-gray-600 mb-1">
|
||||||
|
{{ __('Select an Editor') }}
|
||||||
|
</div>
|
||||||
|
<Select v-model="currentEditor" :options="getEditorOptions()" />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<div class="flex mt-4">
|
||||||
|
<Link
|
||||||
|
v-model="quiz"
|
||||||
|
class="flex-1"
|
||||||
|
doctype="LMS Quiz"
|
||||||
|
:label="__('Select a Quiz')"
|
||||||
|
/>
|
||||||
|
<Button @click="addQuiz()" class="self-end ml-2">
|
||||||
|
<template #icon>
|
||||||
|
<Plus class="h-4 w-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="text-xs text-gray-600 mb-1">
|
||||||
|
{{ __('Add an image, video, pdf or audio.') }}
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<FileUploader
|
||||||
|
v-if="!file"
|
||||||
|
:fileTypes="['image/*', 'video/*', 'audio/*', '.pdf']"
|
||||||
|
:validateFile="validateFile"
|
||||||
|
@success="(data) => addFile(data)"
|
||||||
|
>
|
||||||
|
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||||
|
<div class="">
|
||||||
|
<Button @click="openFileSelector" :loading="uploading">
|
||||||
|
{{
|
||||||
|
uploading
|
||||||
|
? __('Uploading {0}%').format(progress)
|
||||||
|
: __('Upload an File')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FileUploader>
|
||||||
|
<div v-else class="">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="border rounded-md p-2 mr-2">
|
||||||
|
<FileText class="h-4 w-4 stroke-1.5 text-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-xs">
|
||||||
|
{{ file.file_name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
import { FileUploader, Button, Select, Tooltip } from 'frappe-ui'
|
||||||
|
import { Plus, FileText } from 'lucide-vue-next'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const quiz = ref(null)
|
||||||
|
const file = ref(null)
|
||||||
|
const lessonEditor = ref(null)
|
||||||
|
const instructorEditor = ref(null)
|
||||||
|
const currentEditor = ref('Lesson Content')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
editor: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
notesEditor: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const addQuiz = () => {
|
||||||
|
getCurrentEditor().caret.setToLastBlock('end', 0)
|
||||||
|
if (quiz.value) {
|
||||||
|
getCurrentEditor().blocks.insert('quiz', {
|
||||||
|
quiz: quiz.value,
|
||||||
|
})
|
||||||
|
quiz.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addFile = (data) => {
|
||||||
|
getCurrentEditor().caret.setToLastBlock('end', 0)
|
||||||
|
getCurrentEditor().blocks.insert('upload', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateFile = (file) => {
|
||||||
|
let extension = file.name.split('.').pop().toLowerCase()
|
||||||
|
if (!['jpg', 'jpeg', 'png', 'mp4', 'mov', 'mp3'].includes(extension)) {
|
||||||
|
return 'Only image and video files are allowed.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEditorOptions = () => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Lesson Content',
|
||||||
|
value: 'Lesson Content',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Instructor Content',
|
||||||
|
value: 'Instructor Content',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentEditor = () => {
|
||||||
|
return currentEditor.value == 'Lesson Content'
|
||||||
|
? lessonEditor.value
|
||||||
|
: instructorEditor.value
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.editor, props.notesEditor],
|
||||||
|
([newEditor, newNotesEditor], [oldEditor, oldNotesEditor]) => {
|
||||||
|
lessonEditor.value = newEditor
|
||||||
|
instructorEditor.value = newNotesEditor
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -1,30 +1,28 @@
|
|||||||
<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>
|
<template #prefix>
|
||||||
<Plus class="h-4 w-4" />
|
<Plus class="h-4 w-4" />
|
||||||
</template>
|
</template>
|
||||||
<span>
|
<span>
|
||||||
{{ __('Add') }}
|
{{ __('Add Live Class') }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</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">
|
||||||
>
|
|
||||||
<div class="font-semibold text-gray-900 text-lg mb-4">
|
|
||||||
{{ cls.title }}
|
{{ cls.title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="leading-5 text-gray-700 text-sm mb-4">
|
|
||||||
{{ cls.description }}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center mb-2">
|
<div class="flex items-center mb-2">
|
||||||
<Calendar class="w-4 h-4 stroke-1.5 text-gray-700" />
|
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||||
<span class="ml-2">
|
<span class="ml-2">
|
||||||
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -35,9 +33,11 @@
|
|||||||
{{ formatTime(cls.time) }}
|
{{ formatTime(cls.time) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2 text-gray-900 mt-auto">
|
<div class="mb-5">
|
||||||
|
{{ cls.description }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<a
|
<a
|
||||||
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
|
||||||
:href="cls.start_url"
|
:href="cls.start_url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
|
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"
|
||||||
@@ -56,6 +56,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div v-else class="text-sm italic text-gray-600">
|
<div v-else class="text-sm italic text-gray-600">
|
||||||
{{ __('No live classes scheduled') }}
|
{{ __('No live classes scheduled') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -88,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',
|
||||||
|
|||||||
@@ -1,203 +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 v-if="getRole(member)">
|
|
||||||
{{ getRole(member) }}
|
|
||||||
</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 } 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>
|
|
||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -14,13 +14,7 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #body-content>
|
<template #body-content>
|
||||||
<Link doctype="LMS Course" v-model="course" :label="__('Course')" />
|
<Link doctype="LMS Course" v-model="course" />
|
||||||
<Link
|
|
||||||
doctype="Course Evaluator"
|
|
||||||
v-model="evaluator"
|
|
||||||
:label="__('Evaluator')"
|
|
||||||
class="mt-4"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
@@ -32,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({
|
||||||
@@ -52,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,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -66,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')
|
||||||
|
|||||||
@@ -15,24 +15,17 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #body-content>
|
<template #body-content>
|
||||||
<FormControl
|
<FormControl label="Title" v-model="chapter.title" class="mb-4" />
|
||||||
ref="chapterInput"
|
|
||||||
label="Title"
|
|
||||||
v-model="chapter.title"
|
|
||||||
class="mb-4"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, FormControl, createResource } from 'frappe-ui'
|
import { Dialog, FormControl, createResource } from 'frappe-ui'
|
||||||
import { defineModel, reactive, watch, ref } from 'vue'
|
import { defineModel, reactive, watch, inject } from 'vue'
|
||||||
import { createToast } from '@/utils/'
|
import { createToast, formatTime } from '@/utils/'
|
||||||
import { capture } from '@/telemetry'
|
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const outline = defineModel('outline')
|
const outline = defineModel('outline')
|
||||||
const chapterInput = ref(null)
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
course: {
|
course: {
|
||||||
@@ -43,7 +36,6 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const chapter = reactive({
|
const chapter = reactive({
|
||||||
title: '',
|
title: '',
|
||||||
})
|
})
|
||||||
@@ -99,12 +91,10 @@ const addChapter = (close) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
capture('chapter_created')
|
|
||||||
chapterReference.submit(
|
chapterReference.submit(
|
||||||
{ name: data.name },
|
{ name: data.name },
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
chapter.title = ''
|
|
||||||
outline.value.reload()
|
outline.value.reload()
|
||||||
createToast({
|
createToast({
|
||||||
text: 'Chapter added successfully',
|
text: 'Chapter added successfully',
|
||||||
@@ -168,12 +158,4 @@ watch(
|
|||||||
chapter.title = newChapter?.title
|
chapter.title = newChapter?.title
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(show, () => {
|
|
||||||
if (show.value) {
|
|
||||||
setTimeout(() => {
|
|
||||||
chapterInput.value.$el.querySelector('input').focus()
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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')
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -1,178 +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"
|
|
||||||
/>
|
|
||||||
<FormControl type="textarea" v-model="profile.bio" :label="__('Bio')" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
FormControl,
|
|
||||||
FileUploader,
|
|
||||||
Button,
|
|
||||||
createResource,
|
|
||||||
} 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>
|
|
||||||
@@ -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,
|
||||||
})
|
})
|
||||||
@@ -177,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Dialog
|
|
||||||
v-model="show"
|
|
||||||
:options="{
|
|
||||||
size: '4xl',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<template #body>
|
|
||||||
<div class="p-4">
|
|
||||||
<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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
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>
|
|
||||||
@@ -17,61 +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" />
|
||||||
/>
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
class="flex items-center"
|
||||||
:text="
|
:text="
|
||||||
__(
|
__(
|
||||||
'Time must be in 24 hour format (HH:mm). Example 11:30 or 22:00'
|
'Time must be in 24 hour format (HH:mm). Example 11:30 or 22:00'
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<FormControl
|
<span>
|
||||||
v-model="liveClass.time"
|
{{ __('Time') }}
|
||||||
type="time"
|
</span>
|
||||||
:label="__('Time')"
|
<Info class="stroke-2 w-3 h-3 ml-1" />
|
||||||
class="mb-4"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<FormControl
|
</div>
|
||||||
v-model="liveClass.timezone"
|
<Input v-model="liveClass.time" />
|
||||||
type="select"
|
|
||||||
:options="getTimezoneOptions()"
|
|
||||||
:label="__('Timezone')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<FormControl
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
v-model="liveClass.date"
|
{{ __('Timezone') }}
|
||||||
type="date"
|
</div>
|
||||||
class="mb-4"
|
<Select
|
||||||
:label="__('Date')"
|
v-model="liveClass.timezone"
|
||||||
/>
|
:options="getTimezoneOptions()"
|
||||||
<Tooltip :text="__('Duration of the live class in minutes')">
|
|
||||||
<FormControl
|
|
||||||
type="number"
|
|
||||||
v-model="liveClass.duration"
|
|
||||||
:label="__('Duration')"
|
|
||||||
class="mb-4"
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
|
{{ __('Date') }}
|
||||||
|
</div>
|
||||||
|
<DatePicker v-model="liveClass.date" inputClass="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
|
<Tooltip
|
||||||
|
class="flex items-center"
|
||||||
|
:text="__('Duration of the live class in minutes')"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{ __('Duration') }}
|
||||||
|
</span>
|
||||||
|
<Info class="stroke-2 w-3 h-3 ml-1" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<FormControl
|
</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"
|
v-model="liveClass.auto_recording"
|
||||||
type="select"
|
|
||||||
:options="getRecordingOptions()"
|
:options="getRecordingOptions()"
|
||||||
:label="__('Auto Recording')"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FormControl
|
</div>
|
||||||
v-model="liveClass.description"
|
<div>
|
||||||
type="textarea"
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
:label="__('Description')"
|
{{ __('Description') }}
|
||||||
/>
|
</div>
|
||||||
|
<Textarea v-model="liveClass.description" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -85,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/'
|
||||||
@@ -153,7 +169,7 @@ 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.'
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -1,351 +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 accent-gray-900"
|
|
||||||
/>
|
|
||||||
<label for="existing">
|
|
||||||
{{ __('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"
|
|
||||||
/>
|
|
||||||
<label for="new">
|
|
||||||
{{ __('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"
|
|
||||||
/>
|
|
||||||
<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}`]"
|
|
||||||
/>
|
|
||||||
<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}`]"
|
|
||||||
/>
|
|
||||||
</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 : ''
|
|
||||||
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>
|
|
||||||
@@ -1,327 +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: 'Members',
|
|
||||||
description: 'Manage the members of your learning system',
|
|
||||||
icon: 'UserRoundPlus',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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: 'Settings',
|
|
||||||
hideLabel: true,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full bg-gray-200 rounded-full h-1 my-2">
|
|
||||||
<div
|
|
||||||
class="bg-gray-900 h-1 rounded-full"
|
|
||||||
:style="{ width: progressBarWidth }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
progress: {
|
|
||||||
type: Number,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const progressBarWidth = computed(() => {
|
|
||||||
const formattedPercentage = Math.min(Math.ceil(props.progress), 100)
|
|
||||||
return `${formattedPercentage}%`
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -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 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 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>
|
||||||
|
|
||||||
<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}`]"
|
||||||
@@ -136,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>
|
||||||
@@ -202,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>
|
||||||
@@ -218,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}'
|
||||||
@@ -269,28 +212,21 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
Badge,
|
createDocumentResource,
|
||||||
Button,
|
Button,
|
||||||
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 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 props = defineProps({
|
const props = defineProps({
|
||||||
quizName: {
|
quizName: {
|
||||||
@@ -310,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) {
|
||||||
@@ -394,16 +279,6 @@ const attempts = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
|
||||||
() => quiz.data,
|
|
||||||
() => {
|
|
||||||
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) {
|
||||||
@@ -433,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()
|
||||||
}
|
}
|
||||||
@@ -442,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) => {
|
||||||
@@ -453,17 +328,10 @@ const markAnswer = (index) => {
|
|||||||
|
|
||||||
const getAnswers = () => {
|
const getAnswers = () => {
|
||||||
let answers = []
|
let answers = []
|
||||||
const type = questionDetails.data.type
|
|
||||||
|
|
||||||
if (type == 'Choices') {
|
|
||||||
selectedOptions.forEach((value, index) => {
|
selectedOptions.forEach((value, index) => {
|
||||||
if (selectedOptions[index])
|
if (selectedOptions[index])
|
||||||
answers.push(questionDetails.data[`option_${index + 1}`])
|
answers.push(questionDetails.data[`option_${index + 1}`])
|
||||||
})
|
})
|
||||||
} else {
|
|
||||||
answers.push(possibleAnswer.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return answers
|
return answers
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,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
|
||||||
}
|
}
|
||||||
@@ -488,8 +355,6 @@ const checkAnswer = () => {
|
|||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
let type = questionDetails.data.type
|
|
||||||
if (type == 'Choices') {
|
|
||||||
selectedOptions.forEach((option, index) => {
|
selectedOptions.forEach((option, index) => {
|
||||||
if (option) {
|
if (option) {
|
||||||
showAnswers[index] = option && data[index]
|
showAnswers[index] = option && data[index]
|
||||||
@@ -499,9 +364,6 @@ const checkAnswer = () => {
|
|||||||
showAnswers[index] = undefined
|
showAnswers[index] = undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
|
||||||
showAnswers.push(data)
|
|
||||||
}
|
|
||||||
addToLocalStorage()
|
addToLocalStorage()
|
||||||
if (!quiz.data.show_answers) {
|
if (!quiz.data.show_answers) {
|
||||||
resetQuestion()
|
resetQuestion()
|
||||||
@@ -513,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
|
||||||
}),
|
}),
|
||||||
@@ -524,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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -537,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)
|
||||||
@@ -553,15 +412,9 @@ const submitQuiz = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createSubmission = () => {
|
const createSubmission = () => {
|
||||||
quizSubmission.submit(
|
quizSubmission.reload().then(() => {
|
||||||
{},
|
attempts.reload()
|
||||||
{
|
})
|
||||||
onSuccess(data) {
|
|
||||||
if (quiz.data && quiz.data.max_attempts) attempts.reload()
|
|
||||||
if (quiz.data.duration) clearInterval(timerInterval)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetQuiz = () => {
|
const resetQuiz = () => {
|
||||||
@@ -569,15 +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 getSubmissionColumns = () => {
|
const getSubmissionColumns = () => {
|
||||||
|
|||||||
@@ -23,8 +23,4 @@ const props = defineProps({
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const redirectToLogin = () => {
|
|
||||||
window.location.href = `/login`
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Dialog
|
|
||||||
v-model="show"
|
|
||||||
:options="{
|
|
||||||
size: 'xl',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<template #body>
|
|
||||||
<div class="p-5 space-y-4">
|
|
||||||
<div class="text-lg font-semibold">
|
|
||||||
{{ __('Add a quiz to your lesson') }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Link
|
|
||||||
v-model="quiz"
|
|
||||||
doctype="LMS Quiz"
|
|
||||||
:label="__('Select a quiz')"
|
|
||||||
:onCreate="(value, close) => redirectToQuizForm()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end space-x-2">
|
|
||||||
<Button variant="solid" @click="addQuiz()">
|
|
||||||
{{ __('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 props = defineProps({
|
|
||||||
onQuizAddition: {
|
|
||||||
type: Function,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await nextTick()
|
|
||||||
show.value = true
|
|
||||||
})
|
|
||||||
|
|
||||||
const addQuiz = () => {
|
|
||||||
props.onQuizAddition(quiz.value)
|
|
||||||
show.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const redirectToQuizForm = () => {
|
|
||||||
window.open('/lms/quizzes/new', '_blank')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,70 +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'
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.CodeMirror pre.CodeMirror-line,
|
|
||||||
.CodeMirror pre.CodeMirror-line-like {
|
|
||||||
font-family: revert;
|
|
||||||
}
|
|
||||||
|
|
||||||
.CodeMirror {
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,144 +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"
|
|
||||||
/>
|
|
||||||
</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, FileText } 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>
|
|
||||||
@@ -6,58 +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' : '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">
|
|
||||||
{{ 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: {
|
||||||
@@ -68,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>
|
||||||
|
|||||||
@@ -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('')
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,17 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<Tooltip :text="user.full_name">
|
|
||||||
<Avatar
|
<Avatar
|
||||||
class="avatar border border-gray-300 cursor-auto"
|
class="avatar border border-gray-300"
|
||||||
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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div ref="videoContainer" class="video-block group relative">
|
|
||||||
<video
|
|
||||||
@timeupdate="updateTime"
|
|
||||||
@ended="videoEnded"
|
|
||||||
@click="togglePlay"
|
|
||||||
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>
|
|
||||||
@@ -5,7 +5,6 @@ import router from './router'
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import dayjs from '@/utils/dayjs'
|
import dayjs from '@/utils/dayjs'
|
||||||
import { createDialog } from '@/utils/dialogs'
|
|
||||||
import translationPlugin from './translation'
|
import translationPlugin from './translation'
|
||||||
import { usersStore } from './stores/user'
|
import { usersStore } from './stores/user'
|
||||||
import { sessionStore } from './stores/session'
|
import { sessionStore } from './stores/session'
|
||||||
@@ -31,10 +30,8 @@ app.provide('$dayjs', dayjs)
|
|||||||
app.provide('$socket', initSocket())
|
app.provide('$socket', initSocket())
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
const { userResource, allUsers } = usersStore()
|
const { userResource } = usersStore()
|
||||||
let { isLoggedIn } = sessionStore()
|
let { isLoggedIn } = sessionStore()
|
||||||
|
|
||||||
app.provide('$user', userResource)
|
app.provide('$user', userResource)
|
||||||
app.provide('$allUsers', allUsers)
|
|
||||||
app.config.globalProperties.$user = userResource
|
app.config.globalProperties.$user = userResource
|
||||||
app.config.globalProperties.$dialog = createDialog
|
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="badge.doc">
|
|
||||||
<div class="p-5 flex flex-col items-center mt-40">
|
|
||||||
<div class="text-3xl font-semibold">
|
|
||||||
{{ badge.doc.title }}
|
|
||||||
</div>
|
|
||||||
<img :src="badge.doc.image" :alt="badge.doc.title" class="h-60 mt-2" />
|
|
||||||
<div class="text-lg">
|
|
||||||
{{
|
|
||||||
__('This badge has been awarded to {0} on {1}.').format(
|
|
||||||
userName,
|
|
||||||
dayjs(issuedOn.data?.issued_on).format('DD MMM YYYY')
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
<div class="text-lg mt-2">
|
|
||||||
{{ badge.doc.description }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import { createDocumentResource, createResource, Breadcrumbs } from 'frappe-ui'
|
|
||||||
import { computed, inject } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
const allUsers = inject('$allUsers')
|
|
||||||
const dayjs = inject('$dayjs')
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
badgeName: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
email: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const badge = createDocumentResource({
|
|
||||||
doctype: 'LMS Badge',
|
|
||||||
name: props.badgeName,
|
|
||||||
})
|
|
||||||
|
|
||||||
const userName = computed(() => {
|
|
||||||
const user = Object.values(allUsers.data).find(
|
|
||||||
(user) => user.name === props.email
|
|
||||||
)
|
|
||||||
return user ? user.full_name : props.email
|
|
||||||
})
|
|
||||||
|
|
||||||
const issuedOn = createResource({
|
|
||||||
url: 'frappe.client.get_value',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doctype: 'LMS Badge Assignment',
|
|
||||||
filters: {
|
|
||||||
member: props.email,
|
|
||||||
badge: props.badgeName,
|
|
||||||
},
|
|
||||||
fieldname: 'issued_on',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess(data) {
|
|
||||||
if (!data.issued_on) {
|
|
||||||
router.push({ name: 'Courses' })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
auto: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: 'Badges',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: badge.doc.title,
|
|
||||||
route: {
|
|
||||||
name: 'Badge',
|
|
||||||
params: {
|
|
||||||
badge: badge.doc.name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -13,9 +13,9 @@
|
|||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
<div v-if="batch.data" class="grid grid-cols-[70%,30%] h-screen">
|
<div v-if="batch.data" class="grid grid-cols-[70%,30%] h-full">
|
||||||
<div class="border-r-2">
|
<div class="border-r-2">
|
||||||
<Tabs v-model="tabIndex" :tabs="tabs" tablistClass="overflow-y-hidden">
|
<Tabs v-model="tabIndex" :tabs="tabs" tablistClass="overflow-x-visible">
|
||||||
<template #tab="{ tab, selected }" class="overflow-x-hidden">
|
<template #tab="{ tab, selected }" class="overflow-x-hidden">
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #default="{ tab }">
|
<template #default="{ tab }">
|
||||||
<div class="pt-5 px-5 pb-10">
|
<div class="pt-5 px-10 pb-10">
|
||||||
<div v-if="tab.label == 'Courses'">
|
<div v-if="tab.label == 'Courses'">
|
||||||
<BatchCourses :batch="batch.data.name" />
|
<BatchCourses :batch="batch.data.name" />
|
||||||
</div>
|
</div>
|
||||||
@@ -66,10 +66,9 @@
|
|||||||
<Discussions
|
<Discussions
|
||||||
doctype="LMS Batch"
|
doctype="LMS Batch"
|
||||||
:docname="batch.data.name"
|
:docname="batch.data.name"
|
||||||
:title="__('Discussions')"
|
title="Discussions"
|
||||||
:key="batch.data.name"
|
:key="batch.data.name"
|
||||||
:singleThread="true"
|
:singleThread="true"
|
||||||
:scrollToBottom="true"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,40 +79,21 @@
|
|||||||
<div class="text-2xl font-semibold mb-2">
|
<div class="text-2xl font-semibold mb-2">
|
||||||
{{ batch.data.title }}
|
{{ batch.data.title }}
|
||||||
</div>
|
</div>
|
||||||
<div v-html="batch.data.description" class="leading-5 mb-2"></div>
|
<div v-html="batch.data.description" class="leading-5 mb-4"></div>
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
<div class="flex avatar-group overlap mb-5">
|
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
<div
|
<span>
|
||||||
class="h-6 mr-1"
|
{{ dayjs(batch.data.start_date).format('DD MMMM YYYY') }} -
|
||||||
:class="{
|
{{ dayjs(batch.data.end_date).format('DD MMMM YYYY') }}
|
||||||
'avatar-group overlap': batch.data.instructors.length > 1,
|
</span>
|
||||||
}"
|
|
||||||
>
|
|
||||||
<UserAvatar
|
|
||||||
v-for="instructor in batch.data.instructors"
|
|
||||||
:user="instructor"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<CourseInstructors :instructors="batch.data.instructors" />
|
<div class="flex items-center mb-6">
|
||||||
</div>
|
|
||||||
<DateRange
|
|
||||||
:startDate="batch.data.start_date"
|
|
||||||
:endDate="batch.data.end_date"
|
|
||||||
class="mb-3"
|
|
||||||
/>
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<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 mb-4">
|
|
||||||
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
|
||||||
<span>
|
|
||||||
{{ batch.data.timezone }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<AnnouncementModal
|
<AnnouncementModal
|
||||||
v-model="showAnnouncementModal"
|
v-model="showAnnouncementModal"
|
||||||
@@ -169,9 +149,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { Breadcrumbs, Button, createResource, Tabs, Badge } from 'frappe-ui'
|
import { Breadcrumbs, Button, createResource, Tabs, Badge } from 'frappe-ui'
|
||||||
import { computed, inject, ref } from 'vue'
|
import { computed, inject, ref } from 'vue'
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
|
||||||
import {
|
import {
|
||||||
|
Calendar,
|
||||||
Clock,
|
Clock,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
@@ -181,9 +160,8 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
SendIcon,
|
SendIcon,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Globe,
|
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { formatTime, updateDocumentTitle } from '@/utils'
|
import { formatTime } from '@/utils'
|
||||||
import BatchDashboard from '@/components/BatchDashboard.vue'
|
import BatchDashboard from '@/components/BatchDashboard.vue'
|
||||||
import BatchCourses from '@/components/BatchCourses.vue'
|
import BatchCourses from '@/components/BatchCourses.vue'
|
||||||
import LiveClass from '@/components/LiveClass.vue'
|
import LiveClass from '@/components/LiveClass.vue'
|
||||||
@@ -192,8 +170,8 @@ import Assessments from '@/components/Assessments.vue'
|
|||||||
import Announcements from '@/components/Annoucements.vue'
|
import Announcements from '@/components/Annoucements.vue'
|
||||||
import AnnouncementModal from '@/components/Modals/AnnouncementModal.vue'
|
import AnnouncementModal from '@/components/Modals/AnnouncementModal.vue'
|
||||||
import Discussions from '@/components/Discussions.vue'
|
import Discussions from '@/components/Discussions.vue'
|
||||||
import DateRange from '@/components/Common/DateRange.vue'
|
|
||||||
|
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const showAnnouncementModal = ref(false)
|
const showAnnouncementModal = ref(false)
|
||||||
|
|
||||||
@@ -286,13 +264,4 @@ const redirectToLogin = () => {
|
|||||||
const openAnnouncementModal = () => {
|
const openAnnouncementModal = () => {
|
||||||
showAnnouncementModal.value = true
|
showAnnouncementModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageMeta = computed(() => {
|
|
||||||
return {
|
|
||||||
title: batch.data?.title,
|
|
||||||
description: batch.data?.description,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
updateDocumentTitle(pageMeta)
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -8,31 +8,31 @@
|
|||||||
{{ __('Save') }}
|
{{ __('Save') }}
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
<div class="w-1/2 mx-auto py-5">
|
<div class="py-5">
|
||||||
<div class="">
|
<div class="container">
|
||||||
<div class="text-lg font-semibold mb-4">
|
<div class="text-lg font-semibold mb-4">
|
||||||
{{ __('Details') }}
|
{{ __('Details') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-10 mb-4 space-y-2">
|
<div class="grid grid-cols-2 gap-10">
|
||||||
<div>
|
<div>
|
||||||
<FormControl v-model="batch.title" :label="__('Title')" />
|
<FormControl
|
||||||
|
v-model="batch.title"
|
||||||
|
:label="__('Title')"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="batch.description"
|
||||||
|
:label="__('Description')"
|
||||||
|
type="textarea"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col space-y-2">
|
<div>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="batch.published"
|
v-model="batch.published"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:label="__('Published')"
|
:label="__('Published')"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
|
||||||
v-model="batch.allow_self_enrollment"
|
|
||||||
type="checkbox"
|
|
||||||
:label="__('Allow self enrollment')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-4">
|
|
||||||
<div>
|
|
||||||
<FileUploader
|
<FileUploader
|
||||||
v-if="!batch.image"
|
v-if="!batch.image"
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
@@ -40,15 +40,19 @@
|
|||||||
:validateFile="validateFile"
|
:validateFile="validateFile"
|
||||||
@success="(file) => saveImage(file)"
|
@success="(file) => saveImage(file)"
|
||||||
>
|
>
|
||||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
<template
|
||||||
|
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||||
|
>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<Button @click="openFileSelector" :loading="uploading">
|
<Button @click="openFileSelector" :loading="uploading">
|
||||||
{{ uploading ? `Uploading ${progress}%` : 'Upload an image' }}
|
{{
|
||||||
|
uploading ? `Uploading ${progress}%` : 'Upload an image'
|
||||||
|
}}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</FileUploader>
|
</FileUploader>
|
||||||
<div v-else class="mb-4">
|
<div v-else class="mt-4">
|
||||||
<div class="text-xs text-gray-600 mb-1">
|
<div class="text-xs text-gray-600 mb-1">
|
||||||
{{ __('Meta Image') }}
|
{{ __('Meta Image') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -71,19 +75,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MultiSelect
|
|
||||||
v-model="instructors"
|
|
||||||
doctype="User"
|
|
||||||
:label="__('Instructors')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
</div>
|
||||||
<FormControl
|
<div class="container border-b mb-5">
|
||||||
v-model="batch.description"
|
|
||||||
:label="__('Description')"
|
|
||||||
type="textarea"
|
|
||||||
class="my-4"
|
|
||||||
/>
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-600 mb-1">
|
<label class="block text-sm text-gray-600 mb-1">
|
||||||
{{ __('Batch Details') }}
|
{{ __('Batch Details') }}
|
||||||
@@ -97,9 +91,9 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="container border-b mb-5">
|
||||||
<div class="text-lg font-semibold mb-4">
|
<div class="text-lg font-semibold mb-4">
|
||||||
{{ __('Date and Time') }}
|
{{ __('Settings') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-10">
|
<div class="grid grid-cols-2 gap-10">
|
||||||
<div>
|
<div>
|
||||||
@@ -115,8 +109,6 @@
|
|||||||
type="date"
|
type="date"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="batch.start_time"
|
v-model="batch.start_time"
|
||||||
:label="__('Start Time')"
|
:label="__('Start Time')"
|
||||||
@@ -129,20 +121,7 @@
|
|||||||
type="time"
|
type="time"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
|
||||||
v-model="batch.timezone"
|
|
||||||
:label="__('Timezone')"
|
|
||||||
type="text"
|
|
||||||
class="mb-4"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="text-lg font-semibold mb-4">
|
|
||||||
{{ __('Settings') }}
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-10">
|
|
||||||
<div>
|
<div>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="batch.seat_count"
|
v-model="batch.seat_count"
|
||||||
@@ -156,8 +135,6 @@
|
|||||||
type="date"
|
type="date"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="batch.medium"
|
v-model="batch.medium"
|
||||||
type="select"
|
type="select"
|
||||||
@@ -183,7 +160,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="">
|
<div class="container">
|
||||||
<div class="text-lg font-semibold mb-4">
|
<div class="text-lg font-semibold mb-4">
|
||||||
{{ __('Payment') }}
|
{{ __('Payment') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -211,14 +188,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import { computed, onMounted, inject, reactive } from 'vue'
|
||||||
computed,
|
|
||||||
onMounted,
|
|
||||||
inject,
|
|
||||||
reactive,
|
|
||||||
onBeforeUnmount,
|
|
||||||
ref,
|
|
||||||
} from 'vue'
|
|
||||||
import {
|
import {
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -228,11 +198,9 @@ import {
|
|||||||
createResource,
|
createResource,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { getFileSize, showToast } from '../utils'
|
import { getFileSize, showToast } from '../utils'
|
||||||
import { X, FileText } from 'lucide-vue-next'
|
import { X, FileText } from 'lucide-vue-next'
|
||||||
import { capture } from '@/telemetry'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
@@ -253,43 +221,21 @@ const batch = reactive({
|
|||||||
end_date: '',
|
end_date: '',
|
||||||
start_time: '',
|
start_time: '',
|
||||||
end_time: '',
|
end_time: '',
|
||||||
timezone: '',
|
|
||||||
evaluation_end_date: '',
|
evaluation_end_date: '',
|
||||||
seat_count: '',
|
seat_count: '',
|
||||||
medium: '',
|
medium: '',
|
||||||
category: '',
|
category: '',
|
||||||
allow_self_enrollment: false,
|
|
||||||
image: null,
|
image: null,
|
||||||
paid_batch: false,
|
paid_batch: false,
|
||||||
currency: '',
|
currency: '',
|
||||||
amount: 0,
|
amount: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const instructors = ref([])
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!user.data) window.location.href = '/login'
|
if (!user.data) window.location.href = '/login'
|
||||||
if (props.batchName != 'new') {
|
if (props.batchName != 'new') {
|
||||||
batchDetail.reload()
|
batchDetail.reload()
|
||||||
} else {
|
|
||||||
capture('batch_form_opened')
|
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', keyboardShortcut)
|
|
||||||
})
|
|
||||||
|
|
||||||
const keyboardShortcut = (e) => {
|
|
||||||
if (
|
|
||||||
e.key === 's' &&
|
|
||||||
(e.ctrlKey || e.metaKey) &&
|
|
||||||
!e.target.classList.contains('ProseMirror')
|
|
||||||
) {
|
|
||||||
saveBatch()
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
window.removeEventListener('keydown', keyboardShortcut)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const newBatch = createResource({
|
const newBatch = createResource({
|
||||||
@@ -298,10 +244,7 @@ const newBatch = createResource({
|
|||||||
return {
|
return {
|
||||||
doc: {
|
doc: {
|
||||||
doctype: 'LMS Batch',
|
doctype: 'LMS Batch',
|
||||||
meta_image: batch.image?.file_url,
|
meta_image: batch.image.file_url,
|
||||||
instructors: instructors.value.map((instructor) => ({
|
|
||||||
instructor: instructor,
|
|
||||||
})),
|
|
||||||
...batch,
|
...batch,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -318,13 +261,9 @@ const batchDetail = createResource({
|
|||||||
},
|
},
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
Object.keys(data).forEach((key) => {
|
Object.keys(data).forEach((key) => {
|
||||||
if (key == 'instructors') {
|
if (Object.hasOwn(batch, key)) batch[key] = data[key]
|
||||||
data.instructors.forEach((instructor) => {
|
|
||||||
instructors.value.push(instructor.instructor)
|
|
||||||
})
|
})
|
||||||
} else if (Object.hasOwn(batch, key)) batch[key] = data[key]
|
let checkboxes = ['published', 'paid_batch']
|
||||||
})
|
|
||||||
let checkboxes = ['published', 'paid_batch', 'allow_self_enrollment']
|
|
||||||
for (let idx in checkboxes) {
|
for (let idx in checkboxes) {
|
||||||
let key = checkboxes[idx]
|
let key = checkboxes[idx]
|
||||||
batch[key] = batch[key] ? true : false
|
batch[key] = batch[key] ? true : false
|
||||||
@@ -340,10 +279,7 @@ const editBatch = createResource({
|
|||||||
doctype: 'LMS Batch',
|
doctype: 'LMS Batch',
|
||||||
name: props.batchName,
|
name: props.batchName,
|
||||||
fieldname: {
|
fieldname: {
|
||||||
meta_image: batch.image?.file_url,
|
meta_image: batch.image.file_url,
|
||||||
instructors: instructors.value.map((instructor) => ({
|
|
||||||
instructor: instructor,
|
|
||||||
})),
|
|
||||||
...batch,
|
...batch,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -376,7 +312,6 @@ const createNewBatch = () => {
|
|||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
capture('batch_created')
|
|
||||||
router.push({
|
router.push({
|
||||||
name: 'BatchDetail',
|
name: 'BatchDetail',
|
||||||
params: {
|
params: {
|
||||||
@@ -447,7 +382,7 @@ const breadcrumbs = computed(() => {
|
|||||||
}
|
}
|
||||||
crumbs.push({
|
crumbs.push({
|
||||||
label: props.batchName == 'new' ? 'New Batch' : 'Edit Batch',
|
label: props.batchName == 'new' ? 'New Batch' : 'Edit Batch',
|
||||||
route: { name: 'BatchForm', params: { batchName: props.batchName } },
|
route: { name: 'BatchCreation', params: { batchName: props.batchName } },
|
||||||
})
|
})
|
||||||
return crumbs
|
return crumbs
|
||||||
})
|
})
|
||||||
@@ -11,23 +11,20 @@
|
|||||||
<div class="my-3">
|
<div class="my-3">
|
||||||
{{ batch.data.description }}
|
{{ batch.data.description }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="flex items-center justify-between w-1/2">
|
||||||
class="flex flex-col gap-2 lg:gap-0 lg:flex-row lg:items-center justify-between lg:w-1/2"
|
|
||||||
>
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<BookOpen class="h-4 w-4 text-gray-700 mr-2" />
|
<BookOpen class="h-4 w-4 text-gray-700 mr-2" />
|
||||||
<span> {{ batch.data?.courses?.length }} {{ __('Courses') }} </span>
|
<span> {{ batch.data?.courses?.length }} {{ __('Courses') }} </span>
|
||||||
</div>
|
</div>
|
||||||
<span class="hidden lg:block" v-if="batch.data.courses"
|
<span v-if="batch.data.courses">·</span>
|
||||||
>·</span
|
<div class="flex items-center">
|
||||||
>
|
<Calendar class="h-4 w-4 text-gray-700 mr-2" />
|
||||||
<DateRange
|
<span>
|
||||||
:startDate="batch.data.start_date"
|
{{ dayjs(batch.data.start_date).format('DD MMM YYYY') }} -
|
||||||
:endDate="batch.data.end_date"
|
{{ dayjs(batch.data.end_date).format('DD MMM YYYY') }}
|
||||||
/>
|
</span>
|
||||||
<span class="hidden lg:block" v-if="batch.data.start_date"
|
</div>
|
||||||
>·</span
|
<span v-if="batch.data.start_date">·</span>
|
||||||
>
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<Clock class="h-4 w-4 text-gray-700 mr-2" />
|
<Clock class="h-4 w-4 text-gray-700 mr-2" />
|
||||||
<span>
|
<span>
|
||||||
@@ -36,29 +33,15 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex avatar-group overlap mt-3">
|
|
||||||
<div
|
|
||||||
class="h-6 mr-1"
|
|
||||||
:class="{
|
|
||||||
'avatar-group overlap': batch.data.instructors.length > 1,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<UserAvatar
|
|
||||||
v-for="instructor in batch.data.instructors"
|
|
||||||
:user="instructor"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<CourseInstructors :instructors="batch.data.instructors" />
|
<div class="grid grid-cols-[60%,20%] gap-20 mt-10">
|
||||||
</div>
|
<div class="">
|
||||||
</div>
|
|
||||||
<div class="grid lg:grid-cols-[60%,20%] gap-4 lg:gap-20 mt-10">
|
|
||||||
<div class="order-2 lg:order-none">
|
|
||||||
<div
|
<div
|
||||||
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 mt-6"
|
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 mt-6"
|
||||||
v-html="batch.data.batch_details"
|
v-html="batch.data.batch_details"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="order-1 lg:order-none">
|
<div>
|
||||||
<BatchOverlay :batch="batch" />
|
<BatchOverlay :batch="batch" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,7 +51,7 @@
|
|||||||
{{ __('Courses') }}
|
{{ __('Courses') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 mt-5">
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8 mt-5">
|
||||||
<div
|
<div
|
||||||
v-if="batch.data.courses"
|
v-if="batch.data.courses"
|
||||||
v-for="course in courses.data"
|
v-for="course in courses.data"
|
||||||
@@ -97,17 +80,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, inject } from 'vue'
|
import { Breadcrumbs, createResource, Button } from 'frappe-ui'
|
||||||
import { useRouter } from 'vue-router'
|
import { BookOpen, Calendar, Clock } from 'lucide-vue-next'
|
||||||
import { BookOpen, Clock } from 'lucide-vue-next'
|
import { formatTime } from '../utils'
|
||||||
import { formatTime, updateDocumentTitle } from '@/utils'
|
import { computed, inject, ref } from 'vue'
|
||||||
import { Breadcrumbs, createResource } from 'frappe-ui'
|
|
||||||
import CourseCard from '@/components/CourseCard.vue'
|
|
||||||
import BatchOverlay from '@/components/BatchOverlay.vue'
|
import BatchOverlay from '@/components/BatchOverlay.vue'
|
||||||
import DateRange from '../components/Common/DateRange.vue'
|
import CourseCard from '@/components/CourseCard.vue'
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
import { useRouter } from 'vue-router'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
|
||||||
|
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -125,6 +106,16 @@ const batch = createResource({
|
|||||||
batch: props.batchName,
|
batch: props.batchName,
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
|
onSuccess(data) {
|
||||||
|
if (data.students?.includes(user.data?.name)) {
|
||||||
|
router.push({
|
||||||
|
name: 'Batch',
|
||||||
|
params: {
|
||||||
|
batchName: props.batchName,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const courses = createResource({
|
const courses = createResource({
|
||||||
@@ -144,15 +135,6 @@ const breadcrumbs = computed(() => {
|
|||||||
})
|
})
|
||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
const pageMeta = computed(() => {
|
|
||||||
return {
|
|
||||||
title: batch.data?.title,
|
|
||||||
description: batch.data?.description,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
updateDocumentTitle(pageMeta)
|
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.batch-description p {
|
.batch-description p {
|
||||||
|
|||||||
@@ -5,29 +5,21 @@
|
|||||||
>
|
>
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
class="h-7"
|
class="h-7"
|
||||||
:items="[{ label: __('Batches'), route: { name: 'Batches' } }]"
|
:items="[{ label: __('All Batches'), route: { name: 'Batches' } }]"
|
||||||
/>
|
/>
|
||||||
<div class="flex space-x-2">
|
<div class="flex">
|
||||||
<div class="w-44">
|
|
||||||
<Select
|
|
||||||
v-if="categories.data?.length"
|
|
||||||
v-model="currentCategory"
|
|
||||||
:options="categories.data"
|
|
||||||
:placeholder="__('Category')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<router-link
|
<router-link
|
||||||
v-if="user.data?.is_moderator"
|
v-if="user.data"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'BatchForm',
|
name: 'BatchCreation',
|
||||||
params: { batchName: 'new' },
|
params: { batchName: 'new' },
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Button variant="solid">
|
<Button variant="solid">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus class="h-4 w-4 stroke-1.5" />
|
<Plus class="h-4 w-4" />
|
||||||
</template>
|
</template>
|
||||||
{{ __('New') }}
|
{{ __('New Batch') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,7 +33,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<Tabs
|
<Tabs
|
||||||
v-model="tabIndex"
|
v-model="tabIndex"
|
||||||
:tabs="makeTabs"
|
:tabs="tabs"
|
||||||
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
|
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
|
||||||
>
|
>
|
||||||
<template #tab="{ tab, selected }">
|
<template #tab="{ tab, selected }">
|
||||||
@@ -70,7 +62,7 @@
|
|||||||
<template #default="{ tab }">
|
<template #default="{ tab }">
|
||||||
<div
|
<div
|
||||||
v-if="tab.batches && tab.batches.value.length"
|
v-if="tab.batches && tab.batches.value.length"
|
||||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5 m-5"
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5 mt-5 mx-5"
|
||||||
>
|
>
|
||||||
<router-link
|
<router-link
|
||||||
v-for="batch in tab.batches.value"
|
v-for="batch in tab.batches.value"
|
||||||
@@ -95,29 +87,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import { createListResource, Breadcrumbs, Button, Tabs, Badge } from 'frappe-ui'
|
||||||
createListResource,
|
|
||||||
createResource,
|
|
||||||
Breadcrumbs,
|
|
||||||
Button,
|
|
||||||
Tabs,
|
|
||||||
Badge,
|
|
||||||
Select,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import { Plus } from 'lucide-vue-next'
|
import { Plus } from 'lucide-vue-next'
|
||||||
import BatchCard from '@/components/BatchCard.vue'
|
import BatchCard from '@/components/BatchCard.vue'
|
||||||
import { inject, ref, computed, onMounted, watch } from 'vue'
|
import { inject, ref, computed } from 'vue'
|
||||||
import { updateDocumentTitle } from '@/utils'
|
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const currentCategory = ref(null)
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
let queries = new URLSearchParams(location.search)
|
|
||||||
if (queries.has('category')) {
|
|
||||||
currentCategory.value = queries.get('category')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const batches = createListResource({
|
const batches = createListResource({
|
||||||
doctype: 'LMS Batch',
|
doctype: 'LMS Batch',
|
||||||
@@ -126,82 +101,32 @@ const batches = createListResource({
|
|||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const categories = createResource({
|
|
||||||
url: 'lms.lms.api.get_categories',
|
|
||||||
makeParams() {
|
|
||||||
return {
|
|
||||||
doctype: 'LMS Batch',
|
|
||||||
filters: {
|
|
||||||
published: 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
cache: ['batchCategories'],
|
|
||||||
auto: true,
|
|
||||||
transform(data) {
|
|
||||||
data.unshift({
|
|
||||||
label: '',
|
|
||||||
value: null,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const tabIndex = ref(0)
|
const tabIndex = ref(0)
|
||||||
let tabs
|
const tabs = [
|
||||||
|
{
|
||||||
|
label: 'Upcoming',
|
||||||
|
batches: computed(() => batches.data?.upcoming || []),
|
||||||
|
count: computed(() => batches.data?.upcoming?.length),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const makeTabs = computed(() => {
|
if (user.data?.is_moderator) {
|
||||||
tabs = []
|
|
||||||
addToTabs('Upcoming')
|
|
||||||
|
|
||||||
if (user.data?.is_moderator) {
|
|
||||||
addToTabs('Archived')
|
|
||||||
addToTabs('Private')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.data) {
|
|
||||||
addToTabs('Enrolled')
|
|
||||||
}
|
|
||||||
|
|
||||||
return tabs
|
|
||||||
})
|
|
||||||
|
|
||||||
const getBatches = (type) => {
|
|
||||||
if (currentCategory.value && currentCategory.value != '') {
|
|
||||||
return batches.data[type].filter(
|
|
||||||
(batch) => batch.category == currentCategory.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return batches.data[type]
|
|
||||||
}
|
|
||||||
|
|
||||||
const addToTabs = (label) => {
|
|
||||||
let batches = getBatches(label.toLowerCase().split(' ').join('_'))
|
|
||||||
tabs.push({
|
tabs.push({
|
||||||
label,
|
label: 'Archived',
|
||||||
batches: computed(() => batches),
|
batches: computed(() => batches.data?.archived),
|
||||||
count: computed(() => batches.length),
|
count: computed(() => batches.data?.archived?.length),
|
||||||
|
})
|
||||||
|
tabs.push({
|
||||||
|
label: 'Private',
|
||||||
|
batches: computed(() => batches.data?.private),
|
||||||
|
count: computed(() => batches.data?.private?.length),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (user.data) {
|
||||||
|
tabs.push({
|
||||||
|
label: 'Enrolled',
|
||||||
|
batches: computed(() => batches.data?.enrolled),
|
||||||
|
count: computed(() => batches.data?.enrolled?.length),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
|
||||||
() => currentCategory.value,
|
|
||||||
() => {
|
|
||||||
let queries = new URLSearchParams(location.search)
|
|
||||||
if (currentCategory.value) {
|
|
||||||
queries.set('category', currentCategory.value)
|
|
||||||
} else {
|
|
||||||
queries.delete('category')
|
|
||||||
}
|
|
||||||
history.pushState(null, '', `${location.pathname}?${queries.toString()}`)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const pageMeta = computed(() => {
|
|
||||||
return {
|
|
||||||
title: 'Batches',
|
|
||||||
description: 'All batches divided by categories',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
updateDocumentTitle(pageMeta)
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,50 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="">
|
<div class="">
|
||||||
<header
|
|
||||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
|
||||||
>
|
|
||||||
<Breadcrumbs
|
|
||||||
class="h-7"
|
|
||||||
:items="[{ label: __('Billing Details'), route: { name: 'Billing' } }]"
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
<div
|
<div
|
||||||
v-if="access.data?.access && orderSummary.data"
|
v-if="access.data?.access && orderSummary.data"
|
||||||
class="pt-5 pb-10 mx-5"
|
class="mt-10 w-1/2 mx-auto"
|
||||||
>
|
>
|
||||||
<!-- <div class="mb-5">
|
<div class="text-3xl font-bold">
|
||||||
<div class="text-lg font-semibold">
|
{{ __('Billing Details') }}
|
||||||
{{ __('Address') }}
|
|
||||||
</div>
|
</div>
|
||||||
</div> -->
|
<div class="text-gray-600 mt-1">
|
||||||
<div class="flex flex-col lg:flex-row justify-between">
|
{{ __('Enter the billing information to complete the payment.') }}
|
||||||
<div
|
|
||||||
class="h-fit bg-gray-100 rounded-md p-5 space-y-4 lg:order-last mb-10 lg:mt-10 text-sm font-medium lg:w-1/4"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between space-x-2">
|
|
||||||
<div class="text-gray-600">
|
|
||||||
{{ __('Ordered Item') }}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="">
|
<div class="border rounded-md p-5 mt-5">
|
||||||
|
<div class="text-xl font-semibold">
|
||||||
|
{{ __('Summary') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-600 mt-1">
|
||||||
|
{{ __('Review the details of your purchase.') }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
{{ orderSummary.data.title }}
|
{{ orderSummary.data.title }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-if="orderSummary.data.gst_applied"
|
:class="{
|
||||||
class="flex items-center justify-between"
|
'font-semibold text-xl': !orderSummary.data.gst_applied,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<div class="text-gray-600">
|
{{
|
||||||
{{ __('Original Amount') }}
|
orderSummary.data.gst_applied
|
||||||
</div>
|
? orderSummary.data.original_amount_formatted
|
||||||
<div class="">
|
: orderSummary.data.total_amount_formatted
|
||||||
{{ orderSummary.data.original_amount_formatted }}
|
}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="orderSummary.data.gst_applied"
|
v-if="orderSummary.data.gst_applied"
|
||||||
class="flex items-center justify-between mt-2"
|
class="flex items-center justify-between mt-2"
|
||||||
>
|
>
|
||||||
<div class="text-gray-600">
|
<div>
|
||||||
{{ __('GST Amount') }}
|
{{ __('GST Amount') }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -52,91 +46,109 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between border-t border-gray-400 pt-4 mt-2"
|
v-if="orderSummary.data.gst_applied"
|
||||||
|
class="flex items-center justify-between mt-2"
|
||||||
>
|
>
|
||||||
<div class="text-lg font-semibold">
|
<div>
|
||||||
{{ __('Total') }}
|
{{ __('Total Amount') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-lg font-semibold">
|
<div class="font-semibold text-2xl">
|
||||||
{{ orderSummary.data.total_amount_formatted }}
|
{{ orderSummary.data.total_amount_formatted }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 lg:mr-10">
|
<div class="text-xl font-semibold mt-10">
|
||||||
<div class="mb-5">
|
|
||||||
<div class="text-lg font-semibold">
|
|
||||||
{{ __('Address') }}
|
{{ __('Address') }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-gray-600 mt-1">
|
||||||
|
{{ __('Specify your billing address correctly.') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
<div class="grid grid-cols-2 gap-5 mt-4">
|
||||||
<div class="space-y-4">
|
<div>
|
||||||
<FormControl
|
<div class="mt-4">
|
||||||
:label="__('Billing Name')"
|
<div class="mb-1.5 text-sm text-gray-700">
|
||||||
v-model="billingDetails.billing_name"
|
{{ __('Billing Name') }}
|
||||||
/>
|
</div>
|
||||||
<FormControl
|
<Input type="text" v-model="billingDetails.billing_name" />
|
||||||
:label="__('Address Line 1')"
|
</div>
|
||||||
v-model="billingDetails.address_line1"
|
<div class="mt-4">
|
||||||
/>
|
<div class="mb-1.5 text-sm text-gray-700">
|
||||||
<FormControl
|
{{ __('Address Line 1') }}
|
||||||
:label="__('Address Line 2')"
|
</div>
|
||||||
v-model="billingDetails.address_line2"
|
<Input type="text" v-model="billingDetails.address_line1" />
|
||||||
/>
|
</div>
|
||||||
<FormControl :label="__('City')" v-model="billingDetails.city" />
|
<div class="mt-4">
|
||||||
<FormControl
|
<div class="mb-1.5 text-sm text-gray-700">
|
||||||
:label="__('State')"
|
{{ __('Address Line 2') }}
|
||||||
v-model="billingDetails.state"
|
</div>
|
||||||
/>
|
<Input type="text" v-model="billingDetails.address_line2" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="mb-1.5 text-sm text-gray-700">
|
||||||
|
{{ __('City') }}
|
||||||
|
</div>
|
||||||
|
<Input type="text" v-model="billingDetails.city" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="mb-1.5 text-sm text-gray-700">
|
||||||
|
{{ __('State') }}
|
||||||
|
</div>
|
||||||
|
<Input type="text" v-model="billingDetails.state" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="mb-1.5 text-sm text-gray-700">
|
||||||
|
{{ __('Country') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
|
||||||
<Link
|
<Link
|
||||||
doctype="Country"
|
doctype="Country"
|
||||||
:value="billingDetails.country"
|
:value="billingDetails.country"
|
||||||
@change="(option) => changeCurrency(option)"
|
@change="(option) => changeCurrency(option)"
|
||||||
:label="__('Country')"
|
|
||||||
/>
|
|
||||||
<FormControl
|
|
||||||
:label="__('Postal Code')"
|
|
||||||
v-model="billingDetails.pincode"
|
|
||||||
/>
|
|
||||||
<FormControl
|
|
||||||
:label="__('Phone Number')"
|
|
||||||
v-model="billingDetails.phone"
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="mb-1.5 text-sm text-gray-700">
|
||||||
|
{{ __('Postal Code') }}
|
||||||
|
</div>
|
||||||
|
<Input type="text" v-model="billingDetails.pincode" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="mb-1.5 text-sm text-gray-700">
|
||||||
|
{{ __('Phone Number') }}
|
||||||
|
</div>
|
||||||
|
<Input type="text" v-model="billingDetails.phone" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="mb-1.5 text-sm text-gray-700">
|
||||||
|
{{ __('Source') }}
|
||||||
|
</div>
|
||||||
<Link
|
<Link
|
||||||
doctype="LMS Source"
|
doctype="LMS Source"
|
||||||
:value="billingDetails.source"
|
:value="billingDetails.source"
|
||||||
@change="(option) => (billingDetails.source = option)"
|
@change="(option) => (billingDetails.source = option)"
|
||||||
:label="__('Where did you hear about us?')"
|
|
||||||
/>
|
|
||||||
<FormControl
|
|
||||||
v-if="billingDetails.country == 'India'"
|
|
||||||
:label="__('GST Number')"
|
|
||||||
v-model="billingDetails.gstin"
|
|
||||||
/>
|
|
||||||
<FormControl
|
|
||||||
v-if="billingDetails.country == 'India'"
|
|
||||||
:label="__('Pan Number')"
|
|
||||||
v-model="billingDetails.pan"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="billingDetails.country == 'India'" class="mt-4">
|
||||||
|
<div class="mb-1.5 text-sm text-gray-700">
|
||||||
|
{{ __('GST Number') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between border-t pt-4 mt-8">
|
<Input type="text" v-model="billingDetails.gstin" />
|
||||||
<p class="text-gray-600">
|
</div>
|
||||||
{{
|
<div v-if="billingDetails.country == 'India'" class="mt-4">
|
||||||
__(
|
<div class="mb-1.5 text-sm text-gray-700">
|
||||||
'Make sure to enter the right billing name as the same will be used in your invoice.'
|
{{ __('Pan Number') }}
|
||||||
)
|
</div>
|
||||||
}}
|
<Input type="text" v-model="billingDetails.pan" />
|
||||||
</p>
|
</div>
|
||||||
<Button variant="solid" size="md" @click="generatePaymentLink()">
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="solid" class="mt-8" @click="generatePaymentLink()">
|
||||||
{{ __('Proceed to Payment') }}
|
{{ __('Proceed to Payment') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="access.data?.message">
|
<div v-else-if="access.data?.message">
|
||||||
<NotPermitted
|
<NotPermitted
|
||||||
:text="access.data.message"
|
:text="access.data.message"
|
||||||
@@ -149,24 +161,17 @@
|
|||||||
<div v-else-if="!user.data?.name">
|
<div v-else-if="!user.data?.name">
|
||||||
<NotPermitted
|
<NotPermitted
|
||||||
text="Please login to access this page."
|
text="Please login to access this page."
|
||||||
:buttonLink="`/login?redirect-to=/lms/billing/${type}/${name}`"
|
:buttonLink="`/login?redirect-to=/billing/${type}/${name}`"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import { Input, Button, createResource } from 'frappe-ui'
|
||||||
Input,
|
|
||||||
Button,
|
|
||||||
createResource,
|
|
||||||
FormControl,
|
|
||||||
Breadcrumbs,
|
|
||||||
Tooltip,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import { reactive, inject, onMounted, ref } from 'vue'
|
import { reactive, inject, onMounted, ref } from 'vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import NotPermitted from '@/components/NotPermitted.vue'
|
import NotPermitted from '@/components/NotPermitted.vue'
|
||||||
import { showToast } from '@/utils/'
|
import { createToast } from '@/utils/'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
|
||||||
@@ -197,8 +202,8 @@ const access = createResource({
|
|||||||
name: props.name,
|
name: props.name,
|
||||||
},
|
},
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
setBillingDetails(data.address)
|
|
||||||
orderSummary.submit()
|
orderSummary.submit()
|
||||||
|
setBillingDetails(data.address)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -219,49 +224,84 @@ const orderSummary = createResource({
|
|||||||
const billingDetails = reactive({})
|
const billingDetails = reactive({})
|
||||||
|
|
||||||
const setBillingDetails = (data) => {
|
const setBillingDetails = (data) => {
|
||||||
billingDetails.billing_name = data?.billing_name || ''
|
billingDetails.billing_name = data.billing_name || ''
|
||||||
billingDetails.address_line1 = data?.address_line1 || ''
|
billingDetails.address_line1 = data.address_line1 || ''
|
||||||
billingDetails.address_line2 = data?.address_line2 || ''
|
billingDetails.address_line2 = data.address_line2 || ''
|
||||||
billingDetails.city = data?.city || ''
|
billingDetails.city = data.city || ''
|
||||||
billingDetails.state = data?.state || ''
|
billingDetails.state = data.state || ''
|
||||||
billingDetails.country = data?.country || ''
|
billingDetails.country = data.country || ''
|
||||||
billingDetails.pincode = data?.pincode || ''
|
billingDetails.pincode = data.pincode || ''
|
||||||
billingDetails.phone = data?.phone || ''
|
billingDetails.phone = data.phone || ''
|
||||||
billingDetails.source = data?.source || ''
|
billingDetails.source = data.source || ''
|
||||||
billingDetails.gstin = data?.gstin || ''
|
billingDetails.gstin = data.gstin || ''
|
||||||
billingDetails.pan = data?.pan || ''
|
billingDetails.pan = data.pan || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const paymentLink = createResource({
|
const paymentOptions = createResource({
|
||||||
url: 'lms.lms.payments.get_payment_link',
|
url: 'lms.lms.utils.get_payment_options',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
return {
|
return {
|
||||||
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
|
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
|
||||||
docname: props.name,
|
docname: props.name,
|
||||||
title: orderSummary.data.title,
|
phone: billingDetails.phone,
|
||||||
amount: orderSummary.data.original_amount,
|
country: billingDetails.country,
|
||||||
total_amount: orderSummary.data.amount,
|
|
||||||
currency: orderSummary.data.currency,
|
|
||||||
address: billingDetails,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const generatePaymentLink = () => {
|
const generatePaymentLink = () => {
|
||||||
paymentLink.submit(
|
paymentOptions.submit(
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
validate() {
|
validate(params) {
|
||||||
if (!billingDetails.source) {
|
|
||||||
return __('Please let us know where you heard about us from.')
|
|
||||||
}
|
|
||||||
return validateAddress()
|
return validateAddress()
|
||||||
},
|
},
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
window.location.href = data
|
data.handler = (response) => {
|
||||||
|
let doctype = props.type == 'course' ? 'LMS Course' : 'LMS Batch'
|
||||||
|
let docname = props.name
|
||||||
|
handleSuccess(response, doctype, docname, data.order_id)
|
||||||
|
}
|
||||||
|
let rzp1 = new Razorpay(data)
|
||||||
|
rzp1.open()
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast(__('Error'), err.messages?.[0] || err, 'x')
|
showError(err)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentResource = createResource({
|
||||||
|
url: 'lms.lms.utils.verify_payment',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
response: values.response,
|
||||||
|
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
|
||||||
|
docname: props.name,
|
||||||
|
address: billingDetails,
|
||||||
|
order_id: values.orderId,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSuccess = (response, doctype, docname, orderId) => {
|
||||||
|
paymentResource.submit(
|
||||||
|
{
|
||||||
|
response: response,
|
||||||
|
orderId: orderId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
createToast({
|
||||||
|
title: 'Success',
|
||||||
|
text: 'Payment Successful',
|
||||||
|
icon: 'check',
|
||||||
|
iconClasses: 'bg-green-600 text-white rounded-md p-px',
|
||||||
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = data
|
||||||
|
}, 3000)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -300,7 +340,6 @@ const validateAddress = () => {
|
|||||||
'Assam',
|
'Assam',
|
||||||
'Bihar',
|
'Bihar',
|
||||||
'Chhattisgarh',
|
'Chhattisgarh',
|
||||||
'Delhi',
|
|
||||||
'Goa',
|
'Goa',
|
||||||
'Gujarat',
|
'Gujarat',
|
||||||
'Haryana',
|
'Haryana',
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
<template>
|
|
||||||
<header
|
|
||||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
|
||||||
>
|
|
||||||
<Breadcrumbs :items="breadcrumbs" />
|
|
||||||
<div>
|
|
||||||
<FormControl
|
|
||||||
type="text"
|
|
||||||
placeholder="Search"
|
|
||||||
v-model="searchQuery"
|
|
||||||
@input="participants.reload()"
|
|
||||||
class="w-40"
|
|
||||||
>
|
|
||||||
<template #prefix>
|
|
||||||
<Search class="w-4 stroke-1.5 text-gray-600" name="search" />
|
|
||||||
</template>
|
|
||||||
</FormControl>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 m-5">
|
|
||||||
<div
|
|
||||||
v-if="participants.data?.length"
|
|
||||||
v-for="participant in participantsList"
|
|
||||||
>
|
|
||||||
<router-link
|
|
||||||
:to="{
|
|
||||||
name: 'Profile',
|
|
||||||
params: { username: participant.username },
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div class="flex shadow rounded-md h-full p-2">
|
|
||||||
<UserAvatar :user="participant" size="3xl" class="mr-2" />
|
|
||||||
<div>
|
|
||||||
<router-link
|
|
||||||
:to="{
|
|
||||||
name: 'Profile',
|
|
||||||
params: { username: participant.username },
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div class="text-lg font-semibold mb-2">
|
|
||||||
{{ participant.full_name }}
|
|
||||||
</div>
|
|
||||||
</router-link>
|
|
||||||
<div class="leading-5" v-for="course in participant.courses">
|
|
||||||
{{ course }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import { Breadcrumbs, FormControl, createResource } from 'frappe-ui'
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
|
||||||
import { Search } from 'lucide-vue-next'
|
|
||||||
import { updateDocumentTitle } from '@/utils'
|
|
||||||
|
|
||||||
const searchQuery = ref('')
|
|
||||||
|
|
||||||
const participants = createResource({
|
|
||||||
url: 'lms.lms.api.get_certified_participants',
|
|
||||||
method: 'GET',
|
|
||||||
cache: 'certified-participants',
|
|
||||||
auto: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
|
||||||
return [{ label: 'Certified Participants', to: '/certified-participants' }]
|
|
||||||
})
|
|
||||||
|
|
||||||
const pageMeta = computed(() => {
|
|
||||||
return {
|
|
||||||
title: 'Certified Participants',
|
|
||||||
description: 'All participants that have been certified.',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const participantsList = computed(() => {
|
|
||||||
if (searchQuery.value) {
|
|
||||||
return participants.data.filter((participant) => {
|
|
||||||
return participant.full_name
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(searchQuery.value.toLowerCase())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return participants.data
|
|
||||||
})
|
|
||||||
|
|
||||||
updateDocumentTitle(pageMeta)
|
|
||||||
</script>
|
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span
|
<span
|
||||||
class="h-6 mr-1"
|
class="mr-1"
|
||||||
:class="{
|
:class="{
|
||||||
'avatar-group overlap': course.data.instructors.length > 1,
|
'avatar-group overlap': course.data.instructors.length > 1,
|
||||||
}"
|
}"
|
||||||
@@ -51,7 +51,17 @@
|
|||||||
:user="instructor"
|
:user="instructor"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<CourseInstructors :instructors="course.data.instructors" />
|
<span v-if="course.data.instructors.length == 1">
|
||||||
|
{{ course.data.instructors[0].full_name }}
|
||||||
|
</span>
|
||||||
|
<span v-if="course.data.instructors.length == 2">
|
||||||
|
{{ course.data.instructors[0].first_name }} and
|
||||||
|
{{ course.data.instructors[1].first_name }}
|
||||||
|
</span>
|
||||||
|
<span v-if="course.data.instructors.length > 2">
|
||||||
|
{{ course.data.instructors[0].first_name }} and
|
||||||
|
{{ course.data.instructors.length - 1 }} others
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex mt-3 mb-4 w-fit">
|
<div class="flex mt-3 mb-4 w-fit">
|
||||||
@@ -70,9 +80,14 @@
|
|||||||
class="course-description"
|
class="course-description"
|
||||||
></div>
|
></div>
|
||||||
<div class="mt-10">
|
<div class="mt-10">
|
||||||
<CourseOutline :courseName="course.data.name" :showOutline="true" />
|
<CourseOutline
|
||||||
|
:courseName="course.data.name"
|
||||||
|
:showOutline="true"
|
||||||
|
title="Course Outline"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CourseReviews
|
<CourseReviews
|
||||||
|
v-if="course.data.avg_rating"
|
||||||
:courseName="course.data.name"
|
:courseName="course.data.name"
|
||||||
:avg_rating="course.data.avg_rating"
|
:avg_rating="course.data.avg_rating"
|
||||||
:membership="course.data.membership"
|
:membership="course.data.membership"
|
||||||
@@ -94,7 +109,6 @@ import CourseOutline from '@/components/CourseOutline.vue'
|
|||||||
import CourseReviews from '@/components/CourseReviews.vue'
|
import CourseReviews from '@/components/CourseReviews.vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { updateDocumentTitle } from '@/utils'
|
import { updateDocumentTitle } from '@/utils'
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
courseName: {
|
courseName: {
|
||||||
@@ -145,12 +159,6 @@ updateDocumentTitle(pageMeta)
|
|||||||
padding: revert;
|
padding: revert;
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-description ul {
|
|
||||||
list-style: disc;
|
|
||||||
margin: revert;
|
|
||||||
padding: revert;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-group {
|
.avatar-group {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -7,31 +7,10 @@
|
|||||||
class="h-7"
|
class="h-7"
|
||||||
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
|
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
|
||||||
/>
|
/>
|
||||||
<div class="flex space-x-2 justify-end">
|
<div class="flex">
|
||||||
<div class="w-46 md:w-44">
|
|
||||||
<FormControl
|
|
||||||
v-if="categories.data?.length"
|
|
||||||
type="select"
|
|
||||||
v-model="currentCategory"
|
|
||||||
:options="categories.data"
|
|
||||||
:placeholder="__('Category')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="w-28 md:w-36">
|
|
||||||
<FormControl
|
|
||||||
type="text"
|
|
||||||
placeholder="Search"
|
|
||||||
v-model="searchQuery"
|
|
||||||
@input="courses.reload()"
|
|
||||||
>
|
|
||||||
<template #prefix>
|
|
||||||
<Search class="w-4 h-4 stroke-1.5 text-gray-600" name="search" />
|
|
||||||
</template>
|
|
||||||
</FormControl>
|
|
||||||
</div>
|
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: 'CourseForm',
|
name: 'CreateCourse',
|
||||||
params: {
|
params: {
|
||||||
courseName: 'new',
|
courseName: 'new',
|
||||||
},
|
},
|
||||||
@@ -41,16 +20,23 @@
|
|||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus class="h-4 w-4" />
|
<Plus class="h-4 w-4" />
|
||||||
</template>
|
</template>
|
||||||
{{ __('New') }}
|
{{ __('New Course') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="">
|
<div class="">
|
||||||
|
<div
|
||||||
|
v-if="courses.data.length == 0 && courses.list.loading"
|
||||||
|
class="p-5 text-base text-gray-700"
|
||||||
|
>
|
||||||
|
{{ __('Loading Courses...') }}
|
||||||
|
</div>
|
||||||
<Tabs
|
<Tabs
|
||||||
|
v-else
|
||||||
v-model="tabIndex"
|
v-model="tabIndex"
|
||||||
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
|
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
|
||||||
:tabs="makeTabs"
|
:tabs="tabs"
|
||||||
>
|
>
|
||||||
<template #tab="{ tab, selected }">
|
<template #tab="{ tab, selected }">
|
||||||
<div>
|
<div>
|
||||||
@@ -79,8 +65,8 @@
|
|||||||
name: 'Lesson',
|
name: 'Lesson',
|
||||||
params: {
|
params: {
|
||||||
courseName: course.name,
|
courseName: course.name,
|
||||||
chapterNumber: course.current_lesson.split('-')[0],
|
chapterNumber: course.current_lesson.split('.')[0],
|
||||||
lessonNumber: course.current_lesson.split('-')[1],
|
lessonNumber: course.current_lesson.split('.')[1],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: course.membership
|
: course.membership
|
||||||
@@ -118,124 +104,63 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import { createListResource, Breadcrumbs, Tabs, Badge, Button } from 'frappe-ui'
|
||||||
Breadcrumbs,
|
|
||||||
Tabs,
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
FormControl,
|
|
||||||
createResource,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import CourseCard from '@/components/CourseCard.vue'
|
import CourseCard from '@/components/CourseCard.vue'
|
||||||
import { Plus, Search } from 'lucide-vue-next'
|
import { Plus } from 'lucide-vue-next'
|
||||||
import { ref, computed, inject, onMounted, watch } from 'vue'
|
import { ref, computed, inject } from 'vue'
|
||||||
import { updateDocumentTitle } from '@/utils'
|
import { updateDocumentTitle } from '@/utils'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const searchQuery = ref('')
|
const courses = createListResource({
|
||||||
const currentCategory = ref(null)
|
type: 'list',
|
||||||
|
doctype: 'LMS Course',
|
||||||
onMounted(() => {
|
cache: ['courses', user?.data?.email],
|
||||||
let queries = new URLSearchParams(location.search)
|
|
||||||
if (queries.has('category')) {
|
|
||||||
currentCategory.value = queries.get('category')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const courses = createResource({
|
|
||||||
url: 'lms.lms.utils.get_courses',
|
url: 'lms.lms.utils.get_courses',
|
||||||
cache: ['courses', user.data?.email],
|
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const tabIndex = ref(0)
|
const tabIndex = ref(0)
|
||||||
let tabs
|
const tabs = [
|
||||||
|
{
|
||||||
|
label: 'All',
|
||||||
|
courses: computed(() => courses.data?.live || []),
|
||||||
|
count: computed(() => courses.data?.live?.length),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Upcoming',
|
||||||
|
courses: computed(() => courses.data?.upcoming),
|
||||||
|
count: computed(() => courses.data?.upcoming?.length),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const makeTabs = computed(() => {
|
if (user.data) {
|
||||||
tabs = []
|
tabs.push({
|
||||||
addToTabs('Live')
|
label: 'Enrolled',
|
||||||
addToTabs('New')
|
courses: computed(() => courses.data?.enrolled),
|
||||||
addToTabs('Upcoming')
|
count: computed(() => courses.data?.enrolled?.length),
|
||||||
|
})
|
||||||
if (user.data) {
|
|
||||||
addToTabs('Enrolled')
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
user.data.is_moderator ||
|
user.data.is_moderator ||
|
||||||
user.data.is_instructor ||
|
user.data.is_instructor ||
|
||||||
courses.data?.created?.length
|
courses.data?.created?.length
|
||||||
) {
|
) {
|
||||||
addToTabs('Created')
|
tabs.push({
|
||||||
|
label: 'Created',
|
||||||
|
courses: computed(() => courses.data?.created),
|
||||||
|
count: computed(() => courses.data?.created?.length),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.data.is_moderator) {
|
if (user.data.is_moderator) {
|
||||||
addToTabs('Under Review')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tabs
|
|
||||||
})
|
|
||||||
|
|
||||||
const addToTabs = (label) => {
|
|
||||||
let courses = getCourses(label.toLowerCase().split(' ').join('_'))
|
|
||||||
tabs.push({
|
tabs.push({
|
||||||
label,
|
label: 'Under Review',
|
||||||
courses: computed(() => courses),
|
courses: computed(() => courses.data?.under_review),
|
||||||
count: computed(() => courses.length),
|
count: computed(() => courses.data?.under_review?.length),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCourses = (type) => {
|
|
||||||
let courseList = courses.data[type]
|
|
||||||
if (searchQuery.value) {
|
|
||||||
let query = searchQuery.value.toLowerCase()
|
|
||||||
courseList = courseList.filter(
|
|
||||||
(course) =>
|
|
||||||
course.title.toLowerCase().includes(query) ||
|
|
||||||
course.short_introduction.toLowerCase().includes(query) ||
|
|
||||||
course.tags.filter((tag) => tag.toLowerCase().includes(query)).length
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (currentCategory.value && currentCategory.value != '') {
|
|
||||||
courseList = courseList.filter(
|
|
||||||
(course) => course.category == currentCategory.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return courseList
|
|
||||||
}
|
|
||||||
|
|
||||||
const categories = createResource({
|
|
||||||
url: 'lms.lms.api.get_categories',
|
|
||||||
makeParams() {
|
|
||||||
return {
|
|
||||||
doctype: 'LMS Course',
|
|
||||||
filters: {
|
|
||||||
published: 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
cache: ['courseCategories'],
|
|
||||||
auto: true,
|
|
||||||
transform(data) {
|
|
||||||
data.unshift({
|
|
||||||
label: '',
|
|
||||||
value: null,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => currentCategory.value,
|
|
||||||
() => {
|
|
||||||
let queries = new URLSearchParams(location.search)
|
|
||||||
if (currentCategory.value) {
|
|
||||||
queries.set('category', currentCategory.value)
|
|
||||||
} else {
|
|
||||||
queries.delete('category')
|
|
||||||
}
|
|
||||||
history.pushState(null, '', `${location.pathname}?${queries.toString()}`)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const pageMeta = computed(() => {
|
const pageMeta = computed(() => {
|
||||||
return {
|
return {
|
||||||
title: 'Courses',
|
title: 'Courses',
|
||||||
|
|||||||
@@ -7,6 +7,19 @@
|
|||||||
>
|
>
|
||||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||||
<div class="flex items-center mt-3 md:mt-0">
|
<div class="flex items-center mt-3 md:mt-0">
|
||||||
|
<router-link
|
||||||
|
v-if="courseResource.data"
|
||||||
|
:to="{
|
||||||
|
name: 'CourseDetail',
|
||||||
|
params: { courseName: courseResource.data.name },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button>
|
||||||
|
<span>
|
||||||
|
{{ __('View Course') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
<Button variant="solid" @click="submitCourse()" class="ml-2">
|
<Button variant="solid" @click="submitCourse()" class="ml-2">
|
||||||
<span>
|
<span>
|
||||||
{{ __('Save') }}
|
{{ __('Save') }}
|
||||||
@@ -86,14 +99,13 @@
|
|||||||
:label="__('Preview Video')"
|
:label="__('Preview Video')"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
/>
|
/>
|
||||||
<div class="mb-4">
|
<div>
|
||||||
<div class="mb-1.5 text-xs text-gray-600">
|
<div class="mb-1.5 text-xs text-gray-600">
|
||||||
{{ __('Tags') }}
|
{{ __('Tags') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div
|
<div
|
||||||
v-if="course.tags"
|
v-for="tag in course.tags.split(', ')"
|
||||||
v-for="tag in course.tags?.split(', ')"
|
|
||||||
class="flex items-center bg-gray-100 p-2 rounded-md mr-2"
|
class="flex items-center bg-gray-100 p-2 rounded-md mr-2"
|
||||||
>
|
>
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
@@ -102,72 +114,30 @@
|
|||||||
@click="removeTag(tag)"
|
@click="removeTag(tag)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<FormControl
|
<FormControl v-model="newTag" @keyup.enter="updateTags()" />
|
||||||
v-model="newTag"
|
|
||||||
@keyup.enter="updateTags()"
|
|
||||||
id="tags"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 mb-4">
|
|
||||||
<Link
|
|
||||||
doctype="LMS Category"
|
|
||||||
v-model="course.category"
|
|
||||||
:label="__('Category')"
|
|
||||||
:onCreate="(value, close) => openSettings(close)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<MultiSelect
|
|
||||||
v-model="instructors"
|
|
||||||
doctype="User"
|
|
||||||
:label="__('Instructors')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="container border-t">
|
<div class="container border-t">
|
||||||
<div class="text-lg font-semibold mt-5 mb-4">
|
<div class="text-lg font-semibold mt-5 mb-4">
|
||||||
{{ __('Settings') }}
|
{{ __('Settings') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-3 gap-10 mb-4">
|
<div class="flex items-center justify-between mb-5">
|
||||||
<div
|
|
||||||
v-if="user.data?.is_moderator"
|
|
||||||
class="flex flex-col space-y-3"
|
|
||||||
>
|
|
||||||
<FormControl
|
<FormControl
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
v-model="course.published"
|
v-model="course.published"
|
||||||
:label="__('Published')"
|
:label="__('Published')"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
|
||||||
v-model="course.published_on"
|
|
||||||
:label="__('Published On')"
|
|
||||||
type="date"
|
|
||||||
class="mb-5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col space-y-3">
|
|
||||||
<FormControl
|
<FormControl
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
v-model="course.upcoming"
|
v-model="course.upcoming"
|
||||||
:label="__('Upcoming')"
|
:label="__('Upcoming')"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
|
||||||
type="checkbox"
|
|
||||||
v-model="course.featured"
|
|
||||||
:label="__('Featured')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col space-y-3">
|
|
||||||
<FormControl
|
<FormControl
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
v-model="course.disable_self_learning"
|
v-model="course.disable_self_learning"
|
||||||
:label="__('Disable Self Enrollment')"
|
:label="__('Disable Self Enrollment')"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
|
||||||
type="checkbox"
|
|
||||||
v-model="course.enable_certification"
|
|
||||||
:label="__('Completion Certificate')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="container border-t">
|
<div class="container border-t">
|
||||||
@@ -195,7 +165,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-l pt-5">
|
<div class="border-l px-5 pt-5">
|
||||||
|
<!-- <CreateOutline v-if="courseResource.doc" :course="courseResource.doc"/> -->
|
||||||
<CourseOutline
|
<CourseOutline
|
||||||
v-if="courseResource.data"
|
v-if="courseResource.data"
|
||||||
:courseName="courseResource.data.name"
|
:courseName="courseResource.data.name"
|
||||||
@@ -212,37 +183,20 @@ import {
|
|||||||
TextEditor,
|
TextEditor,
|
||||||
Button,
|
Button,
|
||||||
createResource,
|
createResource,
|
||||||
|
createDocumentResource,
|
||||||
FormControl,
|
FormControl,
|
||||||
FileUploader,
|
FileUploader,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import {
|
import { inject, onMounted, computed, ref, reactive, watch } from 'vue'
|
||||||
inject,
|
import { convertToTitleCase, showToast, getFileSize } from '../utils'
|
||||||
onMounted,
|
|
||||||
onBeforeUnmount,
|
|
||||||
computed,
|
|
||||||
ref,
|
|
||||||
reactive,
|
|
||||||
watch,
|
|
||||||
} from 'vue'
|
|
||||||
import {
|
|
||||||
convertToTitleCase,
|
|
||||||
showToast,
|
|
||||||
getFileSize,
|
|
||||||
updateDocumentTitle,
|
|
||||||
} from '@/utils'
|
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { FileText, X } from 'lucide-vue-next'
|
import { FileText, X } from 'lucide-vue-next'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import CourseOutline from '@/components/CourseOutline.vue'
|
import CourseOutline from '@/components/CourseOutline.vue'
|
||||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
|
||||||
import { capture } from '@/telemetry'
|
|
||||||
import { useSettings } from '@/stores/settings'
|
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const newTag = ref('')
|
const newTag = ref('')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const instructors = ref([])
|
|
||||||
const settingsStore = useSettings()
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
courseName: {
|
courseName: {
|
||||||
@@ -258,46 +212,20 @@ const course = reactive({
|
|||||||
course_image: null,
|
course_image: null,
|
||||||
tags: '',
|
tags: '',
|
||||||
published: false,
|
published: false,
|
||||||
published_on: '',
|
|
||||||
featured: false,
|
|
||||||
upcoming: false,
|
upcoming: false,
|
||||||
disable_self_learning: false,
|
disable_self_learning: false,
|
||||||
enable_certification: false,
|
|
||||||
paid_course: false,
|
paid_course: false,
|
||||||
course_price: '',
|
course_price: '',
|
||||||
currency: '',
|
currency: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (
|
if (!user.data?.is_moderator || !user.data?.is_instructor) {
|
||||||
props.courseName == 'new' &&
|
|
||||||
!user.data?.is_moderator &&
|
|
||||||
!user.data?.is_instructor
|
|
||||||
) {
|
|
||||||
router.push({ name: 'Courses' })
|
router.push({ name: 'Courses' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.courseName !== 'new') {
|
if (props.courseName !== 'new') {
|
||||||
courseResource.reload()
|
courseResource.reload()
|
||||||
} else {
|
|
||||||
capture('course_form_opened')
|
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', keyboardShortcut)
|
|
||||||
})
|
|
||||||
|
|
||||||
const keyboardShortcut = (e) => {
|
|
||||||
if (
|
|
||||||
e.key === 's' &&
|
|
||||||
(e.ctrlKey || e.metaKey) &&
|
|
||||||
!e.target.classList.contains('ProseMirror')
|
|
||||||
) {
|
|
||||||
submitCourse()
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
window.removeEventListener('keydown', keyboardShortcut)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const courseCreationResource = createResource({
|
const courseCreationResource = createResource({
|
||||||
@@ -306,10 +234,7 @@ const courseCreationResource = createResource({
|
|||||||
return {
|
return {
|
||||||
doc: {
|
doc: {
|
||||||
doctype: 'LMS Course',
|
doctype: 'LMS Course',
|
||||||
image: course.course_image?.file_url || '',
|
image: course.course_image.file_url,
|
||||||
instructors: instructors.value.map((instructor) => ({
|
|
||||||
instructor: instructor,
|
|
||||||
})),
|
|
||||||
...values,
|
...values,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -324,10 +249,7 @@ const courseEditResource = createResource({
|
|||||||
doctype: 'LMS Course',
|
doctype: 'LMS Course',
|
||||||
name: values.course,
|
name: values.course,
|
||||||
fieldname: {
|
fieldname: {
|
||||||
image: course.course_image?.file_url || '',
|
image: course.course_image.file_url,
|
||||||
instructors: instructors.value.map((instructor) => ({
|
|
||||||
instructor: instructor,
|
|
||||||
})),
|
|
||||||
...course,
|
...course,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -345,20 +267,13 @@ const courseResource = createResource({
|
|||||||
auto: false,
|
auto: false,
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
Object.keys(data).forEach((key) => {
|
Object.keys(data).forEach((key) => {
|
||||||
if (key == 'instructors') {
|
if (Object.hasOwn(course, key)) course[key] = data[key]
|
||||||
instructors.value = []
|
|
||||||
data.instructors.forEach((instructor) => {
|
|
||||||
instructors.value.push(instructor.instructor)
|
|
||||||
})
|
|
||||||
} else if (Object.hasOwn(course, key)) course[key] = data[key]
|
|
||||||
})
|
})
|
||||||
let checkboxes = [
|
let checkboxes = [
|
||||||
'published',
|
'published',
|
||||||
'upcoming',
|
'upcoming',
|
||||||
'disable_self_learning',
|
'disable_self_learning',
|
||||||
'paid_course',
|
'paid_course',
|
||||||
'featured',
|
|
||||||
'enable_certification',
|
|
||||||
]
|
]
|
||||||
for (let idx in checkboxes) {
|
for (let idx in checkboxes) {
|
||||||
let key = checkboxes[idx]
|
let key = checkboxes[idx]
|
||||||
@@ -366,7 +281,6 @@ const courseResource = createResource({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.image) imageResource.reload({ image: data.image })
|
if (data.image) imageResource.reload({ image: data.image })
|
||||||
check_permission()
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -383,6 +297,12 @@ const imageResource = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const getTags = computed(() => {
|
||||||
|
return courseResource.doc?.tags
|
||||||
|
? courseResource.doc.tags.split(', ')
|
||||||
|
: tags.value?.split(', ')
|
||||||
|
})
|
||||||
|
|
||||||
const submitCourse = () => {
|
const submitCourse = () => {
|
||||||
if (courseResource.data) {
|
if (courseResource.data) {
|
||||||
courseEditResource.submit(
|
courseEditResource.submit(
|
||||||
@@ -401,15 +321,14 @@ const submitCourse = () => {
|
|||||||
} else {
|
} else {
|
||||||
courseCreationResource.submit(course, {
|
courseCreationResource.submit(course, {
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
capture('course_created')
|
|
||||||
showToast('Success', 'Course created successfully', 'check')
|
showToast('Success', 'Course created successfully', 'check')
|
||||||
router.push({
|
router.push({
|
||||||
name: 'CourseForm',
|
name: 'CreateCourse',
|
||||||
params: { courseName: data.name },
|
params: { courseName: data.name },
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast('Error', err.messages?.[0] || err, 'x')
|
showToast(err)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -430,7 +349,7 @@ const validateMandatoryFields = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (course.paid_course && (!course.course_price || !course.currency)) {
|
if (course.paid_course && (!course.course_price || !course.currency)) {
|
||||||
return __('Course price and currency are mandatory for paid courses')
|
return 'Course price and currency are mandatory for paid courses'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,8 +364,8 @@ watch(
|
|||||||
|
|
||||||
const validateFile = (file) => {
|
const validateFile = (file) => {
|
||||||
let extension = file.name.split('.').pop().toLowerCase()
|
let extension = file.name.split('.').pop().toLowerCase()
|
||||||
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
|
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||||
return __('Only image file is allowed.')
|
return 'Only image file is allowed.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,27 +392,6 @@ const removeImage = () => {
|
|||||||
course.course_image = null
|
course.course_image = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const openSettings = (close) => {
|
|
||||||
close()
|
|
||||||
settingsStore.activeTab = 'Categories'
|
|
||||||
settingsStore.isSettingsOpen = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const check_permission = () => {
|
|
||||||
let user_is_instructor = false
|
|
||||||
if (user.data?.is_moderator) return
|
|
||||||
|
|
||||||
instructors.value.forEach((instructor) => {
|
|
||||||
if (!user_is_instructor && instructor == user.data?.name) {
|
|
||||||
user_is_instructor = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!user_is_instructor) {
|
|
||||||
router.push({ name: 'Courses' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let crumbs = [
|
let crumbs = [
|
||||||
{
|
{
|
||||||
@@ -509,17 +407,8 @@ const breadcrumbs = computed(() => {
|
|||||||
}
|
}
|
||||||
crumbs.push({
|
crumbs.push({
|
||||||
label: props.courseName == 'new' ? 'New Course' : 'Edit Course',
|
label: props.courseName == 'new' ? 'New Course' : 'Edit Course',
|
||||||
route: { name: 'CourseForm', params: { courseName: props.courseName } },
|
route: { name: 'CreateCourse', params: { courseName: props.courseName } },
|
||||||
})
|
})
|
||||||
return crumbs
|
return crumbs
|
||||||
})
|
})
|
||||||
|
|
||||||
const pageMeta = computed(() => {
|
|
||||||
return {
|
|
||||||
title: 'Create a Course',
|
|
||||||
description: 'Create or edit a course for your learning system.',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
updateDocumentTitle(pageMeta)
|
|
||||||
</script>
|
</script>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="">
|
<div class="">
|
||||||
<div class="grid md:grid-cols-[75%,25%] h-screen">
|
<div class="grid md:grid-cols-[75%,25%] h-full">
|
||||||
<div class="border-r">
|
<div class="border-r">
|
||||||
<header
|
<header
|
||||||
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b overflow-hidden bg-white px-3 py-2.5 sm:px-5"
|
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b overflow-hidden bg-white px-3 py-2.5 sm:px-5"
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
<div
|
<div
|
||||||
v-show="openInstructorEditor"
|
v-show="openInstructorEditor"
|
||||||
id="instructor-notes"
|
id="instructor-notes"
|
||||||
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 py-3"
|
class="py-3"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,43 +52,40 @@
|
|||||||
<label class="block font-medium text-gray-600 mb-1">
|
<label class="block font-medium text-gray-600 mb-1">
|
||||||
{{ __('Content') }}
|
{{ __('Content') }}
|
||||||
</label>
|
</label>
|
||||||
<div
|
<div id="content" class="py-3"></div>
|
||||||
id="content"
|
|
||||||
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 py-3"
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="">
|
<div class="">
|
||||||
<div class="sticky top-0 p-5">
|
<div class="sticky top-0 p-5">
|
||||||
<LessonHelp />
|
<LessonPlugins :editor="editor" :notesEditor="instructorEditor" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Breadcrumbs, FormControl, createResource, Button } from 'frappe-ui'
|
|
||||||
import {
|
import {
|
||||||
computed,
|
Breadcrumbs,
|
||||||
reactive,
|
FormControl,
|
||||||
onMounted,
|
createResource,
|
||||||
inject,
|
Button,
|
||||||
ref,
|
createDocumentResource,
|
||||||
onBeforeUnmount,
|
} from 'frappe-ui'
|
||||||
} from 'vue'
|
import { computed, reactive, onMounted, inject, ref, watch } from 'vue'
|
||||||
import EditorJS from '@editorjs/editorjs'
|
import EditorJS from '@editorjs/editorjs'
|
||||||
import LessonHelp from '@/components/LessonHelp.vue'
|
import { createToast } from '../utils'
|
||||||
|
import LessonPlugins from '@/components/LessonPlugins.vue'
|
||||||
|
import { getEditorTools } from '../utils'
|
||||||
import { ChevronRight } from 'lucide-vue-next'
|
import { ChevronRight } from 'lucide-vue-next'
|
||||||
import { updateDocumentTitle, createToast, getEditorTools } from '@/utils'
|
import { useRouter } from 'vue-router'
|
||||||
import { capture } from '@/telemetry'
|
|
||||||
|
|
||||||
const editor = ref(null)
|
const editor = ref(null)
|
||||||
const instructorEditor = ref(null)
|
const instructorEditor = ref(null)
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const openInstructorEditor = ref(false)
|
const openInstructorEditor = ref(false)
|
||||||
let autoSaveInterval
|
const router = useRouter()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
courseName: {
|
courseName: {
|
||||||
@@ -106,10 +103,9 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
if (!user.data?.is_moderator || !user.data?.is_instructor) {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
capture('lesson_form_opened')
|
|
||||||
editor.value = renderEditor('content')
|
editor.value = renderEditor('content')
|
||||||
instructorEditor.value = renderEditor('instructor-notes')
|
instructorEditor.value = renderEditor('instructor-notes')
|
||||||
})
|
})
|
||||||
@@ -117,8 +113,7 @@ onMounted(() => {
|
|||||||
const renderEditor = (holder) => {
|
const renderEditor = (holder) => {
|
||||||
return new EditorJS({
|
return new EditorJS({
|
||||||
holder: holder,
|
holder: holder,
|
||||||
tools: getEditorTools(true),
|
tools: getEditorTools(),
|
||||||
autofocus: true,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,17 +138,7 @@ const lessonDetails = createResource({
|
|||||||
Object.keys(data.lesson).forEach((key) => {
|
Object.keys(data.lesson).forEach((key) => {
|
||||||
lesson[key] = data.lesson[key]
|
lesson[key] = data.lesson[key]
|
||||||
})
|
})
|
||||||
lesson.include_in_preview = data?.lesson?.include_in_preview
|
lesson.include_in_preview = data.include_in_preview ? true : false
|
||||||
? true
|
|
||||||
: false
|
|
||||||
addLessonContent(data)
|
|
||||||
addInstructorNotes(data)
|
|
||||||
enableAutoSave()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const addLessonContent = (data) => {
|
|
||||||
editor.value.isReady.then(() => {
|
editor.value.isReady.then(() => {
|
||||||
if (data.lesson.content) {
|
if (data.lesson.content) {
|
||||||
editor.value.render(JSON.parse(data.lesson.content))
|
editor.value.render(JSON.parse(data.lesson.content))
|
||||||
@@ -164,12 +149,11 @@ const addLessonContent = (data) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const addInstructorNotes = (data) => {
|
|
||||||
instructorEditor.value.isReady.then(() => {
|
instructorEditor.value.isReady.then(() => {
|
||||||
if (data.lesson.instructor_content) {
|
if (data.lesson.instructor_content) {
|
||||||
instructorEditor.value.render(JSON.parse(data.lesson.instructor_content))
|
instructorEditor.value.render(
|
||||||
|
JSON.parse(data.lesson.instructor_content)
|
||||||
|
)
|
||||||
} else if (data.lesson.instructor_notes) {
|
} else if (data.lesson.instructor_notes) {
|
||||||
let blocks = convertToJSON(data.lesson)
|
let blocks = convertToJSON(data.lesson)
|
||||||
instructorEditor.value.render({
|
instructorEditor.value.render({
|
||||||
@@ -177,16 +161,8 @@ const addInstructorNotes = (data) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
},
|
||||||
const enableAutoSave = () => {
|
|
||||||
autoSaveInterval = setInterval(() => {
|
|
||||||
saveLesson()
|
|
||||||
}, 10000)
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
clearInterval(autoSaveInterval)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const newLessonResource = createResource({
|
const newLessonResource = createResource({
|
||||||
@@ -364,7 +340,6 @@ const createNewLesson = () => {
|
|||||||
{ lesson: data.name },
|
{ lesson: data.name },
|
||||||
{
|
{
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
capture('lesson_created')
|
|
||||||
showToast('Success', 'Lesson created successfully', 'check')
|
showToast('Success', 'Lesson created successfully', 'check')
|
||||||
lessonDetails.reload()
|
lessonDetails.reload()
|
||||||
},
|
},
|
||||||
@@ -387,6 +362,9 @@ const editCurrentLesson = () => {
|
|||||||
validate() {
|
validate() {
|
||||||
return validateLesson()
|
return validateLesson()
|
||||||
},
|
},
|
||||||
|
onSuccess() {
|
||||||
|
showToast('Success', 'Lesson updated successfully', 'check')
|
||||||
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast('Error', err.message, 'x')
|
showToast('Error', err.message, 'x')
|
||||||
},
|
},
|
||||||
@@ -425,7 +403,7 @@ const breadcrumbs = computed(() => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: lessonDetails.data?.course_title,
|
label: lessonDetails.data?.course_title,
|
||||||
route: { name: 'CourseForm', params: { courseName: props.courseName } },
|
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -445,7 +423,7 @@ const breadcrumbs = computed(() => {
|
|||||||
crumbs.push({
|
crumbs.push({
|
||||||
label: lessonDetails?.data?.lesson ? 'Edit Lesson' : 'Create Lesson',
|
label: lessonDetails?.data?.lesson ? 'Edit Lesson' : 'Create Lesson',
|
||||||
route: {
|
route: {
|
||||||
name: 'LessonForm',
|
name: 'CreateLesson',
|
||||||
params: {
|
params: {
|
||||||
courseName: props.courseName,
|
courseName: props.courseName,
|
||||||
chapterNumber: props.chapterNumber,
|
chapterNumber: props.chapterNumber,
|
||||||
@@ -455,130 +433,17 @@ const breadcrumbs = computed(() => {
|
|||||||
})
|
})
|
||||||
return crumbs
|
return crumbs
|
||||||
})
|
})
|
||||||
|
|
||||||
const pageMeta = computed(() => {
|
|
||||||
return {
|
|
||||||
title: 'Lesson Editor',
|
|
||||||
description: 'Create and edit lessons for your course',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
updateDocumentTitle(pageMeta)
|
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.embed-tool__caption,
|
.embed-tool__caption {
|
||||||
.cdx-simple-image__caption {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ce-toolbar__actions {
|
||||||
|
right: 108%;
|
||||||
|
}
|
||||||
|
|
||||||
.ce-block__content {
|
.ce-block__content {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.codex-editor--narrow .ce-toolbar__actions {
|
|
||||||
right: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ce-toolbar__content {
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeBoxHolder {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeBoxTextArea {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 30px;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 2px 2px 2px 0;
|
|
||||||
border: none !important;
|
|
||||||
outline: none !important;
|
|
||||||
font: 14px monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeBoxSelectDiv {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: flex-start;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeBoxSelectInput {
|
|
||||||
border-radius: 0 0 20px 2px;
|
|
||||||
padding: 2px 26px;
|
|
||||||
padding-top: 0;
|
|
||||||
padding-right: 0;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none !important;
|
|
||||||
outline: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeBoxSelectDropIcon {
|
|
||||||
position: absolute !important;
|
|
||||||
left: 10px !important;
|
|
||||||
bottom: 0 !important;
|
|
||||||
width: unset !important;
|
|
||||||
height: unset !important;
|
|
||||||
font-size: 16px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeBoxSelectPreview {
|
|
||||||
display: none;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: flex-start;
|
|
||||||
border-radius: 2px;
|
|
||||||
box-shadow: 0 3px 15px -3px rgba(13, 20, 33, 0.13);
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
margin: 5px 0;
|
|
||||||
max-height: 30vh;
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: auto;
|
|
||||||
z-index: 10000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeBoxSelectItem {
|
|
||||||
width: 100%;
|
|
||||||
padding: 5px 20px;
|
|
||||||
margin: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeBoxSelectedItem {
|
|
||||||
background-color: lightblue !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeBoxShow {
|
|
||||||
display: flex !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
color: #abb2bf;
|
|
||||||
background-color: #282c34;
|
|
||||||
}
|
|
||||||
|
|
||||||
.light {
|
|
||||||
color: #383a42;
|
|
||||||
background-color: #fafafa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeBoxTextArea {
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose :where(pre):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
|
|
||||||
overflow-x: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
iframe {
|
|
||||||
border-top: 3px solid theme('colors.gray.700');
|
|
||||||
border-bottom: 3px solid theme('colors.gray.700');
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -149,7 +149,7 @@ const newJob = createResource({
|
|||||||
return {
|
return {
|
||||||
doc: {
|
doc: {
|
||||||
doctype: 'Job Opportunity',
|
doctype: 'Job Opportunity',
|
||||||
company_logo: job.image?.file_url,
|
company_logo: job.image.file_url,
|
||||||
...job,
|
...job,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,91 +50,38 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div v-if="job.data" class="max-w-3xl mx-auto">
|
<div v-if="job.data">
|
||||||
<div class="p-4">
|
<div class="p-5 sm:p-5">
|
||||||
<div class="space-y-5 mb-10">
|
<div class="flex mb-4">
|
||||||
<div class="flex items-center">
|
|
||||||
<img
|
<img
|
||||||
:src="job.data.company_logo"
|
:src="job.data.company_logo"
|
||||||
class="w-16 h-16 rounded-lg object-contain mr-4"
|
class="w-16 h-16 rounded-lg object-contain mr-4"
|
||||||
:alt="job.data.company_name"
|
:alt="job.data.company_name"
|
||||||
/>
|
/>
|
||||||
<div class="text-2xl font-semibold mb-4">
|
<div>
|
||||||
|
<div class="text-2xl font-semibold mb-2">
|
||||||
{{ job.data.job_title }}
|
{{ job.data.job_title }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div
|
{{ __('posted by') }}
|
||||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-10 gap-y-5 md:gap-y-5"
|
<span class="font-medium">{{ job.data.company_name }}</span>
|
||||||
|
{{ __('on') }}
|
||||||
|
<span class="font-medium">{{
|
||||||
|
dayjs(job.data.creation).format('DD MMM YYYY')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mt-2">
|
||||||
|
<Badge :label="job.data.type" theme="green" size="lg" />
|
||||||
|
<Badge
|
||||||
|
:label="job.data.location"
|
||||||
|
theme="gray"
|
||||||
|
size="lg"
|
||||||
|
class="ml-4"
|
||||||
>
|
>
|
||||||
<div class="flex items-center space-x-2">
|
<template #prefix>
|
||||||
<span class="p-4 bg-green-50 rounded-full">
|
<MapPin class="h-4 w-4 stroke-1.5" />
|
||||||
<Building2 class="h-4 w-4 text-green-500" />
|
</template>
|
||||||
</span>
|
</Badge>
|
||||||
<div class="flex flex-col space-y-2">
|
|
||||||
<span class="text-xs text-gray-600 font-medium uppercase">
|
|
||||||
{{ __('Organisation') }}
|
|
||||||
</span>
|
|
||||||
<span class="text-sm font-semibold">
|
|
||||||
{{ job.data.company_name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<span class="p-4 bg-red-50 rounded-full">
|
|
||||||
<MapPin class="h-4 w-4 text-red-500" />
|
|
||||||
</span>
|
|
||||||
<div class="flex flex-col space-y-2">
|
|
||||||
<span class="text-xs text-gray-600 font-medium uppercase">
|
|
||||||
{{ __('Location') }}
|
|
||||||
</span>
|
|
||||||
<span class="text-sm font-semibold">
|
|
||||||
{{ job.data.location }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<span class="p-4 bg-yellow-50 rounded-full">
|
|
||||||
<ClipboardType class="h-4 w-4 text-yellow-500" />
|
|
||||||
</span>
|
|
||||||
<div class="flex flex-col space-y-2">
|
|
||||||
<span class="text-xs font-medium text-gray-600 uppercase">
|
|
||||||
{{ __('Category') }}
|
|
||||||
</span>
|
|
||||||
<span class="text-sm font-semibold">
|
|
||||||
{{ job.data.type }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<span class="p-4 bg-blue-50 rounded-full">
|
|
||||||
<CalendarDays class="h-4 w-4 text-blue-500" />
|
|
||||||
</span>
|
|
||||||
<div class="flex flex-col space-y-2">
|
|
||||||
<span class="text-xs text-gray-600 font-medium uppercase">
|
|
||||||
{{ __('Posted on') }}
|
|
||||||
</span>
|
|
||||||
<span class="text-sm font-semibold">
|
|
||||||
{{ dayjs(job.data.creation).format('DD MMM YYYY') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="applicationCount.data"
|
|
||||||
class="flex items-center space-x-2"
|
|
||||||
>
|
|
||||||
<span class="p-4 bg-purple-50 rounded-full">
|
|
||||||
<SquareUserRound class="h-4 w-4 text-purple-500" />
|
|
||||||
</span>
|
|
||||||
<div class="flex flex-col space-y-2">
|
|
||||||
<span class="text-xs text-gray-600 font-medium uppercase">
|
|
||||||
{{ __('Applications Received') }}
|
|
||||||
</span>
|
|
||||||
<span class="text-sm font-semibold">
|
|
||||||
{{ applicationCount.data }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,19 +99,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Button, Breadcrumbs, createResource } from 'frappe-ui'
|
import { Badge, Button, Breadcrumbs, createResource } from 'frappe-ui'
|
||||||
import { inject, ref, computed } from 'vue'
|
import { inject, ref, onMounted } from 'vue'
|
||||||
import { updateDocumentTitle } from '@/utils'
|
import { MapPin, SendHorizonal, Pencil } from 'lucide-vue-next'
|
||||||
import JobApplicationModal from '@/components/Modals/JobApplicationModal.vue'
|
import JobApplicationModal from '@/components/Modals/JobApplicationModal.vue'
|
||||||
import {
|
|
||||||
MapPin,
|
|
||||||
SendHorizonal,
|
|
||||||
Pencil,
|
|
||||||
Building2,
|
|
||||||
CalendarDays,
|
|
||||||
ClipboardType,
|
|
||||||
SquareUserRound,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const dayjs = inject('$dayjs')
|
const dayjs = inject('$dayjs')
|
||||||
@@ -188,7 +126,6 @@ const job = createResource({
|
|||||||
if (user.data?.name) {
|
if (user.data?.name) {
|
||||||
jobApplication.submit()
|
jobApplication.submit()
|
||||||
}
|
}
|
||||||
applicationCount.submit()
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -205,18 +142,6 @@ const jobApplication = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const applicationCount = createResource({
|
|
||||||
url: 'frappe.client.get_count',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doctype: 'LMS Job Application',
|
|
||||||
filters: {
|
|
||||||
job: job.data?.name,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const openApplicationModal = () => {
|
const openApplicationModal = () => {
|
||||||
showApplicationModal.value = true
|
showApplicationModal.value = true
|
||||||
}
|
}
|
||||||
@@ -224,13 +149,4 @@ const openApplicationModal = () => {
|
|||||||
const redirectToLogin = (job) => {
|
const redirectToLogin = (job) => {
|
||||||
window.location.href = `/login?redirect-to=/job-openings/${job}`
|
window.location.href = `/login?redirect-to=/job-openings/${job}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageMeta = computed(() => {
|
|
||||||
return {
|
|
||||||
title: job.data?.job_title,
|
|
||||||
description: job.data?.description,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
updateDocumentTitle(pageMeta)
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -26,9 +26,9 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div v-if="jobs.data?.length">
|
<div v-if="jobs.data">
|
||||||
<div class="divide-y lg:w-3/4 mx-auto p-5">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5 p-5">
|
||||||
<div v-for="job in jobs.data">
|
<div v-if="jobs.data.length" v-for="job in jobs.data">
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: 'JobDetail',
|
name: 'JobDetail',
|
||||||
@@ -41,17 +41,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-gray-700 italic p-5 w-fit mx-auto">
|
|
||||||
{{ __('No jobs posted') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Button, Breadcrumbs, createResource } from 'frappe-ui'
|
import { Button, Breadcrumbs, createResource } from 'frappe-ui'
|
||||||
import { Plus } from 'lucide-vue-next'
|
import { Plus } from 'lucide-vue-next'
|
||||||
import { inject, computed } from 'vue'
|
import { inject } from 'vue'
|
||||||
import JobCard from '@/components/JobCard.vue'
|
import JobCard from '@/components/JobCard.vue'
|
||||||
import { updateDocumentTitle } from '@/utils'
|
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
|
||||||
@@ -60,13 +56,4 @@ const jobs = createResource({
|
|||||||
cache: ['jobs'],
|
cache: ['jobs'],
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const pageMeta = computed(() => {
|
|
||||||
return {
|
|
||||||
title: 'Jobs',
|
|
||||||
description: 'An open job board for the community',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
updateDocumentTitle(pageMeta)
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
>
|
>
|
||||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||||
</header>
|
</header>
|
||||||
<div class="grid md:grid-cols-[70%,30%] h-screen">
|
<div class="grid md:grid-cols-[70%,30%] h-full">
|
||||||
<div
|
<div
|
||||||
v-if="lesson.data.no_preview"
|
v-if="lesson.data.no_preview"
|
||||||
class="border-r text-center pt-10 px-5 md:px-0 pb-10"
|
class="border-r-2 text-center pt-10 px-5 md:px-0 pb-10"
|
||||||
>
|
>
|
||||||
<p class="mb-4">
|
<p class="mb-4">
|
||||||
{{
|
{{
|
||||||
@@ -18,18 +18,14 @@
|
|||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="user.data"
|
|
||||||
:to="{ name: 'CourseDetail', params: { courseName: courseName } }"
|
:to="{ name: 'CourseDetail', params: { courseName: courseName } }"
|
||||||
>
|
>
|
||||||
<Button variant="solid">
|
<Button variant="solid">
|
||||||
{{ __('Start Learning') }}
|
{{ __('Start Learning') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<Button v-else @click="redirectToLogin()">
|
|
||||||
{{ __('Login') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="border-r container pt-5 pb-10 px-5">
|
<div v-else class="border-r-2 container pt-5 pb-10 px-5">
|
||||||
<div class="flex flex-col md:flex-row md:items-center justify-between">
|
<div class="flex flex-col md:flex-row md:items-center justify-between">
|
||||||
<div class="text-3xl font-semibold">
|
<div class="text-3xl font-semibold">
|
||||||
{{ lesson.data.title }}
|
{{ lesson.data.title }}
|
||||||
@@ -58,7 +54,7 @@
|
|||||||
<router-link
|
<router-link
|
||||||
v-if="allowEdit()"
|
v-if="allowEdit()"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'LessonForm',
|
name: 'CreateLesson',
|
||||||
params: {
|
params: {
|
||||||
courseName: courseName,
|
courseName: courseName,
|
||||||
chapterNumber: props.chapterNumber,
|
chapterNumber: props.chapterNumber,
|
||||||
@@ -90,23 +86,12 @@
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
|
||||||
v-else
|
|
||||||
:to="{
|
|
||||||
name: 'CourseDetail',
|
|
||||||
params: { courseName: courseName },
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Button>
|
|
||||||
{{ __('Back to Course') }}
|
|
||||||
</Button>
|
|
||||||
</router-link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center mt-2">
|
<div class="flex items-center mt-2">
|
||||||
<span
|
<span
|
||||||
class="h-6 mr-1"
|
class="mr-1"
|
||||||
:class="{
|
:class="{
|
||||||
'avatar-group overlap': lesson.data.instructors.length > 1,
|
'avatar-group overlap': lesson.data.instructors.length > 1,
|
||||||
}"
|
}"
|
||||||
@@ -116,12 +101,21 @@
|
|||||||
:user="instructor"
|
:user="instructor"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<CourseInstructors :instructors="lesson.data.instructors" />
|
<span v-if="lesson.data.instructors.length == 1">
|
||||||
|
{{ lesson.data.instructors[0].full_name }}
|
||||||
|
</span>
|
||||||
|
<span v-if="lesson.data.instructors.length == 2">
|
||||||
|
{{ lesson.data.instructors[0].first_name }} and
|
||||||
|
{{ lesson.data.instructors[1].first_name }}
|
||||||
|
</span>
|
||||||
|
<span v-if="lesson.data.instructors.length > 2">
|
||||||
|
{{ lesson.data.instructors[0].first_name }} and
|
||||||
|
{{ lesson.data.instructors.length - 1 }} others
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
lesson.data.instructor_content &&
|
lesson.data.instructor_content?.blocks?.length &&
|
||||||
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 &&
|
|
||||||
allowInstructorContent()
|
allowInstructorContent()
|
||||||
"
|
"
|
||||||
class="bg-gray-100 p-3 rounded-md mt-6"
|
class="bg-gray-100 p-3 rounded-md mt-6"
|
||||||
@@ -158,7 +152,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-20">
|
<div class="mt-20">
|
||||||
<Discussions
|
<Discussions
|
||||||
v-if="allowDiscussions"
|
v-if="allowDiscussions()"
|
||||||
:title="'Questions'"
|
:title="'Questions'"
|
||||||
:doctype="'Course Lesson'"
|
:doctype="'Course Lesson'"
|
||||||
:docname="lesson.data.name"
|
:docname="lesson.data.name"
|
||||||
@@ -167,50 +161,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sticky top-10">
|
<div class="sticky top-10">
|
||||||
<div class="bg-gray-50 py-5 px-2 border-b">
|
<div class="bg-gray-50 p-5 border-b-2">
|
||||||
<div class="text-lg font-semibold">
|
<div class="text-lg font-semibold">
|
||||||
{{ lesson.data.course_title }}
|
{{ lesson.data.course_title }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="user && lesson.data.membership" class="text-sm mt-3">
|
<div v-if="user && lesson.data.membership" class="text-sm mt-3">
|
||||||
{{ Math.ceil(lessonProgress) }}% {{ __('completed') }}
|
{{ Math.ceil(lesson.data.membership.progress) }}% completed
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
<ProgressBar
|
|
||||||
v-if="user && lesson.data.membership"
|
v-if="user && lesson.data.membership"
|
||||||
:progress="lessonProgress"
|
class="w-full bg-gray-200 rounded-full h-1 my-2"
|
||||||
/>
|
>
|
||||||
|
<div
|
||||||
|
class="bg-gray-900 h-1 rounded-full"
|
||||||
|
:style="{
|
||||||
|
width: Math.ceil(lesson.data.membership.progress) + '%',
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<CourseOutline
|
</div>
|
||||||
:courseName="courseName"
|
<CourseOutline :courseName="courseName" :key="chapterNumber" />
|
||||||
:key="chapterNumber"
|
|
||||||
:getProgress="lesson.data.membership ? true : false"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createResource, Breadcrumbs, Button } from 'frappe-ui'
|
import { createResource, Breadcrumbs, Button } from 'frappe-ui'
|
||||||
import { computed, watch, inject, ref, onMounted, onBeforeUnmount } from 'vue'
|
import { computed, watch, ref, inject, createApp } from 'vue'
|
||||||
import CourseOutline from '@/components/CourseOutline.vue'
|
import CourseOutline from '@/components/CourseOutline.vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||||
import Discussions from '@/components/Discussions.vue'
|
import Discussions from '@/components/Discussions.vue'
|
||||||
import { getEditorTools, updateDocumentTitle } from '../utils'
|
import { getEditorTools } from '../utils'
|
||||||
import EditorJS from '@editorjs/editorjs'
|
import EditorJS from '@editorjs/editorjs'
|
||||||
import LessonContent from '@/components/LessonContent.vue'
|
import LessonContent from '@/components/LessonContent.vue'
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
|
||||||
import ProgressBar from '@/components/ProgressBar.vue'
|
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const allowDiscussions = ref(false)
|
let editor, instructorEditor
|
||||||
const editor = ref(null)
|
|
||||||
const instructorEditor = ref(null)
|
|
||||||
const lessonProgress = ref(0)
|
|
||||||
const timer = ref(0)
|
|
||||||
let timerInterval
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
courseName: {
|
courseName: {
|
||||||
@@ -227,10 +216,6 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
startTimer()
|
|
||||||
})
|
|
||||||
|
|
||||||
const lesson = createResource({
|
const lesson = createResource({
|
||||||
url: 'lms.lms.utils.get_lesson',
|
url: 'lms.lms.utils.get_lesson',
|
||||||
cache: ['lesson', props.courseName, props.chapterNumber, props.lessonNumber],
|
cache: ['lesson', props.courseName, props.chapterNumber, props.lessonNumber],
|
||||||
@@ -243,31 +228,24 @@ const lesson = createResource({
|
|||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
lessonProgress.value = data.membership?.progress
|
if (data.membership)
|
||||||
if (data.content) editor.value = renderEditor('editor', data.content)
|
current_lesson.submit({
|
||||||
if (
|
name: data.membership.name,
|
||||||
data.instructor_content &&
|
lesson_name: data.name,
|
||||||
JSON.parse(data.instructor_content)?.blocks?.length > 1
|
})
|
||||||
)
|
markProgress(data)
|
||||||
instructorEditor.value = renderEditor(
|
|
||||||
|
if (data.content) editor = renderEditor('editor', data.content)
|
||||||
|
if (data.instructor_content?.blocks?.length)
|
||||||
|
instructorEditor = renderEditor(
|
||||||
'instructor-content',
|
'instructor-content',
|
||||||
data.instructor_content
|
data.instructor_content
|
||||||
)
|
)
|
||||||
editor.value?.isReady.then(() => {
|
|
||||||
checkIfDiscussionsAllowed()
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!editor.value && data.body) {
|
|
||||||
const quizRegex = /\{\{ Quiz\(".*"\) \}\}/
|
|
||||||
const hasQuiz = quizRegex.test(data.body)
|
|
||||||
if (!hasQuiz) allowDiscussions.value = true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const renderEditor = (holder, content) => {
|
const renderEditor = (holder, content) => {
|
||||||
// empty the holder
|
// empty the holder
|
||||||
if (document.getElementById(holder))
|
|
||||||
document.getElementById(holder).innerHTML = ''
|
document.getElementById(holder).innerHTML = ''
|
||||||
return new EditorJS({
|
return new EditorJS({
|
||||||
holder: holder,
|
holder: holder,
|
||||||
@@ -278,12 +256,22 @@ const renderEditor = (holder, content) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const markProgress = () => {
|
const markProgress = (data) => {
|
||||||
if (user.data && lesson.data && !lesson.data.progress) {
|
if (user.data && !data.progress) progress.submit()
|
||||||
progress.submit()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const current_lesson = createResource({
|
||||||
|
url: 'frappe.client.set_value',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'LMS Enrollment',
|
||||||
|
name: values.name,
|
||||||
|
fieldname: 'current_lesson',
|
||||||
|
value: values.lesson_name,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const progress = createResource({
|
const progress = createResource({
|
||||||
url: 'lms.lms.doctype.course_lesson.course_lesson.save_progress',
|
url: 'lms.lms.doctype.course_lesson.course_lesson.save_progress',
|
||||||
makeParams() {
|
makeParams() {
|
||||||
@@ -292,9 +280,6 @@ const progress = createResource({
|
|||||||
course: props.courseName,
|
course: props.courseName,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess(data) {
|
|
||||||
lessonProgress.value = data
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
@@ -323,48 +308,21 @@ watch(
|
|||||||
[newChapterNumber, newLessonNumber],
|
[newChapterNumber, newLessonNumber],
|
||||||
[oldChapterNumber, oldLessonNumber]
|
[oldChapterNumber, oldLessonNumber]
|
||||||
) => {
|
) => {
|
||||||
if (newChapterNumber || newLessonNumber) {
|
if (newChapterNumber && newLessonNumber) {
|
||||||
editor.value = null
|
|
||||||
instructorEditor.value = null
|
|
||||||
allowDiscussions.value = false
|
|
||||||
lesson.submit({
|
lesson.submit({
|
||||||
chapter: newChapterNumber,
|
chapter: newChapterNumber,
|
||||||
lesson: newLessonNumber,
|
lesson: newLessonNumber,
|
||||||
})
|
})
|
||||||
clearInterval(timerInterval)
|
|
||||||
timer.value = 0
|
|
||||||
startTimer()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const startTimer = () => {
|
const allowDiscussions = () => {
|
||||||
timerInterval = setInterval(() => {
|
return (
|
||||||
timer.value++
|
lesson.data?.membership ||
|
||||||
if (timer.value == 30) {
|
|
||||||
clearInterval(timerInterval)
|
|
||||||
markProgress()
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
clearInterval(timerInterval)
|
|
||||||
})
|
|
||||||
|
|
||||||
const checkIfDiscussionsAllowed = () => {
|
|
||||||
let quizPresent = false
|
|
||||||
JSON.parse(lesson.data?.content)?.blocks?.forEach((block) => {
|
|
||||||
if (block.type === 'quiz') quizPresent = true
|
|
||||||
})
|
|
||||||
|
|
||||||
if (
|
|
||||||
!quizPresent &&
|
|
||||||
(lesson.data?.membership ||
|
|
||||||
user.data?.is_moderator ||
|
user.data?.is_moderator ||
|
||||||
user.data?.is_instructor)
|
user.data?.is_instructor
|
||||||
)
|
)
|
||||||
allowDiscussions.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowEdit = () => {
|
const allowEdit = () => {
|
||||||
@@ -378,19 +336,6 @@ const allowInstructorContent = () => {
|
|||||||
if (lesson.data?.instructors.includes(user.data?.name)) return true
|
if (lesson.data?.instructors.includes(user.data?.name)) return true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectToLogin = () => {
|
|
||||||
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageMeta = computed(() => {
|
|
||||||
return {
|
|
||||||
title: lesson.data?.title,
|
|
||||||
description: lesson.data?.course,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
updateDocumentTitle(pageMeta)
|
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.avatar-group {
|
.avatar-group {
|
||||||
@@ -444,114 +389,11 @@ updateDocumentTitle(pageMeta)
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.codex-editor__redactor {
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.embed-tool__caption {
|
.embed-tool__caption {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ce-block__content {
|
|
||||||
max-width: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codex-editor__redactor {
|
|
||||||
padding-bottom: 0px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeBoxHolder {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeBoxTextArea {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 30px;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 2px 2px 2px 0;
|
|
||||||
border: none !important;
|
|
||||||
outline: none !important;
|
|
||||||
font: 14px monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeBoxSelectDiv {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: flex-start;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeBoxSelectInput {
|
|
||||||
border-radius: 0 0 20px 2px;
|
|
||||||
padding: 2px 26px;
|
|
||||||
padding-top: 0;
|
|
||||||
padding-right: 0;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none !important;
|
|
||||||
outline: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeBoxSelectDropIcon {
|
|
||||||
position: absolute !important;
|
|
||||||
left: 10px !important;
|
|
||||||
bottom: 0 !important;
|
|
||||||
width: unset !important;
|
|
||||||
height: unset !important;
|
|
||||||
font-size: 16px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeBoxSelectPreview {
|
|
||||||
display: none;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: flex-start;
|
|
||||||
border-radius: 2px;
|
|
||||||
box-shadow: 0 3px 15px -3px rgba(13, 20, 33, 0.13);
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
margin: 5px 0;
|
|
||||||
max-height: 30vh;
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: auto;
|
|
||||||
z-index: 10000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeBoxSelectItem {
|
|
||||||
width: 100%;
|
|
||||||
padding: 5px 20px;
|
|
||||||
margin: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeBoxSelectItem:hover {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeBoxSelectedItem {
|
|
||||||
background-color: lightblue !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeBoxShow {
|
|
||||||
display: flex !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
color: #abb2bf;
|
|
||||||
background-color: #282c34;
|
|
||||||
}
|
|
||||||
|
|
||||||
.light {
|
|
||||||
color: #383a42;
|
|
||||||
background-color: #fafafa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeBoxTextArea {
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
iframe {
|
|
||||||
border-top: 3px solid theme('colors.gray.700');
|
|
||||||
border-bottom: 3px solid theme('colors.gray.700');
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
<template>
|
|
||||||
<header
|
|
||||||
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
|
||||||
>
|
|
||||||
<Breadcrumbs :items="breadcrumbs" />
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<Button
|
|
||||||
@click="markAllAsRead.submit"
|
|
||||||
:loading="markAllAsRead.loading"
|
|
||||||
v-if="activeTab === 'Unread' && unReadNotifications.data?.length > 0"
|
|
||||||
>
|
|
||||||
{{ __('Mark all as read') }}
|
|
||||||
</Button>
|
|
||||||
<TabButtons
|
|
||||||
class="inline-block"
|
|
||||||
:buttons="[{ label: 'Unread', active: true }, { label: 'Read' }]"
|
|
||||||
v-model="activeTab"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div class="w-3/4 mx-auto px-5 pt-6 divide-y">
|
|
||||||
<div
|
|
||||||
v-if="notifications?.length"
|
|
||||||
v-for="log in notifications"
|
|
||||||
class="flex items-center py-2 justify-between"
|
|
||||||
>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<UserAvatar :user="allUsers.data[log.from_user]" class="mr-2" />
|
|
||||||
<div class="notification" v-html="log.subject"></div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<Link
|
|
||||||
v-if="log.link"
|
|
||||||
:to="log.link"
|
|
||||||
@click="markAsRead.submit({ name: log.name })"
|
|
||||||
class="text-gray-600 font-medium text-sm hover:text-gray-700"
|
|
||||||
>
|
|
||||||
{{ __('View') }}
|
|
||||||
</Link>
|
|
||||||
<Tooltip :text="__('Mark as read')">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
v-if="!log.read"
|
|
||||||
@click="markAsRead.submit({ name: log.name })"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<X class="h-4 w-4 text-gray-700 stroke-1.5" />
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-gray-600">
|
|
||||||
{{ __('Nothing to see here.') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import {
|
|
||||||
createListResource,
|
|
||||||
createResource,
|
|
||||||
Breadcrumbs,
|
|
||||||
Link,
|
|
||||||
TabButtons,
|
|
||||||
Button,
|
|
||||||
Tooltip,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import { computed, inject, ref, onMounted } from 'vue'
|
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { X } from 'lucide-vue-next'
|
|
||||||
import { updateDocumentTitle } from '@/utils'
|
|
||||||
|
|
||||||
const user = inject('$user')
|
|
||||||
const socket = inject('$socket')
|
|
||||||
const allUsers = inject('$allUsers')
|
|
||||||
const activeTab = ref('Unread')
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (!user.data) router.push({ name: 'Courses' })
|
|
||||||
|
|
||||||
socket.on('publish_lms_notifications', (data) => {
|
|
||||||
unReadNotifications.reload()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const notifications = computed(() => {
|
|
||||||
return activeTab.value === 'Unread'
|
|
||||||
? unReadNotifications.data
|
|
||||||
: readNotifications.data
|
|
||||||
})
|
|
||||||
|
|
||||||
const unReadNotifications = createListResource({
|
|
||||||
doctype: 'Notification Log',
|
|
||||||
fields: ['subject', 'from_user', 'link', 'read', 'name'],
|
|
||||||
filters: {
|
|
||||||
for_user: user.data?.name,
|
|
||||||
read: 0,
|
|
||||||
},
|
|
||||||
orderBy: 'creation desc',
|
|
||||||
auto: true,
|
|
||||||
cache: 'Unread Notifications',
|
|
||||||
})
|
|
||||||
|
|
||||||
const readNotifications = createListResource({
|
|
||||||
doctype: 'Notification Log',
|
|
||||||
fields: ['subject', 'from_user', 'link', 'read', 'name'],
|
|
||||||
filters: {
|
|
||||||
for_user: user.data?.name,
|
|
||||||
read: 1,
|
|
||||||
},
|
|
||||||
orderBy: 'creation desc',
|
|
||||||
auto: true,
|
|
||||||
cache: 'Read Notifications',
|
|
||||||
})
|
|
||||||
|
|
||||||
const markAsRead = createResource({
|
|
||||||
url: 'lms.lms.api.mark_as_read',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
name: values.name,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess(data) {
|
|
||||||
unReadNotifications.reload()
|
|
||||||
readNotifications.reload()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const markAllAsRead = createResource({
|
|
||||||
url: 'lms.lms.api.mark_all_as_read',
|
|
||||||
onSuccess(data) {
|
|
||||||
unReadNotifications.reload()
|
|
||||||
readNotifications.reload()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
|
||||||
let crumbs = [
|
|
||||||
{
|
|
||||||
label: 'Notifications',
|
|
||||||
route: {
|
|
||||||
name: 'Notifications',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
return crumbs
|
|
||||||
})
|
|
||||||
|
|
||||||
const pageMeta = computed(() => {
|
|
||||||
return {
|
|
||||||
title: 'Notifications',
|
|
||||||
description: 'All your notifications in one place.',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
updateDocumentTitle(pageMeta)
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
.notification strong {
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
.notification b {
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user