Merge remote-tracking branch 'origin/develop' into feat/scorm-progress

This commit is contained in:
Fahid Latheef Alungal
2025-09-07 02:00:32 +05:30
451 changed files with 155495 additions and 39845 deletions

124
.eslintrc Normal file
View File

@@ -0,0 +1,124 @@
{
"env": {
"browser": true,
"node": true,
"es2022": true
},
"parserOptions": {
"sourceType": "module"
},
"extends": "eslint:recommended",
"rules": {
"indent": "off",
"brace-style": "off",
"no-mixed-spaces-and-tabs": "off",
"no-useless-escape": "off",
"space-unary-ops": ["error", { "words": true }],
"linebreak-style": "off",
"quotes": ["off"],
"semi": "off",
"camelcase": "off",
"no-unused-vars": "off",
"no-console": ["warn"],
"no-extra-boolean-cast": ["off"],
"no-control-regex": ["off"],
},
"root": true,
"globals": {
"frappe": true,
"Vue": true,
"SetVueGlobals": true,
"__": true,
"repl": true,
"Class": true,
"locals": true,
"cint": true,
"cstr": true,
"cur_frm": true,
"cur_dialog": true,
"cur_page": true,
"cur_list": true,
"cur_tree": true,
"msg_dialog": true,
"is_null": true,
"in_list": true,
"has_common": true,
"posthog": true,
"has_words": true,
"validate_email": true,
"open_web_template_values_editor": true,
"validate_name": true,
"validate_phone": true,
"validate_url": true,
"get_number_format": true,
"format_number": true,
"format_currency": true,
"comment_when": true,
"open_url_post": true,
"toTitle": true,
"lstrip": true,
"rstrip": true,
"strip": true,
"strip_html": true,
"replace_all": true,
"flt": true,
"precision": true,
"CREATE": true,
"AMEND": true,
"CANCEL": true,
"copy_dict": true,
"get_number_format_info": true,
"strip_number_groups": true,
"print_table": true,
"Layout": true,
"web_form_settings": true,
"$c": true,
"$a": true,
"$i": true,
"$bg": true,
"$y": true,
"$c_obj": true,
"refresh_many": true,
"refresh_field": true,
"toggle_field": true,
"get_field_obj": true,
"get_query_params": true,
"unhide_field": true,
"hide_field": true,
"set_field_options": true,
"getCookie": true,
"getCookies": true,
"get_url_arg": true,
"md5": true,
"$": true,
"jQuery": true,
"moment": true,
"hljs": true,
"Awesomplete": true,
"Sortable": true,
"Showdown": true,
"Taggle": true,
"Gantt": true,
"Slick": true,
"Webcam": true,
"PhotoSwipe": true,
"PhotoSwipeUI_Default": true,
"io": true,
"JsBarcode": true,
"L": true,
"Chart": true,
"DataTable": true,
"Cypress": true,
"cy": true,
"it": true,
"describe": true,
"expect": true,
"context": true,
"before": true,
"beforeEach": true,
"after": true,
"qz": true,
"localforage": true,
"extend_cscript": true
}
}

View File

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

View File

@@ -39,7 +39,7 @@ jobs:
node-version: '18' node-version: '18'
check-latest: true check-latest: true
- name: setup cache for bench - name: setup cache for bench
uses: actions/cache@v2 uses: actions/cache@v4
with: with:
path: ~/bench-cache path: ~/bench-cache
key: ${{ runner.os }} key: ${{ runner.os }}

View File

@@ -7,8 +7,27 @@ on:
branches: [ main ] branches: [ main ]
jobs: jobs:
commit-lint:
name: 'Semantic Commits'
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 200
- uses: actions/setup-node@v4
with:
node-version: 20
check-latest: true
- name: Check commit titles
run: |
npm install @commitlint/cli @commitlint/config-conventional
npx commitlint --verbose --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }}
linters: linters:
name: Semantic Commits name: Semgrep Rules
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
@@ -20,8 +39,17 @@ jobs:
with: with:
python-version: '3.10' python-version: '3.10'
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
${{ runner.os }}-
- name: Install and Run Pre-commit - name: Install and Run Pre-commit
uses: pre-commit/action@v2.0.3 uses: pre-commit/action@v3.0.1
- name: Download Semgrep rules - name: Download Semgrep rules
run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules

View File

@@ -1,8 +1,7 @@
name: Create weekly release name: Create weekly release
on: on:
schedule: schedule:
# 13:00 UTC -> 7pm IST on every Wednesday - cron: '30 3 * * 3'
- cron: '30 4 * * 3'
workflow_dispatch: workflow_dispatch:
jobs: jobs:

View File

@@ -58,7 +58,7 @@ jobs:
echo "127.0.0.1 lms.test" | sudo tee -a /etc/hosts echo "127.0.0.1 lms.test" | sudo tee -a /etc/hosts
- name: Cache pip - name: Cache pip
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: ~/.cache/pip path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }} key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }}
@@ -70,7 +70,7 @@ jobs:
id: yarn-cache-dir-path id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- uses: actions/cache@v3 - uses: actions/cache@v4
id: yarn-cache id: yarn-cache
with: with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }} path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
@@ -79,7 +79,7 @@ jobs:
${{ runner.os }}-yarn-ui- ${{ runner.os }}-yarn-ui-
- name: Cache cypress binary - name: Cache cypress binary
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: ~/.cache/Cypress path: ~/.cache/Cypress
key: ${{ runner.os }}-cypress key: ${{ runner.os }}-cypress
@@ -100,11 +100,12 @@ jobs:
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 bench --site lms.test set-password frappe@example.com admin
bench --site lms.test execute lms.lms.utils.persona_captured
- name: cypress pre-requisites - name: cypress pre-requisites
run: | run: |
cd ~/frappe-bench/apps/lms cd ~/frappe-bench/apps/lms
yarn add cypress@^10 --no-lockfile yarn add cypress@^10 --no-lockfile -W
- name: UI Tests - name: UI Tests
run: cd ~/frappe-bench/ && bench --site lms.test run-ui-tests lms --headless run: cd ~/frappe-bench/ && bench --site lms.test run-ui-tests lms --headless

View File

@@ -1,10 +1,10 @@
exclude: 'node_modules|.git' exclude: 'node_modules|.git'
default_stages: [commit] default_stages: [pre-commit]
fail_fast: false fail_fast: false
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0 rev: v5.0.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
files: "lms.*" files: "lms.*"
@@ -16,17 +16,16 @@ repos:
- id: check-toml - id: check-toml
- id: debug-statements - id: debug-statements
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v2.34.0 rev: v0.8.1
hooks: hooks:
- id: pyupgrade - id: ruff
args: ['--py310-plus'] name: "Run ruff import sorter"
args: ["--select=I", "--fix"]
- repo: https://github.com/adityahase/black - id: ruff
rev: 9cb0a69f4d0030cdf687eddf314468b39ed54119 name: "Run ruff linter"
hooks: - id: ruff-format
- id: black name: "Run ruff formatter"
additional_dependencies: ['click==8.0.4']
- repo: https://github.com/pre-commit/mirrors-prettier - repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.7.1 rev: v2.7.1
@@ -44,12 +43,22 @@ repos:
lms/public/js/lib/.* lms/public/js/lib/.*
)$ )$
- repo: https://github.com/PyCQA/flake8 - repo: https://github.com/pre-commit/mirrors-eslint
rev: 5.0.4 rev: v8.44.0
hooks: hooks:
- id: flake8 - id: eslint
additional_dependencies: ['flake8-bugbear',] types_or: [javascript]
args: ['--config', '.github/helper/flake8.conf'] args: ['--quiet']
exclude: |
(?x)^(
lms/public/dist/.*|
cypress/.*|
.*node_modules.*|
.*boilerplate.*|
lms/www/website_script.js|
lms/templates/includes/.*|
lms/public/js/lib/.*
)$
ci: ci:
autoupdate_schedule: weekly autoupdate_schedule: weekly

View File

@@ -118,6 +118,10 @@ Replace the following parameters with your values:
The script will set up a production-ready instance of Frappe Learning with all the necessary configurations in about 5 minutes. The script will set up a production-ready instance of Frappe Learning with all the necessary configurations in about 5 minutes.
**Note:** To avoid a `404 Page Not Found` error:
- If hosting on a **public server**, make sure your DNS **A record** points to your server's IP.
- If hosting **locally**, map your domain to `127.0.0.1` in your `/etc/hosts` file:
## Development Setup ## Development Setup
### Docker ### Docker

26
commitlint.config.js Normal file
View File

@@ -0,0 +1,26 @@
export default {
parserPreset: "conventional-changelog-conventionalcommits",
rules: {
"subject-empty": [2, "never"],
"type-case": [2, "always", "lower-case"],
"type-empty": [2, "never"],
"type-enum": [
2,
"always",
[
"build",
"chore",
"ci",
"docs",
"feat",
"fix",
"perf",
"refactor",
"revert",
"style",
"test",
"deprecate", // deprecation decision
],
],
},
};

View File

@@ -1,6 +1,6 @@
const { defineConfig } = require("cypress"); import { defineConfig } from "cypress";
module.exports = defineConfig({ export default defineConfig({
projectId: "vandxn", projectId: "vandxn",
adminPassword: "admin", adminPassword: "admin",
testUser: "frappe@example.com", testUser: "frappe@example.com",
@@ -13,6 +13,6 @@ module.exports = defineConfig({
openMode: 0, openMode: 0,
}, },
e2e: { e2e: {
baseUrl: "http://testui:8000", baseUrl: "http://pertest:8000",
}, },
}); });

View File

