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
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
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ echo "Setting Up System Dependencies..."
sudo apt update
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() {
wget -q https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.focal_amd64.deb
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
node-version: '18'
check-latest: true
- name: setup cache for bench
uses: actions/cache@v2
uses: actions/cache@v4
with:
path: ~/bench-cache
key: ${{ runner.os }}
+30 -2
View File
@@ -7,8 +7,27 @@ on:
branches: [ main ]
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:
name: Semantic Commits
name: Semgrep Rules
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
@@ -20,8 +39,17 @@ jobs:
with:
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
uses: pre-commit/action@v2.0.3
uses: pre-commit/action@v3.0.1
- name: Download Semgrep rules
run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules
+1 -2
View File
@@ -1,8 +1,7 @@
name: Create weekly release
on:
schedule:
# 13:00 UTC -> 7pm IST on every Wednesday
- cron: '30 4 * * 3'
- cron: '30 3 * * 3'
workflow_dispatch:
jobs:
+5 -4
View File
@@ -58,7 +58,7 @@ jobs:
echo "127.0.0.1 lms.test" | sudo tee -a /etc/hosts
- name: Cache pip
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }}
@@ -70,7 +70,7 @@ jobs:
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- uses: actions/cache@v3
- uses: actions/cache@v4
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
@@ -79,7 +79,7 @@ jobs:
${{ runner.os }}-yarn-ui-
- name: Cache cypress binary
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.cache/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.tests.ui_test_helpers.create_test_user
bench --site lms.test set-password frappe@example.com admin
bench --site lms.test execute lms.lms.utils.persona_captured
- name: cypress pre-requisites
run: |
cd ~/frappe-bench/apps/lms
yarn add cypress@^10 --no-lockfile
yarn add cypress@^10 --no-lockfile -W
- name: UI Tests
run: cd ~/frappe-bench/ && bench --site lms.test run-ui-tests lms --headless
+26 -17
View File
@@ -1,10 +1,10 @@
exclude: 'node_modules|.git'
default_stages: [commit]
default_stages: [pre-commit]
fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
rev: v5.0.0
hooks:
- id: trailing-whitespace
files: "lms.*"
@@ -16,17 +16,16 @@ repos:
- id: check-toml
- id: debug-statements
- repo: https://github.com/asottile/pyupgrade
rev: v2.34.0
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.1
hooks:
- id: pyupgrade
args: ['--py310-plus']
- repo: https://github.com/adityahase/black
rev: 9cb0a69f4d0030cdf687eddf314468b39ed54119
hooks:
- id: black
additional_dependencies: ['click==8.0.4']
- id: ruff
name: "Run ruff import sorter"
args: ["--select=I", "--fix"]
- id: ruff
name: "Run ruff linter"
- id: ruff-format
name: "Run ruff formatter"
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.7.1
@@ -44,12 +43,22 @@ repos:
lms/public/js/lib/.*
)$
- repo: https://github.com/PyCQA/flake8
rev: 5.0.4
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v8.44.0
hooks:
- id: flake8
additional_dependencies: ['flake8-bugbear',]
args: ['--config', '.github/helper/flake8.conf']
- id: eslint
types_or: [javascript]
args: ['--quiet']
exclude: |
(?x)^(
lms/public/dist/.*|
cypress/.*|
.*node_modules.*|
.*boilerplate.*|
lms/www/website_script.js|
lms/templates/includes/.*|
lms/public/js/lib/.*
)$
ci:
autoupdate_schedule: weekly
+4
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.
**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
### Docker
+26
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
],
],
},
};
+3 -3
View File
@@ -1,6 +1,6 @@
const { defineConfig } = require("cypress");
import { defineConfig } from "cypress";
module.exports = defineConfig({
export default defineConfig({
projectId: "vandxn",
adminPassword: "admin",
testUser: "frappe@example.com",
@@ -13,6 +13,6 @@ module.exports = defineConfig({
openMode: 0,
},
e2e: {
baseUrl: "http://testui:8000",
baseUrl: "http://pertest:8000",
},
});
+172
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");
});
});
+21 -12
View File
@@ -1,12 +1,15 @@
describe("Course Creation", () => {
it("creates a new course", () => {
cy.login();
cy.wait(1000);
cy.wait(500);
cy.visit("/lms/courses");
// Close onboarding modal
cy.closeOnboardingModal();
// Create a course
cy.get("button").contains("New").click();
cy.wait(1000);
cy.get("button").contains("Create").click();
cy.wait(500);
cy.url().should("include", "/courses/new/edit");
cy.get("label").contains("Title").type("Test Course");
@@ -19,12 +22,16 @@ describe("Course Creation", () => {
);
cy.fixture("profile.png", "base64").then((fileContent) => {
cy.get('input[type="file"]').attachFile({
fileContent,
fileName: "profile.png",
mimeType: "image/png",
encoding: "base64",
});
cy.get("div")
.contains("Course Image")
.siblings("div")
.children('input[type="file"]')
.attachFile({
fileContent,
fileName: "profile.png",
mimeType: "image/png",
encoding: "base64",
});
});
cy.get("label")
@@ -91,15 +98,16 @@ describe("Course Creation", () => {
// View Course
cy.wait(1000);
cy.visit("/lms");
cy.wait(500);
cy.visit("/lms/courses");
cy.closeOnboardingModal();
cy.url().should("include", "/lms/courses");
cy.get(".grid a:first").within(() => {
cy.get("div").contains("Test Course");
cy.get("div").contains(
"Test Course Short Introduction to test the UI"
);
cy.get(".course-image")
cy.get(".bg-cover")
.invoke("css", "background-image")
.should("include", "/files/profile");
});
@@ -132,6 +140,7 @@ describe("Course Creation", () => {
);
// Add Discussion
cy.get("span").contains("Community").click();
cy.button("New Question").click();
cy.wait(500);
cy.get("[id^=headlessui-dialog-panel-").within(() => {
+19
View File
@@ -25,6 +25,7 @@
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
import "cypress-file-upload";
import "cypress-real-events";
Cypress.Commands.add("login", (email, password) => {
if (!email) {
@@ -37,6 +38,9 @@ Cypress.Commands.add("login", (email, password) => {
url: "/api/method/login",
method: "POST",
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);
});
});
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.");
}
});
});
+3 -3
View File
@@ -16,9 +16,9 @@ cd frappe-bench
# Use containers instead of localhost
bench set-mariadb-host mariadb
bench set-redis-cache-host redis:6379
bench set-redis-queue-host redis:6379
bench set-redis-socketio-host redis:6379
bench set-redis-cache-host redis://redis:6379
bench set-redis-queue-host redis://redis:6379
bench set-redis-socketio-host redis://redis:6379
# Remove redis, watch from Procfile
sed -i '/redis/d' ./Procfile
+1
View File
@@ -2,4 +2,5 @@ node_modules
.DS_Store
dist
dist-ssr
dev-dist
*.local
+10
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
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']
}
}
+198 -18
View File
@@ -2,9 +2,205 @@
<html lang="en">
<head>
<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" />
<title>Frappe Learning</title>
<title>{{ title }}</title>
<meta name="title" content="{{ meta.title }}" />
<meta name="image" content="{{ meta.image }}" />
<meta name="description" content="{{ meta.description }}" />
@@ -23,26 +219,10 @@
<p>
{{ meta.description }}
</p>
<p>
The content here is just for seo purposes. The actual content will be loaded in a few seconds.
</p>
<p>
Seo checks if a page has more than 300 words. So, here are some more words to make it more than 300 words.
Page descriptions are the HTML meta tags that provide a brief summary of a web page.
Search engines use meta descriptions to help identify the page's topic - they don't use them to rank the page, but they do use them to determine whether or not to display the page in search results.
Meta descriptions are important because they're often the first thing people see when they're deciding which search result to click on.
They're also important because they can help improve your click-through rate (CTR) from search results.
A good meta description can entice people to click on your page instead of someone else's.
</p>
<a href="{{ meta.link }}">Know More</a>
</div>
</div>
<div id="modals"></div>
<div id="popovers"></div>
<script>
window.csrf_token = '{{ csrf_token }}'
window.setup_complete = '{{ setup_complete }}'
document.getElementById('seo-content').style.display = 'none';
</script>
<script type="module" src="/src/main.js"></script>
+12 -3
View File
@@ -2,6 +2,7 @@
"name": "frappe-ui-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"serve": "vite preview",
@@ -9,6 +10,10 @@
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html"
},
"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/code": "^2.9.0",
"@editorjs/editorjs": "^2.29.0",
@@ -23,18 +28,22 @@
"ace-builds": "^1.36.2",
"apexcharts": "^4.3.0",
"chart.js": "^4.4.1",
"codemirror-editor-vue3": "^2.8.0",
"codemirror": "^6.0.1",
"dayjs": "^1.11.6",
"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",
"markdown-it": "^14.0.0",
"pinia": "^2.0.33",
"plyr": "^3.7.8",
"socket.io-client": "^4.7.2",
"tailwindcss": "^3.3.3",
"tailwindcss": "3.4.15",
"thememirror": "^2.0.1",
"typescript": "^5.7.2",
"vue": "^3.4.23",
"vue-chartjs": "^5.3.0",
"vue-codemirror": "^6.1.1",
"vue-draggable-next": "^2.2.1",
"vue-router": "^4.0.12",
"vue3-apexcharts": "^1.8.0",
+1 -1
View File
@@ -1,4 +1,4 @@
module.exports = {
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
Binary file not shown.
+4
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

+40 -20
View File
@@ -1,38 +1,58 @@
<template>
<Layout>
<router-view />
</Layout>
<Dialogs />
<Toasts />
<FrappeUIProvider>
<Layout>
<div class="text-base">
<router-view />
</div>
</Layout>
<InstallPrompt v-if="isMobile" />
<Dialogs />
</FrappeUIProvider>
</template>
<script setup>
import { Toasts } from 'frappe-ui'
import { FrappeUIProvider } from 'frappe-ui'
import { Dialogs } from '@/utils/dialogs'
import { computed, onMounted, onUnmounted } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
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 MobileLayout from './components/MobileLayout.vue'
import { stopSession } from '@/telemetry'
import { init as initTelemetry } from '@/telemetry'
import { usersStore } from '@/stores/user'
import NoSidebarLayout from './components/NoSidebarLayout.vue'
import InstallPrompt from './components/InstallPrompt.vue'
const screenSize = useScreenSize()
let { userResource } = usersStore()
const { isMobile } = useScreenSize()
const router = useRouter()
const noSidebar = ref(false)
const { userResource } = usersStore()
const Layout = computed(() => {
if (screenSize.width < 640) {
return MobileLayout
router.beforeEach((to, from, next) => {
if (to.query.fromLesson || to.path === '/persona') {
noSidebar.value = true
} else {
return DesktopLayout
noSidebar.value = false
}
next()
})
onMounted(async () => {
if (!userResource.data) return
await initTelemetry()
const Layout = computed(() => {
if (noSidebar.value) {
return NoSidebarLayout
}
if (isMobile.value) {
return MobileLayout
}
return DesktopLayout
})
onUnmounted(() => {
stopSession()
noSidebar.value = false
})
watch(userResource, () => {
if (userResource.data) {
posthogSettings.reload()
}
})
</script>
+446 -74
View File
@@ -39,7 +39,11 @@
{{ __('More') }}
</span>
</div>
<Button v-if="isModerator" variant="ghost" @click="openPageModal()">
<Button
v-if="isModerator && !readOnlyMode"
variant="ghost"
@click="openPageModal()"
>
<template #icon>
<Plus class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
</template>
@@ -62,34 +66,109 @@
</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
v-if="
userResource.data?.user_type == 'System User' &&
userResource.data?.is_fc_site
userResource.data?.is_system_manager && userResource.data?.is_fc_site
"
:isSidebarCollapsed="sidebarStore.isSidebarCollapsed"
/>
<SidebarLink
:link="{
label: sidebarStore.isSidebarCollapsed ? 'Expand' : 'Collapse',
}"
:isCollapsed="sidebarStore.isSidebarCollapsed"
@click="toggleSidebar()"
class="m-2"
<GettingStartedBanner
v-if="showOnboarding && !isOnboardingStepsCompleted"
:isSidebarCollapsed="sidebarStore.isSidebarCollapsed"
appName="learning"
/>
<div
class="flex items-center mt-4"
:class="
sidebarStore.isSidebarCollapsed ? 'flex-col space-y-3' : 'flex-row'
"
>
<template #icon>
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<CollapseSidebar
class="h-4.5 w-4.5 text-ink-gray-7 duration-300 ease-in-out"
:class="{
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed,
}"
<div
class="flex items-center flex-1"
:class="
sidebarStore.isSidebarCollapsed
? 'flex-col space-y-3'
: '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>
</SidebarLink>
<template #body>
<div
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>
<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>
<PageModal
v-model="showPageModal"
@@ -102,18 +181,52 @@
import UserDropdown from '@/components/UserDropdown.vue'
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
import SidebarLink from '@/components/SidebarLink.vue'
import { useStorage } from '@vueuse/core'
import { ref, onMounted, inject, watch } from 'vue'
import { getSidebarLinks } from '../utils'
import {
ref,
onMounted,
inject,
watch,
reactive,
markRaw,
h,
onUnmounted,
} from 'vue'
import { getSidebarLinks } from '@/utils'
import { usersStore } from '@/stores/user'
import { sessionStore } from '@/stores/session'
import { useSidebar } from '@/stores/sidebar'
import { useSettings } from '@/stores/settings'
import { ChevronRight, Plus } from 'lucide-vue-next'
import { Button, createResource, TrialBanner } from 'frappe-ui'
import { Button, call, createResource, Tooltip } from 'frappe-ui'
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()
let sidebarStore = useSidebar()
const socket = inject('$socket')
@@ -124,12 +237,30 @@ const isModerator = ref(false)
const isInstructor = ref(false)
const pageToEdit = ref(null)
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(() => {
addNotifications()
setSidebarLinks()
setUpOnboarding()
socket.on('publish_lms_notifications', (data) => {
unreadNotifications.reload()
})
addNotifications()
})
const setSidebarLinks = () => {
sidebarSettings.reload(
{},
{
@@ -144,7 +275,7 @@ onMounted(() => {
},
}
)
})
}
const unreadNotifications = createResource({
cache: 'Unread Notifications Count',
@@ -184,57 +315,83 @@ const addNotifications = () => {
const addQuizzes = () => {
if (isInstructor.value || isModerator.value) {
sidebarLinks.value.push({
sidebarLinks.value.splice(4, 0, {
label: 'Quizzes',
icon: 'CircleHelp',
to: 'Quizzes',
activeFor: ['Quizzes', 'QuizForm'],
activeFor: [
'Quizzes',
'QuizForm',
'QuizSubmissionList',
'QuizSubmission',
],
})
}
}
const addAssignments = () => {
if (isInstructor.value || isModerator.value) {
sidebarLinks.value.push({
sidebarLinks.value.splice(5, 0, {
label: 'Assignments',
icon: 'Pencil',
to: 'Assignments',
activeFor: ['Assignments', 'AssignmentForm'],
activeFor: [
'Assignments',
'AssignmentForm',
'AssignmentSubmissionList',
'AssignmentSubmission',
],
})
}
}
const addPrograms = () => {
let activeFor = ['Programs', 'ProgramForm']
let index = 1
let canAddProgram = false
if (
!isInstructor.value &&
!isModerator.value &&
settingsStore.learningPaths.data
) {
sidebarLinks.value = sidebarLinks.value.filter(
(link) => link.label !== 'Courses'
)
activeFor.push('CourseDetail')
activeFor.push('Lesson')
index = 0
canAddProgram = true
} else if (isInstructor.value || isModerator.value) {
canAddProgram = true
}
if (canAddProgram) {
sidebarLinks.value.splice(index, 0, {
label: 'Programs',
icon: 'Route',
to: 'Programs',
activeFor: activeFor,
const addProgrammingExercises = () => {
if (isInstructor.value || isModerator.value) {
sidebarLinks.value.splice(3, 0, {
label: 'Programming Exercises',
icon: 'Code',
to: 'ProgrammingExercises',
activeFor: [
'ProgrammingExercises',
'ProgrammingExerciseForm',
'ProgrammingExerciseSubmissions',
'ProgrammingExerciseSubmission',
],
})
}
}
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) => {
showPageModal.value = true
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 = () => {
sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed
localStorage.setItem(
@@ -287,4 +430,233 @@ const toggleWebPages = () => {
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>
+1 -1
View File
@@ -3,7 +3,7 @@
<template #target="{ togglePopover }">
<button
: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()"
>
+16 -14
View File
@@ -2,17 +2,24 @@
<Dialog
v-model="show"
:options="{
title:
type == 'quiz'
? __('Add a quiz to your lesson')
: __('Add an assignment to your lesson'),
size: 'xl',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick: () => {
addAssessment()
},
},
],
}"
>
<template #body>
<div class="p-5 space-y-4">
<div v-if="type == 'quiz'" class="text-lg font-semibold">
{{ __('Add a quiz to your lesson') }}
</div>
<div v-else class="text-lg font-semibold">
{{ __('Add an assignment to your lesson') }}
</div>
<template #body-content>
<div class="">
<div>
<Link
v-if="type == 'quiz'"
@@ -29,17 +36,12 @@
:onCreate="(value, close) => redirectToForm()"
/>
</div>
<div class="flex justify-end space-x-2">
<Button variant="solid" @click="addAssessment()">
{{ __('Save') }}
</Button>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, Button } from 'frappe-ui'
import { Dialog } from 'frappe-ui'
import { onMounted, ref, nextTick } from 'vue'
import Link from '@/components/Controls/Link.vue'
+34 -4
View File
@@ -4,7 +4,7 @@
<div class="text-lg font-semibold text-ink-gray-9">
{{ __('Assessments') }}
</div>
<Button v-if="canSeeAddButton()" @click="showModal = true">
<Button v-if="canAddAssessments()" @click="showModal = true">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
@@ -40,7 +40,7 @@
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key == 'assessment_type'">
{{ row[column.key] == 'LMS Quiz' ? 'Quiz' : 'Assignment' }}
{{ getAssessmentTypeLabel(row[column.key]) }}
</div>
<div v-else-if="column.key == 'title'">
{{ row[column.key] }}
@@ -100,6 +100,7 @@ import { Plus, Trash2 } from 'lucide-vue-next'
const user = inject('$user')
const showModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({
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 {
return {
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
}
@@ -211,7 +231,7 @@ const getAssessmentColumns = () => {
}
const getStatusTheme = (status) => {
if (status === 'Pass') {
if (status === 'Pass' || status === 'Passed') {
return 'green'
} else if (status === 'Not Graded') {
return 'orange'
@@ -219,4 +239,14 @@ const getStatusTheme = (status) => {
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>
+23 -14
View File
@@ -1,10 +1,13 @@
<template>
<div
v-if="assignment.data"
class="grid grid-cols-[65%,35%] h-full"
:class="{ 'border rounded-lg': !showTitle }"
class="grid grid-cols-2 h-full"
: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="submissionName === 'new'">
{{ __('Submission by') }} {{ user.data?.full_name }}
@@ -50,7 +53,7 @@
!['Pass', 'Fail'].includes(submissionResource.doc?.status) &&
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.") }}
{{
@@ -116,7 +119,7 @@
/>
</div>
<div v-else>
<div class="text-sm mb-4">
<div class="text-sm mb-2 text-ink-gray-7">
{{ __('Write your answer here') }}
</div>
<TextEditor
@@ -138,9 +141,10 @@
<div class="text-sm text-ink-gray-5 font-medium mb-2">
{{ __('Comments by Evaluator') }}:
</div>
<div class="leading-5">
{{ submissionResource.doc.comments }}
</div>
<div
class="leading-5 text-ink-gray-9"
v-html="submissionResource.doc.comments"
></div>
</div>
<!-- Grading -->
@@ -187,10 +191,11 @@ import {
FileUploader,
FormControl,
TextEditor,
toast,
} from 'frappe-ui'
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { FileText, X } from 'lucide-vue-next'
import { showToast, getFileSize } from '@/utils'
import { getFileSize } from '@/utils'
import { useRouter } from 'vue-router'
const submissionFile = ref(null)
@@ -198,7 +203,6 @@ const answer = ref(null)
const comments = ref(null)
const router = useRouter()
const user = inject('$user')
const showTitle = router.currentRoute.value.name == 'AssignmentSubmission'
const isDirty = ref(false)
const props = defineProps({
@@ -210,6 +214,10 @@ const props = defineProps({
type: String,
default: 'new',
},
showTitle: {
type: Boolean,
default: true,
},
})
onMounted(() => {
@@ -277,7 +285,7 @@ const submissionResource = createDocumentResource({
doctype: 'LMS Assignment Submission',
name: props.submissionName,
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
toast.error(err.messages?.[0] || err)
},
auto: false,
cache: [user.data?.name, props.assignmentID],
@@ -331,7 +339,7 @@ const submitAssignment = () => {
},
{
onSuccess(data) {
showToast(__('Success'), __('Changes saved successfully'), 'check')
toast.success(__('Changes saved successfully'))
},
}
)
@@ -345,7 +353,7 @@ const addNewSubmission = () => {
{},
{
onSuccess(data) {
showToast('Success', 'Assignment submitted successfully.', 'check')
toast.success(__('Assignment submitted successfully'))
if (router.currentRoute.value.name == 'AssignmentSubmission') {
router.push({
name: 'AssignmentSubmission',
@@ -353,6 +361,7 @@ const addNewSubmission = () => {
assignmentID: props.assignmentID,
submissionName: data.name,
},
query: { fromLesson: router.currentRoute.value.query.fromLesson },
})
} else {
markLessonProgress()
@@ -362,7 +371,7 @@ const addNewSubmission = () => {
submissionResource.reload()
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)
@@ -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>
+3 -4
View File
@@ -1,6 +1,6 @@
<template>
<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"
>
<div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9">
@@ -70,9 +70,8 @@
</div>
</template>
<script setup>
import { Badge } from 'frappe-ui'
import { formatTime } from '../utils'
import { Clock, BookOpen, Globe } from 'lucide-vue-next'
import { formatTime } from '@/utils'
import { Clock, Globe } from 'lucide-vue-next'
import DateRange from '@/components/Common/DateRange.vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue'
+9 -3
View File
@@ -63,6 +63,9 @@
</ListSelectBanner>
</ListView>
</div>
<div v-else class="text-sm italic text-ink-gray-5">
{{ __('No courses added') }}
</div>
<BatchCourseModal
v-model="showCourseModal"
:batch="batch"
@@ -83,9 +86,10 @@ import {
ListRows,
ListView,
ListRowItem,
toast,
} from 'frappe-ui'
import { Plus, Trash2 } from 'lucide-vue-next'
import { showToast } from '@/utils'
const readOnlyMode = window.read_only_mode
const showCourseModal = ref(false)
const user = inject('$user')
@@ -102,7 +106,6 @@ const courses = createResource({
params: {
batch: props.batch,
},
cache: ['batchCourses', props.batchName],
auto: true,
})
@@ -148,7 +151,7 @@ const removeCourses = (selections, unselectAll) => {
{
onSuccess(data) {
courses.reload()
showToast(__('Success'), __('Courses deleted successfully'), 'check')
toast.success(__('Courses deleted successfully'))
unselectAll()
},
}
@@ -156,6 +159,9 @@ const removeCourses = (selections, unselectAll) => {
}
const canSeeAddButton = () => {
if (readOnlyMode) {
return false
}
return user.data?.is_moderator || user.data?.is_evaluator
}
</script>
+1 -2
View File
@@ -6,13 +6,12 @@
:courses="batch.data.courses"
/>
<Assessments :batch="batch.data.name" />
<StudentHeatmap />
<!-- <StudentHeatmap /> -->
</div>
</template>
<script setup>
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
import Assessments from '@/components/Assessments.vue'
import StudentHeatmap from '@/components/StudentHeatmap.vue'
const props = defineProps({
batch: {
+50 -123
View File
@@ -1,44 +1,49 @@
<template>
<div v-if="user.data?.is_student">
<div
v-if="feedbackList.data?.length"
class="bg-surface-blue-2 text-blue-700 p-2 rounded-md mb-5"
>
{{ __('Thank you for providing your feedback!') }}
</div>
<div v-else class="flex justify-between items-center mb-5">
<div class="text-lg font-semibold">
{{ __('Help Us Improve') }}
<div>
<div class="leading-5 mb-4">
<div v-if="readOnly">
{{ __('Thank you for providing your feedback.') }}
<span
@click="showFeedbackForm = !showFeedbackForm"
class="underline cursor-pointer"
>{{ __('Click here') }}</span
>
{{ __('to view your feedback.') }}
</div>
<div v-else>
{{ __('Help us improve by providing your feedback.') }}
</div>
</div>
<Button @click="submitFeedback()">
{{ __('Submit') }}
</Button>
</div>
<div class="space-y-8">
<div class="flex items-center justify-between">
<Rating
v-for="key in ratingKeys"
v-model="feedback[key]"
:label="__(convertToTitleCase(key))"
<div class="space-y-4" :class="showFeedbackForm ? 'block' : 'hidden'">
<div class="space-y-4">
<Rating
v-for="key in ratingKeys"
v-model="feedback[key]"
:label="__(convertToTitleCase(key))"
:readonly="readOnly"
/>
</div>
<FormControl
v-model="feedback.feedback"
type="textarea"
:label="__('Feedback')"
:rows="9"
:readonly="readOnly"
/>
<Button v-if="!readOnly" @click="submitFeedback">
{{ __('Submit Feedback') }}
</Button>
</div>
<FormControl
v-model="feedback.feedback"
type="textarea"
:label="__('Feedback')"
:rows="7"
:readonly="readOnly"
/>
</div>
</div>
<div v-else-if="feedbackList.data?.length">
<div class="text-lg font-semibold mb-5">
{{ __('Average of Feedback Received') }}
<div class="leading-5 text-sm mb-2 mt-5">
{{ __('Average Feedback Received') }}
</div>
<div class="flex items-center justify-between mb-10">
<div class="space-y-4">
<Rating
v-for="key in ratingKeys"
v-model="average[key]"
@@ -47,82 +52,32 @@
/>
</div>
<div class="text-lg font-semibold mb-5">
{{ __('All Feedback') }}
</div>
<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>
<Button variant="outline" class="mt-5" @click="showAllFeedback = true">
{{ __('View all feedback') }}
</Button>
</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.') }}
</div>
<FeedbackModal
v-if="feedbackList.data?.length"
v-model="showAllFeedback"
:feedbackList="feedbackList.data"
/>
</template>
<script setup>
import { computed, inject, onMounted, reactive, ref, watch } from 'vue'
import { inject, onMounted, reactive, ref, watch } from 'vue'
import { convertToTitleCase } from '@/utils'
import {
Avatar,
Button,
createListResource,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
Rating,
} from 'frappe-ui'
import { Button, createListResource, FormControl, Rating } from 'frappe-ui'
import FeedbackModal from '@/components/Modals/FeedbackModal.vue'
const user = inject('$user')
const ratingKeys = ['content', 'instructors', 'value']
const readOnly = ref(false)
const average = reactive({})
const feedback = reactive({})
const showFeedbackForm = ref(true)
const showAllFeedback = ref(false)
const props = defineProps({
batch: {
@@ -168,6 +123,7 @@ watch(
if (feedbackList.data.length) {
let data = feedbackList.data
readOnly.value = true
showFeedbackForm.value = false
ratingKeys.forEach((key) => {
average[key] = 0
@@ -202,40 +158,11 @@ const submitFeedback = () => {
{
onSuccess: () => {
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>
<style>
.feedback-list > button > div {
+108 -72
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.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 }}
<span v-if="seats_left > 1">
@@ -24,7 +29,10 @@
>
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
</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" />
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
</div>
@@ -46,81 +54,105 @@
{{ batch.data.timezone }}
</span>
</div>
<router-link
v-if="isModerator || isStudent"
:to="{
name: 'Batch',
params: {
batchName: batch.data.name,
},
}"
>
<Button variant="solid" class="w-full mt-4">
<span>
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
</span>
<div v-if="!readOnlyMode">
<router-link
v-if="canAccessBatch"
:to="{
name: 'Batch',
params: {
batchName: batch.data.name,
},
}"
>
<Button variant="solid" class="w-full mt-4">
<template #prefix>
<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>
</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">
<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()"
>
{{ __('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>
<router-link
v-if="isModerator"
:to="{
name: 'BatchForm',
params: {
batchName: batch.data.name,
},
}"
>
<Button class="w-full mt-2">
<template #prefix>
<Pencil class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Edit') }}
</span>
</Button>
</router-link>
</div>
</div>
</template>
<script setup>
import { inject, computed } from 'vue'
import { Badge, Button, createResource } from 'frappe-ui'
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
import { formatNumberIntoCurrency, formatTime, showToast } from '@/utils'
import { Button, createResource, toast } from 'frappe-ui'
import {
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 { useRouter } from 'vue-router'
const router = useRouter()
const user = inject('$user')
const dayjs = inject('$dayjs')
const readOnlyMode = window.read_only_mode
const props = defineProps({
batch: {
@@ -146,11 +178,7 @@ const enrollInBatch = () => {
{},
{
onSuccess(data) {
showToast(
__('Success'),
__('You have been enrolled in this batch'),
'check'
)
toast.success(__('You have been enrolled in this batch'))
router.push({
name: 'Batch',
params: {
@@ -176,4 +204,12 @@ const isStudent = computed(() => {
const isModerator = computed(() => {
return user.data?.is_moderator
})
const isEvaluator = computed(() => {
return user.data?.is_evaluator
})
const canAccessBatch = computed(() => {
return isModerator.value || isStudent.value || isEvaluator.value
})
</script>
+102 -193
View File
@@ -1,108 +1,64 @@
<template>
<div class="">
<div v-if="batch.data" class="">
<div class="w-full flex items-center justify-between pb-4">
<div class="font-medium text-ink-gray-7">
{{ __('Statistics') }}
</div>
</div>
<div class="grid grid-cols-4 gap-5 mb-8">
<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">
<User class="w-5 h-5 stroke-1.5" />
</div>
<div class="flex items-center space-x-2">
<span class="font-semibold">
{{ students.data?.length }}
</span>
<span class="">
{{ __('Students') }}
</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">
<GraduationCap class="w-5 h-5 stroke-1.5" />
</div>
<div class="flex items-center space-x-2">
<span class="font-semibold">
{{ 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="grid grid-cols-2 md:grid-cols-4 gap-5 mb-8">
<NumberChart
class="border rounded-md"
:config="{ title: __('Students'), value: students.data?.length || 0 }"
/>
<NumberChart
class="border rounded-md"
:config="{
title: __('Certified'),
value: certificationCount.data || 0,
}"
/>
<NumberChart
class="border rounded-md"
:config="{
title: __('Courses'),
value: batch.data.courses?.length || 0,
}"
/>
<NumberChart
class="border rounded-md"
:config="{ title: __('Assessments'), value: assessmentCount || 0 }"
/>
<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>
<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>
@@ -110,7 +66,7 @@
<div class="text-ink-gray-7 font-medium">
{{ __('Students') }}
</div>
<Button @click="openStudentModal()">
<Button v-if="!readOnlyMode" @click="openStudentModal()">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
@@ -201,9 +157,10 @@
</div>
<StudentModal
:batch="props.batch.name"
:batch="props.batch.data.name"
v-model="showStudentModal"
v-model:reloadStudents="students"
v-model:batchModal="props.batch"
/>
<BatchStudentProgress
:student="selectedStudent"
@@ -213,6 +170,7 @@
<script setup>
import {
Avatar,
AxisChart,
Button,
createResource,
FeatherIcon,
@@ -223,6 +181,8 @@ import {
ListRows,
ListView,
ListRowItem,
NumberChart,
toast,
} from 'frappe-ui'
import {
BookOpen,
@@ -234,7 +194,6 @@ import {
} from 'lucide-vue-next'
import { ref, watch } from 'vue'
import StudentModal from '@/components/Modals/StudentModal.vue'
import { showToast } from '@/utils'
import ProgressBar from '@/components/ProgressBar.vue'
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
import ApexChart from 'vue3-apexcharts'
@@ -244,9 +203,9 @@ const showStudentModal = ref(false)
const showStudentProgressModal = ref(false)
const selectedStudent = ref(null)
const chartData = ref(null)
const chartOptions = ref(null)
const showProgressChart = ref(false)
const assessmentCount = ref(0)
const readOnlyMode = window.read_only_mode
const props = defineProps({
batch: {
@@ -257,14 +216,15 @@ const props = defineProps({
const students = createResource({
url: 'lms.lms.utils.get_batch_students',
cache: ['students', props.batch.name],
params: {
batch: props.batch?.name,
batch: props.batch?.data?.name,
},
auto: true,
onSuccess(data) {
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) {
students.reload()
showToast(__('Success'), __('Students deleted successfully'), 'check')
props.batch.reload()
toast.success(__('Students deleted successfully'))
unselectAll()
},
}
@@ -329,96 +290,49 @@ const removeStudents = (selections, unselectAll) => {
}
const getChartData = () => {
let categories = {}
let tasks = []
let data = []
if (!students.data?.length) return []
Object.keys(students.data[0].courses).forEach((course) => {
categories[course] = {
value: 0,
type: 'course',
label: course,
}
students.data.forEach((row) => {
tasks = countAssessments(row, tasks)
tasks = countCourses(row, tasks)
})
Object.keys(students.data?.[0].assessments).forEach((assessment) => {
categories[assessment] = {
value: 0,
type: 'assessment',
label: assessment,
}
})
students.data.forEach((student) => {
Object.keys(student.courses).forEach((course) => {
if (student.courses[course] === 100) {
categories[course].value += 1
}
})
Object.keys(student.assessments).forEach((assessment) => {
if (student.assessments[assessment].result === 'Pass') {
categories[assessment].value += 1
}
tasks.forEach((task) => {
data.push({
task: task.label,
value: task.value,
})
})
chartOptions.value = getChartOptions(categories)
return [
{
name: __('Completed by Students'),
data: Object.values(categories).map((item) => item.value),
},
]
return data
}
const getChartOptions = (categories) => {
const courseColor = theme.colors.green[700]
const assessmentColor = theme.colors.blue[700]
const maxY =
students.data?.length % 5
? students.data?.length + (5 - (students.data?.length % 5))
: students.data?.length
const countAssessments = (row, tasks) => {
Object.keys(row.assessments).forEach((assessment) => {
if (row.assessments[assessment].result === 'Pass') {
tasks.filter((task) => task.label === assessment).length
? tasks.filter((task) => task.label === assessment)[0].value++
: tasks.push({
value: 1,
label: assessment,
})
}
})
return tasks
}
return {
chart: {
type: 'bar',
toolbar: {
show: false,
},
},
plotOptions: {
bar: {
distributed: true,
borderRadius: 3,
borderRadiusApplication: 'end',
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 */
},
}
const countCourses = (row, tasks) => {
Object.keys(row.courses).forEach((course) => {
if (row.courses[course] === 100) {
tasks.filter((task) => task.label === course).length
? tasks.filter((task) => task.label === course)[0].value++
: tasks.push({
value: 1,
label: course,
})
}
})
return tasks
}
watch(students, () => {
@@ -432,14 +346,9 @@ const certificationCount = createResource({
params: {
doctype: 'LMS Certificate',
filters: {
batch_name: props.batch.name,
batch_name: props.batch?.data?.name,
},
},
auto: true,
})
</script>
<style>
.apexcharts-legend {
display: none !important;
}
</style>
-130
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>
+21 -3
View File
@@ -1,6 +1,16 @@
<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
v-if="
v-else-if="
certification.data &&
certification.data.membership &&
certification.data.paid_certificate &&
@@ -25,7 +35,7 @@
</Button>
</router-link>
<router-link
v-else-if="!certification.data.membership.certficate"
v-else-if="!certification.data.membership.certificate"
:to="{
name: 'CourseCertification',
params: {
@@ -61,7 +71,15 @@ const certification = createResource({
params: {
course: props.courseName,
},
auto: true,
auto: user.data ? true : false,
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>
+127 -109
View File
@@ -1,126 +1,140 @@
<template>
<Combobox v-model="selectedValue" nullable v-slot="{ open: isComboboxOpen }">
<Popover class="w-full" v-model:show="showOptions">
<template #target="{ open: openPopover, togglePopover }">
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
<div class="w-full">
<button
class="flex w-full items-center justify-between focus:outline-none"
:class="inputClasses"
@click="() => togglePopover()"
>
<div class="flex items-center">
<slot name="prefix" />
<span
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
v-if="selectedValue"
>
{{ displayValue(selectedValue) }}
</span>
<span class="text-base leading-5 text-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"
/>
<div>
<div v-if="label" class="text-xs text-ink-gray-5 mb-1">
{{ __(label) }}
<span class="text-ink-red-3" v-if="attrs.required">*</span>
</div>
<Combobox
v-model="selectedValue"
nullable
v-slot="{ open: isComboboxOpen }"
>
<Popover class="w-full" v-model:show="showOptions">
<template #target="{ open: openPopover, togglePopover }">
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
<div class="w-full">
<button
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
@click="selectedValue = null"
class="flex w-full items-center justify-between focus:outline-none"
: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>
</div>
<ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
static
</slot>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
>
<div
class="mt-1.5"
v-for="group in groups"
:key="group.key"
v-show="group.items.length > 0"
<div class="relative px-1.5 pt-0.5">
<ComboboxInput
ref="search"
class="form-input w-full"
type="text"
@change="
(e) => {
query = e.target.value
}
"
:value="query"
autocomplete="off"
placeholder="Search"
/>
<button
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
@click="selectedValue = null"
>
<X class="h-4 w-4 stroke-1.5 text-ink-gray-7" />
</button>
</div>
<ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
static
>
<div
v-if="group.group && !group.hideLabel"
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
class="mt-1.5"
v-for="group in groups"
:key="group.key"
v-show="group.items.length > 0"
>
{{ group.group }}
</div>
<ComboboxOption
as="template"
v-for="option in group.items"
:key="option.value"
:value="option"
v-slot="{ active, selected }"
>
<li
:class="[
'flex items-center rounded px-2.5 py-2 text-base',
{ 'bg-surface-gray-2': active },
]"
<div
v-if="group.group && !group.hideLabel"
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
>
<slot
name="item-prefix"
v-bind="{ active, selected, option }"
/>
<slot
name="item-label"
v-bind="{ active, selected, option }"
{{ group.group }}
</div>
<ComboboxOption
as="template"
v-for="option in group.items"
:key="option.value"
:value="option"
v-slot="{ active, selected }"
>
<li
:class="[
'flex items-center rounded px-2.5 py-2 text-base',
{ 'bg-surface-gray-2': active },
]"
>
<div class="flex flex-col space-y-1">
<div>
{{ option.label }}
<slot
name="item-prefix"
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
v-if="option.description"
class="text-xs text-ink-gray-7"
v-html="option.description"
></div>
</div>
</slot>
</li>
</ComboboxOption>
</slot>
</li>
</ComboboxOption>
</div>
<li
v-if="groups.length == 0"
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-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>
<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>
</template>
</Popover>
</Combobox>
</template>
</Popover>
</Combobox>
</div>
</template>
<script setup>
@@ -130,7 +144,7 @@ import {
ComboboxOptions,
ComboboxOption,
} from '@headlessui/vue'
import { Popover, Button } from 'frappe-ui'
import { Popover } from 'frappe-ui'
import { ChevronDown, X } from 'lucide-vue-next'
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
@@ -147,6 +161,10 @@ const props = defineProps({
type: String,
default: 'md',
},
label: {
type: String,
default: '',
},
variant: {
type: String,
default: 'subtle',
@@ -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>
+162
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>
@@ -5,7 +5,7 @@
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 }}
</span>
<div
@@ -146,7 +146,6 @@ function resetEditor(value: string, resetHistory = false) {
value = getModelValue()
aceEditor?.setValue(value)
aceEditor?.clearSelection()
console.log(isDark.value)
aceEditor?.setTheme(isDark.value ? 'ace/theme/twilight' : 'ace/theme/chrome')
props.autofocus && aceEditor?.focus()
if (resetHistory) {
@@ -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>
+2 -1
View File
@@ -12,6 +12,7 @@
:variant="attrs.variant"
:placeholder="attrs.placeholder"
:filterable="false"
:readonly="attrs.readonly"
>
<template #target="{ open, togglePopover }">
<slot name="target" v-bind="{ open, togglePopover }" />
@@ -34,7 +35,7 @@
<Button
variant="ghost"
class="w-full !justify-start"
label="Create New"
:label="__('Create New')"
@click="attrs.onCreate(value, close)"
>
<template #prefix>
@@ -4,78 +4,92 @@
{{ label }}
<span class="text-ink-red-3" v-if="required">*</span>
</label>
<div class="grid grid-cols-3 gap-1">
<Button
ref="emails"
v-for="value in values"
:key="value"
:label="value"
theme="gray"
variant="subtle"
class="rounded-md"
@keydown.delete.capture.stop="removeLastValue"
>
<template #suffix>
<X @click="removeValue(value)" class="h-4 w-4 stroke-1.5" />
</template>
</Button>
<div class="">
<Combobox v-model="selectedValue" nullable>
<Popover class="w-full" v-model:show="showOptions">
<template #target="{ togglePopover }">
<ComboboxInput
ref="search"
class="search-input form-input w-full focus-visible:!ring-0"
type="text"
:value="query"
@change="
(e) => {
query = e.target.value
showOptions = true
}
"
autocomplete="off"
@focus="() => togglePopover()"
@keydown.delete.capture.stop="removeLastValue"
/>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-white py-1 text-base shadow-2xl"
<div class="w-full">
<Combobox v-model="selectedValue" nullable>
<Popover class="w-full" v-model:show="showOptions">
<template #target="{ togglePopover }">
<ComboboxInput
ref="search"
class="search-input form-input w-full focus-visible:!ring-0"
type="text"
:value="query"
@change="
(e) => {
query = e.target.value
showOptions = true
}
"
autocomplete="off"
@focus="() => togglePopover()"
@keydown.delete.capture.stop="removeLastValue"
/>
</template>
<template #body="{ isOpen, close }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
>
<ComboboxOptions
class="my-1 min-h-[6rem] max-h-[12rem] overflow-y-auto px-1.5"
static
>
<ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
static
<ComboboxOption
v-for="option in options"
:key="option.value"
:value="option"
v-slot="{ active }"
>
<ComboboxOption
v-for="option in options"
:key="option.value"
:value="option"
v-slot="{ active }"
<li
:class="[
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
{ 'bg-surface-gray-2': active },
]"
>
<li
:class="[
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
{ '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 class="flex flex-col gap-1 p-1">
<div class="text-base font-medium text-ink-gray-8">
{{ option.description }}
</div>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</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>
</template>
</Popover>
</Combobox>
</div>
</template>
</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>
<!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> -->
@@ -90,9 +104,9 @@ import {
ComboboxOption,
} from '@headlessui/vue'
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 { X } from 'lucide-vue-next'
import { X, Plus } from 'lucide-vue-next'
const props = defineProps({
label: {
@@ -124,7 +138,7 @@ const props = defineProps({
})
const values = defineModel()
const attrs = useAttrs()
const emails = ref([])
const search = ref(null)
const error = ref(null)
@@ -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>
+77 -66
View File
@@ -1,36 +1,57 @@
<template>
<div
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"
>
<div
class="course-image"
:class="{ 'default-image': !course.image }"
:style="{ backgroundImage: 'url(\'' + encodeURI(course.image) + '\')' }"
class="w-[100%] h-[168px] bg-cover bg-center bg-no-repeat"
:style="
course.image
? { backgroundImage: `url('${encodeURI(course.image)}')` }
: {
backgroundImage: getGradientColor(),
backgroundBlendMode: 'screen',
}
"
>
<div
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 class="flex items-center flex-wrap relative top-4 px-2 w-fit">
<div
v-for="tag in course.tags"
class="text-xs bg-white text-gray-800 px-2 py-0.5 rounded-md"
v-if="course.featured"
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 }}
</div>
</div>
<div v-if="!course.image" class="image-placeholder">
{{ course.title[0] }}
</div> -->
<div
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 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 v-if="course.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" />
{{ course.lessons }}
</span>
@@ -39,8 +60,8 @@
<div v-if="course.enrollments">
<Tooltip :text="__('Enrolled Students')">
<span class="flex items-center text-ink-gray-7">
<Users class="h-4 w-4 stroke-1. mr-1" />
<span class="flex items-center">
<Users class="h-4 w-4 stroke-1.5 mr-1" />
{{ course.enrollments }}
</span>
</Tooltip>
@@ -48,29 +69,27 @@
<div v-if="course.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" />
{{ course.rating }}
</span>
</Tooltip>
</div>
<div v-if="course.status != 'Approved'">
<Badge
variant="subtle"
:theme="course.status === 'Under Review' ? 'orange' : 'blue'"
size="sm"
>
{{ course.status }}
</Badge>
</div>
<Tooltip v-if="course.featured" :text="__('Featured')">
<Award class="size-4 stroke-2 text-ink-amber-3" />
</Tooltip>
</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 }}
</div>
<div class="short-introduction text-ink-gray-7 text-sm">
<div class="short-introduction text-sm">
{{ course.short_introduction }}
</div>
@@ -79,11 +98,8 @@
:progress="course.membership.progress"
/>
<div
v-if="user && course.membership"
class="text-sm text-ink-gray-7 mt-2 mb-4"
>
{{ Math.ceil(course.membership.progress) }}% completed
<div v-if="user && course.membership" class="text-sm mt-2 mb-4">
{{ Math.ceil(course.membership.progress) }}% {{ __('completed') }}
</div>
<div class="flex items-center justify-between mt-auto">
@@ -103,21 +119,23 @@
<div v-if="course.paid_course" class="font-semibold">
{{ course.price }}
</div>
<div
<Tooltip
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') }}
</div>
<GraduationCap class="size-5 stroke-1.5 text-ink-gray-7" />
</Tooltip>
</div>
</div>
</div>
</template>
<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 { 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 ProgressBar from '@/components/ProgressBar.vue'
@@ -129,16 +147,24 @@ const props = defineProps({
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>
<style>
.course-image {
height: 168px;
width: 100%;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.course-card-pills {
background: #ffffff;
margin-left: 0;
@@ -152,14 +178,6 @@ const props = defineProps({
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 {
display: inline-flex;
align-items: center;
@@ -168,14 +186,7 @@ const props = defineProps({
.avatar-group .avatar {
transition: margin 0.1s ease-in-out;
}
.image-placeholder {
display: flex;
align-items: center;
flex: 1;
font-size: 5rem;
color: theme('colors.gray.700');
font-weight: 600;
}
.avatar-group.overlap .avatar + .avatar {
margin-left: calc(-8px);
}
+135 -90
View File
@@ -1,5 +1,5 @@
<template>
<div class="border-2 rounded-md min-w-80">
<div class="border-2 rounded-md min-w-80 max-w-sm">
<iframe
v-if="course.data.video_link"
:src="video_link"
@@ -9,88 +9,120 @@
<div v-if="course.data.paid_course" class="text-2xl font-semibold mb-3">
{{ course.data.price }}
</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
v-else-if="course.data.paid_course"
:to="{
name: 'Lesson',
name: 'Billing',
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,
type: 'course',
name: course.data.name,
},
}"
>
<Button variant="solid" size="md" class="w-full">
<template #prefix>
<CreditCard class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Continue Learning') }}
{{ __('Buy this course') }}
</span>
</Button>
</router-link>
<CertificationLinks :courseName="course.data.name" />
</div>
<router-link
v-else-if="course.data.paid_course"
:to="{
name: 'Billing',
params: {
type: 'course',
name: course.data.name,
},
}"
>
<Button variant="solid" size="md" class="w-full">
<Badge
v-else-if="course.data.disable_self_learning"
theme="blue"
size="lg"
>
{{ __('Contact the Administrator to enroll for this course.') }}
</Badge>
<Button
v-else-if="!user.data?.is_moderator && !is_instructor()"
@click="enrollStudent()"
variant="solid"
class="w-full"
size="md"
>
<template #prefix>
<BookText class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Buy this course') }}
{{ __('Start Learning') }}
</span>
</Button>
</router-link>
<div
v-else-if="course.data.disable_self_learning"
class="bg-surface-blue-2 text-blue-900 text-sm rounded-md py-1 px-3"
>
{{ __('Contact the Administrator to enroll for this course.') }}
</div>
<Button
v-else
@click="enrollStudent()"
variant="solid"
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
v-if="canGetCertificate"
@click="fetchCertificate()"
variant="subtle"
class="w-full mt-2"
size="md"
>
<template #prefix>
<GraduationCap class="size-4 stroke-1.5" />
</template>
{{ __('Get Certificate') }}
</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="mt-8 font-medium text-ink-gray-9">
<div
class="font-medium text-ink-gray-9"
:class="{ 'mt-8': !readOnlyMode }"
>
{{ __('This course has:') }}
</div>
<div class="flex items-center text-ink-gray-9">
@@ -110,7 +142,7 @@
v-if="parseInt(course.data.rating) > 0"
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">
{{ course.data.rating }} {{ __('Rating') }}
</span>
@@ -136,18 +168,35 @@
</div>
</div>
</div>
<CourseProgressSummary
v-model="showProgressModal"
:courseName="course.data.name"
:enrollments="course.data.enrollments"
/>
</template>
<script setup>
import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next'
import { computed, inject } from 'vue'
import { Button, createResource, Tooltip } from 'frappe-ui'
import { showToast, formatAmount } from '@/utils/'
import {
BookOpen,
BookText,
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 { useRouter } from 'vue-router'
import CertificationLinks from '@/components/CertificationLinks.vue'
import CourseProgressSummary from '@/components/Modals/CourseProgressSummary.vue'
const router = useRouter()
const user = inject('$user')
const showProgressModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({
course: {
@@ -165,31 +214,19 @@ const video_link = computed(() => {
function enrollStudent() {
if (!user.data) {
showToast(
__('Please Login'),
__('You need to login first to enroll for this course'),
'alert-circle'
)
toast.success(__('You need to login first to enroll for this course'))
setTimeout(() => {
window.location.href = `/login?redirect-to=${window.location.pathname}`
}, 2000)
}, 500)
} else {
const enrollStudentResource = createResource({
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
call('lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership', {
course: props.course.data.name,
})
enrollStudentResource
.submit({
course: props.course.data.name,
})
.then(() => {
capture('enrolled_in_course', {
course: props.course.data.name,
})
showToast(
__('Success'),
__('You have been enrolled in this course'),
'check'
)
toast.success(__('You have been enrolled in this course'))
setTimeout(() => {
router.push({
name: 'Lesson',
@@ -199,7 +236,11 @@ function enrollStudent() {
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,
})
}
const showProgressSummary = () => {
showProgressModal.value = true
}
</script>
@@ -1,5 +1,5 @@
<template>
<div class="text-ink-gray-7">
<div class="">
<span v-if="instructors?.length == 1">
<router-link
:to="{
@@ -19,7 +19,7 @@
>
{{ instructors[0].first_name }}
</router-link>
and
{{ __('and') }}
<router-link
:to="{
name: 'Profile',
@@ -38,7 +38,7 @@
>
{{ instructors[0].first_name }}
</router-link>
and {{ instructors?.length - 1 }} others
{{ __('and') }} {{ instructors?.length - 1 }} {{ __('others') }}
</span>
</div>
</template>
+182 -124
View File
@@ -1,140 +1,161 @@
<template>
<div class="text-base">
<div class="">
<div
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) }}
</div>
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
{{ __('Add Chapter') }}
</Button>
<!-- <span class="font-medium cursor-pointer" @click="expandAllChapters()">
{{ expandAll ? __("Collapse all chapters") : __("Expand all chapters") }}
</span> -->
</div>
<div
:class="{
'border-2 rounded-md py-2 px-2': showOutline && outline.data?.length,
}"
>
<Disclosure
v-slot="{ open }"
v-for="(chapter, index) in outline.data"
:key="chapter.name"
:defaultOpen="openChapterDetail(chapter.idx)"
<Draggable
:list="outline.data"
:disabled="!allowEdit"
item-key="name"
group="chapters"
@end="updateChapterOrder"
>
<DisclosureButton ref="" class="flex items-center w-full p-2 group">
<ChevronRight
:class="{
'rotate-90 transform duration-200': open,
'duration-200': !open,
hidden: chapter.is_scorm_package,
open: index == 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)"
>
{{ 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' : ''
"
<template #item="{ element: chapter, index }">
<div class="chapter-item">
<Disclosure
v-slot="{ open }"
:key="chapter.name"
:defaultOpen="openChapterDetail(chapter.idx)"
>
<DisclosureButton
ref=""
class="flex items-center w-full p-2 group"
>
<router-link
:to="{
name: allowEdit ? 'LessonForm' : 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.number.split('.')[0],
lessonNumber: lesson.number.split('.')[1],
},
<ChevronRight
:class="{
'rotate-90 transform duration-200': open,
'duration-200': !open,
hidden: chapter.is_scorm_package,
open: index == 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">
<MonitorPlay
v-if="lesson.icon === 'icon-youtube'"
class="h-4 w-4 stroke-1 mr-2"
{{ 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"
/>
<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 }}
</Tooltip>
<Tooltip :text="__('Delete Chapter')" placement="bottom">
<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"
@click.prevent="trashChapter(chapter.name)"
class="h-4 w-4 text-ink-red-3 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>
</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-gray-3' : ''
"
>
<router-link
:to="{
name: allowEdit ? 'LessonForm' : 'Lesson',
params: {
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>
</DisclosurePanel>
</Disclosure>
</template>
</Draggable>
</div>
</div>
<ChapterModal
v-if="user.data"
v-model="showChapterModal"
v-model:outline="outline"
:course="courseName"
@@ -142,8 +163,8 @@
/>
</template>
<script setup>
import { Button, createResource, Tooltip } from 'frappe-ui'
import { getCurrentInstance, inject, ref } from 'vue'
import { Button, createResource, Tooltip, toast } from 'frappe-ui'
import { getCurrentInstance, inject, ref, watch } from 'vue'
import Draggable from 'vuedraggable'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
import {
@@ -157,7 +178,6 @@ import {
} from 'lucide-vue-next'
import { useRoute, useRouter } from 'vue-router'
import ChapterModal from '@/components/Modals/ChapterModal.vue'
import { showToast } from '@/utils'
const route = useRoute()
const router = useRouter()
@@ -188,18 +208,38 @@ const props = defineProps({
type: Boolean,
default: false,
},
lessonProgress: {
type: Number,
default: 0,
},
})
const outline = createResource({
url: 'lms.lms.utils.get_course_outline',
cache: ['course_outline', props.courseName],
params: {
course: props.courseName,
progress: props.getProgress,
makeParams() {
return {
course: props.courseName,
progress: props.getProgress,
}
},
auto: true,
})
watch(
() => props.courseName,
() => {
outline.reload()
}
)
watch(
() => props.lessonProgress,
() => {
outline.reload()
}
)
const deleteLesson = createResource({
url: 'lms.lms.api.delete_lesson',
makeParams(values) {
@@ -210,7 +250,7 @@ const deleteLesson = createResource({
},
onSuccess() {
outline.reload()
showToast('Success', 'Lesson deleted successfully', 'check')
toast.success(__('Lesson deleted successfully'))
},
})
@@ -225,7 +265,21 @@ const updateLessonIndex = createResource({
}
},
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({
url: 'lms.lms.api.delete_chapter',
makeParams(values) {
@@ -283,7 +345,7 @@ const deleteChapter = createResource({
},
onSuccess() {
outline.reload()
showToast('Success', 'Chapter deleted successfully', 'check')
toast.success(__('Chapter deleted successfully'))
},
})
@@ -312,11 +374,7 @@ const redirectToChapter = (chapter) => {
event.preventDefault()
if (props.allowEdit) return
if (!user.data) {
showToast(
__('You are not enrolled'),
__('Please enroll for this course to view this lesson'),
'alert-circle'
)
toast.success(__('Please enroll for this course to view this lesson'))
return
}
+16 -7
View File
@@ -35,14 +35,14 @@
<span class="text-ink-gray-7">
{{ review.creation }}
</span>
<div class="flex mt-2">
<div class="flex mt-2 space-x-1">
<Star
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="
index <= Math.ceil(review.rating)
? 'fill-orange-500'
: 'fill-gray-600'
? 'fill-yellow-500'
: 'fill-gray-300'
"
/>
</div>
@@ -64,7 +64,7 @@
<script setup>
import { Star } from 'lucide-vue-next'
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 ReviewModal from '@/components/Modals/ReviewModal.vue'
@@ -101,12 +101,21 @@ const hasReviewed = createResource({
const reviews = createResource({
url: 'lms.lms.utils.get_reviews',
cache: ['course_reviews', props.courseName],
params: {
course: props.courseName,
makeParams() {
return {
course: props.courseName,
}
},
auto: true,
})
watch(
() => props.courseName,
() => {
reviews.reload()
}
)
const showReviewModal = ref(false)
function openReviewModal() {
+18 -17
View File
@@ -27,16 +27,18 @@
</span>
</div>
<Dropdown
v-if="user.data.name == reply.owner && !reply.editable"
v-if="
user.data.name == reply.owner && !reply.editable && !readOnlyMode
"
:options="[
{
label: 'Edit',
label: __('Edit'),
onClick() {
reply.editable = true
},
},
{
label: 'Delete',
label: __('Delete'),
onClick() {
deleteReply(reply)
},
@@ -71,7 +73,7 @@
</div>
<TextEditor
v-if="renderEditor"
v-if="renderEditor && !readOnlyMode"
class="mt-5"
:content="newReply"
:mentions="mentionUsers"
@@ -80,7 +82,7 @@
: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"
/>
<div class="flex justify-between mt-2">
<div v-if="!readOnlyMode" class="flex justify-between mt-2">
<span> </span>
<Button @click="postReply()">
<span>
@@ -91,12 +93,11 @@
</div>
</template>
<script setup>
import { createResource, TextEditor, Button, Dropdown } from 'frappe-ui'
import { timeAgo } from '../utils'
import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
import { timeAgo } from '@/utils'
import UserAvatar from '@/components/UserAvatar.vue'
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
import { ref, inject, onMounted } from 'vue'
import { createToast } from '../utils'
import { ref, inject, onMounted, onUnmounted } from 'vue'
const showTopics = defineModel('showTopics')
const newReply = ref('')
@@ -105,6 +106,7 @@ const user = inject('$user')
const allUsers = inject('$allUsers')
const mentionUsers = ref([])
const renderEditor = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({
topic: {
@@ -189,14 +191,7 @@ const postReply = () => {
replies.reload()
},
onError(err) {
createToast({
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,
})
toast.error(err.messages?.[0] || err)
},
}
)
@@ -256,4 +251,10 @@ const deleteReply = (reply) => {
}
)
}
onUnmounted(() => {
socket.off('publish_message')
socket.off('update_message')
socket.off('delete_message')
})
</script>
+18 -6
View File
@@ -1,6 +1,13 @@
<template>
<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)) }}
</Button>
<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"
>
<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">
{{ __(emptyStateTitle) }}
</div>
@@ -65,11 +72,11 @@
<script setup>
import { createResource, Button } from 'frappe-ui'
import UserAvatar from '@/components/UserAvatar.vue'
import { singularize, timeAgo } from '../utils'
import { ref, onMounted, inject } from 'vue'
import { singularize, timeAgo } from '@/utils'
import { ref, onMounted, inject, onUnmounted } from 'vue'
import DiscussionReplies from '@/components/DiscussionReplies.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'
const showTopics = ref(true)
@@ -77,6 +84,7 @@ const currentTopic = ref(null)
const socket = inject('$socket')
const user = inject('$user')
const showTopicModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({
title: {
@@ -97,7 +105,7 @@ const props = defineProps({
},
emptyStateText: {
type: String,
default: 'Start a discussion',
default: 'Start a Discussion',
},
singleThread: {
type: Boolean,
@@ -148,4 +156,8 @@ const showReplies = (topic) => {
const openTopicModal = () => {
showTopicModal.value = true
}
onUnmounted(() => {
socket.off('new_discussion_topic')
})
</script>
+24
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>
@@ -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>
@@ -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>
@@ -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>
+7 -25
View File
@@ -1,36 +1,18 @@
<template>
<svg
width="118"
height="118"
viewBox="0 0 118 118"
width="80"
height="79"
viewBox="0 0 80 79"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M93.9278 0H23.1013C10.3428 0 0 10.3428 0 23.1013V93.9278C0 106.686 10.3428 117.029 23.1013 117.029H93.9278C106.686 117.029 117.029 106.686 117.029 93.9278V23.1013C117.029 10.3428 106.686 0 93.9278 0Z"
fill="url(#paint0_radial_174_336)"
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="M93.9278 0H23.1013C10.3428 0 0 10.3428 0 23.1013V93.9278C0 106.686 10.3428 117.029 23.1013 117.029H93.9278C106.686 117.029 117.029 106.686 117.029 93.9278V23.1013C117.029 10.3428 106.686 0 93.9278 0Z"
fill="#0B3D3D"
fill-opacity="0.8"
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"
/>
<path
d="M95.1879 33.1294L91.4077 32.0268C80.1721 28.7716 67.9389 30.9242 58.5409 37.7496C52.083 33.0769 43.9975 30.5042 36.1746 30.5042H21.8938V41.0048H36.2796C42.2649 41.0048 48.1978 42.9999 52.923 46.6226L58.5934 50.9279L64.2637 46.6226C70.144 42.1599 77.5469 40.2698 84.7923 41.2673V76.1818C75.5518 75.2367 66.2063 77.7044 58.6459 83.2172C51.0854 77.7044 41.6349 75.2367 32.4994 76.1818V52.8705H21.9988V86.4724H95.3454V33.1294H95.1879Z"
fill="#58FF9B"
/>
<defs>
<radialGradient
id="paint0_radial_174_336"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(117.24 -101.5) rotate(105.042) scale(226.282)"
>
<stop offset="0.445162" stop-color="#1F7676" />
<stop offset="1" stop-color="#0A4B4B" />
</radialGradient>
</defs>
</svg>
</template>
+16
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