@@ -0,0 +1,172 @@
describe("Batch Creation", () => {
it("creates a new batch", () => {
cy.login();
cy.wait(500);
cy.visit("/lms/batches");
cy.closeOnboardingModal();
// Open Settings
cy.get("span").contains("Learning").click();
cy.get("span").contains("Settings").click();
// Add a new member
cy.get('[id^="headlessui-dialog-panel-v-"]')
.find("span")
.contains(/^Members$/)
.click();
cy.get('[id^="headlessui-dialog-panel-v-"]')
.find("button")
.contains("New")
.click();
const dateNow = Date.now();
const randomEmail = `testuser_${dateNow}@example.com`;
const randomName = `Test User ${dateNow}`;
cy.get("input[placeholder='jane@doe.com']").type(randomEmail);
cy.get("input[placeholder='Jane']").type(randomName);
cy.get("button").contains("Add").click();
// Add evaluator
cy.get('[id^="headlessui-dialog-panel-v-"]')
.find("span")
.contains(/^Evaluators$/)
.click();
cy.get('[id^="headlessui-dialog-panel-v-"]')
.find("button")
.contains("New")
.click();
const randomEvaluator = `evaluator${dateNow}@example.com`;
cy.get("input[placeholder='jane@doe.com']").type(randomEvaluator);
cy.get("button").contains("Add").click();
cy.get("div").contains(randomEvaluator).should("be.visible").click();
cy.visit("/lms/batches");
cy.closeOnboardingModal();
// Create a batch
cy.get("button").contains("Create").click();
cy.wait(500);
cy.url().should("include", "/batches/new/edit");
cy.get("label").contains("Title").type("Test Batch");
cy.get("label").contains("Start Date").type("2030-10-01");
cy.get("label").contains("End Date").type("2030-10-31");
cy.get("label").contains("Start Time").type("10:00");
cy.get("label").contains("End Time").type("11:00");
cy.get("label").contains("Timezone").type("IST");
cy.get("label").contains("Seat Count").type("10");
cy.get("label").contains("Published").click();
cy.get("label")
.contains("Short Description")
.type("Test Batch Short Description to test the UI");
cy.get("div[contenteditable=true").invoke(
"text",
"Test Batch 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."
);
/* Instructor */
cy.get("label")
.contains("Instructors")
.parent()
.within(() => {
cy.get("input").click().type("evaluator");
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.button("Save").click();
cy.wait(1000);
let batchName;
cy.url().then((url) => {
console.log(url);
batchName = url.split("/").pop();
cy.wrap(batchName).as("batchName");
});
cy.wait(500);
// View Batch
cy.wait(1000);
cy.visit("/lms/batches");
cy.closeOnboardingModal();
cy.url().should("include", "/lms/batches");
cy.get('[id^="headlessui-radiogroup-v-"]')
.find("span")
.contains("Upcoming")
.should("be.visible")
.click();
cy.get("@batchName").then((batchName) => {
cy.get(`a[href='/lms/batches/details/${batchName}'`).within(() => {
cy.get("div").contains("Test Batch").should("be.visible");
cy.get("div")
.contains("Test Batch Short Description to test the UI")
.should("be.visible");
cy.get("span")
.contains("01 Oct 2030 - 31 Oct 2030")
.should("be.visible");
cy.get("span")
.contains("10:00 AM - 11:00 AM")
.should("be.visible");
cy.get("span").contains("IST").should("be.visible");
cy.get("a").contains("Evaluator").should("be.visible");
cy.get("div")
.contains("10")
.should("be.visible")
.get("span")
.contains("Seats Left")
.should("be.visible");
});
cy.get(`a[href='/lms/batches/details/${batchName}'`).click();
});
cy.get("div").contains("Test Batch").should("be.visible");
cy.get("div")
.contains("Test Batch Short Description to test the UI")
.should("be.visible");
cy.get("a").contains("Evaluator").should("be.visible");
cy.get("span:visible")
.contains("01 Oct 2030 - 31 Oct 2030")
.should("be.visible");
cy.get("span:visible")
.contains("10:00 AM - 11:00 AM")
.should("be.visible");
cy.get("span:visible").contains("IST").should("be.visible");
cy.contains("div:visible", "10 Seats Left").should("be.visible");
cy.get("p")
.contains(
"Test Batch 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."
)
.should("be.visible");
cy.get("button:visible").contains("Manage Batch").click();
/* Add student to batch */
cy.get("button").contains("Add").click();
cy.get('div[id^="headlessui-dialog-panel-v-"]')
.first()
.find("button")
.eq(1)
.click();
cy.get("input[id^='headlessui-combobox-input-v-']").type(randomEmail);
cy.get("div").contains(randomEmail).click();
cy.get("button").contains("Submit").click();
// Verify Seat Count
cy.get("span").contains("Details").click();
cy.contains("div:visible", "9 Seats Left").should("be.visible");
});
});

View File

@@ -1,12 +1,15 @@
describe("Course Creation", () => { describe("Course Creation", () => {
it("creates a new course", () => { it("creates a new course", () => {
cy.login(); cy.login();
cy.wait(1000); cy.wait(500);
cy.visit("/lms/courses"); cy.visit("/lms/courses");
// Close onboarding modal
cy.closeOnboardingModal();
// Create a course // Create a course
cy.get("button").contains("New").click(); cy.get("button").contains("Create").click();
cy.wait(1000); cy.wait(500);
cy.url().should("include", "/courses/new/edit"); cy.url().should("include", "/courses/new/edit");
cy.get("label").contains("Title").type("Test Course"); cy.get("label").contains("Title").type("Test Course");
@@ -19,12 +22,16 @@ describe("Course Creation", () => {
); );
cy.fixture("profile.png", "base64").then((fileContent) => { cy.fixture("profile.png", "base64").then((fileContent) => {
cy.get('input[type="file"]').attachFile({ cy.get("div")
fileContent, .contains("Course Image")
fileName: "profile.png", .siblings("div")
mimeType: "image/png", .children('input[type="file"]')
encoding: "base64", .attachFile({
}); fileContent,
fileName: "profile.png",
mimeType: "image/png",
encoding: "base64",
});
}); });
cy.get("label") cy.get("label")
@@ -91,15 +98,16 @@ describe("Course Creation", () => {
// View Course // View Course
cy.wait(1000); cy.wait(1000);
cy.visit("/lms"); cy.visit("/lms/courses");
cy.wait(500); cy.closeOnboardingModal();
cy.url().should("include", "/lms/courses"); cy.url().should("include", "/lms/courses");
cy.get(".grid a:first").within(() => { cy.get(".grid a:first").within(() => {
cy.get("div").contains("Test Course"); cy.get("div").contains("Test Course");
cy.get("div").contains( cy.get("div").contains(
"Test Course Short Introduction to test the UI" "Test Course Short Introduction to test the UI"
); );
cy.get(".course-image") cy.get(".bg-cover")
.invoke("css", "background-image") .invoke("css", "background-image")
.should("include", "/files/profile"); .should("include", "/files/profile");
}); });
@@ -132,6 +140,7 @@ describe("Course Creation", () => {
); );
// Add Discussion // Add Discussion
cy.get("span").contains("Community").click();
cy.button("New Question").click(); cy.button("New Question").click();
cy.wait(500); cy.wait(500);
cy.get("[id^=headlessui-dialog-panel-").within(() => { cy.get("[id^=headlessui-dialog-panel-").within(() => {

View File

@@ -25,6 +25,7 @@
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
import "cypress-file-upload"; import "cypress-file-upload";
import "cypress-real-events";
Cypress.Commands.add("login", (email, password) => { Cypress.Commands.add("login", (email, password) => {
if (!email) { if (!email) {
@@ -37,6 +38,9 @@ Cypress.Commands.add("login", (email, password) => {
url: "/api/method/login", url: "/api/method/login",
method: "POST", method: "POST",
body: { usr: email, pwd: password }, body: { usr: email, pwd: password },
timeout: 60000,
retryOnStatusCodeFailure: true,
retryOnNetworkFailure: true,
}); });
}); });
@@ -65,3 +69,18 @@ Cypress.Commands.add("paste", { prevSubject: true }, (subject, text) => {
element.dispatchEvent(event); element.dispatchEvent(event);
}); });
}); });
Cypress.Commands.add("closeOnboardingModal", () => {
cy.wait(500);
cy.get("body").then(($body) => {
// Check if any element with class including 'z-50' exists
if ($body.find('[class*="z-50"]').length > 0) {
cy.get('[class*="z-50"]')
.find('button:has(svg[class*="feather-x"])')
.realClick();
cy.wait(1000);
} else {
cy.log("Onboarding modal not found, skipping close.");
}
});
});

View File

@@ -16,9 +16,9 @@ cd frappe-bench
# Use containers instead of localhost # Use containers instead of localhost
bench set-mariadb-host mariadb bench set-mariadb-host mariadb
bench set-redis-cache-host redis:6379 bench set-redis-cache-host redis://redis:6379
bench set-redis-queue-host redis:6379 bench set-redis-queue-host redis://redis:6379
bench set-redis-socketio-host redis:6379 bench set-redis-socketio-host redis://redis:6379
# Remove redis, watch from Procfile # Remove redis, watch from Procfile
sed -i '/redis/d' ./Procfile sed -i '/redis/d' ./Procfile

1
frontend/.gitignore vendored
View File

@@ -2,4 +2,5 @@ node_modules
.DS_Store .DS_Store
dist dist
dist-ssr dist-ssr
dev-dist
*.local *.local

10
frontend/auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
}

119
frontend/components.d.ts vendored Normal file
View File

@@ -0,0 +1,119 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
Annoucements: typeof import('./src/components/Annoucements.vue')['default']
AnnouncementModal: typeof import('./src/components/Modals/AnnouncementModal.vue')['default']
Apps: typeof import('./src/components/Apps.vue')['default']
AppSidebar: typeof import('./src/components/AppSidebar.vue')['default']
AssessmentModal: typeof import('./src/components/Modals/AssessmentModal.vue')['default']
AssessmentPlugin: typeof import('./src/components/AssessmentPlugin.vue')['default']
Assessments: typeof import('./src/components/Assessments.vue')['default']
Assignment: typeof import('./src/components/Assignment.vue')['default']
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
Autocomplete: typeof import('./src/components/Controls/Autocomplete.vue')['default']
BadgeAssignmentForm: typeof import('./src/components/Settings/BadgeAssignmentForm.vue')['default']
BadgeAssignments: typeof import('./src/components/Settings/BadgeAssignments.vue')['default']
BadgeForm: typeof import('./src/components/Settings/BadgeForm.vue')['default']
Badges: typeof import('./src/components/Settings/Badges.vue')['default']
BatchCard: typeof import('./src/components/BatchCard.vue')['default']
BatchCourseModal: typeof import('./src/components/Modals/BatchCourseModal.vue')['default']
BatchCourses: typeof import('./src/components/BatchCourses.vue')['default']
BatchDashboard: typeof import('./src/components/BatchDashboard.vue')['default']
BatchFeedback: typeof import('./src/components/BatchFeedback.vue')['default']
BatchOverlay: typeof import('./src/components/BatchOverlay.vue')['default']
BatchStudentProgress: typeof import('./src/components/Modals/BatchStudentProgress.vue')['default']
BatchStudents: typeof import('./src/components/BatchStudents.vue')['default']
BrandSettings: typeof import('./src/components/Settings/BrandSettings.vue')['default']
BulkCertificates: typeof import('./src/components/Modals/BulkCertificates.vue')['default']
Categories: typeof import('./src/components/Settings/Categories.vue')['default']
CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default']
ChapterModal: typeof import('./src/components/Modals/ChapterModal.vue')['default']
ChildTable: typeof import('./src/components/Controls/ChildTable.vue')['default']
Code: typeof import('./src/components/Controls/Code.vue')['default']
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default']
CourseCard: typeof import('./src/components/CourseCard.vue')['default']
CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default']
CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default']
CourseOutline: typeof import('./src/components/CourseOutline.vue')['default']
CourseProgressSummary: typeof import('./src/components/Modals/CourseProgressSummary.vue')['default']
CourseReviews: typeof import('./src/components/CourseReviews.vue')['default']
CreateOutline: typeof import('./src/components/CreateOutline.vue')['default']
DateRange: typeof import('./src/components/Common/DateRange.vue')['default']
DesktopLayout: typeof import('./src/components/DesktopLayout.vue')['default']
DiscussionModal: typeof import('./src/components/Modals/DiscussionModal.vue')['default']
DiscussionReplies: typeof import('./src/components/DiscussionReplies.vue')['default']
Discussions: typeof import('./src/components/Discussions.vue')['default']
EditCoverImage: typeof import('./src/components/Modals/EditCoverImage.vue')['default']
EditProfile: typeof import('./src/components/Modals/EditProfile.vue')['default']
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
EmailTemplates: typeof import('./src/components/Settings/EmailTemplates.vue')['default']
EmptyState: typeof import('./src/components/EmptyState.vue')['default']
EvaluationModal: typeof import('./src/components/Modals/EvaluationModal.vue')['default']
Evaluators: typeof import('./src/components/Settings/Evaluators.vue')['default']
Event: typeof import('./src/components/Modals/Event.vue')['default']
ExplanationVideos: typeof import('./src/components/Modals/ExplanationVideos.vue')['default']
FeedbackModal: typeof import('./src/components/Modals/FeedbackModal.vue')['default']
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
IconPicker: typeof import('./src/components/Controls/IconPicker.vue')['default']
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
InlineLessonMenu: typeof import('./src/components/Notes/InlineLessonMenu.vue')['default']
InstallPrompt: typeof import('./src/components/InstallPrompt.vue')['default']
InviteIcon: typeof import('./src/components/Icons/InviteIcon.vue')['default']
JobApplicationModal: typeof import('./src/components/Modals/JobApplicationModal.vue')['default']
JobCard: typeof import('./src/components/JobCard.vue')['default']
LessonContent: typeof import('./src/components/LessonContent.vue')['default']
LessonHelp: typeof import('./src/components/LessonHelp.vue')['default']
Link: typeof import('./src/components/Controls/Link.vue')['default']
LiveClass: typeof import('./src/components/LiveClass.vue')['default']
LiveClassAttendance: typeof import('./src/components/Modals/LiveClassAttendance.vue')['default']
LiveClassModal: typeof import('./src/components/Modals/LiveClassModal.vue')['default']
LMSLogo: typeof import('./src/components/Icons/LMSLogo.vue')['default']
Members: typeof import('./src/components/Settings/Members.vue')['default']
MobileLayout: typeof import('./src/components/MobileLayout.vue')['default']
MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default']
NoPermission: typeof import('./src/components/NoPermission.vue')['default']
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
Notes: typeof import('./src/components/Notes/Notes.vue')['default']
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
PaymentSettings: typeof import('./src/components/Settings/PaymentSettings.vue')['default']
Play: typeof import('./src/components/Icons/Play.vue')['default']
ProgressBar: typeof import('./src/components/ProgressBar.vue')['default']
Question: typeof import('./src/components/Modals/Question.vue')['default']
Quiz: typeof import('./src/components/Quiz.vue')['default']
QuizBlock: typeof import('./src/components/QuizBlock.vue')['default']
QuizInVideo: typeof import('./src/components/Modals/QuizInVideo.vue')['default']
Rating: typeof import('./src/components/Controls/Rating.vue')['default']
RelatedCourses: typeof import('./src/components/RelatedCourses.vue')['default']
ReviewModal: typeof import('./src/components/Modals/ReviewModal.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SettingDetails: typeof import('./src/components/Settings/SettingDetails.vue')['default']
SettingFields: typeof import('./src/components/Settings/SettingFields.vue')['default']
Settings: typeof import('./src/components/Settings/Settings.vue')['default']
SidebarLink: typeof import('./src/components/SidebarLink.vue')['default']
StudentHeatmap: typeof import('./src/components/StudentHeatmap.vue')['default']
StudentModal: typeof import('./src/components/Modals/StudentModal.vue')['default']
Tags: typeof import('./src/components/Tags.vue')['default']
UnsplashImageBrowser: typeof import('./src/components/UnsplashImageBrowser.vue')['default']
UpcomingEvaluations: typeof import('./src/components/UpcomingEvaluations.vue')['default']
Uploader: typeof import('./src/components/Controls/Uploader.vue')['default']
UploadPlugin: typeof import('./src/components/UploadPlugin.vue')['default']
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
VideoBlock: typeof import('./src/components/VideoBlock.vue')['default']
VideoStatistics: typeof import('./src/components/Modals/VideoStatistics.vue')['default']
ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default']
ZoomSettings: typeof import('./src/components/Settings/ZoomSettings.vue')['default']
}
}

View File

@@ -2,9 +2,205 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="{{ favicon or '/assets/lms/frontend/favicon.png' }}" /> <link rel="icon" href="{{ favicon }}" />
<link rel="apple-touch-icon" href="public/manifest/apple-icon-180.png" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0F0F0F" media="(prefers-color-scheme: dark)" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="msapplication-navbutton-color" content="#ffffff" />
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2048-2732.jpg"
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2732-2048.jpg"
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1668-2388.jpg"
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2388-1668.jpg"
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1536-2048.jpg"
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2048-1536.jpg"
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1640-2360.jpg"
media="(device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2360-1640.jpg"
media="(device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1668-2224.jpg"
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2224-1668.jpg"
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1620-2160.jpg"
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2160-1620.jpg"
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1488-2266.jpg"
media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2266-1488.jpg"
media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1320-2868.jpg"
media="(device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2868-1320.jpg"
media="(device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1206-2622.jpg"
media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2622-1206.jpg"
media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1290-2796.jpg"
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2796-1290.jpg"
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1179-2556.jpg"
media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2556-1179.jpg"
media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1170-2532.jpg"
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2532-1170.jpg"
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1284-2778.jpg"
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2778-1284.jpg"
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1125-2436.jpg"
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2436-1125.jpg"
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1242-2688.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2688-1242.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-828-1792.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1792-828.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1242-2208.jpg"
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2208-1242.jpg"
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-750-1334.jpg"
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1334-750.jpg"
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-640-1136.jpg"
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1136-640.jpg"
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Frappe Learning</title> <title>{{ title }}</title>
<meta name="title" content="{{ meta.title }}" /> <meta name="title" content="{{ meta.title }}" />
<meta name="image" content="{{ meta.image }}" /> <meta name="image" content="{{ meta.image }}" />
<meta name="description" content="{{ meta.description }}" /> <meta name="description" content="{{ meta.description }}" />
@@ -23,26 +219,10 @@
<p> <p>
{{ meta.description }} {{ meta.description }}
</p> </p>
<p>
The content here is just for seo purposes. The actual content will be loaded in a few seconds.
</p>
<p>
Seo checks if a page has more than 300 words. So, here are some more words to make it more than 300 words.
Page descriptions are the HTML meta tags that provide a brief summary of a web page.
Search engines use meta descriptions to help identify the page's topic - they don't use them to rank the page, but they do use them to determine whether or not to display the page in search results.
Meta descriptions are important because they're often the first thing people see when they're deciding which search result to click on.
They're also important because they can help improve your click-through rate (CTR) from search results.
A good meta description can entice people to click on your page instead of someone else's.
</p>
<a href="{{ meta.link }}">Know More</a> <a href="{{ meta.link }}">Know More</a>
</div> </div>
</div> </div>
<div id="modals"></div>
<div id="popovers"></div>
<script> <script>
window.csrf_token = '{{ csrf_token }}'
window.setup_complete = '{{ setup_complete }}'
document.getElementById('seo-content').style.display = 'none'; document.getElementById('seo-content').style.display = 'none';
</script> </script>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>

View File

@@ -2,6 +2,7 @@
"name": "frappe-ui-frontend", "name": "frappe-ui-frontend",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"serve": "vite preview", "serve": "vite preview",
@@ -9,6 +10,10 @@
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html" "copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html"
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-python": "^6.2.1",
"@editorjs/checklist": "^1.6.0", "@editorjs/checklist": "^1.6.0",
"@editorjs/code": "^2.9.0", "@editorjs/code": "^2.9.0",
"@editorjs/editorjs": "^2.29.0", "@editorjs/editorjs": "^2.29.0",
@@ -23,18 +28,22 @@
"ace-builds": "^1.36.2", "ace-builds": "^1.36.2",
"apexcharts": "^4.3.0", "apexcharts": "^4.3.0",
"chart.js": "^4.4.1", "chart.js": "^4.4.1",
"codemirror-editor-vue3": "^2.8.0", "codemirror": "^6.0.1",
"dayjs": "^1.11.6", "dayjs": "^1.11.6",
"feather-icons": "^4.28.0", "feather-icons": "^4.28.0",
"frappe-ui": "^0.1.109", "frappe-ui": "0.1.173",
"highlight.js": "^11.11.1",
"lucide-vue-next": "^0.383.0", "lucide-vue-next": "^0.383.0",
"markdown-it": "^14.0.0", "markdown-it": "^14.0.0",
"pinia": "^2.0.33", "pinia": "^2.0.33",
"plyr": "^3.7.8",
"socket.io-client": "^4.7.2", "socket.io-client": "^4.7.2",
"tailwindcss": "^3.3.3", "tailwindcss": "3.4.15",
"thememirror": "^2.0.1",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vue": "^3.4.23", "vue": "^3.4.23",
"vue-chartjs": "^5.3.0", "vue-chartjs": "^5.3.0",
"vue-codemirror": "^6.1.1",
"vue-draggable-next": "^2.2.1", "vue-draggable-next": "^2.2.1",
"vue-router": "^4.0.12", "vue-router": "^4.0.12",
"vue3-apexcharts": "^1.8.0", "vue3-apexcharts": "^1.8.0",

View File

@@ -1,4 +1,4 @@
module.exports = { export default {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},

BIN
frontend/public/Remove.mp4 Normal file

Binary file not shown.

View File

@@ -0,0 +1,4 @@
<svg width="80" height="79" viewBox="0 0 80 79" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M57.1285 0.580383H22.8514C10.2309 0.580383 0 10.5649 0 22.8815V56.3332C0 68.6497 10.2309 78.6343 22.8514 78.6343H57.1285C69.749 78.6343 79.9799 68.6497 79.9799 56.3332V22.8815C79.9799 10.5649 69.749 0.580383 57.1285 0.580383Z" fill="#0E7159"/>
<path d="M62.8434 23.6906L60.7869 23.1052C53.6744 21.0702 45.9048 22.4641 39.992 26.8128C35.8502 23.7742 30.7943 22.1854 25.7099 22.2133H17.1406V27.8163H25.7099C29.6232 27.8163 33.508 29.015 36.6787 31.3845L39.992 33.8377L43.3056 31.3845C47.2475 28.4575 52.3032 27.2588 57.1306 28.0393V50.647C51.1035 49.9223 44.9051 51.4834 39.992 55.0795C35.8502 52.0688 30.8515 50.4798 25.7671 50.4798C24.7959 50.4798 23.8247 50.5355 22.8535 50.647V35.0642H17.1406V57.0588H62.8434V23.7185V23.6906Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 856 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,38 +1,58 @@
<template> <template>
<Layout> <FrappeUIProvider>
<router-view /> <Layout>
</Layout> <div class="text-base">
<Dialogs /> <router-view />
<Toasts /> </div>
</Layout>
<InstallPrompt v-if="isMobile" />
<Dialogs />
</FrappeUIProvider>
</template> </template>
<script setup> <script setup>
import { Toasts } from 'frappe-ui' import { FrappeUIProvider } from 'frappe-ui'
import { Dialogs } from '@/utils/dialogs' import { Dialogs } from '@/utils/dialogs'
import { computed, onMounted, onUnmounted } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
import { useScreenSize } from './utils/composables' import { useScreenSize } from './utils/composables'
import { usersStore } from '@/stores/user'
import { useRouter } from 'vue-router'
import { posthogSettings } from '@/telemetry'
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 NoSidebarLayout from './components/NoSidebarLayout.vue'
import { init as initTelemetry } from '@/telemetry' import InstallPrompt from './components/InstallPrompt.vue'
import { usersStore } from '@/stores/user'
const screenSize = useScreenSize() const { isMobile } = useScreenSize()
let { userResource } = usersStore() const router = useRouter()
const noSidebar = ref(false)
const { userResource } = usersStore()
const Layout = computed(() => { router.beforeEach((to, from, next) => {
if (screenSize.width < 640) { if (to.query.fromLesson || to.path === '/persona') {
return MobileLayout noSidebar.value = true
} else { } else {
return DesktopLayout noSidebar.value = false
} }
next()
}) })
onMounted(async () => { const Layout = computed(() => {
if (!userResource.data) return if (noSidebar.value) {
await initTelemetry() return NoSidebarLayout
}
if (isMobile.value) {
return MobileLayout
}
return DesktopLayout
}) })
onUnmounted(() => { onUnmounted(() => {
stopSession() noSidebar.value = false
})
watch(userResource, () => {
if (userResource.data) {
posthogSettings.reload()
}
}) })
</script> </script>

View File

@@ -39,7 +39,11 @@
{{ __('More') }} {{ __('More') }}
</span> </span>
</div> </div>
<Button v-if="isModerator" variant="ghost" @click="openPageModal()"> <Button
v-if="isModerator && !readOnlyMode"
variant="ghost"
@click="openPageModal()"
>
<template #icon> <template #icon>
<Plus class="h-4 w-4 text-ink-gray-7 stroke-1.5" /> <Plus class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
</template> </template>
@@ -62,34 +66,109 @@
</div> </div>
</div> </div>
</div> </div>
<div> <div class="m-2 flex flex-col gap-1">
<div
v-if="readOnlyMode && !sidebarStore.isSidebarCollapsed"
class="z-10 m-2 bg-surface-modal py-2.5 px-3 text-xs text-ink-gray-7 leading-5 rounded-md"
>
{{
__(
'This site is being updated. You will not be able to make any changes. Full access will be restored shortly.'
)
}}
</div>
<TrialBanner <TrialBanner
v-if=" v-if="
userResource.data?.user_type == 'System User' && userResource.data?.is_system_manager && userResource.data?.is_fc_site
userResource.data?.is_fc_site
" "
:isSidebarCollapsed="sidebarStore.isSidebarCollapsed" :isSidebarCollapsed="sidebarStore.isSidebarCollapsed"
/> />
<SidebarLink <GettingStartedBanner
:link="{ v-if="showOnboarding && !isOnboardingStepsCompleted"
label: sidebarStore.isSidebarCollapsed ? 'Expand' : 'Collapse', :isSidebarCollapsed="sidebarStore.isSidebarCollapsed"
}" appName="learning"
:isCollapsed="sidebarStore.isSidebarCollapsed" />
@click="toggleSidebar()"
class="m-2" <div
class="flex items-center mt-4"
:class="
sidebarStore.isSidebarCollapsed ? 'flex-col space-y-3' : 'flex-row'
"
> >
<template #icon> <div
<span class="grid h-5 w-6 flex-shrink-0 place-items-center"> class="flex items-center flex-1"
<CollapseSidebar :class="
class="h-4.5 w-4.5 text-ink-gray-7 duration-300 ease-in-out" sidebarStore.isSidebarCollapsed
:class="{ ? 'flex-col space-y-3'
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed, : 'flex-row space-x-3'
}" "
>
<Tooltip v-if="readOnlyMode && sidebarStore.isSidebarCollapsed">
<CircleAlert
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
/> />
</span> <template #body>
</template> <div
</SidebarLink> class="max-w-[30ch] rounded bg-surface-gray-7 px-2 py-1 text-center text-p-xs text-ink-white shadow-xl"
>
{{
__(
'This site is being updated. You will not be able to make any changes. Full access will be restored shortly.'
)
}}
</div>
</template>
</Tooltip>
<Tooltip :text="__('Powered by Learning')">
<Zap
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
@click="redirectToWebsite()"
/>
</Tooltip>
<Tooltip v-if="showOnboarding" :text="__('Help')">
<CircleHelp
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
@click="
() => {
showHelpModal = minimize ? true : !showHelpModal
minimize = !showHelpModal
}
"
/>
</Tooltip>
</div>
<Tooltip
:text="
sidebarStore.isSidebarCollapsed ? __('Expand') : __('Collapse')
"
>
<CollapseSidebar
class="size-4 text-ink-gray-7 duration-300 stroke-1.5 ease-in-out cursor-pointer"
:class="{
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed,
}"
@click="toggleSidebar()"
/>
</Tooltip>
</div>
</div> </div>
<HelpModal
v-if="showOnboarding && showHelpModal"
v-model="showHelpModal"
v-model:articles="articles"
appName="learning"
title="Frappe Learning"
:logo="LMSLogo"
:afterSkip="(step) => capture('onboarding_step_skipped_' + step)"
:afterSkipAll="() => capture('onboarding_steps_skipped')"
:afterReset="(step) => capture('onboarding_step_reset_' + step)"
:afterResetAll="() => capture('onboarding_steps_reset')"
docsLink="https://docs.frappe.io/learning"
/>
<IntermediateStepModal
v-model="showIntermediateModal"
:currentStep="currentStep"
/>
</div> </div>
<PageModal <PageModal
v-model="showPageModal" v-model="showPageModal"
@@ -102,18 +181,52 @@
import UserDropdown from '@/components/UserDropdown.vue' 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 {
import { ref, onMounted, inject, watch } from 'vue' ref,
import { getSidebarLinks } from '../utils' onMounted,
inject,
watch,
reactive,
markRaw,
h,
onUnmounted,
} from 'vue'
import { getSidebarLinks } from '@/utils'
import { usersStore } from '@/stores/user' import { usersStore } from '@/stores/user'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { useSidebar } from '@/stores/sidebar' import { useSidebar } from '@/stores/sidebar'
import { useSettings } from '@/stores/settings' import { useSettings } from '@/stores/settings'
import { ChevronRight, Plus } from 'lucide-vue-next' import { Button, call, createResource, Tooltip } from 'frappe-ui'
import { Button, createResource, TrialBanner } from 'frappe-ui'
import PageModal from '@/components/Modals/PageModal.vue' import PageModal from '@/components/Modals/PageModal.vue'
import { capture } from '@/telemetry'
import LMSLogo from '@/components/Icons/LMSLogo.vue'
import { useRouter } from 'vue-router'
import InviteIcon from './Icons/InviteIcon.vue'
import {
BookOpen,
CircleAlert,
ChevronRight,
Plus,
CircleHelp,
FolderTree,
FileText,
UserPlus,
Users,
BookText,
Zap,
Check,
} from 'lucide-vue-next'
import {
TrialBanner,
HelpModal,
GettingStartedBanner,
useOnboarding,
showHelpModal,
minimize,
IntermediateStepModal,
} from 'frappe-ui/frappe'
const { user, sidebarSettings } = sessionStore() const { user } = sessionStore()
const { userResource } = usersStore() const { userResource } = usersStore()
let sidebarStore = useSidebar() let sidebarStore = useSidebar()
const socket = inject('$socket') const socket = inject('$socket')
@@ -124,12 +237,30 @@ const isModerator = ref(false)
const isInstructor = ref(false) const isInstructor = ref(false)
const pageToEdit = ref(null) const pageToEdit = ref(null)
const settingsStore = useSettings() const settingsStore = useSettings()
const { sidebarSettings } = settingsStore
const showOnboarding = ref(false)
const showIntermediateModal = ref(false)
const currentStep = ref({})
const router = useRouter()
let onboardingDetails
let isOnboardingStepsCompleted = false
const readOnlyMode = window.read_only_mode
const iconProps = {
strokeWidth: 1.5,
width: 16,
height: 16,
}
onMounted(() => { onMounted(() => {
addNotifications()
setSidebarLinks()
setUpOnboarding()
socket.on('publish_lms_notifications', (data) => { socket.on('publish_lms_notifications', (data) => {
unreadNotifications.reload() unreadNotifications.reload()
}) })
addNotifications() })
const setSidebarLinks = () => {
sidebarSettings.reload( sidebarSettings.reload(
{}, {},
{ {
@@ -144,7 +275,7 @@ onMounted(() => {
}, },
} }
) )
}) }
const unreadNotifications = createResource({ const unreadNotifications = createResource({
cache: 'Unread Notifications Count', cache: 'Unread Notifications Count',
@@ -184,57 +315,83 @@ const addNotifications = () => {
const addQuizzes = () => { const addQuizzes = () => {
if (isInstructor.value || isModerator.value) { if (isInstructor.value || isModerator.value) {
sidebarLinks.value.push({ sidebarLinks.value.splice(4, 0, {
label: 'Quizzes', label: 'Quizzes',
icon: 'CircleHelp', icon: 'CircleHelp',
to: 'Quizzes', to: 'Quizzes',
activeFor: ['Quizzes', 'QuizForm'], activeFor: [
'Quizzes',
'QuizForm',
'QuizSubmissionList',
'QuizSubmission',
],
}) })
} }
} }
const addAssignments = () => { const addAssignments = () => {
if (isInstructor.value || isModerator.value) { if (isInstructor.value || isModerator.value) {
sidebarLinks.value.push({ sidebarLinks.value.splice(5, 0, {
label: 'Assignments', label: 'Assignments',
icon: 'Pencil', icon: 'Pencil',
to: 'Assignments', to: 'Assignments',
activeFor: ['Assignments', 'AssignmentForm'], activeFor: [
'Assignments',
'AssignmentForm',
'AssignmentSubmissionList',
'AssignmentSubmission',
],
}) })
} }
} }
const addPrograms = () => { const addProgrammingExercises = () => {
let activeFor = ['Programs', 'ProgramForm'] if (isInstructor.value || isModerator.value) {
let index = 1 sidebarLinks.value.splice(3, 0, {
let canAddProgram = false label: 'Programming Exercises',
icon: 'Code',
if ( to: 'ProgrammingExercises',
!isInstructor.value && activeFor: [
!isModerator.value && 'ProgrammingExercises',
settingsStore.learningPaths.data 'ProgrammingExerciseForm',
) { 'ProgrammingExerciseSubmissions',
sidebarLinks.value = sidebarLinks.value.filter( 'ProgrammingExerciseSubmission',
(link) => link.label !== 'Courses' ],
)
activeFor.push('CourseDetail')
activeFor.push('Lesson')
index = 0
canAddProgram = true
} else if (isInstructor.value || isModerator.value) {
canAddProgram = true
}
if (canAddProgram) {
sidebarLinks.value.splice(index, 0, {
label: 'Programs',
icon: 'Route',
to: 'Programs',
activeFor: activeFor,
}) })
} }
} }
const addPrograms = async () => {
let canAddProgram = await checkIfCanAddProgram()
if (!canAddProgram) return
let activeFor = ['Programs', 'ProgramDetail']
let index = 2
sidebarLinks.value.splice(index, 0, {
label: 'Programs',
icon: 'Route',
to: 'Programs',
activeFor: activeFor,
})
}
const checkIfCanAddProgram = async () => {
if (isModerator.value || isInstructor.value) {
return true
}
const programs = await call('lms.lms.utils.get_programs')
return programs.enrolled.length > 0 || programs.published.length > 0
}
const addHome = () => {
sidebarLinks.value.unshift({
label: 'Home',
icon: 'Home',
to: 'Home',
activeFor: ['Home'],
})
}
const openPageModal = (link) => { const openPageModal = (link) => {
showPageModal.value = true showPageModal.value = true
pageToEdit.value = link pageToEdit.value = link
@@ -258,20 +415,6 @@ const deletePage = (link) => {
) )
} }
const getSidebarFromStorage = () => {
return useStorage('sidebar_is_collapsed', false)
}
watch(userResource, () => {
if (userResource.data) {
isModerator.value = userResource.data.is_moderator
isInstructor.value = userResource.data.is_instructor
addPrograms()
addQuizzes()
addAssignments()
}
})
const toggleSidebar = () => { const toggleSidebar = () => {
sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed
localStorage.setItem( localStorage.setItem(
@@ -287,4 +430,233 @@ const toggleWebPages = () => {
JSON.stringify(sidebarStore.isWebpagesCollapsed) JSON.stringify(sidebarStore.isWebpagesCollapsed)
) )
} }
const getFirstCourse = async () => {
let firstCourse = localStorage.getItem('firstCourse')
if (firstCourse) return firstCourse
return await call('lms.lms.onboarding.get_first_course')
}
const getFirstBatch = async () => {
let firstBatch = localStorage.getItem('firstBatch')
if (firstBatch) return firstBatch
return await call('lms.lms.onboarding.get_first_batch')
}
const steps = reactive([
{
name: 'create_first_course',
title: __('Create your first course'),
icon: markRaw(h(BookOpen, iconProps)),
completed: false,
onClick: () => {
minimize.value = true
router.push({
name: 'Courses',
})
},
},
{
name: 'create_first_chapter',
title: __('Add your first chapter'),
icon: markRaw(h(FolderTree, iconProps)),
completed: false,
dependsOn: 'create_first_course',
onClick: async () => {
minimize.value = true
let course = await getFirstCourse()
if (course) {
router.push({ name: 'CourseForm', params: { courseName: course } })
} else {
router.push({ name: 'CourseForm' })
}
},
},
{
name: 'create_first_lesson',
title: __('Add your first lesson'),
icon: markRaw(h(FileText, iconProps)),
completed: false,
dependsOn: 'create_first_chapter',
onClick: async () => {
minimize.value = true
let course = await getFirstCourse()
if (course) {
router.push({
name: 'CourseForm',
params: { courseName: course },
})
} else {
router.push({ name: 'Courses' })
}
},
},
{
name: 'create_first_quiz',
title: __('Create your first quiz'),
icon: markRaw(h(CircleHelp, iconProps)),
completed: false,
dependsOn: 'create_first_course',
onClick: () => {
minimize.value = true
router.push({ name: 'Quizzes' })
},
},
{
name: 'invite_students',
title: __('Invite your team and students'),
icon: markRaw(h(InviteIcon, iconProps)),
completed: false,
onClick: () => {
minimize.value = true
settingsStore.activeTab = 'Members'
settingsStore.isSettingsOpen = true
},
},
{
name: 'create_first_batch',
title: __('Create your first batch'),
icon: markRaw(h(Users, iconProps)),
completed: false,
onClick: () => {
minimize.value = true
router.push({ name: 'Batches' })
},
},
{
name: 'add_batch_student',
title: __('Add students to your batch'),
icon: markRaw(h(UserPlus, iconProps)),
completed: false,
dependsOn: 'create_first_batch',
onClick: async () => {
minimize.value = true
let batch = await getFirstBatch()
if (batch) {
router.push({
name: 'Batch',
params: {
batchName: batch,
},
})
} else {
router.push({ name: 'Batch' })
}
},
},
{
name: 'add_batch_course',
title: __('Add courses to your batch'),
icon: markRaw(h(BookText, iconProps)),
completed: false,
dependsOn: 'create_first_batch',
onClick: async () => {
minimize.value = true
let batch = await getFirstBatch()
if (batch) {
router.push({
name: 'Batch',
params: {
batchName: batch,
},
hash: '#courses',
})
} else {
router.push({ name: 'Batch' })
}
},
},
])
const articles = ref([
{
title: __('Introduction'),
opened: false,
subArticles: [
{ name: 'introduction', title: __('Introduction') },
{ name: 'setting-up', title: __('Setting up') },
],
},
{
title: __('Creating a course'),
opened: false,
subArticles: [
{ name: 'create-a-course', title: __('Create a course') },
{ name: 'add-a-chapter', title: __('Add a chapter') },
{ name: 'add-a-lesson', title: __('Add a lesson') },
],
},
{
title: __('Creating a batch'),
opened: false,
subArticles: [
{ name: 'create-a-batch', title: __('Create a batch') },
{ name: 'create-a-live-class', title: __('Create a live class') },
],
},
{
title: __('Assessments'),
opened: false,
subArticles: [
{ name: 'quizzes', title: __('Quizzes') },
{ name: 'assignments', title: __('Assignments') },
],
},
{
title: __('Certification'),
opened: false,
subArticles: [
{ name: 'issue-a-certificate', title: __('Issue a Certificate') },
{
name: 'custom-certificate-templates',
title: __('Custom Certificate Templates'),
},
],
},
{
title: __('Monetization'),
opened: false,
subArticles: [
{
name: 'setting-up-payment-gateway',
title: __('Setting up payment gateway'),
},
],
},
{
title: __('Settings'),
opened: false,
subArticles: [{ name: 'roles', title: __('Roles') }],
},
])
const setUpOnboarding = () => {
if (userResource.data?.is_system_manager) {
onboardingDetails = useOnboarding('learning')
onboardingDetails.setUp(steps)
isOnboardingStepsCompleted = onboardingDetails.isOnboardingStepsCompleted
showOnboarding.value = true
}
}
watch(userResource, () => {
if (userResource.data) {
isModerator.value = userResource.data.is_moderator
isInstructor.value = userResource.data.is_instructor
addHome()
addPrograms()
addProgrammingExercises()
addQuizzes()
addAssignments()
setUpOnboarding()
}
})
const redirectToWebsite = () => {
window.open('https://frappe.io/learning', '_blank')
}
onUnmounted(() => {
socket.off('publish_lms_notifications')
})
</script> </script>

View File

@@ -3,7 +3,7 @@
<template #target="{ togglePopover }"> <template #target="{ togglePopover }">
<button <button
:class="[ :class="[
'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-ink-gray-8 hover:bg-surface-gray-2', 'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-ink-gray-7 hover:bg-surface-gray-2',
]" ]"
@click.prevent="togglePopover()" @click.prevent="togglePopover()"
> >

View File

@@ -2,17 +2,24 @@
<Dialog <Dialog
v-model="show" v-model="show"
:options="{ :options="{
title:
type == 'quiz'
? __('Add a quiz to your lesson')
: __('Add an assignment to your lesson'),
size: 'xl', size: 'xl',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick: () => {
addAssessment()
},
},
],
}" }"
> >
<template #body> <template #body-content>
<div class="p-5 space-y-4"> <div class="">
<div v-if="type == 'quiz'" class="text-lg font-semibold">
{{ __('Add a quiz to your lesson') }}
</div>
<div v-else class="text-lg font-semibold">
{{ __('Add an assignment to your lesson') }}
</div>
<div> <div>
<Link <Link
v-if="type == 'quiz'" v-if="type == 'quiz'"
@@ -29,17 +36,12 @@
:onCreate="(value, close) => redirectToForm()" :onCreate="(value, close) => redirectToForm()"
/> />
</div> </div>
<div class="flex justify-end space-x-2">
<Button variant="solid" @click="addAssessment()">
{{ __('Save') }}
</Button>
</div>
</div> </div>
</template> </template>
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, Button } from 'frappe-ui' import { Dialog } from 'frappe-ui'
import { onMounted, ref, nextTick } from 'vue' import { onMounted, ref, nextTick } from 'vue'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'

View File

@@ -4,7 +4,7 @@
<div class="text-lg font-semibold text-ink-gray-9"> <div class="text-lg font-semibold text-ink-gray-9">
{{ __('Assessments') }} {{ __('Assessments') }}
</div> </div>
<Button v-if="canSeeAddButton()" @click="showModal = true"> <Button v-if="canAddAssessments()" @click="showModal = true">
<template #prefix> <template #prefix>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
</template> </template>
@@ -40,7 +40,7 @@
<template #default="{ column, item }"> <template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align"> <ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key == 'assessment_type'"> <div v-if="column.key == 'assessment_type'">
{{ row[column.key] == 'LMS Quiz' ? 'Quiz' : 'Assignment' }} {{ getAssessmentTypeLabel(row[column.key]) }}
</div> </div>
<div v-else-if="column.key == 'title'"> <div v-else-if="column.key == 'title'">
{{ row[column.key] }} {{ row[column.key] }}
@@ -100,6 +100,7 @@ import { Plus, Trash2 } from 'lucide-vue-next'
const user = inject('$user') const user = inject('$user')
const showModal = ref(false) const showModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
batch: { batch: {
@@ -171,6 +172,24 @@ const getRowRoute = (row) => {
}, },
} }
} }
} else if (row.assessment_type == 'LMS Programming Exercise') {
if (row.submission) {
return {
name: 'ProgrammingExerciseSubmission',
params: {
exerciseID: row.assessment_name,
submissionID: row.submission.name,
},
}
} else {
return {
name: 'ProgrammingExerciseSubmission',
params: {
exerciseID: row.assessment_name,
submissionID: 'new',
},
}
}
} else { } else {
return { return {
name: 'QuizPage', name: 'QuizPage',
@@ -181,7 +200,8 @@ const getRowRoute = (row) => {
} }
} }
const canSeeAddButton = () => { const canAddAssessments = () => {
if (readOnlyMode) return false
return user.data?.is_moderator || user.data?.is_evaluator return user.data?.is_moderator || user.data?.is_evaluator
} }
@@ -211,7 +231,7 @@ const getAssessmentColumns = () => {
} }
const getStatusTheme = (status) => { const getStatusTheme = (status) => {
if (status === 'Pass') { if (status === 'Pass' || status === 'Passed') {
return 'green' return 'green'
} else if (status === 'Not Graded') { } else if (status === 'Not Graded') {
return 'orange' return 'orange'
@@ -219,4 +239,14 @@ const getStatusTheme = (status) => {
return 'red' return 'red'
} }
} }
const getAssessmentTypeLabel = (type) => {
if (type == 'LMS Assignment') {
return __('Assignment')
} else if (type == 'LMS Quiz') {
return __('Quiz')
} else if (type == 'LMS Programming Exercise') {
return __('Programming Exercise')
}
}
</script> </script>

View File

@@ -1,10 +1,13 @@
<template> <template>
<div <div
v-if="assignment.data" v-if="assignment.data"
class="grid grid-cols-[65%,35%] h-full" class="grid grid-cols-2 h-full"
:class="{ 'border rounded-lg': !showTitle }" :class="{ 'border rounded-lg overflow-auto': !showTitle }"
> >
<div class="border-r p-5 overflow-y-auto h-[calc(100vh-3.2rem)]"> <div
class="border-r p-5 overflow-y-auto h-[calc(100vh-3.2rem)]"
:class="{ 'h-full': !showTitle }"
>
<div v-if="showTitle" class="text-lg font-semibold mb-5 text-ink-gray-9"> <div v-if="showTitle" class="text-lg font-semibold mb-5 text-ink-gray-9">
<div v-if="submissionName === 'new'"> <div v-if="submissionName === 'new'">
{{ __('Submission by') }} {{ user.data?.full_name }} {{ __('Submission by') }} {{ user.data?.full_name }}
@@ -50,7 +53,7 @@
!['Pass', 'Fail'].includes(submissionResource.doc?.status) && !['Pass', 'Fail'].includes(submissionResource.doc?.status) &&
submissionResource.doc?.owner == user.data?.name submissionResource.doc?.owner == user.data?.name
" "
class="bg-surface-blue-2 p-3 rounded-md leading-5 text-sm mb-4" class="bg-surface-blue-2 text-ink-blue-2 p-3 rounded-md leading-5 text-sm mb-4"
> >
{{ __("You've successfully submitted the assignment.") }} {{ __("You've successfully submitted the assignment.") }}
{{ {{
@@ -116,7 +119,7 @@
/> />
</div> </div>
<div v-else> <div v-else>
<div class="text-sm mb-4"> <div class="text-sm mb-2 text-ink-gray-7">
{{ __('Write your answer here') }} {{ __('Write your answer here') }}
</div> </div>
<TextEditor <TextEditor
@@ -138,9 +141,10 @@
<div class="text-sm text-ink-gray-5 font-medium mb-2"> <div class="text-sm text-ink-gray-5 font-medium mb-2">
{{ __('Comments by Evaluator') }}: {{ __('Comments by Evaluator') }}:
</div> </div>
<div class="leading-5"> <div
{{ submissionResource.doc.comments }} class="leading-5 text-ink-gray-9"
</div> v-html="submissionResource.doc.comments"
></div>
</div> </div>
<!-- Grading --> <!-- Grading -->
@@ -187,10 +191,11 @@ import {
FileUploader, FileUploader,
FormControl, FormControl,
TextEditor, TextEditor,
toast,
} from 'frappe-ui' } from 'frappe-ui'
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue' import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { FileText, X } from 'lucide-vue-next' import { FileText, X } from 'lucide-vue-next'
import { showToast, getFileSize } from '@/utils' import { getFileSize } from '@/utils'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const submissionFile = ref(null) const submissionFile = ref(null)
@@ -198,7 +203,6 @@ const answer = ref(null)
const comments = ref(null) const comments = ref(null)
const router = useRouter() const router = useRouter()
const user = inject('$user') const user = inject('$user')
const showTitle = router.currentRoute.value.name == 'AssignmentSubmission'
const isDirty = ref(false) const isDirty = ref(false)
const props = defineProps({ const props = defineProps({
@@ -210,6 +214,10 @@ const props = defineProps({
type: String, type: String,
default: 'new', default: 'new',
}, },
showTitle: {
type: Boolean,
default: true,
},
}) })
onMounted(() => { onMounted(() => {
@@ -277,7 +285,7 @@ const submissionResource = createDocumentResource({
doctype: 'LMS Assignment Submission', doctype: 'LMS Assignment Submission',
name: props.submissionName, name: props.submissionName,
onError(err) { onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x') toast.error(err.messages?.[0] || err)
}, },
auto: false, auto: false,
cache: [user.data?.name, props.assignmentID], cache: [user.data?.name, props.assignmentID],
@@ -331,7 +339,7 @@ const submitAssignment = () => {
}, },
{ {
onSuccess(data) { onSuccess(data) {
showToast(__('Success'), __('Changes saved successfully'), 'check') toast.success(__('Changes saved successfully'))
}, },
} }
) )
@@ -345,7 +353,7 @@ const addNewSubmission = () => {
{}, {},
{ {
onSuccess(data) { onSuccess(data) {
showToast('Success', 'Assignment submitted successfully.', 'check') toast.success(__('Assignment submitted successfully'))
if (router.currentRoute.value.name == 'AssignmentSubmission') { if (router.currentRoute.value.name == 'AssignmentSubmission') {
router.push({ router.push({
name: 'AssignmentSubmission', name: 'AssignmentSubmission',
@@ -353,6 +361,7 @@ const addNewSubmission = () => {
assignmentID: props.assignmentID, assignmentID: props.assignmentID,
submissionName: data.name, submissionName: data.name,
}, },
query: { fromLesson: router.currentRoute.value.query.fromLesson },
}) })
} else { } else {
markLessonProgress() markLessonProgress()
@@ -362,7 +371,7 @@ const addNewSubmission = () => {
submissionResource.reload() submissionResource.reload()
}, },
onError(err) { onError(err) {
showToast('Error', err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )

View File

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

View File

@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="flex flex-col border-2 hover:bg-surface-gray-2 rounded-md p-4 h-full" class="flex flex-col border hover:border-outline-gray-3 rounded-md p-4 h-full"
style="min-height: 150px" style="min-height: 150px"
> >
<div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9"> <div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9">
@@ -70,9 +70,8 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { Badge } from 'frappe-ui' import { formatTime } from '@/utils'
import { formatTime } from '../utils' import { Clock, Globe } from 'lucide-vue-next'
import { Clock, BookOpen, Globe } from 'lucide-vue-next'
import DateRange from '@/components/Common/DateRange.vue' import DateRange from '@/components/Common/DateRange.vue'
import CourseInstructors from '@/components/CourseInstructors.vue' import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'

View File

@@ -63,6 +63,9 @@
</ListSelectBanner> </ListSelectBanner>
</ListView> </ListView>
</div> </div>
<div v-else class="text-sm italic text-ink-gray-5">
{{ __('No courses added') }}
</div>
<BatchCourseModal <BatchCourseModal
v-model="showCourseModal" v-model="showCourseModal"
:batch="batch" :batch="batch"
@@ -83,9 +86,10 @@ import {
ListRows, ListRows,
ListView, ListView,
ListRowItem, ListRowItem,
toast,
} 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 readOnlyMode = window.read_only_mode
const showCourseModal = ref(false) const showCourseModal = ref(false)
const user = inject('$user') const user = inject('$user')
@@ -102,7 +106,6 @@ const courses = createResource({
params: { params: {
batch: props.batch, batch: props.batch,
}, },
cache: ['batchCourses', props.batchName],
auto: true, auto: true,
}) })
@@ -148,7 +151,7 @@ const removeCourses = (selections, unselectAll) => {
{ {
onSuccess(data) { onSuccess(data) {
courses.reload() courses.reload()
showToast(__('Success'), __('Courses deleted successfully'), 'check') toast.success(__('Courses deleted successfully'))
unselectAll() unselectAll()
}, },
} }
@@ -156,6 +159,9 @@ const removeCourses = (selections, unselectAll) => {
} }
const canSeeAddButton = () => { const canSeeAddButton = () => {
if (readOnlyMode) {
return false
}
return user.data?.is_moderator || user.data?.is_evaluator return user.data?.is_moderator || user.data?.is_evaluator
} }
</script> </script>

View File

@@ -6,13 +6,12 @@
:courses="batch.data.courses" :courses="batch.data.courses"
/> />
<Assessments :batch="batch.data.name" /> <Assessments :batch="batch.data.name" />
<StudentHeatmap /> <!-- <StudentHeatmap /> -->
</div> </div>
</template> </template>
<script setup> <script setup>
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue' import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
import Assessments from '@/components/Assessments.vue' import Assessments from '@/components/Assessments.vue'
import StudentHeatmap from '@/components/StudentHeatmap.vue'
const props = defineProps({ const props = defineProps({
batch: { batch: {

View File

@@ -1,44 +1,49 @@
<template> <template>
<div v-if="user.data?.is_student"> <div v-if="user.data?.is_student">
<div <div>
v-if="feedbackList.data?.length" <div class="leading-5 mb-4">
class="bg-surface-blue-2 text-blue-700 p-2 rounded-md mb-5" <div v-if="readOnly">
> {{ __('Thank you for providing your feedback.') }}
{{ __('Thank you for providing your feedback!') }} <span
</div> @click="showFeedbackForm = !showFeedbackForm"
<div v-else class="flex justify-between items-center mb-5"> class="underline cursor-pointer"
<div class="text-lg font-semibold"> >{{ __('Click here') }}</span
{{ __('Help Us Improve') }} >
{{ __('to view your feedback.') }}
</div>
<div v-else>
{{ __('Help us improve by providing your feedback.') }}
</div>
</div> </div>
<Button @click="submitFeedback()"> <div class="space-y-4" :class="showFeedbackForm ? 'block' : 'hidden'">
{{ __('Submit') }} <div class="space-y-4">
</Button> <Rating
</div> v-for="key in ratingKeys"
<div class="space-y-8"> v-model="feedback[key]"
<div class="flex items-center justify-between"> :label="__(convertToTitleCase(key))"
<Rating :readonly="readOnly"
v-for="key in ratingKeys" />
v-model="feedback[key]" </div>
:label="__(convertToTitleCase(key))" <FormControl
v-model="feedback.feedback"
type="textarea"
:label="__('Feedback')"
:rows="9"
:readonly="readOnly" :readonly="readOnly"
/> />
<Button v-if="!readOnly" @click="submitFeedback">
{{ __('Submit Feedback') }}
</Button>
</div> </div>
<FormControl
v-model="feedback.feedback"
type="textarea"
:label="__('Feedback')"
:rows="7"
:readonly="readOnly"
/>
</div> </div>
</div> </div>
<div v-else-if="feedbackList.data?.length"> <div v-else-if="feedbackList.data?.length">
<div class="text-lg font-semibold mb-5"> <div class="leading-5 text-sm mb-2 mt-5">
{{ __('Average of Feedback Received') }} {{ __('Average Feedback Received') }}
</div> </div>
<div class="flex items-center justify-between mb-10"> <div class="space-y-4">
<Rating <Rating
v-for="key in ratingKeys" v-for="key in ratingKeys"
v-model="average[key]" v-model="average[key]"
@@ -47,82 +52,32 @@
/> />
</div> </div>
<div class="text-lg font-semibold mb-5"> <Button variant="outline" class="mt-5" @click="showAllFeedback = true">
{{ __('All Feedback') }} {{ __('View all feedback') }}
</div> </Button>
<ListView
:columns="feedbackColumns"
:rows="feedbackList.data"
row-key="name"
:options="{
showTooltip: false,
rowHeight: 'h-16',
selectable: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
></ListHeader>
<ListRows>
<ListRow
:row="row"
v-for="row in feedbackList.data"
class="group cursor-pointer feedback-list"
>
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="text-sm"
>
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div v-if="ratingKeys.includes(column.key)">
<Rating v-model="row[column.key]" :readonly="true" />
</div>
<div v-else class="leading-5">
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
</ListView>
</div> </div>
<div v-else class="text-sm italic text-center text-ink-gray-7 mt-5"> <div v-else class="text-ink-gray-7 mt-5 leading-5">
{{ __('No feedback received yet.') }} {{ __('No feedback received yet.') }}
</div> </div>
<FeedbackModal
v-if="feedbackList.data?.length"
v-model="showAllFeedback"
:feedbackList="feedbackList.data"
/>
</template> </template>
<script setup> <script setup>
import { computed, inject, onMounted, reactive, ref, watch } from 'vue' import { inject, onMounted, reactive, ref, watch } from 'vue'
import { convertToTitleCase } from '@/utils' import { convertToTitleCase } from '@/utils'
import { import { Button, createListResource, FormControl, Rating } from 'frappe-ui'
Avatar, import FeedbackModal from '@/components/Modals/FeedbackModal.vue'
Button,
createListResource,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
Rating,
} from 'frappe-ui'
const user = inject('$user') const user = inject('$user')
const ratingKeys = ['content', 'instructors', 'value'] const ratingKeys = ['content', 'instructors', 'value']
const readOnly = ref(false) const readOnly = ref(false)
const average = reactive({}) const average = reactive({})
const feedback = reactive({}) const feedback = reactive({})
const showFeedbackForm = ref(true)
const showAllFeedback = ref(false)
const props = defineProps({ const props = defineProps({
batch: { batch: {
@@ -168,6 +123,7 @@ watch(
if (feedbackList.data.length) { if (feedbackList.data.length) {
let data = feedbackList.data let data = feedbackList.data
readOnly.value = true readOnly.value = true
showFeedbackForm.value = false
ratingKeys.forEach((key) => { ratingKeys.forEach((key) => {
average[key] = 0 average[key] = 0
@@ -202,40 +158,11 @@ const submitFeedback = () => {
{ {
onSuccess: () => { onSuccess: () => {
feedbackList.reload() feedbackList.reload()
showFeedbackForm.value = false
}, },
} }
) )
} }
const feedbackColumns = computed(() => {
return [
{
label: 'Member',
key: 'member_name',
width: '10rem',
},
{
label: 'Feedback',
key: 'feedback',
width: '15rem',
},
{
label: 'Content',
key: 'content',
width: '9rem',
},
{
label: 'Instructors',
key: 'instructors',
width: '9rem',
},
{
label: 'Value',
key: 'value',
width: '9rem',
},
]
})
</script> </script>
<style> <style>
.feedback-list > button > div { .feedback-list > button > div {

View File

@@ -2,7 +2,12 @@
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72"> <div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72">
<div <div
v-if="batch.data.seat_count && seats_left > 0" v-if="batch.data.seat_count && seats_left > 0"
class="text-xs bg-green-100 text-green-700 float-right px-2 py-0.5 rounded-md" class="text-sm bg-green-100 text-green-700 px-2 py-1 rounded-md"
:class="
batch.data.amount || batch.data.courses.length
? 'float-right'
: 'w-fit mb-4'
"
> >
{{ seats_left }} {{ seats_left }}
<span v-if="seats_left > 1"> <span v-if="seats_left > 1">
@@ -24,7 +29,10 @@
> >
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }} {{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
</div> </div>
<div class="flex items-center mb-3 text-ink-gray-7"> <div
v-if="batch.data.courses.length"
class="flex items-center mb-3 text-ink-gray-7"
>
<BookOpen class="h-4 w-4 stroke-1.5 mr-2" /> <BookOpen class="h-4 w-4 stroke-1.5 mr-2" />
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span> <span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
</div> </div>
@@ -46,81 +54,105 @@
{{ batch.data.timezone }} {{ batch.data.timezone }}
</span> </span>
</div> </div>
<router-link <div v-if="!readOnlyMode">
v-if="isModerator || isStudent" <router-link
:to="{ v-if="canAccessBatch"
name: 'Batch', :to="{
params: { name: 'Batch',
batchName: batch.data.name, params: {
}, batchName: batch.data.name,
}" },
> }"
<Button variant="solid" class="w-full mt-4"> >
<span> <Button variant="solid" class="w-full mt-4">
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }} <template #prefix>
</span> <LogIn v-if="isStudent" class="size-4 stroke-1.5" />
<Settings v-else class="size-4 stroke-1.5" />
</template>
<span>
{{ isStudent ? __('Visit Batch') : __('Manage Batch') }}
</span>
</Button>
</router-link>
<router-link
:to="{
name: 'Billing',
params: {
type: 'batch',
name: batch.data.name,
},
}"
v-else-if="
batch.data.paid_batch &&
batch.data.seats_left > 0 &&
batch.data.accept_enrollments
"
>
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
<template #prefix>
<CreditCard class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Register Now') }}
</span>
</Button>
</router-link>
<Button
variant="solid"
class="w-full mt-2"
v-else-if="
batch.data.allow_self_enrollment &&
batch.data.seats_left &&
batch.data.accept_enrollments
"
@click="enrollInBatch()"
>
<template #prefix>
<GraduationCap class="size-4 stroke-1.5" />
</template>
{{ __('Enroll Now') }}
</Button> </Button>
</router-link> <router-link
<router-link v-if="isModerator"
:to="{ :to="{
name: 'Billing', name: 'BatchForm',
params: { params: {
type: 'batch', batchName: batch.data.name,
name: batch.data.name, },
}, }"
}" >
v-else-if=" <Button class="w-full mt-2">
batch.data.paid_batch && <template #prefix>
batch.data.seats_left > 0 && <Pencil class="size-4 stroke-1.5" />
batch.data.accept_enrollments </template>
" <span>
> {{ __('Edit') }}
<Button v-if="!isStudent" class="w-full mt-4" variant="solid"> </span>
<span> </Button>
{{ __('Register Now') }} </router-link>
</span> </div>
</Button>
</router-link>
<Button
variant="solid"
class="w-full mt-2"
v-else-if="
batch.data.allow_self_enrollment &&
batch.data.seats_left &&
batch.data.accept_enrollments
"
@click="enrollInBatch()"
>
{{ __('Enroll Now') }}
</Button>
<router-link
v-if="isModerator"
:to="{
name: 'BatchForm',
params: {
batchName: batch.data.name,
},
}"
>
<Button class="w-full mt-2">
<span>
{{ __('Edit') }}
</span>
</Button>
</router-link>
</div> </div>
</template> </template>
<script setup> <script setup>
import { inject, computed } from 'vue' import { inject, computed } from 'vue'
import { Badge, Button, createResource } from 'frappe-ui' import { Button, createResource, toast } from 'frappe-ui'
import { BookOpen, Clock, Globe } from 'lucide-vue-next' import {
import { formatNumberIntoCurrency, formatTime, showToast } from '@/utils' BookOpen,
Clock,
CreditCard,
Globe,
GraduationCap,
LogIn,
Pencil,
Settings,
} from 'lucide-vue-next'
import { formatNumberIntoCurrency, formatTime } from '@/utils'
import DateRange from '@/components/Common/DateRange.vue' import DateRange from '@/components/Common/DateRange.vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const router = useRouter() const router = useRouter()
const user = inject('$user') const user = inject('$user')
const dayjs = inject('$dayjs') const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
batch: { batch: {
@@ -146,11 +178,7 @@ const enrollInBatch = () => {
{}, {},
{ {
onSuccess(data) { onSuccess(data) {
showToast( toast.success(__('You have been enrolled in this batch'))
__('Success'),
__('You have been enrolled in this batch'),
'check'
)
router.push({ router.push({
name: 'Batch', name: 'Batch',
params: { params: {
@@ -176,4 +204,12 @@ const isStudent = computed(() => {
const isModerator = computed(() => { const isModerator = computed(() => {
return user.data?.is_moderator return user.data?.is_moderator
}) })
const isEvaluator = computed(() => {
return user.data?.is_evaluator
})
const canAccessBatch = computed(() => {
return isModerator.value || isStudent.value || isEvaluator.value
})
</script> </script>

View File

@@ -1,108 +1,64 @@
<template> <template>
<div class=""> <div v-if="batch.data" class="">
<div class="w-full flex items-center justify-between pb-4"> <div class="w-full flex items-center justify-between pb-4">
<div class="font-medium text-ink-gray-7"> <div class="font-medium text-ink-gray-7">
{{ __('Statistics') }} {{ __('Statistics') }}
</div> </div>
</div> </div>
<div class="grid grid-cols-4 gap-5 mb-8"> <div class="grid grid-cols-2 md:grid-cols-4 gap-5 mb-8">
<div <NumberChart
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7" class="border rounded-md"
> :config="{ title: __('Students'), value: students.data?.length || 0 }"
<div class="p-2 rounded-md bg-surface-gray-2 mr-3"> />
<User class="w-5 h-5 stroke-1.5" />
</div> <NumberChart
<div class="flex items-center space-x-2"> class="border rounded-md"
<span class="font-semibold"> :config="{
{{ students.data?.length }} title: __('Certified'),
</span> value: certificationCount.data || 0,
<span class=""> }"
{{ __('Students') }} />
</span>
</div> <NumberChart
</div> class="border rounded-md"
:config="{
<div title: __('Courses'),
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7" value: batch.data.courses?.length || 0,
> }"
<div class="p-2 rounded-md bg-surface-gray-2 mr-3"> />
<GraduationCap class="w-5 h-5 stroke-1.5" />
</div> <NumberChart
<div class="flex items-center space-x-2"> class="border rounded-md"
<span class="font-semibold"> :config="{ title: __('Assessments'), value: assessmentCount || 0 }"
{{ certificationCount.data }}
</span>
<span class="">
{{ __('Certified') }}
</span>
</div>
</div>
<div
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
>
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
<BookOpen class="w-5 h-5 stroke-1.5" />
</div>
<div class="flex items-center space-x-2">
<span class="font-semibold">
{{ batch.courses?.length }}
</span>
<span>
{{ __('Courses') }}
</span>
</div>
</div>
<div
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
>
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
<ShieldCheck class="w-5 h-5 stroke-1.5" />
</div>
<div class="flex items-center space-x-2">
<span class="font-semibold">
{{ assessmentCount }}
</span>
<span>
{{ __('Assessments') }}
</span>
</div>
</div>
</div>
<div v-if="showProgressChart" class="mb-8">
<div class="text-ink-gray-7 font-medium">
{{ __('Progress') }}
</div>
<ApexChart
:options="chartOptions"
:series="chartData"
type="bar"
:height="chartData[0].data.length * 30 + 100"
/> />
<div
class="flex items-center justify-center text-sm text-ink-gray-7 space-x-4"
>
<div class="flex items-center space-x-2">
<div
class="w-3 h-3 rounded-sm"
:style="{ 'background-color': theme.colors.green[600] }"
></div>
<div>
{{ __('Courses') }}
</div>
</div>
<div class="flex items-center space-x-2">
<div
class="w-3 h-3 rounded-sm"
:style="{ 'background-color': theme.colors.blue[600] }"
></div>
<div>
{{ __('Assessments') }}
</div>
</div>
</div>
</div> </div>
<AxisChart
v-if="showProgressChart"
:config="{
data: chartData,
title: __('Batch Summary'),
subtitle: __('Progress of students in courses and assessments'),
xAxis: {
key: 'task',
title: 'Tasks',
type: 'category',
},
yAxis: {
title: __('Number of Students'),
echartOptions: {
minInterval: 1,
},
},
swapXY: true,
series: [
{
name: 'value',
type: 'bar',
},
],
}"
/>
</div> </div>
<div> <div>
@@ -110,7 +66,7 @@
<div class="text-ink-gray-7 font-medium"> <div class="text-ink-gray-7 font-medium">
{{ __('Students') }} {{ __('Students') }}
</div> </div>
<Button @click="openStudentModal()"> <Button v-if="!readOnlyMode" @click="openStudentModal()">
<template #prefix> <template #prefix>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
</template> </template>
@@ -201,9 +157,10 @@
</div> </div>
<StudentModal <StudentModal
:batch="props.batch.name" :batch="props.batch.data.name"
v-model="showStudentModal" v-model="showStudentModal"
v-model:reloadStudents="students" v-model:reloadStudents="students"
v-model:batchModal="props.batch"
/> />
<BatchStudentProgress <BatchStudentProgress
:student="selectedStudent" :student="selectedStudent"
@@ -213,6 +170,7 @@
<script setup> <script setup>
import { import {
Avatar, Avatar,
AxisChart,
Button, Button,
createResource, createResource,
FeatherIcon, FeatherIcon,
@@ -223,6 +181,8 @@ import {
ListRows, ListRows,
ListView, ListView,
ListRowItem, ListRowItem,
NumberChart,
toast,
} from 'frappe-ui' } from 'frappe-ui'
import { import {
BookOpen, BookOpen,
@@ -234,7 +194,6 @@ import {
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import StudentModal from '@/components/Modals/StudentModal.vue' import StudentModal from '@/components/Modals/StudentModal.vue'
import { showToast } from '@/utils'
import ProgressBar from '@/components/ProgressBar.vue' import ProgressBar from '@/components/ProgressBar.vue'
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue' import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
import ApexChart from 'vue3-apexcharts' import ApexChart from 'vue3-apexcharts'
@@ -244,9 +203,9 @@ const showStudentModal = ref(false)
const showStudentProgressModal = ref(false) const showStudentProgressModal = ref(false)
const selectedStudent = ref(null) const selectedStudent = ref(null)
const chartData = ref(null) const chartData = ref(null)
const chartOptions = ref(null)
const showProgressChart = ref(false) const showProgressChart = ref(false)
const assessmentCount = ref(0) const assessmentCount = ref(0)
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
batch: { batch: {
@@ -257,14 +216,15 @@ const props = defineProps({
const students = createResource({ const students = createResource({
url: 'lms.lms.utils.get_batch_students', url: 'lms.lms.utils.get_batch_students',
cache: ['students', props.batch.name],
params: { params: {
batch: props.batch?.name, batch: props.batch?.data?.name,
}, },
auto: true, auto: true,
onSuccess(data) { onSuccess(data) {
chartData.value = getChartData() chartData.value = getChartData()
showProgressChart.value = data.length && true showProgressChart.value =
data.length &&
(props.batch?.data?.courses?.length || assessmentCount.value)
}, },
}) })
@@ -321,7 +281,8 @@ const removeStudents = (selections, unselectAll) => {
{ {
onSuccess(data) { onSuccess(data) {
students.reload() students.reload()
showToast(__('Success'), __('Students deleted successfully'), 'check') props.batch.reload()
toast.success(__('Students deleted successfully'))
unselectAll() unselectAll()
}, },
} }
@@ -329,96 +290,49 @@ const removeStudents = (selections, unselectAll) => {
} }
const getChartData = () => { const getChartData = () => {
let categories = {} let tasks = []
let data = []
if (!students.data?.length) return [] students.data.forEach((row) => {
tasks = countAssessments(row, tasks)
Object.keys(students.data[0].courses).forEach((course) => { tasks = countCourses(row, tasks)
categories[course] = {
value: 0,
type: 'course',
label: course,
}
}) })
Object.keys(students.data?.[0].assessments).forEach((assessment) => { tasks.forEach((task) => {
categories[assessment] = { data.push({
value: 0, task: task.label,
type: 'assessment', value: task.value,
label: assessment,
}
})
students.data.forEach((student) => {
Object.keys(student.courses).forEach((course) => {
if (student.courses[course] === 100) {
categories[course].value += 1
}
})
Object.keys(student.assessments).forEach((assessment) => {
if (student.assessments[assessment].result === 'Pass') {
categories[assessment].value += 1
}
}) })
}) })
return data
chartOptions.value = getChartOptions(categories)
return [
{
name: __('Completed by Students'),
data: Object.values(categories).map((item) => item.value),
},
]
} }
const getChartOptions = (categories) => { const countAssessments = (row, tasks) => {
const courseColor = theme.colors.green[700] Object.keys(row.assessments).forEach((assessment) => {
const assessmentColor = theme.colors.blue[700] if (row.assessments[assessment].result === 'Pass') {
const maxY = tasks.filter((task) => task.label === assessment).length
students.data?.length % 5 ? tasks.filter((task) => task.label === assessment)[0].value++
? students.data?.length + (5 - (students.data?.length % 5)) : tasks.push({
: students.data?.length value: 1,
label: assessment,
})
}
})
return tasks
}
return { const countCourses = (row, tasks) => {
chart: { Object.keys(row.courses).forEach((course) => {
type: 'bar', if (row.courses[course] === 100) {
toolbar: { tasks.filter((task) => task.label === course).length
show: false, ? tasks.filter((task) => task.label === course)[0].value++
}, : tasks.push({
}, value: 1,
plotOptions: { label: course,
bar: { })
distributed: true, }
borderRadius: 3, })
borderRadiusApplication: 'end', return tasks
horizontal: true,
barHeight: '40%',
},
},
colors: Object.values(categories).map((item) =>
item.type === 'course' ? courseColor : assessmentColor
),
xaxis: {
categories: Object.values(categories).map((item) => item.label),
labels: {
style: {
fontSize: '10px',
},
rotate: 0,
formatter: function (value) {
return value.length > 30 ? `${value.substring(0, 30)}...` : value
},
},
},
yaxis: {
max: maxY,
min: 0,
stepSize: 10,
tickAmount: maxY / 5,
/* reversed: true */
},
}
} }
watch(students, () => { watch(students, () => {
@@ -432,14 +346,9 @@ const certificationCount = createResource({
params: { params: {
doctype: 'LMS Certificate', doctype: 'LMS Certificate',
filters: { filters: {
batch_name: props.batch.name, batch_name: props.batch?.data?.name,
}, },
}, },
auto: true, auto: true,
}) })
</script> </script>
<style>
.apexcharts-legend {
display: none !important;
}
</style>

View File

@@ -1,130 +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-5 text-ink-gray-9">
{{ 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 space-y-2">
<FormControl
:value="cat.category"
type="text"
v-for="cat in categories.data"
class=""
@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>

View File

@@ -1,6 +1,16 @@
<template> <template>
<Button
v-if="certification.data && certification.data.certificate"
@click="downloadCertificate"
class=""
>
<template #prefix>
<GraduationCap class="size-4 stroke-1.5" />
</template>
{{ __('View Certificate') }}
</Button>
<div <div
v-if=" v-else-if="
certification.data && certification.data &&
certification.data.membership && certification.data.membership &&
certification.data.paid_certificate && certification.data.paid_certificate &&
@@ -25,7 +35,7 @@
</Button> </Button>
</router-link> </router-link>
<router-link <router-link
v-else-if="!certification.data.membership.certficate" v-else-if="!certification.data.membership.certificate"
:to="{ :to="{
name: 'CourseCertification', name: 'CourseCertification',
params: { params: {
@@ -61,7 +71,15 @@ const certification = createResource({
params: { params: {
course: props.courseName, course: props.courseName,
}, },
auto: true, auto: user.data ? true : false,
cache: ['certificationData', user.data?.name], cache: ['certificationData', user.data?.name],
}) })
const downloadCertificate = () => {
window.open(
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
certification.data.certificate.name
}&format=${encodeURIComponent(certification.data.certificate.template)}`
)
}
</script> </script>

View File

@@ -1,126 +1,140 @@
<template> <template>
<Combobox v-model="selectedValue" nullable v-slot="{ open: isComboboxOpen }"> <div>
<Popover class="w-full" v-model:show="showOptions"> <div v-if="label" class="text-xs text-ink-gray-5 mb-1">
<template #target="{ open: openPopover, togglePopover }"> {{ __(label) }}
<slot name="target" v-bind="{ open: openPopover, togglePopover }"> <span class="text-ink-red-3" v-if="attrs.required">*</span>
<div class="w-full"> </div>
<button <Combobox
class="flex w-full items-center justify-between focus:outline-none" v-model="selectedValue"
:class="inputClasses" nullable
@click="() => togglePopover()" v-slot="{ open: isComboboxOpen }"
> >
<div class="flex items-center"> <Popover class="w-full" v-model:show="showOptions">
<slot name="prefix" /> <template #target="{ open: openPopover, togglePopover }">
<span <slot name="target" v-bind="{ open: openPopover, togglePopover }">
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5" <div class="w-full">
v-if="selectedValue"
>
{{ displayValue(selectedValue) }}
</span>
<span class="text-base leading-5 text-ink-gray-4" v-else>
{{ placeholder || '' }}
</span>
</div>
<ChevronDown class="h-4 w-4 stroke-1.5" />
</button>
</div>
</slot>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-white py-1 text-base shadow-2xl"
>
<div class="relative px-1.5 pt-0.5">
<ComboboxInput
ref="search"
class="form-input w-full"
type="text"
@change="
(e) => {
query = e.target.value
}
"
:value="query"
autocomplete="off"
placeholder="Search"
/>
<button <button
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center" class="flex w-full items-center justify-between focus:outline-none"
@click="selectedValue = null" :class="inputClasses"
@click="() => togglePopover()"
:disabled="attrs.readonly"
> >
<X class="h-4 w-4 stroke-1.5" /> <div class="flex items-center">
<slot name="prefix" />
<span
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
v-if="selectedValue"
>
{{ displayValue(selectedValue) }}
</span>
<span class="text-base leading-5 text-ink-gray-4" v-else>
{{ placeholder || '' }}
</span>
</div>
<ChevronDown class="h-4 w-4 stroke-1.5" />
</button> </button>
</div> </div>
<ComboboxOptions </slot>
class="my-1 max-h-[12rem] overflow-y-auto px-1.5" </template>
static <template #body="{ isOpen }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
> >
<div <div class="relative px-1.5 pt-0.5">
class="mt-1.5" <ComboboxInput
v-for="group in groups" ref="search"
:key="group.key" class="form-input w-full"
v-show="group.items.length > 0" type="text"
@change="
(e) => {
query = e.target.value
}
"
:value="query"
autocomplete="off"
placeholder="Search"
/>
<button
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
@click="selectedValue = null"
>
<X class="h-4 w-4 stroke-1.5 text-ink-gray-7" />
</button>
</div>
<ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
static
> >
<div <div
v-if="group.group && !group.hideLabel" class="mt-1.5"
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4" v-for="group in groups"
:key="group.key"
v-show="group.items.length > 0"
> >
{{ group.group }} <div
</div> v-if="group.group && !group.hideLabel"
<ComboboxOption class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
as="template"
v-for="option in group.items"
:key="option.value"
:value="option"
v-slot="{ active, selected }"
>
<li
:class="[
'flex items-center rounded px-2.5 py-2 text-base',
{ 'bg-surface-gray-2': active },
]"
> >
<slot {{ group.group }}
name="item-prefix" </div>
v-bind="{ active, selected, option }" <ComboboxOption
/> as="template"
<slot v-for="option in group.items"
name="item-label" :key="option.value"
v-bind="{ active, selected, option }" :value="option"
v-slot="{ active, selected }"
>
<li
:class="[
'flex items-center rounded px-2.5 py-2 text-base',
{ 'bg-surface-gray-2': active },
]"
> >
<div class="flex flex-col space-y-1"> <slot
<div> name="item-prefix"
{{ option.label }} v-bind="{ active, selected, option }"
/>
<slot
name="item-label"
v-bind="{ active, selected, option }"
>
<div class="flex flex-col space-y-1 text-ink-gray-8">
<div>
{{ option.label }}
</div>
<div
v-if="
option.description &&
option.description != option.label
"
class="text-xs text-ink-gray-7"
v-html="option.description"
></div>
</div> </div>
<div </slot>
v-if="option.description" </li>
class="text-xs text-ink-gray-7" </ComboboxOption>
v-html="option.description" </div>
></div> <li
</div> v-if="groups.length == 0"
</slot> class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
</li> >
</ComboboxOption> No results found
</li>
</ComboboxOptions>
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
<slot
name="footer"
v-bind="{ value: search?.el._value, close }"
></slot>
</div> </div>
<li
v-if="groups.length == 0"
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
>
No results found
</li>
</ComboboxOptions>
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
<slot
name="footer"
v-bind="{ value: search?.el._value, close }"
></slot>
</div> </div>
</div> </div>
</div> </template>
</template> </Popover>
</Popover> </Combobox>
</Combobox> </div>
</template> </template>
<script setup> <script setup>
@@ -130,7 +144,7 @@ import {
ComboboxOptions, ComboboxOptions,
ComboboxOption, ComboboxOption,
} from '@headlessui/vue' } from '@headlessui/vue'
import { Popover, Button } from 'frappe-ui' import { Popover } from 'frappe-ui'
import { ChevronDown, X } from 'lucide-vue-next' import { ChevronDown, X } from 'lucide-vue-next'
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue' import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
@@ -147,6 +161,10 @@ const props = defineProps({
type: String, type: String,
default: 'md', default: 'md',
}, },
label: {
type: String,
default: '',
},
variant: { variant: {
type: String, type: String,
default: 'subtle', default: 'subtle',

View File

@@ -0,0 +1,149 @@
<template>
<div>
<div class="text-xs text-ink-gray-5 mb-2">
{{ label }}
</div>
<div class="overflow-x-auto border rounded-md">
<div
class="grid items-center space-x-4 p-2 border-b"
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
>
<div
v-for="(column, index) in columns"
:key="index"
class="text-sm text-ink-gray-5"
>
{{ column }}
</div>
<div></div>
</div>
<div
v-for="(row, rowIndex) in rows"
:key="rowIndex"
class="grid items-center space-x-4 p-2"
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
>
<template v-for="key in Object.keys(row)" :key="key">
<input
v-if="showKey(key)"
v-model="row[key]"
class="py-1.5 px-2 border-none focus:ring-0 focus:border focus:border-gray-300 focus:bg-surface-gray-2 rounded-sm text-sm focus:outline-none"
/>
</template>
<div class="relative" ref="menuRef">
<Button
variant="ghost"
@click="(event: MouseEvent) => toggleMenu(rowIndex, event)"
>
<template #icon>
<Ellipsis
class="size-4 text-ink-gray-7 stroke-1.5 cursor-pointer"
/>
</template>
</Button>
<div
v-if="menuOpenIndex === rowIndex"
class="absolute right-[30px] top-5 mt-1 w-32 bg-surface-white border border-outline-gray-1 rounded-md shadow-sm"
>
<button
@click="deleteRow(rowIndex)"
class="flex items-center space-x-2 w-full text-left px-3 py-2 text-sm text-ink-red-3"
>
<Trash2 class="size-4 stroke-1.5" />
<span>
{{ __('Delete') }}
</span>
</button>
</div>
</div>
</div>
</div>
<div class="mt-2">
<Button @click="addRow">
<template #prefix>
<Plus class="size-4 text-ink-gray-7" />
</template>
{{ __('Add Row') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Button } from 'frappe-ui'
import { Ellipsis, Plus, Trash2 } from 'lucide-vue-next'
import { onClickOutside } from '@vueuse/core'
const rows = defineModel<Cell[][]>()
const menuRef = ref(null)
const menuOpenIndex = ref<number | null>(null)
const menuTopPosition = ref<string>('')
const emit = defineEmits<{
(e: 'update:modelValue', value: Cell[][]): void
}>()
type Cell = {
value: string
editable?: boolean
}
const props = withDefaults(
defineProps<{
modelValue?: Cell[][]
columns?: string[]
label?: string
}>(),
{
columns: [],
}
)
const columns = ref(props.columns)
watch(rows, () => {
if (rows.value?.length < 1) {
addRow()
}
})
const addRow = () => {
if (!rows.value) {
rows.value = []
}
let newRow: { [key: string]: string } = {}
columns.value.forEach((column: any) => {
newRow[column.toLowerCase().split(' ').join('_')] = ''
})
rows.value.push(newRow)
emit('update:modelValue', rows.value)
}
const deleteRow = (index: number) => {
rows.value.splice(index, 1)
emit('update:modelValue', rows.value)
}
const getGridTemplateColumns = () => {
return [...Array(columns.value.length).fill('1fr'), '0.25fr'].join(' ')
}
const toggleMenu = (index: number, event: MouseEvent) => {
menuOpenIndex.value = menuOpenIndex.value === index ? null : index
menuTopPosition.value = `${event.clientY + 10}px`
}
onClickOutside(menuRef, () => {
menuOpenIndex.value = null
})
const showKey = (key: string) => {
let columnsLower = columns.value.map((col) =>
col.toLowerCase().split(' ').join('_')
)
return columnsLower.includes(key)
}
</script>

View File

@@ -0,0 +1,162 @@
<template>
<div class="flex w-full flex-col gap-1.5">
<div v-if="label" class="text-xs text-ink-gray-5">
{{ __(label) }}
</div>
<codemirror
v-model="code"
:extensions="extensions"
:tab-size="2"
:autofocus="autofocus"
:indent-with-tab="true"
:style="{ height: height, maxHeight: maxHeight }"
:disabled="readonly"
@blur="emitEditorValue"
:class="{
'border border-outline-gray-1': showBorder,
}"
/>
<Button
v-if="showSaveButton"
@click="emit('save', code)"
class="mt-3 w-full text-base"
>
{{ __('Save') }}
</Button>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, computed, watch } from 'vue'
import { Button } from 'frappe-ui'
import { Codemirror } from 'vue-codemirror'
import { autocompletion, closeBrackets } from '@codemirror/autocomplete'
import { LanguageSupport } from '@codemirror/language'
import { EditorView } from '@codemirror/view'
import { tomorrow } from 'thememirror'
const props = withDefaults(
defineProps<{
language: 'json' | 'javascript' | 'html' | 'css' | 'python'
modelValue: string | object | Array<string | object> | null
height?: string
maxHeight?: string
autofocus?: boolean
showSaveButton?: boolean
showLineNumbers?: boolean
completions?: Function | null
label?: string
showBorder?: boolean
required?: boolean
readonly?: boolean
}>(),
{
language: 'javascript',
modelValue: null,
height: 'auto',
maxHeight: '250px',
showLineNumbers: true,
completions: null,
}
)
const emit = defineEmits(['update:modelValue', 'save'])
const code = ref<string>('')
watch(
() => props.modelValue,
(newVal) => {
code.value =
typeof newVal === 'string' ? newVal : JSON.stringify(newVal, null, 2)
},
{ immediate: true }
)
watch(code, (val) => {
emit('update:modelValue', val)
})
const errorMessage = ref('')
const emitEditorValue = () => {
try {
errorMessage.value = ''
let value = code.value || ''
if (!props.showSaveButton && !props.readonly) {
emit('update:modelValue', value)
}
} catch (e) {
console.error('Error while parsing JSON for editor', e)
errorMessage.value = `Invalid object/JSON: ${e.message}`
}
}
const languageExtension = ref<LanguageSupport>()
const autocompleteExtension = ref()
async function setLanguageExtension() {
const importMap = {
json: () => import('@codemirror/lang-json'),
javascript: () => import('@codemirror/lang-javascript'),
html: () => import('@codemirror/lang-html'),
css: () => import('@codemirror/lang-css'),
python: () => import('@codemirror/lang-python'),
}
const languageImport = importMap[props.language]
if (!languageImport) return
const module = await languageImport()
languageExtension.value = (module as any)[props.language]()
if (props.completions) {
const languageData = (module as any)[`${props.language}Language`]
autocompleteExtension.value = languageData.data.of({
autocomplete: props.completions,
})
}
}
onMounted(async () => {
await setLanguageExtension()
})
watch(
() => props.language,
async () => {
await setLanguageExtension()
},
{ immediate: true }
)
const extensions = computed(() => {
const baseExtensions = [
closeBrackets(),
tomorrow,
EditorView.theme({
'&': {
fontFamily: 'monospace',
fontSize: '12px',
},
'.cm-gutters': {
display: props.showLineNumbers ? 'flex' : 'none',
},
}),
]
if (languageExtension.value) {
baseExtensions.push(languageExtension.value)
}
if (autocompleteExtension.value) {
baseExtensions.push(autocompleteExtension.value)
}
const autocompletionOptions = {
activateOnTyping: true,
maxRenderedOptions: 10,
closeOnBlur: false,
icons: false,
optionClass: () => 'flex h-7 !px-2 items-center rounded !text-gray-600',
}
baseExtensions.push(autocompletion(autocompletionOptions))
return baseExtensions
})
</script>

View File

@@ -5,7 +5,7 @@
height: height, height: height,
}" }"
> >
<span class="text-xs text-ink-gray-7" v-if="label"> <span class="text-xs text-ink-gray-7 mb-1" v-if="label">
{{ label }} {{ label }}
</span> </span>
<div <div
@@ -146,7 +146,6 @@ function resetEditor(value: string, resetHistory = false) {
value = getModelValue() value = getModelValue()
aceEditor?.setValue(value) aceEditor?.setValue(value)
aceEditor?.clearSelection() aceEditor?.clearSelection()
console.log(isDark.value)
aceEditor?.setTheme(isDark.value ? 'ace/theme/twilight' : 'ace/theme/chrome') aceEditor?.setTheme(isDark.value ? 'ace/theme/twilight' : 'ace/theme/chrome')
props.autofocus && aceEditor?.focus() props.autofocus && aceEditor?.focus()
if (resetHistory) { if (resetHistory) {

View File

@@ -0,0 +1,108 @@
<template>
<div>
<div class="text-xs text-ink-gray-5 mb-1">
{{ __(label) }}
</div>
<Popover placement="bottom" class="!block">
<template #target="{ togglePopover, isOpen }">
<div class="space-y-2">
<FormControl
type="text"
autocomplete="off"
class="w-full"
:placeholder="__('Set Color')"
@focus="togglePopover"
:modelValue="modelValue"
@update:modelValue="(val: string) => emit('update:modelValue', val)"
>
<template #prefix>
<div
class="size-4 rounded-full"
:style="
modelValue
? {
backgroundColor:
theme.backgroundColor[modelValue.toLowerCase()][400],
}
: {}
"
>
<Palette
v-if="!modelValue"
class="size-4 stroke-1.5 text-ink-gray-5"
/>
</div>
</template>
<template #suffix>
<Button variant="ghost">
<X
class="size-3 text-ink-gray-5"
@click="emit('update:modelValue', null)"
/>
</Button>
</template>
</FormControl>
</div>
</template>
<template #body="{ close }">
<div class="rounded-lg bg-surface-white p-3 border w-fit mt-2">
<div class="text-xs text-ink-gray-5 mb-1.5">
{{ __('Swatches') }}
</div>
<div class="grid grid-cols-7 gap-2">
<div
v-for="color in colors"
:key="color"
class="size-5 rounded-full cursor-pointer"
:style="{
backgroundColor:
theme.backgroundColor[color.toLowerCase()][400],
}"
@click="
(e) => {
emit('update:modelValue', color)
close()
emit('change', color)
}
"
></div>
</div>
</div>
</template>
</Popover>
<div class="text-sm text-ink-gray-5 mt-2">
{{ description }}
</div>
</div>
</template>
<script setup lang="ts">
import { Button, FormControl, Popover } from 'frappe-ui'
import { computed } from 'vue'
import { Palette, X } from 'lucide-vue-next'
import { theme } from '@/utils/theme'
const emit = defineEmits(['update:modelValue', 'change'])
const props = defineProps<{
modelValue: string
label: string
description?: string
}>()
const colors = computed(() => {
return [
'Red',
'Blue',
'Green',
'Amber',
'Purple',
'Cyan',
'Orange',
'Violet',
'Pink',
'Teal',
'Gray',
'Yellow',
]
})
</script>

View File

@@ -12,6 +12,7 @@
:variant="attrs.variant" :variant="attrs.variant"
:placeholder="attrs.placeholder" :placeholder="attrs.placeholder"
:filterable="false" :filterable="false"
:readonly="attrs.readonly"
> >
<template #target="{ open, togglePopover }"> <template #target="{ open, togglePopover }">
<slot name="target" v-bind="{ open, togglePopover }" /> <slot name="target" v-bind="{ open, togglePopover }" />
@@ -34,7 +35,7 @@
<Button <Button
variant="ghost" variant="ghost"
class="w-full !justify-start" class="w-full !justify-start"
label="Create New" :label="__('Create New')"
@click="attrs.onCreate(value, close)" @click="attrs.onCreate(value, close)"
> >
<template #prefix> <template #prefix>

View File

@@ -4,78 +4,92 @@
{{ label }} {{ label }}
<span class="text-ink-red-3" v-if="required">*</span> <span class="text-ink-red-3" v-if="required">*</span>
</label> </label>
<div class="grid grid-cols-3 gap-1"> <div class="w-full">
<Button <Combobox v-model="selectedValue" nullable>
ref="emails" <Popover class="w-full" v-model:show="showOptions">
v-for="value in values" <template #target="{ togglePopover }">
:key="value" <ComboboxInput
:label="value" ref="search"
theme="gray" class="search-input form-input w-full focus-visible:!ring-0"
variant="subtle" type="text"
class="rounded-md" :value="query"
@keydown.delete.capture.stop="removeLastValue" @change="
> (e) => {
<template #suffix> query = e.target.value
<X @click="removeValue(value)" class="h-4 w-4 stroke-1.5" /> showOptions = true
</template> }
</Button> "
<div class=""> autocomplete="off"
<Combobox v-model="selectedValue" nullable> @focus="() => togglePopover()"
<Popover class="w-full" v-model:show="showOptions"> @keydown.delete.capture.stop="removeLastValue"
<template #target="{ togglePopover }"> />
<ComboboxInput </template>
ref="search" <template #body="{ isOpen, close }">
class="search-input form-input w-full focus-visible:!ring-0" <div v-show="isOpen">
type="text" <div
:value="query" class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
@change=" >
(e) => { <ComboboxOptions
query = e.target.value class="my-1 min-h-[6rem] max-h-[12rem] overflow-y-auto px-1.5"
showOptions = true static
}
"
autocomplete="off"
@focus="() => togglePopover()"
@keydown.delete.capture.stop="removeLastValue"
/>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-white py-1 text-base shadow-2xl"
> >
<ComboboxOptions <ComboboxOption
class="my-1 max-h-[12rem] overflow-y-auto px-1.5" v-for="option in options"
static :key="option.value"
:value="option"
v-slot="{ active }"
> >
<ComboboxOption <li
v-for="option in options" :class="[
:key="option.value" 'flex cursor-pointer items-center rounded px-2 py-1 text-base',
:value="option" { 'bg-surface-gray-2': active },
v-slot="{ active }" ]"
> >
<li <div class="flex flex-col gap-1 p-1">
:class="[ <div class="text-base font-medium text-ink-gray-8">
'flex cursor-pointer items-center rounded px-2 py-1 text-base', {{ option.description }}
{ 'bg-surface-gray-2': active },
]"
>
<div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium">
{{ option.description }}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div> </div>
</li> <div class="text-sm text-ink-gray-5">
</ComboboxOption> {{ option.value }}
</ComboboxOptions> </div>
</div> </div>
</li>
</ComboboxOption>
<div class="h-10"></div>
<div
v-if="attrs.onCreate"
class="absolute bottom-2 left-1 w-[99%] pt-2 bg-white border-t"
>
<Button
variant="ghost"
class="w-full !justify-start"
:label="__('Create New')"
@click="attrs.onCreate(close)"
>
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div>
</ComboboxOptions>
</div> </div>
</template> </div>
</Popover> </template>
</Combobox> </Popover>
</Combobox>
</div>
<div v-if="values.length" class="grid grid-cols-2 gap-2 mt-1">
<div
v-for="value in values"
class="flex items-center justify-between break-all bg-surface-gray-2 text-ink-gray-7 word-wrap p-2 rounded-md mr-2"
>
<span class="break-all">
{{ value }}
</span>
<X
class="size-4 stroke-1.5 cursor-pointer"
@click="removeValue(value)"
/>
</div> </div>
</div> </div>
<!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> --> <!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> -->
@@ -90,9 +104,9 @@ import {
ComboboxOption, ComboboxOption,
} from '@headlessui/vue' } from '@headlessui/vue'
import { createResource, Popover, Button } from 'frappe-ui' import { createResource, Popover, Button } from 'frappe-ui'
import { ref, computed, nextTick } from 'vue' import { ref, computed, nextTick, useAttrs } from 'vue'
import { watchDebounced } from '@vueuse/core' import { watchDebounced } from '@vueuse/core'
import { X } from 'lucide-vue-next' import { X, Plus } from 'lucide-vue-next'
const props = defineProps({ const props = defineProps({
label: { label: {
@@ -124,7 +138,7 @@ const props = defineProps({
}) })
const values = defineModel() const values = defineModel()
const attrs = useAttrs()
const emails = ref([]) const emails = ref([])
const search = ref(null) const search = ref(null)
const error = ref(null) const error = ref(null)

View File

@@ -0,0 +1,76 @@
<template>
<div class="mb-4">
<div v-if="label" class="text-xs text-ink-gray-5 mb-2">
{{ __(label) }}
<span class="text-ink-red-3">*</span>
</div>
<FileUploader
v-if="!modelValue"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file: File) => saveImage(file)"
>
<template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="flex items-center">
<div class="border rounded-md w-fit py-7 px-20">
<Image class="size-5 stroke-1 text-ink-gray-7" />
</div>
<div class="ml-4">
<Button @click="openFileSelector">
{{ __('Upload') }}
</Button>
<div class="mt-1 text-ink-gray-5 text-sm leading-5">
{{ __(description) }}
</div>
</div>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="flex items-center">
<img :src="modelValue" class="border rounded-md w-44 h-auto" />
<div class="ml-4">
<Button @click="removeImage()">
{{ __('Remove') }}
</Button>
<div
v-if="description"
class="mt-2 text-ink-gray-5 text-sm leading-5"
>
{{ __(description) }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { validateFile } from '@/utils'
import { Button, FileUploader } from 'frappe-ui'
import { Image } from 'lucide-vue-next'
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const props = withDefaults(
defineProps<{
modelValue: string
label?: string
description?: string
}>(),
{
modelValue: '',
label: '',
description: '',
}
)
const saveImage = (file: any) => {
emit('update:modelValue', file.file_url)
}
const removeImage = () => {
emit('update:modelValue', '')
}
</script>

View File

@@ -1,36 +1,57 @@
<template> <template>
<div <div
v-if="course.title" v-if="course.title"
class="flex flex-col h-full rounded-md border-2 overflow-auto" class="flex flex-col h-full rounded-md overflow-auto text-ink-gray-9"
style="min-height: 350px" style="min-height: 350px"
> >
<div <div
class="course-image" class="w-[100%] h-[168px] bg-cover bg-center bg-no-repeat"
:class="{ 'default-image': !course.image }" :style="
:style="{ backgroundImage: 'url(\'' + encodeURI(course.image) + '\')' }" course.image
? { backgroundImage: `url('${encodeURI(course.image)}')` }
: {
backgroundImage: getGradientColor(),
backgroundBlendMode: 'screen',
}
"
> >
<div <!-- <div class="flex items-center flex-wrap relative top-4 px-2 w-fit">
class="flex items-center flex-wrap space-x-1 relative top-4 px-2 w-fit"
>
<Badge v-if="course.featured" variant="subtle" theme="green" size="md">
{{ __('Featured') }}
</Badge>
<div <div
v-for="tag in course.tags" v-if="course.featured"
class="text-xs bg-white text-gray-800 px-2 py-0.5 rounded-md" class="flex items-center space-x-1 text-xs text-ink-amber-3 bg-surface-white border border-outline-amber-1 px-2 py-0.5 rounded-md mr-1 mb-1"
>
<Star class="size-3 stroke-2" />
<span>
{{ __('Featured') }}
</span>
</div>
<div
v-if="course.tags"
v-for="tag in course.tags?.split(', ')"
class="text-xs border bg-surface-white text-ink-gray-9 px-2 py-0.5 rounded-md mb-1 mr-1"
> >
{{ tag }} {{ tag }}
</div> </div>
</div> </div> -->
<div v-if="!course.image" class="image-placeholder"> <div
{{ course.title[0] }} v-if="!course.image"
class="flex items-center justify-center text-white flex-1 font-extrabold my-auto px-5 text-center leading-6 h-full"
:class="
course.title.length > 32
? 'text-lg'
: course.title.length > 20
? 'text-xl'
: 'text-2xl'
"
>
{{ course.title }}
</div> </div>
</div> </div>
<div class="flex flex-col flex-auto p-4"> <div class="flex flex-col flex-auto p-4 border-x-2 border-b-2 rounded-b-md">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<div v-if="course.lessons"> <div v-if="course.lessons">
<Tooltip :text="__('Lessons')"> <Tooltip :text="__('Lessons')">
<span class="flex items-center text-ink-gray-7"> <span class="flex items-center">
<BookOpen class="h-4 w-4 stroke-1.5 mr-1" /> <BookOpen class="h-4 w-4 stroke-1.5 mr-1" />
{{ course.lessons }} {{ course.lessons }}
</span> </span>
@@ -39,8 +60,8 @@
<div v-if="course.enrollments"> <div v-if="course.enrollments">
<Tooltip :text="__('Enrolled Students')"> <Tooltip :text="__('Enrolled Students')">
<span class="flex items-center text-ink-gray-7"> <span class="flex items-center">
<Users class="h-4 w-4 stroke-1. mr-1" /> <Users class="h-4 w-4 stroke-1.5 mr-1" />
{{ course.enrollments }} {{ course.enrollments }}
</span> </span>
</Tooltip> </Tooltip>
@@ -48,29 +69,27 @@
<div v-if="course.rating"> <div v-if="course.rating">
<Tooltip :text="__('Average Rating')"> <Tooltip :text="__('Average Rating')">
<span class="flex items-center text-ink-gray-7"> <span class="flex items-center">
<Star class="h-4 w-4 stroke-1.5 mr-1" /> <Star class="h-4 w-4 stroke-1.5 mr-1" />
{{ course.rating }} {{ course.rating }}
</span> </span>
</Tooltip> </Tooltip>
</div> </div>
<div v-if="course.status != 'Approved'"> <Tooltip v-if="course.featured" :text="__('Featured')">
<Badge <Award class="size-4 stroke-2 text-ink-amber-3" />
variant="subtle" </Tooltip>
:theme="course.status === 'Under Review' ? 'orange' : 'blue'"
size="sm"
>
{{ course.status }}
</Badge>
</div>
</div> </div>
<div class="text-xl font-semibold leading-6 text-ink-gray-9"> <div
v-if="course.image"
class="font-semibold leading-6"
:class="course.title.length > 32 ? 'text-lg' : 'text-xl'"
>
{{ course.title }} {{ course.title }}
</div> </div>
<div class="short-introduction text-ink-gray-7 text-sm"> <div class="short-introduction text-sm">
{{ course.short_introduction }} {{ course.short_introduction }}
</div> </div>
@@ -79,11 +98,8 @@
:progress="course.membership.progress" :progress="course.membership.progress"
/> />
<div <div v-if="user && course.membership" class="text-sm mt-2 mb-4">
v-if="user && course.membership" {{ Math.ceil(course.membership.progress) }}% {{ __('completed') }}
class="text-sm text-ink-gray-7 mt-2 mb-4"
>
{{ Math.ceil(course.membership.progress) }}% completed
</div> </div>
<div class="flex items-center justify-between mt-auto"> <div class="flex items-center justify-between mt-auto">
@@ -103,21 +119,23 @@
<div v-if="course.paid_course" class="font-semibold"> <div v-if="course.paid_course" class="font-semibold">
{{ course.price }} {{ course.price }}
</div> </div>
<div
<Tooltip
v-if="course.paid_certificate || course.enable_certification" v-if="course.paid_certificate || course.enable_certification"
class="text-xs text-ink-blue-3 bg-surface-blue-1 py-0.5 px-1 rounded-md" :text="__('Get Certified')"
> >
{{ __('Certification') }} <GraduationCap class="size-5 stroke-1.5 text-ink-gray-7" />
</div> </Tooltip>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { BookOpen, Users, Star } from 'lucide-vue-next' import { Award, BookOpen, GraduationCap, Star, Users } 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 { Tooltip } from 'frappe-ui'
import { theme } from '@/utils/theme'
import CourseInstructors from '@/components/CourseInstructors.vue' import CourseInstructors from '@/components/CourseInstructors.vue'
import ProgressBar from '@/components/ProgressBar.vue' import ProgressBar from '@/components/ProgressBar.vue'
@@ -129,16 +147,24 @@ const props = defineProps({
default: null, default: null,
}, },
}) })
const getGradientColor = () => {
let color = props.course.card_gradient?.toLowerCase() || 'blue'
let colorMap = theme.backgroundColor[color]
return `linear-gradient(to top right, black, ${colorMap[400]})`
/* return `bg-gradient-to-br from-${color}-100 via-${color}-200 to-${color}-400` */
/* return `linear-gradient(to bottom right, ${colorMap[100]}, ${colorMap[400]})` */
/* return `radial-gradient(ellipse at 80% 20%, black 20%, ${colorMap[500]} 100%)` */
/* return `radial-gradient(ellipse at 30% 70%, black 50%, ${colorMap[500]} 100%)` */
/* return `radial-gradient(ellipse at 80% 20%, ${colorMap[100]} 0%, ${colorMap[300]} 50%, ${colorMap[500]} 100%)` */
/* return `conic-gradient(from 180deg at 50% 50%, ${colorMap[100]} 0%, ${colorMap[200]} 50%, ${colorMap[400]} 100%)` */
/* return `linear-gradient(135deg, ${colorMap[100]}, ${colorMap[300]}), linear-gradient(120deg, rgba(255,255,255,0.4) 0%, transparent 60%) ` */
/* return `radial-gradient(circle at 20% 30%, ${colorMap[100]} 0%, transparent 40%),
radial-gradient(circle at 80% 40%, ${colorMap[200]} 0%, transparent 50%),
linear-gradient(135deg, ${colorMap[300]} 0%, ${colorMap[400]} 100%);` */
}
</script> </script>
<style> <style>
.course-image {
height: 168px;
width: 100%;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.course-card-pills { .course-card-pills {
background: #ffffff; background: #ffffff;
margin-left: 0; margin-left: 0;
@@ -152,14 +178,6 @@ const props = defineProps({
width: fit-content; width: fit-content;
} }
.default-image {
display: flex;
flex-direction: column;
align-items: center;
background-color: theme('colors.green.100');
color: theme('colors.green.600');
}
.avatar-group { .avatar-group {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -168,14 +186,7 @@ const props = defineProps({
.avatar-group .avatar { .avatar-group .avatar {
transition: margin 0.1s ease-in-out; transition: margin 0.1s ease-in-out;
} }
.image-placeholder {
display: flex;
align-items: center;
flex: 1;
font-size: 5rem;
color: theme('colors.gray.700');
font-weight: 600;
}
.avatar-group.overlap .avatar + .avatar { .avatar-group.overlap .avatar + .avatar {
margin-left: calc(-8px); margin-left: calc(-8px);
} }

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="border-2 rounded-md min-w-80"> <div class="border-2 rounded-md min-w-80 max-w-sm">
<iframe <iframe
v-if="course.data.video_link" v-if="course.data.video_link"
:src="video_link" :src="video_link"
@@ -9,88 +9,120 @@
<div v-if="course.data.paid_course" class="text-2xl font-semibold mb-3"> <div v-if="course.data.paid_course" class="text-2xl font-semibold mb-3">
{{ course.data.price }} {{ course.data.price }}
</div> </div>
<div v-if="course.data.membership" class="space-y-2"> <div v-if="!readOnlyMode">
<div v-if="course.data.membership" class="space-y-2">
<router-link
:to="{
name: 'Lesson',
params: {
courseName: course.name,
chapterNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[0]
: 1,
lessonNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[1]
: 1,
},
}"
>
<Button variant="solid" size="md" class="w-full">
<template #prefix>
<BookText class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Continue Learning') }}
</span>
</Button>
</router-link>
<CertificationLinks :courseName="course.data.name" class="w-full" />
</div>
<router-link <router-link
v-else-if="course.data.paid_course"
:to="{ :to="{
name: 'Lesson', name: 'Billing',
params: { params: {
courseName: course.name, type: 'course',
chapterNumber: course.data.current_lesson name: course.data.name,
? course.data.current_lesson.split('-')[0]
: 1,
lessonNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[1]
: 1,
}, },
}" }"
> >
<Button variant="solid" size="md" class="w-full"> <Button variant="solid" size="md" class="w-full">
<template #prefix>
<CreditCard class="size-4 stroke-1.5" />
</template>
<span> <span>
{{ __('Continue Learning') }} {{ __('Buy this course') }}
</span> </span>
</Button> </Button>
</router-link> </router-link>
<CertificationLinks :courseName="course.data.name" /> <Badge
</div> v-else-if="course.data.disable_self_learning"
<router-link theme="blue"
v-else-if="course.data.paid_course" size="lg"
:to="{ >
name: 'Billing', {{ __('Contact the Administrator to enroll for this course.') }}
params: { </Badge>
type: 'course', <Button
name: course.data.name, v-else-if="!user.data?.is_moderator && !is_instructor()"
}, @click="enrollStudent()"
}" variant="solid"
> class="w-full"
<Button variant="solid" size="md" class="w-full"> size="md"
>
<template #prefix>
<BookText class="size-4 stroke-1.5" />
</template>
<span> <span>
{{ __('Buy this course') }} {{ __('Start Learning') }}
</span> </span>
</Button> </Button>
</router-link> <Button
<div v-if="canGetCertificate"
v-else-if="course.data.disable_self_learning" @click="fetchCertificate()"
class="bg-surface-blue-2 text-blue-900 text-sm rounded-md py-1 px-3" variant="subtle"
> class="w-full mt-2"
{{ __('Contact the Administrator to enroll for this course.') }} size="md"
</div> >
<Button <template #prefix>
v-else <GraduationCap class="size-4 stroke-1.5" />
@click="enrollStudent()" </template>
variant="solid" {{ __('Get Certificate') }}
class="w-full"
size="md"
>
<span>
{{ __('Start Learning') }}
</span>
</Button>
<Button
v-if="canGetCertificate"
@click="fetchCertificate()"
variant="subtle"
class="w-full mt-2"
size="md"
>
{{ __('Get Certificate') }}
</Button>
<router-link
v-if="user?.data?.is_moderator || is_instructor()"
:to="{
name: 'CourseForm',
params: {
courseName: course.data.name,
},
}"
>
<Button variant="subtle" class="w-full mt-2" size="md">
<span>
{{ __('Edit') }}
</span>
</Button> </Button>
</router-link> <Button
v-if="user.data?.is_moderator || is_instructor()"
class="w-full mt-2"
size="md"
@click="showProgressSummary"
>
<template #prefix>
<TrendingUp class="size-4 stroke-1.5" />
{{ __('Progress Summary') }}
</template>
</Button>
<router-link
v-if="user?.data?.is_moderator || is_instructor()"
:to="{
name: 'CourseForm',
params: {
courseName: course.data.name,
},
}"
>
<Button variant="subtle" class="w-full mt-2" size="md">
<template #prefix>
<Pencil class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Edit') }}
</span>
</Button>
</router-link>
</div>
<div class="space-y-4"> <div class="space-y-4">
<div class="mt-8 font-medium text-ink-gray-9"> <div
class="font-medium text-ink-gray-9"
:class="{ 'mt-8': !readOnlyMode }"
>
{{ __('This course has:') }} {{ __('This course has:') }}
</div> </div>
<div class="flex items-center text-ink-gray-9"> <div class="flex items-center text-ink-gray-9">
@@ -110,7 +142,7 @@
v-if="parseInt(course.data.rating) > 0" v-if="parseInt(course.data.rating) > 0"
class="flex items-center text-ink-gray-9" class="flex items-center text-ink-gray-9"
> >
<Star class="h-4 w-4 stroke-1.5 fill-orange-500 text-gray-50" /> <Star class="size-4 stroke-1.5 fill-yellow-500 text-transparent" />
<span class="ml-2"> <span class="ml-2">
{{ course.data.rating }} {{ __('Rating') }} {{ course.data.rating }} {{ __('Rating') }}
</span> </span>
@@ -136,18 +168,35 @@
</div> </div>
</div> </div>
</div> </div>
<CourseProgressSummary
v-model="showProgressModal"
:courseName="course.data.name"
:enrollments="course.data.enrollments"
/>
</template> </template>
<script setup> <script setup>
import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next' import {
import { computed, inject } from 'vue' BookOpen,
import { Button, createResource, Tooltip } from 'frappe-ui' BookText,
import { showToast, formatAmount } from '@/utils/' CreditCard,
GraduationCap,
Pencil,
Star,
TrendingUp,
Users,
} from 'lucide-vue-next'
import { computed, inject, ref } from 'vue'
import { Badge, Button, call, createResource, toast } from 'frappe-ui'
import { formatAmount } from '@/utils/'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import CertificationLinks from '@/components/CertificationLinks.vue' import CertificationLinks from '@/components/CertificationLinks.vue'
import CourseProgressSummary from '@/components/Modals/CourseProgressSummary.vue'
const router = useRouter() const router = useRouter()
const user = inject('$user') const user = inject('$user')
const showProgressModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
course: { course: {
@@ -165,31 +214,19 @@ const video_link = computed(() => {
function enrollStudent() { function enrollStudent() {
if (!user.data) { if (!user.data) {
showToast( toast.success(__('You need to login first to enroll for this course'))
__('Please Login'),
__('You need to login first to enroll for this course'),
'alert-circle'
)
setTimeout(() => { setTimeout(() => {
window.location.href = `/login?redirect-to=${window.location.pathname}` window.location.href = `/login?redirect-to=${window.location.pathname}`
}, 2000) }, 500)
} else { } else {
const enrollStudentResource = createResource({ call('lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership', {
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership', course: props.course.data.name,
}) })
enrollStudentResource
.submit({
course: props.course.data.name,
})
.then(() => { .then(() => {
capture('enrolled_in_course', { capture('enrolled_in_course', {
course: props.course.data.name, course: props.course.data.name,
}) })
showToast( toast.success(__('You have been enrolled in this course'))
__('Success'),
__('You have been enrolled in this course'),
'check'
)
setTimeout(() => { setTimeout(() => {
router.push({ router.push({
name: 'Lesson', name: 'Lesson',
@@ -199,7 +236,11 @@ function enrollStudent() {
lessonNumber: 1, lessonNumber: 1,
}, },
}) })
}, 2000) }, 1000)
})
.catch((err) => {
toast.warning(__(err.messages?.[0] || err))
console.error(err)
}) })
} }
} }
@@ -247,4 +288,8 @@ const fetchCertificate = () => {
member: user.data?.name, member: user.data?.name,
}) })
} }
const showProgressSummary = () => {
showProgressModal.value = true
}
</script> </script>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="text-ink-gray-7"> <div class="">
<span v-if="instructors?.length == 1"> <span v-if="instructors?.length == 1">
<router-link <router-link
:to="{ :to="{
@@ -19,7 +19,7 @@
> >
{{ instructors[0].first_name }} {{ instructors[0].first_name }}
</router-link> </router-link>
and {{ __('and') }}
<router-link <router-link
:to="{ :to="{
name: 'Profile', name: 'Profile',
@@ -38,7 +38,7 @@
> >
{{ instructors[0].first_name }} {{ instructors[0].first_name }}
</router-link> </router-link>
and {{ instructors?.length - 1 }} others {{ __('and') }} {{ instructors?.length - 1 }} {{ __('others') }}
</span> </span>
</div> </div>
</template> </template>

View File

@@ -1,140 +1,161 @@
<template> <template>
<div class="text-base"> <div class="">
<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 space-x-2 mb-4 px-2"
:class="{
'sticky top-0 z-10 bg-surface-white border-b px-3 py-2.5 sm:px-5':
allowEdit,
}"
> >
<div class="font-semibold text-lg leading-5 text-ink-gray-9"> <div
class="font-semibold text-lg leading-5 text-ink-gray-9"
:class="{ 'font-medium text-p-base': allowEdit }"
>
{{ __(title) }} {{ __(title) }}
</div> </div>
<Button size="sm" v-if="allowEdit" @click="openChapterModal()"> <Button size="sm" v-if="allowEdit" @click="openChapterModal()">
{{ __('Add Chapter') }} {{ __('Add Chapter') }}
</Button> </Button>
<!-- <span class="font-medium cursor-pointer" @click="expandAllChapters()">
{{ expandAll ? __("Collapse all chapters") : __("Expand all chapters") }}
</span> -->
</div> </div>
<div <div
:class="{ :class="{
'border-2 rounded-md py-2 px-2': showOutline && outline.data?.length, 'border-2 rounded-md py-2 px-2': showOutline && outline.data?.length,
}" }"
> >
<Disclosure <Draggable
v-slot="{ open }" :list="outline.data"
v-for="(chapter, index) in outline.data" :disabled="!allowEdit"
:key="chapter.name" item-key="name"
:defaultOpen="openChapterDetail(chapter.idx)" group="chapters"
@end="updateChapterOrder"
> >
<DisclosureButton ref="" class="flex items-center w-full p-2 group"> <template #item="{ element: chapter, index }">
<ChevronRight <div class="chapter-item">
:class="{ <Disclosure
'rotate-90 transform duration-200': open, v-slot="{ open }"
'duration-200': !open, :key="chapter.name"
hidden: chapter.is_scorm_package, :defaultOpen="openChapterDetail(chapter.idx)"
open: index == 1, >
}" <DisclosureButton
class="h-4 w-4 text-ink-gray-9 stroke-1" ref=""
/> class="flex items-center w-full p-2 group"
<div
class="text-base text-left text-ink-gray-9 font-medium leading-5 ml-2"
@click="redirectToChapter(chapter)"
>
{{ chapter.title }}
</div>
<div class="flex ml-auto space-x-4">
<Tooltip :text="__('Edit Chapter')" placement="bottom">
<FilePenLine
v-if="allowEdit"
@click.prevent="openChapterModal(chapter)"
class="h-4 w-4 text-ink-gray-9 invisible group-hover:visible"
/>
</Tooltip>
<Tooltip :text="__('Delete Chapter')" placement="bottom">
<Trash2
v-if="allowEdit"
@click.prevent="trashChapter(chapter.name)"
class="h-4 w-4 text-ink-red-3 invisible group-hover:visible"
/>
</Tooltip>
</div>
</DisclosureButton>
<DisclosurePanel v-if="!chapter.is_scorm_package">
<Draggable
v-if="!chapter.is_scorm_package"
:list="chapter.lessons"
: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 text-ink-gray-9"
:class="
isActiveLesson(lesson.number) ? 'bg-surface-selected' : ''
"
> >
<router-link <ChevronRight
:to="{ :class="{
name: allowEdit ? 'LessonForm' : 'Lesson', 'rotate-90 transform duration-200': open,
params: { 'duration-200': !open,
courseName: courseName, hidden: chapter.is_scorm_package,
chapterNumber: lesson.number.split('.')[0], open: index == 1,
lessonNumber: lesson.number.split('.')[1],
},
}" }"
class="h-4 w-4 text-ink-gray-9 stroke-1"
/>
<div
class="text-base text-left text-ink-gray-9 font-medium leading-5 ml-2"
@click="redirectToChapter(chapter)"
> >
<div class="flex items-center text-sm leading-5 group"> {{ chapter.title }}
<MonitorPlay </div>
v-if="lesson.icon === 'icon-youtube'" <div class="flex ml-auto space-x-4">
class="h-4 w-4 stroke-1 mr-2" <Tooltip :text="__('Edit Chapter')" placement="bottom">
<FilePenLine
v-if="allowEdit"
@click.prevent="openChapterModal(chapter)"
class="h-4 w-4 text-ink-gray-9 invisible group-hover:visible"
/> />
<HelpCircle </Tooltip>
v-else-if="lesson.icon === 'icon-quiz'" <Tooltip :text="__('Delete Chapter')" placement="bottom">
class="h-4 w-4 stroke-1 mr-2"
/>
<FileText
v-else-if="lesson.icon === 'icon-list'"
class="h-4 w-4 text-ink-gray-9 stroke-1 mr-2"
/>
{{ lesson.title }}
<Trash2 <Trash2
v-if="allowEdit" v-if="allowEdit"
@click.prevent="trashLesson(lesson.name, chapter.name)" @click.prevent="trashChapter(chapter.name)"
class="h-4 w-4 text-ink-red-3 ml-auto invisible group-hover:visible" class="h-4 w-4 text-ink-red-3 invisible group-hover:visible"
/> />
<Check </Tooltip>
v-if="lesson.is_complete" </div>
class="h-4 w-4 text-green-700 ml-2" </DisclosureButton>
/> <DisclosurePanel v-if="!chapter.is_scorm_package">
</div> <Draggable
</router-link> v-if="!chapter.is_scorm_package"
</div> :list="chapter.lessons"
</template> :disabled="!allowEdit"
</Draggable> item-key="name"
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8"> group="items"
<router-link @end="updateOutline"
v-if="!chapter.is_scorm_package" :data-chapter="chapter.name"
:to="{ >
name: 'LessonForm', <template #item="{ element: lesson }">
params: { <div
courseName: courseName, class="outline-lesson pl-8 py-2 pr-4 text-ink-gray-9"
chapterNumber: chapter.idx, :class="
lessonNumber: chapter.lessons.length + 1, isActiveLesson(lesson.number) ? 'bg-surface-gray-3' : ''
}, "
}" >
> <router-link
<Button> :to="{
{{ __('Add Lesson') }} name: allowEdit ? 'LessonForm' : 'Lesson',
</Button> params: {
</router-link> courseName: courseName,
chapterNumber: lesson.number.split('.')[0],
lessonNumber: lesson.number.split('.')[1],
},
}"
>
<div class="flex items-center text-sm leading-5 group">
<MonitorPlay
v-if="lesson.icon === 'icon-youtube'"
class="h-4 w-4 stroke-1 mr-2"
/>
<HelpCircle
v-else-if="lesson.icon === 'icon-quiz'"
class="h-4 w-4 stroke-1 mr-2"
/>
<FileText
v-else-if="lesson.icon === 'icon-list'"
class="h-4 w-4 text-ink-gray-9 stroke-1 mr-2"
/>
{{ lesson.title }}
<Trash2
v-if="allowEdit"
@click.prevent="
trashLesson(lesson.name, chapter.name)
"
class="h-4 w-4 text-ink-red-3 ml-auto invisible group-hover:visible"
/>
<Check
v-if="lesson.is_complete"
class="h-4 w-4 text-green-700 ml-2"
/>
</div>
</router-link>
</div>
</template>
</Draggable>
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
<router-link
v-if="!chapter.is_scorm_package"
:to="{
name: 'LessonForm',
params: {
courseName: courseName,
chapterNumber: chapter.idx,
lessonNumber: chapter.lessons.length + 1,
},
}"
>
<Button>
{{ __('Add Lesson') }}
</Button>
</router-link>
</div>
</DisclosurePanel>
</Disclosure>
</div> </div>
</DisclosurePanel> </template>
</Disclosure> </Draggable>
</div> </div>
</div> </div>
<ChapterModal <ChapterModal
v-if="user.data"
v-model="showChapterModal" v-model="showChapterModal"
v-model:outline="outline" v-model:outline="outline"
:course="courseName" :course="courseName"
@@ -142,8 +163,8 @@
/> />
</template> </template>
<script setup> <script setup>
import { Button, createResource, Tooltip } from 'frappe-ui' import { Button, createResource, Tooltip, toast } from 'frappe-ui'
import { getCurrentInstance, inject, ref } from 'vue' import { getCurrentInstance, inject, ref, watch } from 'vue'
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue' import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
import { import {
@@ -157,7 +178,6 @@ import {
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import ChapterModal from '@/components/Modals/ChapterModal.vue' import ChapterModal from '@/components/Modals/ChapterModal.vue'
import { showToast } from '@/utils'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -188,18 +208,38 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
lessonProgress: {
type: Number,
default: 0,
},
}) })
const outline = createResource({ const outline = createResource({
url: 'lms.lms.utils.get_course_outline', url: 'lms.lms.utils.get_course_outline',
cache: ['course_outline', props.courseName], cache: ['course_outline', props.courseName],
params: { makeParams() {
course: props.courseName, return {
progress: props.getProgress, course: props.courseName,
progress: props.getProgress,
}
}, },
auto: true, auto: true,
}) })
watch(
() => props.courseName,
() => {
outline.reload()
}
)
watch(
() => props.lessonProgress,
() => {
outline.reload()
}
)
const deleteLesson = createResource({ const deleteLesson = createResource({
url: 'lms.lms.api.delete_lesson', url: 'lms.lms.api.delete_lesson',
makeParams(values) { makeParams(values) {
@@ -210,7 +250,7 @@ const deleteLesson = createResource({
}, },
onSuccess() { onSuccess() {
outline.reload() outline.reload()
showToast('Success', 'Lesson deleted successfully', 'check') toast.success(__('Lesson deleted successfully'))
}, },
}) })
@@ -225,7 +265,21 @@ const updateLessonIndex = createResource({
} }
}, },
onSuccess() { onSuccess() {
showToast('Success', 'Lesson moved successfully', 'check') toast.success(__('Lesson moved successfully'))
},
})
const updateChapterIndex = createResource({
url: 'lms.lms.api.update_chapter_index',
makeParams(values) {
return {
chapter: values.chapter,
course: values.course,
idx: values.idx,
}
},
onSuccess() {
toast.success(__('Chapter moved successfully'))
}, },
}) })
@@ -274,6 +328,14 @@ const updateOutline = (e) => {
}) })
} }
const updateChapterOrder = (e) => {
updateChapterIndex.submit({
chapter: e.item.__draggable_context.element.name,
course: props.courseName,
idx: e.newIndex,
})
}
const deleteChapter = createResource({ const deleteChapter = createResource({
url: 'lms.lms.api.delete_chapter', url: 'lms.lms.api.delete_chapter',
makeParams(values) { makeParams(values) {
@@ -283,7 +345,7 @@ const deleteChapter = createResource({
}, },
onSuccess() { onSuccess() {
outline.reload() outline.reload()
showToast('Success', 'Chapter deleted successfully', 'check') toast.success(__('Chapter deleted successfully'))
}, },
}) })
@@ -312,11 +374,7 @@ const redirectToChapter = (chapter) => {
event.preventDefault() event.preventDefault()
if (props.allowEdit) return if (props.allowEdit) return
if (!user.data) { if (!user.data) {
showToast( toast.success(__('Please enroll for this course to view this lesson'))
__('You are not enrolled'),
__('Please enroll for this course to view this lesson'),
'alert-circle'
)
return return
} }

View File

@@ -35,14 +35,14 @@
<span class="text-ink-gray-7"> <span class="text-ink-gray-7">
{{ review.creation }} {{ review.creation }}
</span> </span>
<div class="flex mt-2"> <div class="flex mt-2 space-x-1">
<Star <Star
v-for="index in 5" v-for="index in 5"
class="h-5 w-5 text-ink-gray-1 rounded-sm mr-2" class="size-4 text-transparent rounded-sm"
:class=" :class="
index <= Math.ceil(review.rating) index <= Math.ceil(review.rating)
? 'fill-orange-500' ? 'fill-yellow-500'
: 'fill-gray-600' : 'fill-gray-300'
" "
/> />
</div> </div>
@@ -64,7 +64,7 @@
<script setup> <script setup>
import { Star } from 'lucide-vue-next' import { Star } from 'lucide-vue-next'
import { createResource, Button } from 'frappe-ui' import { createResource, Button } from 'frappe-ui'
import { computed, ref, inject } from 'vue' import { watch, ref, inject } from 'vue'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import ReviewModal from '@/components/Modals/ReviewModal.vue' import ReviewModal from '@/components/Modals/ReviewModal.vue'
@@ -101,12 +101,21 @@ const hasReviewed = createResource({
const reviews = createResource({ const reviews = createResource({
url: 'lms.lms.utils.get_reviews', url: 'lms.lms.utils.get_reviews',
cache: ['course_reviews', props.courseName], cache: ['course_reviews', props.courseName],
params: { makeParams() {
course: props.courseName, return {
course: props.courseName,
}
}, },
auto: true, auto: true,
}) })
watch(
() => props.courseName,
() => {
reviews.reload()
}
)
const showReviewModal = ref(false) const showReviewModal = ref(false)
function openReviewModal() { function openReviewModal() {

View File

@@ -27,16 +27,18 @@
</span> </span>
</div> </div>
<Dropdown <Dropdown
v-if="user.data.name == reply.owner && !reply.editable" v-if="
user.data.name == reply.owner && !reply.editable && !readOnlyMode
"
:options="[ :options="[
{ {
label: 'Edit', label: __('Edit'),
onClick() { onClick() {
reply.editable = true reply.editable = true
}, },
}, },
{ {
label: 'Delete', label: __('Delete'),
onClick() { onClick() {
deleteReply(reply) deleteReply(reply)
}, },
@@ -71,7 +73,7 @@
</div> </div>
<TextEditor <TextEditor
v-if="renderEditor" v-if="renderEditor && !readOnlyMode"
class="mt-5" class="mt-5"
:content="newReply" :content="newReply"
:mentions="mentionUsers" :mentions="mentionUsers"
@@ -80,7 +82,7 @@
:fixedMenu="true" :fixedMenu="true"
editorClass="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none border border-outline-gray-2 rounded-b-md min-h-[7rem] py-1 px-2" editorClass="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none border border-outline-gray-2 rounded-b-md min-h-[7rem] py-1 px-2"
/> />
<div class="flex justify-between mt-2"> <div v-if="!readOnlyMode" class="flex justify-between mt-2">
<span> </span> <span> </span>
<Button @click="postReply()"> <Button @click="postReply()">
<span> <span>
@@ -91,12 +93,11 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { createResource, TextEditor, Button, Dropdown } from 'frappe-ui' import { createResource, TextEditor, Button, Dropdown, toast } 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 } from 'vue' import { ref, inject, onMounted, onUnmounted } from 'vue'
import { createToast } from '../utils'
const showTopics = defineModel('showTopics') const showTopics = defineModel('showTopics')
const newReply = ref('') const newReply = ref('')
@@ -105,6 +106,7 @@ const user = inject('$user')
const allUsers = inject('$allUsers') const allUsers = inject('$allUsers')
const mentionUsers = ref([]) const mentionUsers = ref([])
const renderEditor = ref(false) const renderEditor = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
topic: { topic: {
@@ -189,14 +191,7 @@ const postReply = () => {
replies.reload() replies.reload()
}, },
onError(err) { onError(err) {
createToast({ toast.error(err.messages?.[0] || err)
title: 'Error',
text: err.messages?.[0] || err,
icon: 'x',
iconClasses: 'bg-surface-red-5 text-ink-white rounded-md p-px',
position: 'top-center',
timeout: 10,
})
}, },
} }
) )
@@ -256,4 +251,10 @@ const deleteReply = (reply) => {
} }
) )
} }
onUnmounted(() => {
socket.off('publish_message')
socket.off('update_message')
socket.off('delete_message')
})
</script> </script>

View File

@@ -1,6 +1,13 @@
<template> <template>
<div> <div>
<Button v-if="!singleThread" class="float-right" @click="openTopicModal()"> <Button
v-if="!singleThread && !readOnlyMode"
class="float-right"
@click="openTopicModal()"
>
<template #prefix>
<Plus class="size-4" />
</template>
{{ __('New {0}').format(singularize(title)) }} {{ __('New {0}').format(singularize(title)) }}
</Button> </Button>
<div class="text-xl font-semibold text-ink-gray-9"> <div class="text-xl font-semibold text-ink-gray-9">
@@ -45,7 +52,7 @@
class="flex flex-col items-center justify-center border-2 border-dashed mt-5 py-8 rounded-md" class="flex flex-col items-center justify-center border-2 border-dashed mt-5 py-8 rounded-md"
> >
<MessageSquareText class="w-7 h-7 text-ink-gray-4 stroke-1.5 mr-2" /> <MessageSquareText class="w-7 h-7 text-ink-gray-4 stroke-1.5 mr-2" />
<div class=""> <div class="mt-2">
<div v-if="emptyStateTitle" class="font-medium mb-2"> <div v-if="emptyStateTitle" class="font-medium mb-2">
{{ __(emptyStateTitle) }} {{ __(emptyStateTitle) }}
</div> </div>
@@ -65,11 +72,11 @@
<script setup> <script setup>
import { createResource, Button } from 'frappe-ui' import { createResource, Button } from 'frappe-ui'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { singularize, timeAgo } from '../utils' import { singularize, timeAgo } from '@/utils'
import { ref, onMounted, inject } from 'vue' import { ref, onMounted, inject, onUnmounted } 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 { MessageSquareText, Plus } from 'lucide-vue-next'
import { getScrollContainer } from '@/utils/scrollContainer' import { getScrollContainer } from '@/utils/scrollContainer'
const showTopics = ref(true) const showTopics = ref(true)
@@ -77,6 +84,7 @@ const currentTopic = ref(null)
const socket = inject('$socket') const socket = inject('$socket')
const user = inject('$user') const user = inject('$user')
const showTopicModal = ref(false) const showTopicModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
title: { title: {
@@ -97,7 +105,7 @@ const props = defineProps({
}, },
emptyStateText: { emptyStateText: {
type: String, type: String,
default: 'Start a discussion', default: 'Start a Discussion',
}, },
singleThread: { singleThread: {
type: Boolean, type: Boolean,
@@ -148,4 +156,8 @@ const showReplies = (topic) => {
const openTopicModal = () => { const openTopicModal = () => {
showTopicModal.value = true showTopicModal.value = true
} }
onUnmounted(() => {
socket.off('new_discussion_topic')
})
</script> </script>

View File

@@ -0,0 +1,24 @@
<template>
<div class="flex flex-col items-center justify-center mt-60">
<GraduationCap class="size-10 mx-auto stroke-1 text-ink-gray-5" />
<div class="text-lg font-semibold text-ink-gray-7 mb-2.5">
{{ __('No {0}').format(type?.toLowerCase()) }}
</div>
<div
class="leading-5 text-base w-full md:w-2/5 text-base text-center text-ink-gray-7"
>
{{
__(
'There are no {0} currently. Keep an eye out, fresh learning experiences are on the way!'
).format(type?.toLowerCase())
}}
</div>
</div>
</template>
<script setup lang="ts">
import { BookOpen, GraduationCap } from 'lucide-vue-next'
const props = defineProps({
type: String,
})
</script>

View File

@@ -1,23 +0,0 @@
<template>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_1584_1676)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M3.17474 0.625C2.34632 0.625 1.67474 1.29657 1.67474 2.125V7.475C1.67474 8.30343 2.34632 8.975 3.17474 8.975H14.8247C15.6532 8.975 16.3247 8.30343 16.3247 7.475V2.125C16.3247 1.29657 15.6532 0.625 14.8247 0.625H3.17474ZM2.67474 2.125C2.67474 1.84886 2.8986 1.625 3.17474 1.625H14.8247C15.1009 1.625 15.3247 1.84886 15.3247 2.125V7.475C15.3247 7.75114 15.1009 7.975 14.8247 7.975H3.17474C2.8986 7.975 2.67474 7.75114 2.67474 7.475V2.125ZM4.27478 10.0749C3.99864 10.0749 3.77478 10.2987 3.77478 10.5749V12.6749C3.77478 12.951 3.99864 13.1749 4.27478 13.1749C4.55092 13.1749 4.77478 12.951 4.77478 12.6749V11.0749H6.92478V12.6749C6.92478 12.951 7.14864 13.1749 7.42478 13.1749C7.70092 13.1749 7.92478 12.951 7.92478 12.6749V10.5749C7.92478 10.2987 7.70092 10.0749 7.42478 10.0749H4.27478ZM10.0749 10.5749C10.0749 10.2987 10.2987 10.0749 10.5749 10.0749H13.7249C14.001 10.0749 14.2249 10.2987 14.2249 10.5749V12.6749C14.2249 12.951 14.001 13.1749 13.7249 13.1749C13.4487 13.1749 13.2249 12.951 13.2249 12.6749V11.0749H11.0749V12.6749C11.0749 12.951 10.851 13.1749 10.5749 13.1749C10.2987 13.1749 10.0749 12.951 10.0749 12.6749V10.5749ZM1.125 14.275C0.848858 14.275 0.625 14.4988 0.625 14.775V16.875C0.625 17.1511 0.848858 17.375 1.125 17.375C1.40114 17.375 1.625 17.1511 1.625 16.875V15.275H3.775V16.875C3.775 17.1511 3.99886 17.375 4.275 17.375C4.55114 17.375 4.775 17.1511 4.775 16.875V14.775C4.775 14.4988 4.55114 14.275 4.275 14.275H1.125ZM13.2252 14.775C13.2252 14.4988 13.4491 14.275 13.7252 14.275H16.8752C17.1514 14.275 17.3752 14.4988 17.3752 14.775V16.875C17.3752 17.1511 17.1514 17.375 16.8752 17.375C16.5991 17.375 16.3752 17.1511 16.3752 16.875V15.275H14.2252V16.875C14.2252 17.1511 14.0014 17.375 13.7252 17.375C13.4491 17.375 13.2252 17.1511 13.2252 16.875V14.775ZM7.42511 14.275C7.14897 14.275 6.92511 14.4988 6.92511 14.775V16.875C6.92511 17.1511 7.14897 17.375 7.42511 17.375C7.70125 17.375 7.92511 17.1511 7.92511 16.875V15.275H10.0751V16.875C10.0751 17.1511 10.299 17.375 10.5751 17.375C10.8513 17.375 11.0751 17.1511 11.0751 16.875V14.775C11.0751 14.4988 10.8513 14.275 10.5751 14.275H7.42511Z"
fill="#525252"
/>
</g>
<defs>
<clipPath id="clip0_1584_1676">
<rect width="18" height="18" fill="white" />
</clipPath>
</defs>
</svg>
</template>

View File

@@ -0,0 +1,23 @@
<template>
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="0.75"
y="0.75"
width="30.5"
height="30.5"
rx="6.25"
stroke="currentColor"
stroke-width="1.5"
/>
<path
d="M24.5011 14.1124C23.3954 12.4873 21.532 11.5477 19.594 11.6747C18.7616 10.1384 17.2211 9.12267 15.4198 9.0084C14.1651 8.93222 12.8979 9.3766 11.9165 10.24C11.2456 10.8367 10.7611 11.5223 10.463 12.2968C10.289 12.7539 9.89151 13.0459 9.46912 13.0459H6.5V15.5852H9.46912C10.9226 15.5852 12.2271 14.6584 12.7737 13.2237C12.9227 12.8301 13.1712 12.4873 13.5439 12.1571C14.0284 11.7255 14.662 11.4969 15.2583 11.535C16.1528 11.5985 16.7863 12.0175 17.1839 12.538C17.6063 13.0205 17.8423 13.7696 17.979 14.5187C18.774 14.2902 19.6437 14.0997 20.476 14.2394C21.1593 14.3536 21.7929 14.7218 22.2525 15.2678C22.327 15.3567 22.4016 15.4456 22.4637 15.5471C23.06 16.4232 23.1718 17.5024 22.7743 18.5689C22.414 19.5592 21.0847 20.4607 19.9791 20.4607H11.3326C10.1524 20.4607 9.18339 19.5592 9.03432 18.4038H6.54969C6.71119 20.9686 8.78585 23 11.3326 23H19.9915C22.1283 23 24.3769 21.451 25.1098 19.4704C25.7931 17.6167 25.5695 15.6614 24.5135 14.0997L24.5011 14.1124Z"
fill="currentColor"
/>
</svg>
</template>

View File

@@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M13.5 0C13.7761 0 14 0.223858 14 0.5V2H15.5C15.7761 2 16 2.22386 16 2.5C16 2.77614 15.7761 3 15.5 3H14V4.5C14 4.77614 13.7761 5 13.5 5C13.2239 5 13 4.77614 13 4.5V3H11.5C11.2239 3 11 2.77614 11 2.5C11 2.22386 11.2239 2 11.5 2H13V0.5C13 0.223858 13.2239 0 13.5 0ZM7.9998 2C4.6862 2 2 4.6862 2 7.9998C2 9.49431 2.54643 10.8612 3.45041 11.9116C4.18218 10.8499 5.63104 9.51974 7.99595 9.50011L8.0001 9.50008C9.89267 9.50009 11.5613 10.456 12.5506 11.91C13.4537 10.8598 13.9996 9.49355 13.9996 7.9998C13.9996 7.72366 14.2235 7.4998 14.4996 7.4998C14.7757 7.4998 14.9996 7.72366 14.9996 7.9998C14.9996 11.8657 11.8657 14.9996 7.9998 14.9996C4.13392 14.9996 1 11.8657 1 7.9998C1 4.13392 4.13392 1 7.9998 1C8.27594 1 8.4998 1.22386 8.4998 1.5C8.4998 1.77614 8.27594 2 7.9998 2ZM11.8227 12.6242C11.0281 11.3487 9.61378 10.5008 8.00216 10.5001C5.94811 10.518 4.73746 11.7366 4.17676 12.6241C5.21484 13.4833 6.54702 13.9996 7.9998 13.9996C9.45251 13.9996 10.7846 13.4833 11.8227 12.6242ZM8 4.5C7.0335 4.5 6.25 5.2835 6.25 6.25C6.25 7.2165 7.0335 8 8 8C8.9665 8 9.75 7.2165 9.75 6.25C9.75 5.2835 8.9665 4.5 8 4.5ZM5.25 6.25C5.25 4.73122 6.48122 3.5 8 3.5C9.51878 3.5 10.75 4.73122 10.75 6.25C10.75 7.76878 9.51878 9 8 9C6.48122 9 5.25 7.76878 5.25 6.25Z"
fill="currentColor"
/>
</svg>
</template>

View File

@@ -1,36 +1,18 @@
<template> <template>
<svg <svg
width="118" width="80"
height="118" height="79"
viewBox="0 0 118 118" viewBox="0 0 80 79"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<path <path
d="M93.9278 0H23.1013C10.3428 0 0 10.3428 0 23.1013V93.9278C0 106.686 10.3428 117.029 23.1013 117.029H93.9278C106.686 117.029 117.029 106.686 117.029 93.9278V23.1013C117.029 10.3428 106.686 0 93.9278 0Z" d="M57.1285 0.580383H22.8514C10.2309 0.580383 0 10.5649 0 22.8815V56.3332C0 68.6497 10.2309 78.6343 22.8514 78.6343H57.1285C69.749 78.6343 79.9799 68.6497 79.9799 56.3332V22.8815C79.9799 10.5649 69.749 0.580383 57.1285 0.580383Z"
fill="url(#paint0_radial_174_336)" fill="#0E7159"
/> />
<path <path
d="M93.9278 0H23.1013C10.3428 0 0 10.3428 0 23.1013V93.9278C0 106.686 10.3428 117.029 23.1013 117.029H93.9278C106.686 117.029 117.029 106.686 117.029 93.9278V23.1013C117.029 10.3428 106.686 0 93.9278 0Z" d="M62.8434 23.6906L60.7869 23.1052C53.6744 21.0702 45.9048 22.4641 39.992 26.8128C35.8502 23.7742 30.7943 22.1854 25.7099 22.2133H17.1406V27.8163H25.7099C29.6232 27.8163 33.508 29.015 36.6787 31.3845L39.992 33.8377L43.3056 31.3845C47.2475 28.4575 52.3032 27.2588 57.1306 28.0393V50.647C51.1035 49.9223 44.9051 51.4834 39.992 55.0795C35.8502 52.0688 30.8515 50.4798 25.7671 50.4798C24.7959 50.4798 23.8247 50.5355 22.8535 50.647V35.0642H17.1406V57.0588H62.8434V23.7185V23.6906Z"
fill="#0B3D3D" fill="white"
fill-opacity="0.8"
/> />
<path
d="M95.1879 33.1294L91.4077 32.0268C80.1721 28.7716 67.9389 30.9242 58.5409 37.7496C52.083 33.0769 43.9975 30.5042 36.1746 30.5042H21.8938V41.0048H36.2796C42.2649 41.0048 48.1978 42.9999 52.923 46.6226L58.5934 50.9279L64.2637 46.6226C70.144 42.1599 77.5469 40.2698 84.7923 41.2673V76.1818C75.5518 75.2367 66.2063 77.7044 58.6459 83.2172C51.0854 77.7044 41.6349 75.2367 32.4994 76.1818V52.8705H21.9988V86.4724H95.3454V33.1294H95.1879Z"
fill="#58FF9B"
/>
<defs>
<radialGradient
id="paint0_radial_174_336"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(117.24 -101.5) rotate(105.042) scale(226.282)"
>
<stop offset="0.445162" stop-color="#1F7676" />
<stop offset="1" stop-color="#0A4B4B" />
</radialGradient>
</defs>
</svg> </svg>
</template> </template>

View File

@@ -0,0 +1,16 @@
<template>
<svg
width="20"
height="20"
viewBox="0 0 68 75"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0 6.78182C0 1.60212 5.5742 -1.65958 10.09 0.879521L64.09 31.2545C68.6916 33.8443 68.6916 40.4693 64.09 43.0595L10.09 73.4345C5.5744 75.9736 0 72.7119 0 67.5322V6.78182ZM26.2695 38.5201C26.2695 37.3248 25.2265 37.9342 26.2695 38.5201C27.332 39.1178 27.332 37.9225 26.2695 38.5201Z"
fill="white"
/>
</svg>
</template>

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