Compare commits

...

232 Commits

Author SHA1 Message Date
frappe-pr-bot
7fac29e3e4 chore: update POT file 2024-10-25 10:37:03 +00:00
Jannat Patel
224bb18d3e Merge pull request #1077 from pateljannat/issues-45
fix: show live class start button only to moderators and evaluators
2024-10-23 12:53:42 +05:30
Jannat Patel
aab7bdcc20 fix: show live class start button only to moderators and evaluators 2024-10-23 11:02:16 +05:30
Jannat Patel
c5ca428d98 Merge pull request #1076 from pateljannat/issues-44
fix: misc issues
2024-10-23 10:55:42 +05:30
Frappe PR Bot
af0cc7126b chore(release): Bumped to Version 2.9.0 2024-10-23 05:09:14 +00:00
Jannat Patel
a085050d27 build: removed frappe-ui package 2024-10-23 10:36:26 +05:30
Jannat Patel
2442f35f56 fix: added is_instructor to jinja 2024-10-23 10:35:26 +05:30
Jannat Patel
ed79ea536b Merge pull request #1072 from frappe/pot_develop_2024-10-18
chore: update POT file
2024-10-18 23:06:49 +05:30
frappe-pr-bot
b3d0aecd14 chore: update POT file 2024-10-18 16:04:26 +00:00
Jannat Patel
5f43e67c0b Merge pull request #1068 from pateljannat/payment-issues
fix: batch enrollment after payment completion
2024-10-17 10:39:46 +05:30
Jannat Patel
49a765a9a6 style: fix spacing 2024-10-17 10:31:56 +05:30
Jannat Patel
4d82bc86e8 style: fix spacing 2024-10-17 10:30:06 +05:30
Jannat Patel
8fe02b83b8 fix: batch enrollment after payment completion 2024-10-17 09:27:24 +05:30
Jannat Patel
9c9075606b Merge pull request #1059 from frappe/pot_develop_2024-10-11
chore: update POT file
2024-10-15 19:38:24 +05:30
Jannat Patel
53285a0d19 fix: misc issues 2024-10-14 19:17:32 +05:30
Jannat Patel
9cdeaebb47 Merge pull request #1062 from pateljannat/quiz-timer
feat: timer in quiz
2024-10-14 16:11:55 +05:30
Jannat Patel
a9cb52c68b fix: hide timer instructions if duration is not set 2024-10-14 15:49:27 +05:30
Jannat Patel
f33e950e83 feat: timer in quiz 2024-10-14 14:31:26 +05:30
Jannat Patel
9c9b5963fe Merge pull request #1060 from pateljannat/issues-43
fix: redirect to login before enrollment
2024-10-11 22:33:52 +05:30
Jannat Patel
1597054cc9 fix: redirect to login before enrollment 2024-10-11 22:18:18 +05:30
frappe-pr-bot
deba6aa845 chore: update POT file 2024-10-11 16:04:13 +00:00
Jannat Patel
2d8ba3b84e Merge pull request #1058 from pateljannat/issues-42
fix: batch self enrollment
2024-10-11 19:22:50 +05:30
Jannat Patel
e56b28abad chore: removed unnecessary lines 2024-10-11 19:17:56 +05:30
Jannat Patel
eb350c5a20 fix: batch self enrollment 2024-10-11 19:16:40 +05:30
Jannat Patel
961d5ec77b Merge pull request #1057 from pateljannat/settings-minor-changes
fix: misc ux issues
2024-10-11 16:18:19 +05:30
Jannat Patel
fa566514aa fix: image fetch for settings 2024-10-11 15:32:41 +05:30
Jannat Patel
6e97449bf7 fix: misc ux issues 2024-10-11 13:39:30 +05:30
Jannat Patel
016dafb3c3 Merge pull request #1056 from pateljannat/issues-41
fix: misc issues
2024-10-10 16:43:59 +05:30
Jannat Patel
675bcc8956 test: replaced FrappeTestCase with UnitTestCase 2024-10-10 16:20:53 +05:30
Jannat Patel
aba4c034fc fix: misc issues 2024-10-10 14:48:59 +05:30
Jannat Patel
c76d8c582f Merge pull request #1052 from pateljannat/issues-40
fix: misc quiz issues
2024-10-09 19:17:01 +05:30
Jannat Patel
f1cb0e6f3c fix: usd conversion 2024-10-09 19:07:25 +05:30
Jannat Patel
d296687456 fix: misc quiz issues 2024-10-09 16:03:56 +05:30
Jannat Patel
5b68001c94 Merge pull request #1049 from pateljannat/issues-39
fix: create order for razorpay
2024-10-09 11:59:57 +05:30
Frappe PR Bot
736d79b8c9 chore(release): Bumped to Version 2.8.0 2024-10-09 06:04:56 +00:00
Jannat Patel
98c0bd5f3e Merge pull request #1042 from frappe/pot_develop_2024-10-04
chore: update POT file
2024-10-09 11:34:01 +05:30
Jannat Patel
8b1d9bb5a9 fix: create order for razorpay 2024-10-09 11:31:31 +05:30
Jannat Patel
289a0f9122 Merge pull request #1046 from pateljannat/issues-38
fix: quiz columns
2024-10-08 16:27:04 +05:30
Jannat Patel
3cd08c80c8 fix: reduced with of marks column 2024-10-08 16:09:21 +05:30
Jannat Patel
3d82c36250 fix: quiz columns 2024-10-08 16:03:37 +05:30
Jannat Patel
9b9af0215a Merge pull request #1045 from pateljannat/issues-37
fix: using google docs viewer to render pdf
2024-10-08 12:37:19 +05:30
Jannat Patel
2e4cf02737 fix: using google docs viewer to render pdf 2024-10-08 12:00:51 +05:30
Jannat Patel
438e9e1c47 Merge pull request #1044 from pateljannat/open-ended-questions
feat: open ended questions
2024-10-08 10:37:44 +05:30
Jannat Patel
36ded70eef fix: only allow instructor and moderator on submission page 2024-10-08 10:21:45 +05:30
Jannat Patel
ba78a15a1f fix: ui test button label 2024-10-08 10:14:04 +05:30
Jannat Patel
93061194bb fix: error toast when saving marks 2024-10-08 10:13:07 +05:30
Jannat Patel
6d41e4e552 feat: open ended questions 2024-10-07 21:18:42 +05:30
frappe-pr-bot
3b06968d0a chore: update POT file 2024-10-04 16:04:32 +00:00
Frappe PR Bot
fc81f1aa26 chore(release): Bumped to Version 2.7.0 2024-10-02 06:53:07 +00:00
Jannat Patel
59d8848125 Merge pull request #1035 from pateljannat/payments
feat: payments app integration
2024-10-02 12:22:00 +05:30
Jannat Patel
a067695f71 fix: removed help article from course lesson 2024-10-01 15:43:45 +05:30
Jannat Patel
be870e8145 fix: payment gateway fields 2024-10-01 15:17:17 +05:30
Jannat Patel
8a17dca351 fix: minor ui changes 2024-10-01 10:43:37 +05:30
Jannat Patel
1c9f636ad1 Merge pull request #1032 from frappe/pot_develop_2024-09-27
chore: update POT file
2024-10-01 09:42:57 +05:30
Jannat Patel
008cc66cdd chore: refactor payment settings 2024-09-30 18:30:53 +05:30
Jannat Patel
b6bf9c0032 Merge branch 'develop' of https://github.com/frappe/lms into payments 2024-09-30 10:16:52 +05:30
Jannat Patel
d295898674 Merge pull request #1033 from pateljannat/issues-35
fix: misc UI fixes
2024-09-27 22:15:43 +05:30
frappe-pr-bot
4fdca4691a chore: update POT file 2024-09-27 16:04:07 +00:00
Jannat Patel
7c055af496 fix: telemetry capture issue 2024-09-27 21:32:46 +05:30
Jannat Patel
60a3da283e refactor: billing page ui 2024-09-27 14:14:03 +05:30
Jannat Patel
576258ec6e fix: pass options to setting fields 2024-09-27 07:05:16 +05:30
Jannat Patel
01120fbc48 chore: resolved conflicts 2024-09-27 06:24:12 +05:30
Jannat Patel
ad07f883b5 fix: misc UI fixes 2024-09-27 06:19:38 +05:30
Jannat Patel
bb9b179e05 Merge pull request #1031 from pateljannat/brand-settings
feat:  brand settings
2024-09-26 14:01:19 +05:30
Jannat Patel
11a9bff57d fix: dirty form for branding section 2024-09-26 12:58:00 +05:30
Jannat Patel
e18f0c9dad feat: brand settings 2024-09-26 12:09:58 +05:30
Jannat Patel
41ad3d00de Merge pull request #1030 from pateljannat/fix-evaluation-issue
fix: evaluation error message issue
2024-09-25 11:29:42 +05:30
Frappe PR Bot
b74c1670ca chore(release): Bumped to Version 2.6.0 2024-09-25 05:47:45 +00:00
Jannat Patel
33c76e842f fix: evaluation error message issue 2024-09-25 11:10:26 +05:30
Jannat Patel
35a7cce283 feat: payment gateway settings 2024-09-25 10:50:53 +05:30
Jannat Patel
e0f569c382 feat: payment flow with payments app 2024-09-24 18:14:34 +05:30
Jannat Patel
d8ab88be28 Merge branch 'develop' of https://github.com/frappe/lms into payments 2024-09-24 14:14:49 +05:30
Jannat Patel
04552bdef6 Merge pull request #1025 from pateljannat/categories-in-courses
feat: course categories
2024-09-24 12:55:41 +05:30
Jannat Patel
ad5bf89b35 test: enter instructor value 2024-09-24 12:32:07 +05:30
Jannat Patel
88b38dfd83 test: category test in course form in UI 2024-09-24 12:22:34 +05:30
Jannat Patel
75e9ca395f chore: removed unnecessary custom fields 2024-09-24 10:59:27 +05:30
Jannat Patel
6fb206cc4e feat: updating category from settings 2024-09-24 10:33:23 +05:30
Jannat Patel
62cb198492 Merge branch 'develop' of https://github.com/frappe/lms into categories-in-courses 2024-09-23 19:23:49 +05:30
Jannat Patel
9609329f01 Merge pull request #1028 from pateljannat/fix-signup-customisations
fix: signup conditions
2024-09-23 19:12:29 +05:30
Jannat Patel
c93808af94 fix: signup conditions 2024-09-23 18:41:35 +05:30
Jannat Patel
58866260ec Merge branch 'develop' of https://github.com/frappe/lms into categories-in-courses 2024-09-23 18:39:19 +05:30
Jannat Patel
e6157ff411 Merge pull request #1027 from pateljannat/refactor-signup-customisations
refactor: signup customisations
2024-09-23 18:23:04 +05:30
Jannat Patel
8cca8920ee chore: removed unnecessary file 2024-09-23 18:07:08 +05:30
Jannat Patel
ab039dbd46 refactor: signup customisations 2024-09-23 18:04:36 +05:30
Jannat Patel
9853ab3fd9 Merge pull request #1024 from frappe/pot_develop_2024-09-20
chore: update POT file
2024-09-23 16:11:57 +05:30
Jannat Patel
dc2bf9f13e feat: category settings 2024-09-23 16:11:17 +05:30
Jannat Patel
7c90ca4040 feat: category in settings 2024-09-20 22:15:59 +05:30
frappe-pr-bot
75a90e1f39 chore: update POT file 2024-09-20 16:04:14 +00:00
Jannat Patel
bc4b17cc3d Merge pull request #1022 from pateljannat/evaluator_name_issue
fix: evaluator name issue
2024-09-19 15:18:05 +05:30
Jannat Patel
8c454a333e fix: evaluator name issue 2024-09-19 14:19:55 +05:30
Jannat Patel
cef4b70182 feat: payment through payments app 2024-09-19 12:46:56 +05:30
Jannat Patel
3cda563583 fix: padding of settings modal 2024-09-18 14:46:29 +05:30
Jannat Patel
545326a02a Merge pull request #1021 from pateljannat/fix-help-video-url
fix: url for lesson help videos
2024-09-18 14:42:33 +05:30
Jannat Patel
14ce5d7e23 fix: url for lesson help videos 2024-09-18 11:49:38 +05:30
Frappe PR Bot
b6422d1046 chore(release): Bumped to Version 2.5.0 2024-09-18 04:46:24 +00:00
Jannat Patel
7196bbe221 Merge pull request #1019 from pateljannat/issues-34
fix: misc issues
2024-09-18 09:03:33 +05:30
Jannat Patel
bed16c3726 fix: condition to show course addition button 2024-09-18 08:56:35 +05:30
Jannat Patel
d18ca232e3 fix: misc issues 2024-09-18 08:43:16 +05:30
Jannat Patel
d1200d0fa9 fix: profile page empty bio error 2024-09-17 11:48:49 +05:30
Jannat Patel
d1c88b306f Merge pull request #1018 from pateljannat/batch-quiz
feat: quiz page
2024-09-17 10:42:34 +05:30
Jannat Patel
7f2723f9cb Merge pull request #1014 from pateljannat/batch-welcome-email-fix
fix: batch confirmation email trigger
2024-09-17 10:30:00 +05:30
Jannat Patel
8df4bef71a feat: quiz page 2024-09-17 10:25:59 +05:30
Jannat Patel
aa87622606 Merge pull request #1017 from pateljannat/refactor-lesson-editor
refactor: adding quiz and uploading content to a lesson
2024-09-16 19:19:42 +05:30
Jannat Patel
b91339fe28 fix: removed unnecessary files 2024-09-16 18:50:06 +05:30
Jannat Patel
17d4973ab8 test: fixed course creation 2024-09-16 18:41:25 +05:30
Jannat Patel
3c12548420 Merge pull request #1016 from frappe/pot_develop_2024-09-13
chore: update POT file
2024-09-16 16:50:16 +05:30
Jannat Patel
20c10f1645 feat: help guide videos 2024-09-16 16:49:17 +05:30
Jannat Patel
a7843e0e3a refactor: uploading content in lesson 2024-09-16 10:33:16 +05:30
frappe-pr-bot
169ea4385f chore: update POT file 2024-09-13 16:04:09 +00:00
Jannat Patel
9549f3a3ed Merge pull request #1015 from pateljannat/course-completion-funnel
chore: analytics for course completion
2024-09-13 11:37:22 +05:30
Jannat Patel
ba66c2549f chore: renamed lesson_progress to course_progress for analytics 2024-09-13 11:21:59 +05:30
Jannat Patel
76c3e630cc chore: analytics for course completion 2024-09-13 11:18:17 +05:30
Jannat Patel
7a0b952638 style: trigger condition 2024-09-13 09:57:41 +05:30
Jannat Patel
5966a3edad fix: batch confirmation email trigger 2024-09-13 09:57:06 +05:30
Jannat Patel
d44c7cd9fc Merge pull request #1010 from pateljannat/evaluation-calendar
feat: Evaluation and Certification from Learning Portal
2024-09-12 16:48:51 +05:30
Jannat Patel
46553987ac fix: certification tab title 2024-09-12 16:22:43 +05:30
Jannat Patel
45725f1f6e chore: bumped up frappe-ui 2024-09-12 14:31:55 +05:30
Jannat Patel
58369ba65e fix: evaluator info and other styles 2024-09-11 19:51:14 +05:30
Jannat Patel
5ce67dda2e Merge pull request #1002 from prachi8848/cusrsor
feat: added a cusrsor
2024-09-11 15:05:48 +05:30
Jannat Patel
237ff8db07 Merge pull request #1008 from frappe/pot_develop_2024-09-06
chore: update POT file
2024-09-10 21:02:55 +05:30
Jannat Patel
7da608ed44 feat: certification details and form 2024-09-10 21:00:49 +05:30
Jannat Patel
60f2e86b42 feat: evaluation feedback record 2024-09-09 20:05:08 +05:30
frappe-pr-bot
b5e67a25d2 chore: update POT file 2024-09-06 16:03:59 +00:00
Jannat Patel
9d2ef4929c Merge pull request #1007 from pateljannat/fix-notification
fix: certificate request creation email
2024-09-06 10:49:52 +05:30
Jannat Patel
050084e552 style: fix formatting 2024-09-06 10:40:25 +05:30
Jannat Patel
86e9739218 fix: certificate request creation email 2024-09-06 10:19:25 +05:30
Jannat Patel
bd94890da7 Merge pull request #1005 from pateljannat/minor-ui-fix
fix: member-list ui
2024-09-05 20:36:41 +05:30
Jannat Patel
965f6adb90 style: fixed formatting 2024-09-05 20:31:22 +05:30
Jannat Patel
4979569cf3 fix: member-list ui 2024-09-05 19:59:44 +05:30
Frappe PR Bot
5c21a0532a chore(release): Bumped to Version 2.4.0 2024-09-04 05:00:25 +00:00
sonali8848
a2025c0571 feat: added a cusrsor 2024-09-02 09:41:22 +00:00
Jannat Patel
e07aae3fb0 Merge pull request #997 from pateljannat/issues-33
fix: slides rendering issue
2024-08-29 19:26:08 +05:30
Jannat Patel
65d628ffc0 fix: slides rendering issue 2024-08-29 11:10:43 +05:30
Jannat Patel
bf290bbf0a Merge pull request #994 from akhilnarang/fix-user-creation
fix(overrides): call parent's `after_insert()` as well
2024-08-27 14:56:11 +05:30
Akhil Narang
3c9059025b fix(overrides): call parent's after_insert() as well
Signed-off-by: Akhil Narang <me@akhilnarang.dev>
2024-08-27 14:08:35 +05:30
Jannat Patel
4b0413720b Merge pull request #993 from pateljannat/quiz-submission-issue
fix: quiz submission report issue
2024-08-27 11:58:14 +05:30
Jannat Patel
f8b4ff4bd3 fix: quiz submission report issue 2024-08-27 10:46:06 +05:30
Jannat Patel
3b8ff171f4 Merge pull request #989 from frappe/pot_develop_2024-08-23
chore: update POT file
2024-08-26 14:45:59 +05:30
frappe-pr-bot
dec270a10b chore: update POT file 2024-08-23 16:04:00 +00:00
Jannat Patel
152a339c4e Merge pull request #986 from pateljannat/app-switcher
feat: App switcher
2024-08-23 12:40:16 +05:30
Jannat Patel
395fe700e0 fix: removed switch to desk 2024-08-23 12:22:11 +05:30
Jannat Patel
ec25e895dc feat: app switcher 2024-08-23 12:21:22 +05:30
Frappe PR Bot
e02e4c7ab4 chore(release): Bumped to Version 2.3.0 2024-08-21 05:20:24 +00:00
Jannat Patel
e69cc9af1a Merge pull request #980 from pateljannat/member-addition
feat: Add users from the portal
2024-08-19 14:11:59 +05:30
Jannat Patel
98b8464e1a fix: ui test 2024-08-19 13:07:48 +05:30
Jannat Patel
0170fcc111 Merge pull request #968 from frappe/pot_develop_2024-08-16
chore: update POT file
2024-08-19 13:04:21 +05:30
Jannat Patel
0be5439e81 fix: tests 2024-08-19 12:31:46 +05:30
Jannat Patel
63f857b8fc fix: linters 2024-08-19 12:12:51 +05:30
Jannat Patel
a3b8ed8f91 fix: documented the api 2024-08-19 12:03:32 +05:30
Jannat Patel
cdd46667f3 feat: add new member 2024-08-19 11:47:17 +05:30
frappe-pr-bot
2f8acea988 chore: update POT file 2024-08-16 16:04:13 +00:00
Jannat Patel
75f0e5b9f1 feat: search member 2024-08-16 20:59:51 +05:30
Jannat Patel
ce51129e84 feat: member list 2024-08-16 11:26:11 +05:30
Jannat Patel
86aa8b0a2a Merge pull request #967 from pateljannat/issues-32
fix: settings ui
2024-08-14 12:47:31 +05:30
Jannat Patel
aeae62a45c chore: linters 2024-08-14 12:35:56 +05:30
Jannat Patel
6b12df44a0 fix: settings ui 2024-08-14 12:12:13 +05:30
Frappe PR Bot
a710183bc7 chore(release): Bumped to Version 2.2.0 2024-08-14 05:57:31 +00:00
Jannat Patel
669316ba14 Merge pull request #965 from pateljannat/make-release
ci: automated release PR
2024-08-14 11:08:26 +05:30
Jannat Patel
6c18f9a02f ci: automated release PR 2024-08-14 10:44:11 +05:30
Jannat Patel
363edb9a50 Merge pull request #964 from pateljannat/settings
feat: Settings
2024-08-13 19:14:07 +05:30
Jannat Patel
afbf64170a fix: removed old settings 2024-08-13 19:03:17 +05:30
Jannat Patel
14f36d0c64 chore: removed unnecessary file 2024-08-13 18:59:39 +05:30
Jannat Patel
ceecab395b feat: settings 2024-08-13 18:53:27 +05:30
Jannat Patel
b8eb9fd717 Merge branch 'develop' of https://github.com/frappe/lms into settings 2024-08-13 09:42:17 +05:30
Jannat Patel
230a52f06b Merge pull request #963 from pateljannat/issues-31
fix: misc issues
2024-08-13 09:16:12 +05:30
Jannat Patel
3e82608d5f chore: fixed linters 2024-08-12 20:13:57 +05:30
Jannat Patel
cf2c2345c3 fix: discussions text 2024-08-12 20:10:10 +05:30
Jannat Patel
05ebe4b787 fix: lesson structure issue 2024-08-12 20:09:56 +05:30
Frappe PR Bot
a744a43d14 chore(release): Bumped to Version 2.1.0 2024-08-12 12:32:42 +00:00
Jannat Patel
5abdbfec1f Merge pull request #962 from pateljannat/posthog
chore: product analytics
2024-08-12 18:01:45 +05:30
Jannat Patel
0335b3b4d0 chore: fixed linters 2024-08-12 17:38:59 +05:30
Jannat Patel
703fafd6c3 chore: analytics 2024-08-12 17:13:31 +05:30
Jannat Patel
b956c4e383 Merge branch 'develop' of https://github.com/frappe/lms into posthog 2024-08-12 14:17:04 +05:30
Jannat Patel
d0d1fb2c8c Merge pull request #944 from pateljannat/quiz-creation
Quiz creation
2024-08-12 14:13:23 +05:30
Jannat Patel
d18a6f6e73 chore: removed workspaces from package.json 2024-08-12 13:13:21 +05:30
Jannat Patel
2994144718 feat: quiz creation from lesson form 2024-08-12 12:47:17 +05:30
Jannat Patel
62ab853605 feat: quiz creation from lesson form 2024-08-12 12:46:38 +05:30
Jannat Patel
7f7986d77a chore: fixed linters 2024-08-12 10:04:42 +05:30
Jannat Patel
61f01cc51b chore: resolved conflicts 2024-08-12 10:02:22 +05:30
Jannat Patel
86af8c6301 Merge pull request #960 from mohsinalimat/patch-1
fix: lms setting to lms settings
2024-08-09 22:21:35 +05:30
Jannat Patel
f1b0fcfbfc Merge pull request #961 from frappe/pot_develop_2024-08-09
chore: update POT file
2024-08-09 21:50:21 +05:30
frappe-pr-bot
ab5ce39645 chore: update POT file 2024-08-09 16:04:06 +00:00
Jannat Patel
685e09ce4b feat: question update 2024-08-09 20:38:14 +05:30
MohsinAli
8ed4f775e5 fix: lms setting to lms settings 2024-08-09 13:58:50 +05:30
Jannat Patel
a3a3085b1f fix: update question title in quiz 2024-08-08 22:16:21 +05:30
Jannat Patel
ed97640107 feat: allow updating questions 2024-08-07 18:15:50 +05:30
Jannat Patel
a9e93a679b feat: posthog initialization 2024-08-07 11:31:58 +05:30
Jannat Patel
418c36c09f Merge pull request #957 from frappe/pot_develop_2024-08-06
chore: update POT file
2024-08-06 12:17:49 +05:30
frappe-pr-bot
935f7f1f7b chore: update POT file 2024-08-06 06:32:08 +00:00
Jannat Patel
9a0056b6ca Merge branch 'develop' of https://github.com/frappe/lms into develop 2024-08-06 11:50:23 +05:30
Jannat Patel
cd56da5d85 fix: changed upstream command for pot file generation 2024-08-06 11:47:37 +05:30
Jannat Patel
97d5d853fc Merge pull request #955 from pateljannat/issues-30
fix: lesson auto save
2024-08-06 11:11:50 +05:30
Jannat Patel
8adfe247b2 fix: lesson auto save 2024-08-05 18:14:35 +05:30
Jannat Patel
afe7df2989 fix: fetch question 2024-08-05 16:29:43 +05:30
Jannat Patel
cdb028c69c feat: settings 2024-08-05 15:12:45 +05:30
Jannat Patel
eed330662b Merge pull request #953 from pateljannat/issues-29
fix: course cards home page rendering
2024-08-05 14:20:36 +05:30
Jannat Patel
26db10bbe0 fix: course cards home page rendering 2024-08-05 14:04:08 +05:30
Jannat Patel
14230bd588 Merge pull request #951 from pateljannat/issues-28
fix: spacing and widths
2024-08-05 11:32:12 +05:30
Jannat Patel
699c821edd fix: spacing and widths 2024-08-05 11:12:34 +05:30
Jannat Patel
27ca13ece6 feat: add questions to quiz 2024-08-02 20:20:43 +05:30
Jannat Patel
6820dfc820 fix: make question attachments public 2024-08-01 13:02:20 +05:30
Jannat Patel
471e7d9229 fix: fetch question details 2024-08-01 13:00:37 +05:30
Jannat Patel
e0855a2c1b fix: quiz question population issue 2024-07-31 22:30:29 +05:30
Jannat Patel
6a0b37a4d4 chore: changed release branch to develop 2024-07-31 12:43:04 +05:30
Jannat Patel
f7fd6916e2 chore: release notes action 2024-07-31 12:15:24 +05:30
Jannat Patel
30e61f4b7c Merge pull request #947 from pateljannat/semantic-release
chore: semantic release action
2024-07-31 11:45:42 +05:30
Jannat Patel
48b37d58d8 chore: semantic release action 2024-07-31 11:10:47 +05:30
Jannat Patel
b8c3bdc0b4 feat: open modal to edit question 2024-07-31 11:07:46 +05:30
Jannat Patel
e96f18df7c Merge pull request #946 from pateljannat/issues-27
fix: quiz shuffle issue
2024-07-30 18:04:26 +05:30
Jannat Patel
7d15527831 fix: quiz shuffle issue 2024-07-30 16:58:09 +05:30
Jannat Patel
794c0e760b Merge pull request #945 from pateljannat/issues-26
fix: eval request issues
2024-07-30 15:26:46 +05:30
Jannat Patel
e46a60d00a chore: linters 2024-07-30 15:05:24 +05:30
Jannat Patel
819aac70fd fix: linters 2024-07-30 14:54:21 +05:30
Jannat Patel
ed7db2d7c5 fix: eval request issues 2024-07-30 14:17:17 +05:30
Jannat Patel
a450c846a6 feat: questions table 2024-07-29 19:44:04 +05:30
Jannat Patel
fa774b0db2 feat: quiz creation 2024-07-19 20:05:28 +05:30
Jannat Patel
98a56f9117 Merge pull request #936 from pateljannat/image-pasting
fix: batch filters on desk
2024-07-19 12:19:34 +05:30
Jannat Patel
cbc4b8c59d chore: removed unused plugin 2024-07-19 12:07:35 +05:30
Jannat Patel
69d266e018 feat: image pasting 2024-07-19 11:55:36 +05:30
Jannat Patel
4bc3ac1665 fix: batch filters on desk 2024-07-18 21:29:40 +05:30
Jannat Patel
e0de9d70de Merge pull request #933 from pateljannat/issues-25
fix: misc issues
2024-07-17 11:40:35 +05:30
Jannat Patel
493bab8163 fix: multiline tags 2024-07-17 11:29:14 +05:30
Jannat Patel
25a2d82e82 fix: misc issues 2024-07-16 16:11:24 +05:30
Jannat Patel
0183677494 Merge pull request #932 from pateljannat/batch-categories
feat: batch categories filter
2024-07-15 14:45:57 +05:30
Jannat Patel
7ae9244896 feat: batch categories filter 2024-07-15 13:45:38 +05:30
Jannat Patel
15330cb41d Merge pull request #928 from pateljannat/completion-certificate
feat: completion certificate
2024-07-12 20:36:06 +05:30
Jannat Patel
166996d77a chore: removed unnecessary lines 2024-07-12 20:17:42 +05:30
Jannat Patel
4943e0e902 chore: fixed linters 2024-07-12 19:58:52 +05:30
Jannat Patel
1db6a8bfda chore: fixed linters 2024-07-12 19:56:01 +05:30
Jannat Patel
57f43b256a feat: enable certification from course form 2024-07-12 19:46:45 +05:30
Jannat Patel
23b2e8d682 feat: generate certificate from course page 2024-07-12 15:56:50 +05:30
Jannat Patel
6e1d62340f feat: completion certificate 2024-07-11 18:12:15 +05:30
175 changed files with 16209 additions and 8675 deletions

View File

@@ -22,7 +22,7 @@ git config user.name "frappe-pr-bot"
echo "Setting the correct git remote..."
# Here, the git remote is a local file path by default. Let's change it to the upstream repo.
git remote add upstream https://github.com/frappe/lms.git
git remote set-url upstream https://github.com/frappe/lms.git
echo "Creating a new branch..."
isodate=$(date -u +"%Y-%m-%d")

View File

@@ -5,7 +5,7 @@ on:
workflow_dispatch:
jobs:
regeneratee-pot-file:
regenerate-pot-file:
name: Release
runs-on: ubuntu-latest
strategy:

27
.github/workflows/make_release_pr.yml vendored Normal file
View File

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

32
.github/workflows/on_release.yml vendored Normal file
View File

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

39
.github/workflows/release_notes.yml vendored Normal file
View File

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

View File

@@ -99,6 +99,7 @@ jobs:
cd ~/frappe-bench/
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
- name: cypress pre-requisites
run: |

3
.gitignore vendored
View File

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

3
.gitmodules vendored Normal file
View File

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

21
.releaserc Normal file
View File

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

View File

@@ -5,7 +5,7 @@ describe("Course Creation", () => {
cy.visit("/lms/courses");
// Create a course
cy.get("a").contains("New Course").click();
cy.get("a").contains("New").click();
cy.wait(1000);
cy.url().should("include", "/courses/new/edit");
@@ -31,12 +31,35 @@ describe("Course Creation", () => {
.contains("Preview Video")
.type("https://www.youtube.com/embed/-LPmw2Znl2c");
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
cy.get(".search-input").click().type("frappe");
cy.wait(1000);
cy.get("label")
.contains("Category")
.parent()
.within(() => {
cy.get("button").click();
});
cy.get("[id^=headlessui-combobox-option-")
.should("be.visible")
.first()
.click();
/* Instructor */
cy.get("label")
.contains("Instructors")
.parent()
.within(() => {
cy.get("input").click().type("frappe");
cy.get("input")
.invoke("attr", "aria-controls")
.as("instructor_list_id");
});
cy.get("@instructor_list_id").then((instructor_list_id) => {
cy.get(`[id^=${instructor_list_id}`)
.should("be.visible")
.within(() => {
cy.get("[id^=headlessui-combobox-option-").first().click();
});
});
cy.get("label").contains("Published").click();
cy.get("label").contains("Published On").type("2021-01-01");
cy.button("Save").click();
@@ -61,21 +84,7 @@ describe("Course Creation", () => {
cy.wait(1000);
cy.get("label").contains("Title").type("Test Lesson");
/* cy.get("#content .ce-block")
.click()
.invoke("text", "https://www.youtube.com/watch?v=GoDtyItReto"); */
/* cy.get("#content .ce-block")
.click()
.paste("https://www.youtube.com/watch?v=GoDtyItReto"); */
cy.fixture("Youtube.mov", "base64").then((fileContent) => {
cy.get('input[type="file"]').attachFile({
fileContent,
fileName: "Youtube.mov",
mimeType: "image/png",
encoding: "base64",
});
});
cy.get("#content .ce-block").type(
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
);
@@ -119,12 +128,6 @@ describe("Course Creation", () => {
cy.url().should("include", "/learn/1-1");
cy.get("div").contains("Test Lesson");
cy.get("video")
.should("be.visible")
.children("source")
.invoke("attr", "src")
.should("include", "/files/Youtube");
cy.get("div").contains(
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
);

View File

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

View File

@@ -14,14 +14,16 @@
"@editorjs/editorjs": "^2.29.0",
"@editorjs/embed": "^2.7.0",
"@editorjs/header": "^2.8.1",
"@editorjs/image": "^2.9.0",
"@editorjs/inline-code": "^1.5.0",
"@editorjs/nested-list": "^1.4.2",
"@editorjs/paragraph": "^2.11.3",
"@editorjs/simple-image": "^1.6.0",
"ace-builds": "^1.36.2",
"chart.js": "^4.4.1",
"codemirror-editor-vue3": "^2.8.0",
"dayjs": "^1.11.6",
"feather-icons": "^4.28.0",
"frappe-ui": "^0.1.56",
"frappe-ui": "^0.1.69",
"lucide-vue-next": "^0.383.0",
"markdown-it": "^14.0.0",
"pinia": "^2.0.33",

BIN
frontend/public/Quiz.mp4 Normal file

Binary file not shown.

BIN
frontend/public/Upload.mp4 Normal file

Binary file not shown.

Binary file not shown.

BIN
frontend/public/Youtube.mp4 Normal file

Binary file not shown.

View File

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

View File

@@ -7,7 +7,7 @@
class="flex flex-col overflow-hidden"
:class="isSidebarCollapsed ? 'items-center' : ''"
>
<UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" />
<UserDropdown :isCollapsed="isSidebarCollapsed" />
<div class="flex flex-col" v-if="sidebarSettings.data">
<SidebarLink
v-for="link in sidebarLinks"
@@ -18,7 +18,7 @@
</div>
<div
v-if="sidebarSettings.data?.web_pages?.length || isModerator"
class="pt-1"
class="mt-4"
>
<div
class="flex items-center justify-between pr-2 cursor-pointer"
@@ -27,7 +27,7 @@
>
<div
v-if="!isSidebarCollapsed"
class="flex items-center text-sm font-medium text-gray-600"
class="flex items-center text-sm text-gray-600 my-1"
>
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<ChevronRight
@@ -36,7 +36,7 @@
/>
</span>
<span class="ml-2">
{{ __('Web Pages') }}
{{ __('More') }}
</span>
</div>
<Button v-if="isModerator" variant="ghost" @click="openPageModal()">
@@ -100,13 +100,14 @@ import { ChevronRight, Plus } from 'lucide-vue-next'
import { createResource, Button } from 'frappe-ui'
import PageModal from '@/components/Modals/PageModal.vue'
const { user } = sessionStore()
const { user, sidebarSettings } = sessionStore()
const { userResource } = usersStore()
const socket = inject('$socket')
const unreadCount = ref(0)
const sidebarLinks = ref(getSidebarLinks())
const showPageModal = ref(false)
const isModerator = ref(false)
const isInstructor = ref(false)
const pageToEdit = ref(null)
const showWebPages = ref(false)
@@ -115,6 +116,20 @@ onMounted(() => {
unreadNotifications.reload()
})
addNotifications()
sidebarSettings.reload(
{},
{
onSuccess(data) {
Object.keys(data).forEach((key) => {
if (!parseInt(data[key])) {
sidebarLinks.value = sidebarLinks.value.filter(
(link) => link.label.toLowerCase().split(' ').join('_') !== key
)
}
})
},
}
)
})
const unreadNotifications = createResource({
@@ -153,20 +168,16 @@ const addNotifications = () => {
}
}
const sidebarSettings = createResource({
url: 'lms.lms.api.get_sidebar_settings',
cache: 'Sidebar Settings',
auto: true,
onSuccess(data) {
Object.keys(data).forEach((key) => {
if (!parseInt(data[key])) {
sidebarLinks.value = sidebarLinks.value.filter(
(link) => link.label.toLowerCase().split(' ').join('_') !== key
)
}
const addQuizzes = () => {
if (isInstructor.value || isModerator.value) {
sidebarLinks.value.push({
label: 'Quizzes',
icon: 'CircleHelp',
to: 'Quizzes',
activeFor: ['Quizzes', 'QuizForm'],
})
},
})
}
}
const openPageModal = (link) => {
showPageModal.value = true
@@ -198,6 +209,8 @@ const getSidebarFromStorage = () => {
watch(userResource, () => {
if (userResource.data) {
isModerator.value = userResource.data.is_moderator
isInstructor.value = userResource.data.is_instructor
addQuizzes()
}
})

View File

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

View File

@@ -1,7 +1,15 @@
<template>
<div>
<div class="text-lg font-semibold mb-4">
{{ __('Assessments') }}
<div class="flex items-center justify-between">
<div class="text-lg font-semibold mb-4">
{{ __('Assessments') }}
</div>
<Button v-if="canSeeAddButton()" @click="showModal = true">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('Add') }}
</Button>
</div>
<div v-if="assessments.data?.length">
<ListView
@@ -9,41 +17,76 @@
:rows="assessments.data"
row-key="name"
:options="{
selectable: false,
showTooltip: false,
getRowRoute: (row) => {
if (row.submission) {
return {
name: 'AssignmentSubmission',
params: {
assignmentName: row.assessment_name,
submissionName: row.submission.name,
},
}
} else {
return {
name: 'AssignmentSubmission',
params: {
assignmentName: row.assessment_name,
submissionName: 'new',
},
}
}
},
getRowRoute: (row) => getRowRoute(row),
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
>
<ListHeaderItem :item="item" v-for="item in getAssessmentColumns()">
<template #prefix="{ item }">
<component
v-if="item.icon"
:is="item.icon"
class="h-4 w-4 stroke-1.5 ml-4"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in assessments.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeAssessments(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
<div v-else class="text-sm italic text-gray-600">
{{ __('No Assessments') }}
</div>
</div>
<AssessmentModal
v-model="showModal"
v-model:assessments="assessments"
:batch="props.batch"
/>
</template>
<script setup>
import { ListView, createResource } from 'frappe-ui'
import { inject } from 'vue'
import {
ListView,
ListRow,
ListRows,
ListHeader,
ListHeaderItem,
ListRowItem,
ListSelectBanner,
createResource,
Button,
} from 'frappe-ui'
import { inject, ref } from 'vue'
import AssessmentModal from '@/components/Modals/AssessmentModal.vue'
import { Plus, Trash2 } from 'lucide-vue-next'
const user = inject('$user')
const showModal = ref(false)
const props = defineProps({
batch: {
@@ -74,6 +117,61 @@ const assessments = createResource({
auto: true,
})
const deleteAssessments = createResource({
url: 'lms.lms.api.delete_documents',
makeParams(values) {
return {
doctype: 'LMS Assessment',
documents: values.assessments,
}
},
})
const removeAssessments = (selections, unselectAll) => {
deleteAssessments.submit(
{ assessments: Array.from(selections) },
{
onSuccess(data) {
assessments.reload()
unselectAll()
},
}
)
}
const getRowRoute = (row) => {
if (row.assessment_type == 'LMS Assignment') {
if (row.submission) {
return {
name: 'AssignmentSubmission',
params: {
assignmentName: row.assessment_name,
submissionName: row.submission.name,
},
}
} else {
return {
name: 'AssignmentSubmission',
params: {
assignmentName: row.assessment_name,
submissionName: 'new',
},
}
}
} else {
return {
name: 'QuizPage',
params: {
quizID: row.assessment_name,
},
}
}
}
const canSeeAddButton = () => {
return user.data?.is_moderator || user.data?.is_evaluator
}
const getAssessmentColumns = () => {
let columns = [
{

View File

@@ -56,7 +56,6 @@ const props = defineProps({
onMounted(() => {
setTimeout(() => {
audio.value = document.querySelector('audio')
console.log(audio.value)
audio.value.onloadedmetadata = () => {
duration.value = audio.value.duration
}

View File

@@ -3,6 +3,9 @@
class="flex flex-col shadow hover:bg-gray-100 rounded-md p-4 h-full"
style="min-height: 150px"
>
<div class="text-lg leading-5 font-semibold mb-2">
{{ batch.title }}
</div>
<Badge
v-if="batch.seat_count && batch.seats_left > 0"
theme="green"
@@ -19,49 +22,48 @@
>
{{ __('Sold Out') }}
</Badge>
<div class="text-xl font-semibold mb-1">
{{ batch.title }}
</div>
<div class="short-introduction">
<div class="short-introduction text-sm text-gray-700">
{{ batch.description }}
</div>
<div v-if="batch.amount" class="font-semibold mb-4">
{{ batch.price }}
</div>
<div class="flex flex-col space-y-2 mt-auto">
<div v-if="batch.amount" class="font-semibold text-lg">
{{ batch.price }}
</div>
<div class="flex items-center">
<BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span> {{ batch.courses.length }} {{ __('Courses') }} </span>
</div>
<DateRange
:startDate="batch.start_date"
:endDate="batch.end_date"
class="mb-3"
class="text-sm text-gray-700"
/>
<div class="flex items-center">
<div class="flex items-center text-sm text-gray-700">
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span>
{{ formatTime(batch.start_time) }} - {{ formatTime(batch.end_time) }}
</span>
</div>
<div v-if="batch.timezone" class="flex items-center">
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<div
v-if="batch.timezone"
class="flex items-center text-sm text-gray-700"
>
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-gray-600" />
<span>
{{ batch.timezone }}
</span>
</div>
<div v-if="batch.instructors?.length" class="flex avatar-group overlap">
<div
class="h-6 mr-1"
:class="{ 'avatar-group overlap': batch.instructors.length > 1 }"
>
<UserAvatar
v-for="instructor in batch.instructors"
:user="instructor"
/>
</div>
<CourseInstructors :instructors="batch.instructors" />
</div>
<div
v-if="batch.instructors?.length"
class="flex avatar-group overlap mt-4"
>
<div
class="h-6 mr-1"
:class="{ 'avatar-group overlap': batch.instructors.length > 1 }"
>
<UserAvatar
v-for="instructor in batch.instructors"
:user="instructor"
/>
</div>
<CourseInstructors :instructors="batch.instructors" />
</div>
</div>
</template>
@@ -88,7 +90,7 @@ const props = defineProps({
text-overflow: ellipsis;
width: 100%;
overflow: hidden;
margin: 0.25rem 0 1.25rem;
margin: 0.25rem 0 1rem;
line-height: 1.5;
}

View File

@@ -4,15 +4,11 @@
<div class="text-xl font-semibold">
{{ __('Courses') }}
</div>
<Button
v-if="user.data?.is_moderator"
variant="solid"
@click="openCourseModal()"
>
<Button v-if="canSeeAddButton()" @click="openCourseModal()">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('Add Course') }}
{{ __('Add') }}
</Button>
</div>
<div v-if="courses.data?.length">
@@ -88,6 +84,7 @@ import {
ListRowItem,
} from 'frappe-ui'
import { Plus, Trash2 } from 'lucide-vue-next'
import { showToast } from '@/utils'
const showCourseModal = ref(false)
const user = inject('$user')
@@ -132,23 +129,32 @@ const getCoursesColumns = () => {
]
}
const removeCourse = createResource({
url: 'frappe.client.delete',
const deleteCourses = createResource({
url: 'lms.lms.api.delete_documents',
makeParams(values) {
return {
doctype: 'Batch Course',
name: values.course,
documents: values.courses,
}
},
})
const removeCourses = (selections, unselectAll) => {
selections.forEach(async (course) => {
removeCourse.submit({ course })
})
setTimeout(() => {
courses.reload()
unselectAll()
}, 1000)
deleteCourses.submit(
{
courses: Array.from(selections),
},
{
onSuccess(data) {
courses.reload()
showToast(__('Success'), __('Courses deleted successfully'), 'check')
unselectAll()
},
}
)
}
const canSeeAddButton = () => {
return user.data?.is_moderator || user.data?.is_evaluator
}
</script>

View File

@@ -75,13 +75,14 @@
variant="solid"
class="w-full mt-2"
v-else-if="batch.data.allow_self_enrollment && batch.data.seats_left"
@click="enrollInBatch()"
>
{{ __('Enroll Now') }}
</Button>
<router-link
v-if="isModerator"
:to="{
name: 'BatchCreation',
name: 'BatchForm',
params: {
batchName: batch.data.name,
},
@@ -97,11 +98,13 @@
</template>
<script setup>
import { inject, computed } from 'vue'
import { Badge, Button } from 'frappe-ui'
import { Badge, Button, createResource } from 'frappe-ui'
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
import { formatNumberIntoCurrency, formatTime } from '@/utils'
import { formatNumberIntoCurrency, formatTime, showToast } from '@/utils'
import DateRange from '@/components/Common/DateRange.vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const user = inject('$user')
const props = defineProps({
@@ -111,6 +114,39 @@ const props = defineProps({
},
})
const enroll = createResource({
url: 'lms.lms.utils.enroll_in_batch',
makeParams(values) {
return {
batch: props.batch.data.name,
}
},
})
const enrollInBatch = () => {
if (!user.data) {
window.location.href = `/login?redirect-to=/batches/details/${props.batch.data.name}`
}
enroll.submit(
{},
{
onSuccess(data) {
showToast(
__('Success'),
__('You have been enrolled in this batch'),
'check'
)
router.push({
name: 'Batch',
params: {
batchName: props.batch.data.name,
},
})
},
}
)
}
const seats_left = computed(() => {
if (props.batch.data?.seat_count) {
return props.batch.data?.seat_count - props.batch.data?.students?.length

View File

@@ -1,9 +1,9 @@
<template>
<Button class="float-right mb-3" variant="solid" @click="openStudentModal()">
<Button class="float-right mb-3" @click="openStudentModal()">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('Add Student') }}
{{ __('Add') }}
</Button>
<div class="text-lg font-semibold mb-4">
{{ __('Students') }}
@@ -88,6 +88,7 @@ import {
import { Trash2, Plus } from 'lucide-vue-next'
import { ref } from 'vue'
import StudentModal from '@/components/Modals/StudentModal.vue'
import { showToast } from '@/utils'
const showStudentModal = ref(false)
@@ -135,23 +136,28 @@ const openStudentModal = () => {
showStudentModal.value = true
}
const removeStudent = createResource({
url: 'frappe.client.delete',
const deleteStudents = createResource({
url: 'lms.lms.api.delete_documents',
makeParams(values) {
return {
doctype: 'Batch Student',
name: values.student,
documents: values.students,
}
},
})
const removeStudents = (selections, unselectAll) => {
selections.forEach(async (student) => {
removeStudent.submit({ student })
})
setTimeout(() => {
students.reload()
unselectAll()
}, 500)
deleteStudents.submit(
{
students: Array.from(selections),
},
{
onSuccess(data) {
students.reload()
showToast(__('Success'), __('Students deleted successfully'), 'check')
unselectAll()
},
}
)
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

@@ -108,6 +108,7 @@ const options = createResource({
url: 'frappe.desk.search.search_link',
cache: [props.doctype, text.value],
method: 'POST',
auto: true,
params: {
txt: text.value,
doctype: props.doctype,
@@ -118,6 +119,7 @@ const options = createResource({
return {
label: option.value,
value: option.value,
description: option.description,
}
})
},

View File

@@ -152,24 +152,11 @@ const filterOptions = createResource({
url: 'frappe.desk.search.search_link',
method: 'POST',
cache: [text.value, props.doctype],
auto: true,
params: {
txt: text.value,
doctype: props.doctype,
},
/* transform: (data) => {
let allData = data
.filter((c) => {
return c.description.split(', ')[1]
})
.map((option) => {
let email = option.description.split(', ')[1]
return {
label: option.label || email,
value: email,
}
})
return allData
}, */
})
const options = computed(() => {

View File

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

View File

@@ -2,28 +2,23 @@
<div
v-if="course.title"
class="flex flex-col h-full rounded-md shadow-md text-base overflow-auto"
style="min-height: 320px"
style="min-height: 350px"
>
<div
class="course-image"
:class="{ 'default-image': !course.image }"
:style="{ backgroundImage: 'url(\'' + encodeURI(course.image) + '\')' }"
>
<div class="flex relative top-4 left-4 w-fit flex-wrap">
<Badge
v-if="course.featured"
variant="subtle"
theme="green"
size="md"
class="mr-2"
>
<div
class="flex items-center flex-wrap space-y-1 space-x-1 relative top-4 px-2 w-fit"
>
<Badge v-if="course.featured" variant="subtle" theme="green" size="md">
{{ __('Featured') }}
</Badge>
<Badge
variant="outline"
theme="gray"
size="md"
class="mr-2"
v-for="tag in course.tags"
>
{{ tag }}
@@ -77,7 +72,7 @@
{{ course.title }}
</div>
<div class="short-introduction">
<div class="short-introduction text-gray-700 text-sm">
{{ course.short_introduction }}
</div>

View File

@@ -63,10 +63,19 @@
{{ __('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: 'CreateCourse',
name: 'CourseForm',
params: {
courseName: course.data.name,
},
@@ -107,7 +116,8 @@
import { BookOpen, Users, Star } from 'lucide-vue-next'
import { computed, inject } from 'vue'
import { Button, createResource } from 'frappe-ui'
import { createToast } from '@/utils/'
import { showToast } from '@/utils/'
import { capture } from '@/telemetry'
import { useRouter } from 'vue-router'
const router = useRouter()
@@ -129,14 +139,14 @@ const video_link = computed(() => {
function enrollStudent() {
if (!user.data) {
createToast({
title: 'Please Login',
icon: 'alert-circle',
iconClasses: 'text-yellow-600 bg-yellow-100',
})
showToast(
__('Please Login'),
__('You need to login first to enroll for this course'),
'circle-warn'
)
setTimeout(() => {
window.location.href = `/login?redirect-to=${window.location.pathname}`
}, 3000)
}, 2000)
} else {
const enrollStudentResource = createResource({
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
@@ -146,11 +156,14 @@ function enrollStudent() {
course: props.course.data.name,
})
.then(() => {
createToast({
title: 'Enrolled Successfully',
icon: 'check',
iconClasses: 'text-green-600 bg-green-100',
capture('enrolled_in_course', {
course: props.course.data.name,
})
showToast(
__('Success'),
__('You have been enrolled in this course'),
'check'
)
setTimeout(() => {
router.push({
name: 'Lesson',
@@ -160,7 +173,7 @@ function enrollStudent() {
lessonNumber: 1,
},
})
}, 3000)
}, 2000)
})
}
}
@@ -174,4 +187,38 @@ const is_instructor = () => {
})
return user_is_instructor
}
const canGetCertificate = computed(() => {
if (
props.course.data?.enable_certification &&
props.course.data?.membership?.progress == 100
) {
return true
}
return false
})
const certificate = createResource({
url: 'lms.lms.doctype.lms_certificate.lms_certificate.create_certificate',
makeParams(values) {
return {
course: values.course,
}
},
onSuccess(data) {
window.open(
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
data.name
}&format=${encodeURIComponent(data.template)}`,
'_blank'
)
},
})
const fetchCertificate = () => {
certificate.submit({
course: props.course.data?.name,
member: user.data?.name,
})
}
</script>

View File

@@ -4,7 +4,7 @@
v-if="title && (outline.data?.length || allowEdit)"
class="grid grid-cols-[70%,30%] mb-4 px-2"
>
<div class="font-semibold text-lg">
<div class="font-semibold text-lg leading-5">
{{ __(title) }}
</div>
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
@@ -41,6 +41,7 @@
<DisclosurePanel>
<Draggable
:list="chapter.lessons"
:disabled="!allowEdit"
item-key="name"
group="items"
@end="updateOutline"
@@ -50,7 +51,7 @@
<div class="outline-lesson pl-8 py-2 pr-4">
<router-link
:to="{
name: allowEdit ? 'CreateLesson' : 'Lesson',
name: allowEdit ? 'LessonForm' : 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.number.split('.')[0],
@@ -75,7 +76,7 @@
<Trash2
v-if="allowEdit"
@click.prevent="trashLesson(lesson.name, chapter.name)"
class="h-4 w-4 stroke-1.5 text-gray-700 ml-auto invisible group-hover:visible"
class="h-4 w-4 text-red-500 ml-auto invisible group-hover:visible"
/>
<Check
v-if="lesson.is_complete"
@@ -89,7 +90,7 @@
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
<router-link
:to="{
name: 'CreateLesson',
name: 'LessonForm',
params: {
courseName: courseName,
chapterNumber: chapter.idx,
@@ -118,7 +119,7 @@
</template>
<script setup>
import { Button, createResource } from 'frappe-ui'
import { ref } from 'vue'
import { ref, getCurrentInstance } from 'vue'
import Draggable from 'vuedraggable'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
import {
@@ -137,6 +138,8 @@ const route = useRoute()
const expandAll = ref(true)
const showChapterModal = ref(false)
const currentChapter = ref(null)
const app = getCurrentInstance()
const { $dialog } = app.appContext.config.globalProperties
const props = defineProps({
courseName: {
@@ -201,9 +204,23 @@ const updateLessonIndex = createResource({
})
const trashLesson = (lessonName, chapterName) => {
deleteLesson.submit({
lesson: lessonName,
chapter: chapterName,
$dialog({
title: __('Delete Lesson'),
message: __('Are you sure you want to delete this lesson?'),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
onClick(close) {
deleteLesson.submit({
lesson: lessonName,
chapter: chapterName,
})
close()
},
},
],
})
}

View File

@@ -1,7 +1,7 @@
<template>
<div>
<Button v-if="!singleThread" class="float-right" @click="openTopicModal()">
{{ __('New {0}').format(title) }}
{{ __('New {0}').format(singularize(title)) }}
</Button>
<div class="text-xl font-semibold">
{{ __(title) }}
@@ -65,7 +65,7 @@
<script setup>
import { createResource, Button } from 'frappe-ui'
import UserAvatar from '@/components/UserAvatar.vue'
import { timeAgo } from '../utils'
import { singularize, timeAgo } from '../utils'
import { ref, onMounted, inject } from 'vue'
import DiscussionReplies from '@/components/DiscussionReplies.vue'
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'

View File

@@ -37,7 +37,7 @@
<iframe
:src="getPDFSource(block)"
width="100%"
height="400"
height="700px"
frameborder="0"
allowfullscreen
></iframe>

View File

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

View File

@@ -1,163 +0,0 @@
<template>
<div class="text-lg font-semibold">
{{ __('Components') }}
</div>
<div class="mt-5">
<Tooltip
:text="
__(
'Content such as quiz, video and image will be added in the editor you select.'
)
"
placement="bottom"
>
<div class="">
<div class="text-xs text-gray-600 mb-1">
{{ __('Select an Editor') }}
</div>
<Select v-model="currentEditor" :options="getEditorOptions()" />
</div>
</Tooltip>
<div class="flex mt-4">
<Link
v-model="quiz"
class="flex-1"
doctype="LMS Quiz"
:label="__('Select a Quiz')"
/>
<Button @click="addQuiz()" class="self-end ml-2">
<template #icon>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div>
<div class="mt-4">
<div class="text-xs text-gray-600 mb-1">
{{ __('Add an image, video, pdf or audio.') }}
</div>
<div class="flex">
<FileUploader
v-if="!file"
:fileTypes="['image/*', 'video/*', 'audio/*', '.pdf']"
:validateFile="validateFile"
@success="(data) => addFile(data)"
>
<template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading
? __('Uploading {0}%').format(progress)
: __('Upload a File')
}}
</Button>
</div>
</template>
</FileUploader>
<div v-else class="">
<div class="flex items-center">
<div class="border rounded-md p-2 mr-2">
<FileText class="h-4 w-4 stroke-1.5 text-gray-700" />
</div>
<div class="flex flex-col">
<span class="text-xs">
{{ file.file_name }}
</span>
</div>
</div>
</div>
</div>
</div>
<div class="mt-4">
<div class="text-xs text-gray-600 mb-1">
{{
__(
'To add a YouTube video, paste the URL of the video in the editor.'
)
}}
</div>
<YouTubeExplanation>
<template v-slot="{ togglePopover }">
<div
@click="togglePopover()"
class="flex items-center text-sm underline cursor-pointer"
>
<Info class="w-3 h-3 stroke-1.5 text-gray-700 mr-1" />
{{ __('Learn More') }}
</div>
</template>
</YouTubeExplanation>
</div>
</div>
</template>
<script setup>
import Link from '@/components/Controls/Link.vue'
import { FileUploader, Button, Select, Tooltip } from 'frappe-ui'
import { Plus, FileText, Info } from 'lucide-vue-next'
import { ref, watch } from 'vue'
import YouTubeExplanation from '@/components/Modals/YouTubeExplanation.vue'
const quiz = ref(null)
const file = ref(null)
const lessonEditor = ref(null)
const instructorEditor = ref(null)
const currentEditor = ref('Lesson Content')
const props = defineProps({
editor: {
required: true,
},
notesEditor: {
required: true,
},
})
const addQuiz = () => {
getCurrentEditor().caret.setToLastBlock('end', 0)
if (quiz.value) {
getCurrentEditor().blocks.insert('quiz', {
quiz: quiz.value,
})
quiz.value = null
}
}
const addFile = (data) => {
getCurrentEditor().caret.setToLastBlock('end', 0)
getCurrentEditor().blocks.insert('upload', data)
}
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png', 'mp4', 'mov', 'mp3', 'pdf'].includes(extension)) {
return 'Only image and video files are allowed.'
}
}
const getEditorOptions = () => {
return [
{
label: 'Lesson Content',
value: 'Lesson Content',
},
{
label: 'Instructor Content',
value: 'Instructor Content',
},
]
}
const getCurrentEditor = () => {
return currentEditor.value == 'Lesson Content'
? lessonEditor.value
: instructorEditor.value
}
watch(
() => [props.editor, props.notesEditor],
([newEditor, newNotesEditor], [oldEditor, oldNotesEditor]) => {
lessonEditor.value = newEditor
instructorEditor.value = newNotesEditor
}
)
</script>

View File

@@ -1,33 +1,30 @@
<template>
<Button
v-if="user.data.is_moderator"
variant="solid"
class="float-right mb-5"
@click="openLiveClassModal"
>
<template #prefix>
<Plus class="h-4 w-4" />
</template>
<span>
{{ __('Add Live Class') }}
</span>
</Button>
<div class="text-lg font-semibold mb-5">
{{ __('Live Class') }}
<div class="flex items-center justify-between mb-5">
<div class="text-lg font-semibold">
{{ __('Live Class') }}
</div>
<Button v-if="user.data.is_moderator" @click="openLiveClassModal">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
<span>
{{ __('Add') }}
</span>
</Button>
</div>
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
<div
v-for="cls in liveClasses.data"
class="flex flex-col border rounded-md h-full p-3"
class="flex flex-col border rounded-md h-full text-sm text-gray-700 p-3"
>
<div class="font-semibold text-lg mb-4">
<div class="font-semibold text-gray-900 text-lg mb-4">
{{ cls.title }}
</div>
<div class="mb-4">
<div class="leading-5 text-gray-700 text-sm mb-4">
{{ cls.description }}
</div>
<div class="flex items-center mb-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<Calendar class="w-4 h-4 stroke-1.5 text-gray-700" />
<span class="ml-2">
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span>
@@ -38,8 +35,9 @@
{{ formatTime(cls.time) }}
</span>
</div>
<div class="flex items-center space-x-2 mt-auto">
<div class="flex items-center space-x-2 text-gray-900 mt-auto">
<a
v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url"
target="_blank"
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
@@ -90,7 +88,6 @@ const liveClasses = createListResource({
doctype: 'LMS Live Class',
filters: {
batch_name: props.batch,
date: ['>=', new Date()],
},
fields: [
'title',

View File

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

View File

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

View File

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

View File

@@ -15,17 +15,24 @@
}"
>
<template #body-content>
<FormControl label="Title" v-model="chapter.title" class="mb-4" />
<FormControl
ref="chapterInput"
label="Title"
v-model="chapter.title"
class="mb-4"
/>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, FormControl, createResource } from 'frappe-ui'
import { defineModel, reactive, watch, inject } from 'vue'
import { createToast, formatTime } from '@/utils/'
import { defineModel, reactive, watch, ref } from 'vue'
import { createToast } from '@/utils/'
import { capture } from '@/telemetry'
const show = defineModel()
const outline = defineModel('outline')
const chapterInput = ref(null)
const props = defineProps({
course: {
@@ -36,6 +43,7 @@ const props = defineProps({
type: Object,
},
})
const chapter = reactive({
title: '',
})
@@ -91,10 +99,12 @@ const addChapter = (close) => {
}
},
onSuccess: (data) => {
capture('chapter_created')
chapterReference.submit(
{ name: data.name },
{
onSuccess(data) {
chapter.title = ''
outline.value.reload()
createToast({
text: 'Chapter added successfully',
@@ -158,4 +168,12 @@ watch(
chapter.title = newChapter?.title
}
)
watch(show, () => {
if (show.value) {
setTimeout(() => {
chapterInput.value.$el.querySelector('input').focus()
}, 100)
}
})
</script>

View File

@@ -1,7 +1,7 @@
<template>
<Dialog
:options="{
title: props.title,
title: singularize(props.title),
size: '2xl',
actions: [
{
@@ -35,8 +35,8 @@
</template>
<script setup>
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui'
import { reactive, defineModel, computed } from 'vue'
import { showToast } from '@/utils'
import { reactive, defineModel } from 'vue'
import { showToast, singularize } from '@/utils'
const topics = defineModel('reloadTopics')

View File

@@ -130,11 +130,20 @@ function submitEvaluation(close) {
close()
},
onError(err) {
let message = err.messages?.[0] || err
let unavailabilityMessage
if (typeof message === 'string') {
unavailabilityMessage = message?.includes('unavailable')
} else {
unavailabilityMessage = false
}
createToast({
title: 'Error',
text: err.messages?.[0] || err,
icon: 'x',
iconClasses: 'bg-red-600 text-white rounded-md p-px',
title: unavailabilityMessage ? __('Evaluator is Unavailable') : '',
text: message,
icon: unavailabilityMessage ? 'alert-circle' : 'x',
iconClasses: 'bg-yellow-600 text-white rounded-md p-px',
position: 'top-center',
timeout: 10,
})

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,31 +0,0 @@
<template>
<Popover transition="default">
<template #target="{ isOpen, togglePopover }" class="flex w-full">
<slot v-bind="{ isOpen, togglePopover }"></slot>
</template>
<template #body>
<div
class="absolute left-0 mt-3 w-[35rem] max-w-lg -translate-x-1/2 transform rounded-lg bg-white px-4 sm:px-0 lg:max-w-3xl"
>
<div
class="overflow-hidden rounded-lg p-3 shadow-2xl ring-1 ring-black ring-opacity-5"
>
<video
controls
autoplay
muted
width="100%"
controlsList="nodownload"
oncontextmenu="return false;"
class="rounded-sm"
>
<source src="/Youtube.mov" type="video/mp4" />
</video>
</div>
</div>
</template>
</Popover>
</template>
<script setup>
import { Popover } from 'frappe-ui'
</script>

View File

@@ -24,7 +24,7 @@
<div>
{{ __('Please login to access this page.') }}
</div>
<Button variant="solid" @click="redirectToLogin()" class="mt-2">
<Button @click="redirectToLogin()" class="mt-4">
{{ __('Login') }}
</Button>
</div>

View File

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

View File

@@ -1,10 +1,24 @@
<template>
<div v-if="quiz.data">
<div class="bg-blue-100 py-2 px-2 mb-4 rounded-md text-sm text-blue-800">
<div class="leading-relaxed">
<div
class="bg-blue-100 space-y-1 py-2 px-2 rounded-md text-sm text-blue-800"
>
<div class="leading-5">
{{
__('This quiz consists of {0} questions.').format(
quiz.data.questions.length
__('This quiz consists of {0} questions.').format(questions.length)
}}
</div>
<div v-if="quiz.data?.duration" class="leading-5">
{{
__(
'Please ensure that you complete all the questions in {0} minutes.'
).format(quiz.data.duration)
}}
</div>
<div v-if="quiz.data?.duration" class="leading-5">
{{
__(
'If you fail to do so, the quiz will be automatically submitted when the timer ends.'
)
}}
</div>
@@ -24,14 +38,16 @@
)
}}
</div>
<div v-if="quiz.data.time" class="leading-relaxed">
{{
__(
'The quiz has a time limit. For each question you will be given {0} seconds.'
).format(quiz.data.time)
}}
</div>
</div>
<div v-if="quiz.data.duration" class="flex items-center space-x-2 my-4">
<span class="text-gray-600 text-xs"> {{ __('Time') }}: </span>
<ProgressBar :progress="timerProgress" />
<span class="font-semibold">
{{ formatTimer(timer) }}
</span>
</div>
<div v-if="activeQuestion == 0">
<div class="border text-center p-20 rounded-md">
<div class="font-semibold text-lg">
@@ -59,25 +75,18 @@
</div>
</div>
<div v-else-if="!quizSubmission.data">
<div v-for="(question, qtidx) in quiz.data.questions">
<div v-for="(question, qtidx) in questions">
<div
v-if="qtidx == activeQuestion - 1 && questionDetails.data"
class="border rounded-md p-5"
>
<div class="flex justify-between">
<div class="text-sm">
<div class="text-sm text-gray-600">
<span class="mr-2">
{{ __('Question {0}').format(activeQuestion) }}:
</span>
<span v-if="questionDetails.data.type == 'User Input'">
{{ __('Type your answer') }}
</span>
<span v-else>
{{
questionDetails.data.multiple
? __('Choose all answers that apply')
: __('Choose one answer')
}}
<span>
{{ getInstructions(questionDetails.data) }}
</span>
</div>
<div class="text-gray-900 text-sm font-semibold item-left">
@@ -86,7 +95,7 @@
</div>
</div>
<div
class="text-gray-900 font-semibold mt-2"
class="text-gray-900 font-semibold mt-2 leading-5"
v-html="questionDetails.data.question"
></div>
<div v-if="questionDetails.data.type == 'Choices'" v-for="index in 4">
@@ -141,7 +150,7 @@
{{ questionDetails.data[`explanation_${index}`] }}
</div>
</div>
<div v-else>
<div v-else-if="questionDetails.data.type == 'User Input'">
<FormControl
v-model="possibleAnswer"
type="textarea"
@@ -161,17 +170,31 @@
</Badge>
</div>
</div>
<div class="flex items-center justify-between mt-5">
<div>
<div v-else>
<TextEditor
class="mt-4"
:content="possibleAnswer"
@change="(val) => (possibleAnswer = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
<div class="flex items-center justify-between mt-4">
<div class="text-sm text-gray-600">
{{
__('Question {0} of {1}').format(
activeQuestion,
quiz.data.questions.length
questions.length
)
}}
</div>
<Button
v-if="quiz.data.show_answers && !showAnswers.length"
v-if="
quiz.data.show_answers &&
!showAnswers.length &&
questionDetails.data.type != 'Open Ended'
"
@click="checkAnswer()"
>
<span>
@@ -179,7 +202,7 @@
</span>
</Button>
<Button
v-else-if="activeQuestion != quiz.data.questions.length"
v-else-if="activeQuestion != questions.length"
@click="nextQuetion()"
>
<span>
@@ -195,11 +218,18 @@
</div>
</div>
</div>
<div v-else class="border rounded-md p-20 text-center">
<div v-else class="border rounded-md p-20 text-center space-y-4">
<div class="text-lg font-semibold">
{{ __('Quiz Summary') }}
</div>
<div>
<div v-if="quizSubmission.data.is_open_ended">
{{
__(
"Your submission has been successfully saved. The instructor will review and grade it shortly, and you'll be notified of your final result."
)
}}
</div>
<div v-else>
{{
__(
'You got {0}% correct answers with a score of {1} out of {2}'
@@ -238,19 +268,29 @@
</div>
</template>
<script setup>
import { Badge, Button, createResource, ListView } from 'frappe-ui'
import { ref, watch, reactive, inject } from 'vue'
import {
Badge,
Button,
createResource,
ListView,
TextEditor,
FormControl,
} from 'frappe-ui'
import { ref, watch, reactive, inject, computed } from 'vue'
import { createToast } from '@/utils/'
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
import { timeAgo } from '@/utils'
import FormControl from 'frappe-ui/src/components/FormControl.vue'
const user = inject('$user')
import ProgressBar from '@/components/ProgressBar.vue'
const user = inject('$user')
const activeQuestion = ref(0)
const currentQuestion = ref('')
const selectedOptions = reactive([0, 0, 0, 0])
const showAnswers = reactive([])
let questions = reactive([])
const possibleAnswer = ref(null)
const timer = ref(0)
let timerInterval = null
const props = defineProps({
quizName: {
@@ -270,15 +310,62 @@ const quiz = createResource({
cache: ['quiz', props.quizName],
auto: true,
onSuccess(data) {
if (data.shuffle_questions) {
data.questions = data.questions.sort(() => Math.random() - 0.5)
}
if (data.limit_questions_to) {
data.questions = data.questions.slice(0, data.limit_questions_to)
}
populateQuestions()
setupTimer()
},
})
const populateQuestions = () => {
let data = quiz.data
if (data.shuffle_questions) {
questions = shuffleArray(data.questions)
if (data.limit_questions_to) {
questions = questions.slice(0, data.limit_questions_to)
}
} else {
questions = data.questions
}
}
const setupTimer = () => {
if (quiz.data.duration) {
timer.value = quiz.data.duration * 60
}
}
const startTimer = () => {
timerInterval = setInterval(() => {
timer.value--
if (timer.value == 0) {
clearInterval(timerInterval)
submitQuiz()
}
}, 1000)
}
const formatTimer = (seconds) => {
const hrs = Math.floor(seconds / 3600)
.toString()
.padStart(2, '0')
const mins = Math.floor((seconds % 3600) / 60)
.toString()
.padStart(2, '0')
const secs = (seconds % 60).toString().padStart(2, '0')
return hrs != '00' ? `${hrs}:${mins}:${secs}` : `${mins}:${secs}`
}
const timerProgress = computed(() => {
return (timer.value / (quiz.data.duration * 60)) * 100
})
const shuffleArray = (array) => {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[array[i], array[j]] = [array[j], array[i]]
}
return array
}
const attempts = createResource({
url: 'frappe.client.get_list',
makeParams(values) {
@@ -310,7 +397,7 @@ const attempts = createResource({
watch(
() => quiz.data,
() => {
if (quiz.data) {
if (quiz.data && quiz.data.max_attempts) {
attempts.reload()
resetQuiz()
}
@@ -355,6 +442,7 @@ watch(
const startQuiz = () => {
activeQuestion.value = 1
localStorage.removeItem(quiz.data.title)
if (quiz.data.duration) startTimer()
}
const markAnswer = (index) => {
@@ -425,7 +513,7 @@ const checkAnswer = () => {
const addToLocalStorage = () => {
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
let questionData = {
question_index: activeQuestion.value,
question_name: currentQuestion.value,
answer: getAnswers().join(),
is_correct: showAnswers.filter((answer) => {
return answer != undefined
@@ -436,9 +524,10 @@ const addToLocalStorage = () => {
}
const nextQuetion = () => {
if (!quiz.data.show_answers) {
if (!quiz.data.show_answers && questionDetails.data?.type != 'Open Ended') {
checkAnswer()
} else {
if (questionDetails.data?.type == 'Open Ended') addToLocalStorage()
resetQuestion()
}
}
@@ -453,7 +542,8 @@ const resetQuestion = () => {
const submitQuiz = () => {
if (!quiz.data.show_answers) {
checkAnswer()
if (questionDetails.data.type == 'Open Ended') addToLocalStorage()
else checkAnswer()
setTimeout(() => {
createSubmission()
}, 500)
@@ -463,9 +553,15 @@ const submitQuiz = () => {
}
const createSubmission = () => {
quizSubmission.reload().then(() => {
attempts.reload()
})
quizSubmission.submit(
{},
{
onSuccess(data) {
if (quiz.data && quiz.data.max_attempts) attempts.reload()
if (quiz.data.duration) clearInterval(timerInterval)
},
}
)
}
const resetQuiz = () => {
@@ -473,6 +569,15 @@ const resetQuiz = () => {
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
showAnswers.length = 0
quizSubmission.reset()
populateQuestions()
setupTimer()
}
const getInstructions = (question) => {
if (question.type == 'Choices')
if (question.multiple) return __('Choose all answers that apply')
else return __('Choose one answer')
else return __('Type your answer')
}
const getSubmissionColumns = () => {

View File

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

View File

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

View File

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

View File

@@ -27,7 +27,7 @@
: 'ml-2 w-auto opacity-100'
"
>
{{ link.label }}
{{ __(link.label) }}
</span>
<span v-if="link.count" class="!ml-auto block text-xs text-gray-600">
{{ link.count }}

View File

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

View File

@@ -1,5 +1,5 @@
<template>
<Dropdown :options="userDropdownOptions">
<Dropdown class="p-2" :options="userDropdownOptions">
<template v-slot="{ open }">
<button
class="flex h-12 py-2 items-center rounded-md duration-300 ease-in-out"
@@ -11,11 +11,11 @@
: 'hover:bg-gray-200 px-2 w-52'
"
>
<span
v-if="branding.data?.brand_html"
v-html="branding.data?.brand_html"
<img
v-if="branding.data?.banner_image"
:src="branding.data?.banner_image.file_url"
class="w-8 h-8 rounded flex-shrink-0"
></span>
/>
<LMSLogo v-else class="w-8 h-8 rounded flex-shrink-0" />
<div
class="flex flex-1 flex-col text-left duration-300 ease-in-out"
@@ -28,11 +28,10 @@
<div class="text-base font-medium text-gray-900 leading-none">
<span
v-if="
branding.data?.brand_name &&
branding.data?.brand_name != 'Frappe'
branding.data?.app_name && branding.data?.app_name != 'Frappe'
"
>
{{ branding.data?.brand_name }}
{{ branding.data?.app_name }}
</span>
<span v-else> Learning </span>
</div>
@@ -56,27 +55,31 @@
</button>
</template>
</Dropdown>
<SettingsModal
v-if="userResource.data?.is_moderator"
v-model="showSettingsModal"
/>
</template>
<script setup>
import LMSLogo from '@/components/Icons/LMSLogo.vue'
import { sessionStore } from '@/stores/session'
import { Dropdown } from 'frappe-ui'
import {
ChevronDown,
LogIn,
LogOut,
User,
ArrowRightLeft,
} from 'lucide-vue-next'
import Apps from '@/components/Apps.vue'
import { ChevronDown, LogIn, LogOut, User, Settings } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { convertToTitleCase } from '../utils'
import { usersStore } from '@/stores/user'
import { useSettings } from '@/stores/settings'
import { markRaw, watch, ref } from 'vue'
import SettingsModal from '@/components/Modals/Settings.vue'
const router = useRouter()
const { logout, branding } = sessionStore()
let { userResource } = usersStore()
const settingsStore = useSettings()
let { isLoggedIn } = sessionStore()
const showSettingsModal = ref(false)
const props = defineProps({
isCollapsed: {
@@ -85,6 +88,13 @@ const props = defineProps({
},
})
watch(
() => settingsStore.isSettingsOpen,
(value) => {
showSettingsModal.value = value
}
)
const userDropdownOptions = [
{
icon: User,
@@ -97,11 +107,7 @@ const userDropdownOptions = [
},
},
{
icon: ArrowRightLeft,
label: 'Switch to Desk',
onClick: () => {
window.location.href = '/app'
},
component: markRaw(Apps),
condition: () => {
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
let system_user = cookies.get('system_user')
@@ -109,6 +115,16 @@ const userDropdownOptions = [
else return false
},
},
{
icon: Settings,
label: 'Settings',
onClick: () => {
settingsStore.isSettingsOpen = true
},
condition: () => {
return userResource.data?.is_moderator
},
},
{
icon: LogOut,
label: 'Log out',

View File

@@ -1,10 +1,16 @@
<template>
<div ref="videoContainer" class="video-block group relative">
<video @timeupdate="updateTime" @ended="videoEnded" class="rounded-lg">
<video
@timeupdate="updateTime"
@ended="videoEnded"
@click="togglePlay"
class="rounded-lg border border-gray-100 group cursor-pointer"
ref="videoRef"
>
<source :src="fileURL" :type="type" />
</video>
<div
class="flex items-center space-x-2 bg-gray-200 rounded-lg p-0.5 absolute bottom-3 w-[98%] left-0 right-0 mx-auto"
class="flex items-center space-x-2 bg-gray-200 rounded-md p-0.5 absolute bottom-3 w-[98%] left-0 right-0 mx-auto invisible group-hover:visible"
>
<Button variant="ghost">
<template #icon>
@@ -67,7 +73,6 @@ const props = defineProps({
onMounted(() => {
setTimeout(() => {
videoRef.value = document.querySelector('video')
videoRef.value.onloadedmetadata = () => {
duration.value = videoRef.value.duration
}
@@ -102,6 +107,14 @@ const pauseVideo = () => {
playing.value = false
}
const togglePlay = () => {
if (playing.value) {
pauseVideo()
} else {
playVideo()
}
}
const videoEnded = () => {
playing.value = false
}

View File

@@ -5,6 +5,7 @@ import router from './router'
import App from './App.vue'
import { createPinia } from 'pinia'
import dayjs from '@/utils/dayjs'
import { createDialog } from '@/utils/dialogs'
import translationPlugin from './translation'
import { usersStore } from './stores/user'
import { sessionStore } from './stores/session'
@@ -36,3 +37,4 @@ let { isLoggedIn } = sessionStore()
app.provide('$user', userResource)
app.provide('$allUsers', allUsers)
app.config.globalProperties.$user = userResource
app.config.globalProperties.$dialog = createDialog

View File

@@ -13,13 +13,9 @@
<div class="text-lg font-semibold mb-4">
{{ __('Details') }}
</div>
<div class="grid grid-cols-2 gap-10 mb-4">
<div class="grid grid-cols-2 gap-10 mb-4 space-y-2">
<div>
<FormControl
v-model="batch.title"
:label="__('Title')"
class="mb-4"
/>
<FormControl v-model="batch.title" :label="__('Title')" />
</div>
<div class="flex flex-col space-y-2">
<FormControl
@@ -236,6 +232,7 @@ import MultiSelect from '@/components/Controls/MultiSelect.vue'
import { useRouter } from 'vue-router'
import { getFileSize, showToast } from '../utils'
import { X, FileText } from 'lucide-vue-next'
import { capture } from '@/telemetry'
const router = useRouter()
const user = inject('$user')
@@ -274,6 +271,8 @@ onMounted(() => {
if (!user.data) window.location.href = '/login'
if (props.batchName != 'new') {
batchDetail.reload()
} else {
capture('batch_form_opened')
}
window.addEventListener('keydown', keyboardShortcut)
})
@@ -377,6 +376,7 @@ const createNewBatch = () => {
{},
{
onSuccess(data) {
capture('batch_created')
router.push({
name: 'BatchDetail',
params: {
@@ -447,7 +447,7 @@ const breadcrumbs = computed(() => {
}
crumbs.push({
label: props.batchName == 'new' ? 'New Batch' : 'Edit Batch',
route: { name: 'BatchCreation', params: { batchName: props.batchName } },
route: { name: 'BatchForm', params: { batchName: props.batchName } },
})
return crumbs
})

View File

@@ -5,21 +5,29 @@
>
<Breadcrumbs
class="h-7"
:items="[{ label: __('All Batches'), route: { name: 'Batches' } }]"
:items="[{ label: __('Batches'), route: { name: 'Batches' } }]"
/>
<div class="flex">
<div class="flex space-x-2">
<div class="w-44">
<Select
v-if="categories.data?.length"
v-model="currentCategory"
:options="categories.data"
:placeholder="__('Category')"
/>
</div>
<router-link
v-if="user.data?.is_moderator"
:to="{
name: 'BatchCreation',
name: 'BatchForm',
params: { batchName: 'new' },
}"
>
<Button variant="solid">
<template #prefix>
<Plus class="h-4 w-4" />
<Plus class="h-4 w-4 stroke-1.5" />
</template>
{{ __('New Batch') }}
{{ __('New') }}
</Button>
</router-link>
</div>
@@ -33,7 +41,7 @@
</div>
<Tabs
v-model="tabIndex"
:tabs="tabs"
:tabs="makeTabs"
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
>
<template #tab="{ tab, selected }">
@@ -87,13 +95,29 @@
</div>
</template>
<script setup>
import { createListResource, Breadcrumbs, Button, Tabs, Badge } from 'frappe-ui'
import {
createListResource,
createResource,
Breadcrumbs,
Button,
Tabs,
Badge,
Select,
} from 'frappe-ui'
import { Plus } from 'lucide-vue-next'
import BatchCard from '@/components/BatchCard.vue'
import { inject, ref, computed } from 'vue'
import { inject, ref, computed, onMounted, watch } from 'vue'
import { updateDocumentTitle } from '@/utils'
const user = inject('$user')
const currentCategory = ref(null)
onMounted(() => {
let queries = new URLSearchParams(location.search)
if (queries.has('category')) {
currentCategory.value = queries.get('category')
}
})
const batches = createListResource({
doctype: 'LMS Batch',
@@ -102,35 +126,76 @@ const batches = createListResource({
auto: true,
})
const tabIndex = ref(0)
const tabs = [
{
label: 'Upcoming',
batches: computed(() => batches.data?.upcoming || []),
count: computed(() => batches.data?.upcoming?.length),
const categories = createResource({
url: 'lms.lms.api.get_categories',
makeParams() {
return {
doctype: 'LMS Batch',
filters: {
published: 1,
},
}
},
]
cache: ['batchCategories'],
auto: true,
transform(data) {
data.unshift({
label: '',
value: null,
})
},
})
if (user.data?.is_moderator) {
const tabIndex = ref(0)
let tabs
const makeTabs = computed(() => {
tabs = []
addToTabs('Upcoming')
if (user.data?.is_moderator) {
addToTabs('Archived')
addToTabs('Private')
}
if (user.data) {
addToTabs('Enrolled')
}
return tabs
})
const getBatches = (type) => {
if (currentCategory.value && currentCategory.value != '') {
return batches.data[type].filter(
(batch) => batch.category == currentCategory.value
)
}
return batches.data[type]
}
const addToTabs = (label) => {
let batches = getBatches(label.toLowerCase().split(' ').join('_'))
tabs.push({
label: 'Archived',
batches: computed(() => batches.data?.archived),
count: computed(() => batches.data?.archived?.length),
})
tabs.push({
label: 'Private',
batches: computed(() => batches.data?.private),
count: computed(() => batches.data?.private?.length),
})
}
if (user.data) {
tabs.push({
label: 'Enrolled',
batches: computed(() => batches.data?.enrolled),
count: computed(() => batches.data?.enrolled?.length),
label,
batches: computed(() => batches),
count: computed(() => batches.length),
})
}
watch(
() => currentCategory.value,
() => {
let queries = new URLSearchParams(location.search)
if (currentCategory.value) {
queries.set('category', currentCategory.value)
} else {
queries.delete('category')
}
history.pushState(null, '', `${location.pathname}?${queries.toString()}`)
}
)
const pageMeta = computed(() => {
return {
title: 'Batches',

View File

@@ -1,44 +1,50 @@
<template>
<div class="">
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs
class="h-7"
:items="[{ label: __('Billing Details'), route: { name: 'Billing' } }]"
/>
</header>
<div
v-if="access.data?.access && orderSummary.data"
class="mt-10 w-1/2 mx-auto"
class="pt-5 pb-10 mx-5"
>
<div class="text-3xl font-bold">
{{ __('Billing Details') }}
</div>
<div class="text-gray-600 mt-1">
{{ __('Enter the billing information to complete the payment.') }}
</div>
<div class="border rounded-md p-5 mt-5">
<div class="text-xl font-semibold">
{{ __('Summary') }}
<!-- <div class="mb-5">
<div class="text-lg font-semibold">
{{ __('Address') }}
</div>
<div class="text-gray-600 mt-1">
{{ __('Review the details of your purchase.') }}
</div>
<div class="mt-5">
<div class="flex items-center justify-between">
<div>
</div> -->
<div class="flex flex-col lg:flex-row justify-between">
<div
class="h-fit bg-gray-100 rounded-md p-5 space-y-4 lg:order-last mb-10 lg:mt-10 text-sm font-medium lg:w-1/4"
>
<div class="flex items-center justify-between space-x-2">
<div class="text-gray-600">
{{ __('Ordered Item') }}
</div>
<div class="">
{{ orderSummary.data.title }}
</div>
<div
:class="{
'font-semibold text-xl': !orderSummary.data.gst_applied,
}"
>
{{
orderSummary.data.gst_applied
? orderSummary.data.original_amount_formatted
: orderSummary.data.total_amount_formatted
}}
</div>
<div
v-if="orderSummary.data.gst_applied"
class="flex items-center justify-between"
>
<div class="text-gray-600">
{{ __('Original Amount') }}
</div>
<div class="">
{{ orderSummary.data.original_amount_formatted }}
</div>
</div>
<div
v-if="orderSummary.data.gst_applied"
class="flex items-center justify-between mt-2"
>
<div>
<div class="text-gray-600">
{{ __('GST Amount') }}
</div>
<div>
@@ -46,107 +52,89 @@
</div>
</div>
<div
v-if="orderSummary.data.gst_applied"
class="flex items-center justify-between mt-2"
class="flex items-center justify-between border-t border-gray-400 pt-4 mt-2"
>
<div>
{{ __('Total Amount') }}
<div class="text-lg font-semibold">
{{ __('Total') }}
</div>
<div class="font-semibold text-2xl">
<div class="text-lg font-semibold">
{{ orderSummary.data.total_amount_formatted }}
</div>
</div>
</div>
<div class="text-xl font-semibold mt-10">
{{ __('Address') }}
</div>
<div class="text-gray-600 mt-1">
{{ __('Specify your billing address correctly.') }}
</div>
<div class="grid grid-cols-2 gap-5 mt-4">
<div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Billing Name') }}
</div>
<Input type="text" v-model="billingDetails.billing_name" />
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Address Line 1') }}
</div>
<Input type="text" v-model="billingDetails.address_line1" />
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Address Line 2') }}
</div>
<Input type="text" v-model="billingDetails.address_line2" />
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('City') }}
</div>
<Input type="text" v-model="billingDetails.city" />
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('State') }}
</div>
<Input type="text" v-model="billingDetails.state" />
<div class="flex-1 lg:mr-10">
<div class="mb-5">
<div class="text-lg font-semibold">
{{ __('Address') }}
</div>
</div>
<div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Country') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div class="space-y-4">
<FormControl
:label="__('Billing Name')"
v-model="billingDetails.billing_name"
/>
<FormControl
:label="__('Address Line 1')"
v-model="billingDetails.address_line1"
/>
<FormControl
:label="__('Address Line 2')"
v-model="billingDetails.address_line2"
/>
<FormControl :label="__('City')" v-model="billingDetails.city" />
<FormControl
:label="__('State')"
v-model="billingDetails.state"
/>
</div>
<div class="space-y-4">
<Link
doctype="Country"
:value="billingDetails.country"
@change="(option) => changeCurrency(option)"
:label="__('Country')"
/>
<FormControl
:label="__('Postal Code')"
v-model="billingDetails.pincode"
/>
<FormControl
:label="__('Phone Number')"
v-model="billingDetails.phone"
/>
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Postal Code') }}
</div>
<Input type="text" v-model="billingDetails.pincode" />
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Phone Number') }}
</div>
<Input type="text" v-model="billingDetails.phone" />
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Source') }}
</div>
<Link
doctype="LMS Source"
:value="billingDetails.source"
@change="(option) => (billingDetails.source = option)"
:label="__('Where did you hear about us?')"
/>
<FormControl
v-if="billingDetails.country == 'India'"
:label="__('GST Number')"
v-model="billingDetails.gstin"
/>
<FormControl
v-if="billingDetails.country == 'India'"
:label="__('Pan Number')"
v-model="billingDetails.pan"
/>
</div>
<div v-if="billingDetails.country == 'India'" class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('GST Number') }}
</div>
<Input type="text" v-model="billingDetails.gstin" />
</div>
<div v-if="billingDetails.country == 'India'" class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Pan Number') }}
</div>
<Input type="text" v-model="billingDetails.pan" />
</div>
</div>
<div class="flex items-center justify-between border-t pt-4 mt-8">
<p class="text-gray-600">
{{
__(
'Make sure to enter the right billing name as the same will be used in your invoice.'
)
}}
</p>
<Button variant="solid" size="md" @click="generatePaymentLink()">
{{ __('Proceed to Payment') }}
</Button>
</div>
</div>
<Button variant="solid" class="mt-8" @click="generatePaymentLink()">
{{ __('Proceed to Payment') }}
</Button>
</div>
</div>
<div v-else-if="access.data?.message">
@@ -167,11 +155,18 @@
</div>
</template>
<script setup>
import { Input, Button, createResource } from 'frappe-ui'
import {
Input,
Button,
createResource,
FormControl,
Breadcrumbs,
Tooltip,
} from 'frappe-ui'
import { reactive, inject, onMounted, ref } from 'vue'
import Link from '@/components/Controls/Link.vue'
import NotPermitted from '@/components/NotPermitted.vue'
import { createToast } from '@/utils/'
import { showToast } from '@/utils/'
const user = inject('$user')
@@ -202,8 +197,8 @@ const access = createResource({
name: props.name,
},
onSuccess(data) {
orderSummary.submit()
setBillingDetails(data.address)
orderSummary.submit()
},
})
@@ -224,84 +219,49 @@ const orderSummary = createResource({
const billingDetails = reactive({})
const setBillingDetails = (data) => {
billingDetails.billing_name = data.billing_name || ''
billingDetails.address_line1 = data.address_line1 || ''
billingDetails.address_line2 = data.address_line2 || ''
billingDetails.city = data.city || ''
billingDetails.state = data.state || ''
billingDetails.country = data.country || ''
billingDetails.pincode = data.pincode || ''
billingDetails.phone = data.phone || ''
billingDetails.source = data.source || ''
billingDetails.gstin = data.gstin || ''
billingDetails.pan = data.pan || ''
billingDetails.billing_name = data?.billing_name || ''
billingDetails.address_line1 = data?.address_line1 || ''
billingDetails.address_line2 = data?.address_line2 || ''
billingDetails.city = data?.city || ''
billingDetails.state = data?.state || ''
billingDetails.country = data?.country || ''
billingDetails.pincode = data?.pincode || ''
billingDetails.phone = data?.phone || ''
billingDetails.source = data?.source || ''
billingDetails.gstin = data?.gstin || ''
billingDetails.pan = data?.pan || ''
}
const paymentOptions = createResource({
url: 'lms.lms.utils.get_payment_options',
const paymentLink = createResource({
url: 'lms.lms.payments.get_payment_link',
makeParams(values) {
return {
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
docname: props.name,
phone: billingDetails.phone,
country: billingDetails.country,
title: orderSummary.data.title,
amount: orderSummary.data.original_amount,
total_amount: orderSummary.data.amount,
currency: orderSummary.data.currency,
address: billingDetails,
}
},
})
const generatePaymentLink = () => {
paymentOptions.submit(
paymentLink.submit(
{},
{
validate(params) {
validate() {
if (!billingDetails.source) {
return __('Please let us know where you heard about us from.')
}
return validateAddress()
},
onSuccess(data) {
data.handler = (response) => {
let doctype = props.type == 'course' ? 'LMS Course' : 'LMS Batch'
let docname = props.name
handleSuccess(response, doctype, docname, data.order_id)
}
let rzp1 = new Razorpay(data)
rzp1.open()
window.location.href = data
},
onError(err) {
showError(err)
},
}
)
}
const paymentResource = createResource({
url: 'lms.lms.utils.verify_payment',
makeParams(values) {
return {
response: values.response,
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
docname: props.name,
address: billingDetails,
order_id: values.orderId,
}
},
})
const handleSuccess = (response, doctype, docname, orderId) => {
paymentResource.submit(
{
response: response,
orderId: orderId,
},
{
onSuccess(data) {
createToast({
title: 'Success',
text: 'Payment Successful',
icon: 'check',
iconClasses: 'bg-green-600 text-white rounded-md p-px',
})
setTimeout(() => {
window.location.href = data
}, 3000)
showToast(__('Error'), err.messages?.[0] || err, 'x')
},
}
)

View File

@@ -6,9 +6,10 @@
<div>
<FormControl
type="text"
placeholder="Search Participants"
placeholder="Search"
v-model="searchQuery"
@input="participants.reload()"
class="w-40"
>
<template #prefix>
<Search class="w-4 stroke-1.5 text-gray-600" name="search" />
@@ -18,7 +19,10 @@
</header>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 m-5">
<div v-if="participants.data" v-for="participant in participants.data">
<div
v-if="participants.data?.length"
v-for="participant in participantsList"
>
<router-link
:to="{
name: 'Profile',
@@ -59,12 +63,7 @@ const searchQuery = ref('')
const participants = createResource({
url: 'lms.lms.api.get_certified_participants',
method: 'GET',
debounce: 300,
makeParams(values) {
return {
search_query: searchQuery.value,
}
},
cache: 'certified-participants',
auto: true,
})
@@ -79,5 +78,16 @@ const pageMeta = computed(() => {
}
})
const participantsList = computed(() => {
if (searchQuery.value) {
return participants.data.filter((participant) => {
return participant.full_name
.toLowerCase()
.includes(searchQuery.value.toLowerCase())
})
}
return participants.data
})
updateDocumentTitle(pageMeta)
</script>

View File

@@ -109,6 +109,14 @@
/>
</div>
</div>
<div class="w-1/2 mb-4">
<Link
doctype="LMS Category"
v-model="course.category"
:label="__('Category')"
:onCreate="(value, close) => openSettings(close)"
/>
</div>
<MultiSelect
v-model="instructors"
doctype="User"
@@ -119,7 +127,7 @@
<div class="text-lg font-semibold mt-5 mb-4">
{{ __('Settings') }}
</div>
<div class="grid grid-cols-2 gap-10 mb-4">
<div class="grid grid-cols-3 gap-10 mb-4">
<div
v-if="user.data?.is_moderator"
class="flex flex-col space-y-3"
@@ -147,11 +155,18 @@
v-model="course.featured"
:label="__('Featured')"
/>
</div>
<div class="flex flex-col space-y-3">
<FormControl
type="checkbox"
v-model="course.disable_self_learning"
:label="__('Disable Self Enrollment')"
/>
<FormControl
type="checkbox"
v-model="course.enable_certification"
:label="__('Completion Certificate')"
/>
</div>
</div>
</div>
@@ -214,17 +229,20 @@ import {
showToast,
getFileSize,
updateDocumentTitle,
} from '../utils'
} from '@/utils'
import Link from '@/components/Controls/Link.vue'
import { FileText, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import CourseOutline from '@/components/CourseOutline.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
import { capture } from '@/telemetry'
import { useSettings } from '@/stores/settings'
const user = inject('$user')
const newTag = ref('')
const router = useRouter()
const instructors = ref([])
const settingsStore = useSettings()
const props = defineProps({
courseName: {
@@ -244,6 +262,7 @@ const course = reactive({
featured: false,
upcoming: false,
disable_self_learning: false,
enable_certification: false,
paid_course: false,
course_price: '',
currency: '',
@@ -260,6 +279,8 @@ onMounted(() => {
if (props.courseName !== 'new') {
courseResource.reload()
} else {
capture('course_form_opened')
}
window.addEventListener('keydown', keyboardShortcut)
})
@@ -337,6 +358,7 @@ const courseResource = createResource({
'disable_self_learning',
'paid_course',
'featured',
'enable_certification',
]
for (let idx in checkboxes) {
let key = checkboxes[idx]
@@ -379,9 +401,10 @@ const submitCourse = () => {
} else {
courseCreationResource.submit(course, {
onSuccess(data) {
capture('course_created')
showToast('Success', 'Course created successfully', 'check')
router.push({
name: 'CreateCourse',
name: 'CourseForm',
params: { courseName: data.name },
})
},
@@ -407,7 +430,7 @@ const validateMandatoryFields = () => {
}
}
if (course.paid_course && (!course.course_price || !course.currency)) {
return 'Course price and currency are mandatory for paid courses'
return __('Course price and currency are mandatory for paid courses')
}
}
@@ -423,7 +446,7 @@ watch(
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
return 'Only image file is allowed.'
return __('Only image file is allowed.')
}
}
@@ -450,6 +473,12 @@ const removeImage = () => {
course.course_image = null
}
const openSettings = (close) => {
close()
settingsStore.activeTab = 'Categories'
settingsStore.isSettingsOpen = true
}
const check_permission = () => {
let user_is_instructor = false
if (user.data?.is_moderator) return
@@ -480,7 +509,7 @@ const breadcrumbs = computed(() => {
}
crumbs.push({
label: props.courseName == 'new' ? 'New Course' : 'Edit Course',
route: { name: 'CreateCourse', params: { courseName: props.courseName } },
route: { name: 'CourseForm', params: { courseName: props.courseName } },
})
return crumbs
})

View File

@@ -5,22 +5,33 @@
>
<Breadcrumbs
class="h-7"
:items="[{ label: __('All Courses'), route: { name: 'Courses' } }]"
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
/>
<div class="flex space-x-2">
<FormControl
type="text"
placeholder="Search Course"
v-model="searchQuery"
@input="courses.reload()"
>
<template #prefix>
<Search class="w-4 stroke-1.5 text-gray-600" name="search" />
</template>
</FormControl>
<div class="flex space-x-2 justify-end">
<div class="w-46 md:w-44">
<FormControl
v-if="categories.data?.length"
type="select"
v-model="currentCategory"
:options="categories.data"
:placeholder="__('Category')"
/>
</div>
<div class="w-28 md:w-36">
<FormControl
type="text"
placeholder="Search"
v-model="searchQuery"
@input="courses.reload()"
>
<template #prefix>
<Search class="w-4 h-4 stroke-1.5 text-gray-600" name="search" />
</template>
</FormControl>
</div>
<router-link
:to="{
name: 'CreateCourse',
name: 'CourseForm',
params: {
courseName: 'new',
},
@@ -30,7 +41,7 @@
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('New Course') }}
{{ __('New') }}
</Button>
</router-link>
</div>
@@ -117,11 +128,19 @@ import {
} from 'frappe-ui'
import CourseCard from '@/components/CourseCard.vue'
import { Plus, Search } from 'lucide-vue-next'
import { ref, computed, inject } from 'vue'
import { ref, computed, inject, onMounted, watch } from 'vue'
import { updateDocumentTitle } from '@/utils'
const user = inject('$user')
const searchQuery = ref('')
const currentCategory = ref(null)
onMounted(() => {
let queries = new URLSearchParams(location.search)
if (queries.has('category')) {
currentCategory.value = queries.get('category')
}
})
const courses = createResource({
url: 'lms.lms.utils.get_courses',
@@ -134,29 +153,30 @@ let tabs
const makeTabs = computed(() => {
tabs = []
addToTabs('Live', getCourses('live'))
addToTabs('New', getCourses('new'))
addToTabs('Upcoming', getCourses('upcoming'))
addToTabs('Live')
addToTabs('New')
addToTabs('Upcoming')
if (user.data) {
addToTabs('Enrolled', getCourses('enrolled'))
addToTabs('Enrolled')
if (
user.data.is_moderator ||
user.data.is_instructor ||
courses.data?.created?.length
) {
addToTabs('Created', getCourses('created'))
addToTabs('Created')
}
if (user.data.is_moderator) {
addToTabs('Under Review', getCourses('under_review'))
addToTabs('Under Review')
}
}
return tabs
})
const addToTabs = (label, courses) => {
const addToTabs = (label) => {
let courses = getCourses(label.toLowerCase().split(' ').join('_'))
tabs.push({
label,
courses: computed(() => courses),
@@ -165,14 +185,57 @@ const addToTabs = (label, courses) => {
}
const getCourses = (type) => {
let courseList = courses.data[type]
if (searchQuery.value) {
return courses.data[type].filter((course) =>
course.title.toLowerCase().includes(searchQuery.value.toLowerCase())
let query = searchQuery.value.toLowerCase()
courseList = courseList.filter(
(course) =>
course.title.toLowerCase().includes(query) ||
course.short_introduction.toLowerCase().includes(query) ||
course.tags.filter((tag) => tag.toLowerCase().includes(query)).length
)
}
return courses.data[type]
if (currentCategory.value && currentCategory.value != '') {
courseList = courseList.filter(
(course) => course.category == currentCategory.value
)
}
return courseList
}
const categories = createResource({
url: 'lms.lms.api.get_categories',
makeParams() {
return {
doctype: 'LMS Course',
filters: {
published: 1,
},
}
},
cache: ['courseCategories'],
auto: true,
transform(data) {
data.unshift({
label: '',
value: null,
})
},
})
watch(
() => currentCategory.value,
() => {
let queries = new URLSearchParams(location.search)
if (currentCategory.value) {
queries.set('category', currentCategory.value)
} else {
queries.delete('category')
}
history.pushState(null, '', `${location.pathname}?${queries.toString()}`)
}
)
const pageMeta = computed(() => {
return {
title: 'Courses',

View File

@@ -149,7 +149,7 @@ const newJob = createResource({
return {
doc: {
doctype: 'Job Opportunity',
company_logo: job.image.file_url,
company_logo: job.image?.file_url,
...job,
},
}

View File

@@ -50,51 +50,89 @@
</Button>
</div>
</header>
<div v-if="job.data" class="w-3/4 mx-auto">
<div v-if="job.data" class="max-w-3xl mx-auto">
<div class="p-4">
<div class="flex mb-4">
<img
:src="job.data.company_logo"
class="w-16 h-16 rounded-lg object-contain mr-4"
:alt="job.data.company_name"
/>
<div>
<div class="space-y-5 mb-10">
<div class="flex items-center">
<img
:src="job.data.company_logo"
class="w-16 h-16 rounded-lg object-contain mr-4"
:alt="job.data.company_name"
/>
<div class="text-2xl font-semibold mb-4">
{{ job.data.job_title }}
</div>
<div class="grid grid-cols-3 gap-8">
<div class="grid grid-cols-1 gap-2">
<div class="flex items-center space-x-2">
<Building2 class="h-4 w-4 stroke-1.5" />
<span>{{ job.data.company_name }}</span>
</div>
<div class="flex items-center space-x-2">
<MapPin class="h-4 w-4 stroke-1.5" />
<span>{{ job.data.location }}</span>
</div>
<div>
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-10 gap-y-5 md:gap-y-5"
>
<div class="flex items-center space-x-2">
<span class="p-4 bg-green-50 rounded-full">
<Building2 class="h-4 w-4 text-green-500" />
</span>
<div class="flex flex-col space-y-2">
<span class="text-xs text-gray-600 font-medium uppercase">
{{ __('Organisation') }}
</span>
<span class="text-sm font-semibold">
{{ job.data.company_name }}
</span>
</div>
</div>
<div class="grid grid-cols-1 gap-2">
<div class="flex items-center space-x-2">
<ClipboardType class="h-4 w-4 stroke-1.5" />
<span>{{ job.data.type }}</span>
</div>
<div class="flex items-center space-x-2">
<CalendarDays class="h-4 w-4 stroke-1.5" />
<span>{{
dayjs(job.data.creation).format('DD MMM YYYY')
}}</span>
<div class="flex items-center space-x-2">
<span class="p-4 bg-red-50 rounded-full">
<MapPin class="h-4 w-4 text-red-500" />
</span>
<div class="flex flex-col space-y-2">
<span class="text-xs text-gray-600 font-medium uppercase">
{{ __('Location') }}
</span>
<span class="text-sm font-semibold">
{{ job.data.location }}
</span>
</div>
</div>
<div class="grid grid-cols-1 h-fit">
<div
v-if="applicationCount.data"
class="flex items-center space-x-2"
>
<SquareUserRound class="h-4 w-4 stroke-1.5" />
<span
>{{ applicationCount.data }}
{{ __('applications received') }}</span
>
<div class="flex items-center space-x-2">
<span class="p-4 bg-yellow-50 rounded-full">
<ClipboardType class="h-4 w-4 text-yellow-500" />
</span>
<div class="flex flex-col space-y-2">
<span class="text-xs font-medium text-gray-600 uppercase">
{{ __('Category') }}
</span>
<span class="text-sm font-semibold">
{{ job.data.type }}
</span>
</div>
</div>
<div class="flex items-center space-x-2">
<span class="p-4 bg-blue-50 rounded-full">
<CalendarDays class="h-4 w-4 text-blue-500" />
</span>
<div class="flex flex-col space-y-2">
<span class="text-xs text-gray-600 font-medium uppercase">
{{ __('Posted on') }}
</span>
<span class="text-sm font-semibold">
{{ dayjs(job.data.creation).format('DD MMM YYYY') }}
</span>
</div>
</div>
<div
v-if="applicationCount.data"
class="flex items-center space-x-2"
>
<span class="p-4 bg-purple-50 rounded-full">
<SquareUserRound class="h-4 w-4 text-purple-500" />
</span>
<div class="flex flex-col space-y-2">
<span class="text-xs text-gray-600 font-medium uppercase">
{{ __('Applications Received') }}
</span>
<span class="text-sm font-semibold">
{{ applicationCount.data }}
</span>
</div>
</div>
</div>

View File

@@ -58,7 +58,7 @@
<router-link
v-if="allowEdit()"
:to="{
name: 'CreateLesson',
name: 'LessonForm',
params: {
courseName: courseName,
chapterNumber: props.chapterNumber,
@@ -90,6 +90,17 @@
</span>
</Button>
</router-link>
<router-link
v-else
:to="{
name: 'CourseDetail',
params: { courseName: courseName },
}"
>
<Button>
{{ __('Back to Course') }}
</Button>
</router-link>
</div>
</div>
@@ -109,7 +120,8 @@
</div>
<div
v-if="
lesson.data.instructor_content?.blocks?.length &&
lesson.data.instructor_content &&
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 &&
allowInstructorContent()
"
class="bg-gray-100 p-3 rounded-md mt-6"
@@ -160,12 +172,12 @@
{{ lesson.data.course_title }}
</div>
<div v-if="user && lesson.data.membership" class="text-sm mt-3">
{{ Math.ceil(lesson.data.membership.progress) }}% completed
{{ Math.ceil(lessonProgress) }}% {{ __('completed') }}
</div>
<ProgressBar
v-if="user && lesson.data.membership"
:progress="lesson.data.membership.progress"
:progress="lessonProgress"
/>
</div>
<CourseOutline
@@ -179,7 +191,7 @@
</template>
<script setup>
import { createResource, Breadcrumbs, Button } from 'frappe-ui'
import { computed, watch, inject, ref } from 'vue'
import { computed, watch, inject, ref, onMounted, onBeforeUnmount } from 'vue'
import CourseOutline from '@/components/CourseOutline.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { useRoute } from 'vue-router'
@@ -196,6 +208,9 @@ const route = useRoute()
const allowDiscussions = ref(false)
const editor = ref(null)
const instructorEditor = ref(null)
const lessonProgress = ref(0)
const timer = ref(0)
let timerInterval
const props = defineProps({
courseName: {
@@ -212,6 +227,10 @@ const props = defineProps({
},
})
onMounted(() => {
startTimer()
})
const lesson = createResource({
url: 'lms.lms.utils.get_lesson',
cache: ['lesson', props.courseName, props.chapterNumber, props.lessonNumber],
@@ -224,9 +243,12 @@ const lesson = createResource({
},
auto: true,
onSuccess(data) {
markProgress(data)
lessonProgress.value = data.membership?.progress
if (data.content) editor.value = renderEditor('editor', data.content)
if (data.instructor_content?.blocks?.length)
if (
data.instructor_content &&
JSON.parse(data.instructor_content)?.blocks?.length > 1
)
instructorEditor.value = renderEditor(
'instructor-content',
data.instructor_content
@@ -256,8 +278,10 @@ const renderEditor = (holder, content) => {
})
}
const markProgress = (data) => {
if (user.data && !data.progress) progress.submit()
const markProgress = () => {
if (user.data && lesson.data && !lesson.data.progress) {
progress.submit()
}
}
const progress = createResource({
@@ -268,6 +292,9 @@ const progress = createResource({
course: props.courseName,
}
},
onSuccess(data) {
lessonProgress.value = data
},
})
const breadcrumbs = computed(() => {
@@ -304,10 +331,27 @@ watch(
chapter: newChapterNumber,
lesson: newLessonNumber,
})
clearInterval(timerInterval)
timer.value = 0
startTimer()
}
}
)
const startTimer = () => {
timerInterval = setInterval(() => {
timer.value++
if (timer.value == 30) {
clearInterval(timerInterval)
markProgress()
}
}, 1000)
}
onBeforeUnmount(() => {
clearInterval(timerInterval)
})
const checkIfDiscussionsAllowed = () => {
let quizPresent = false
JSON.parse(lesson.data?.content)?.blocks?.forEach((block) => {
@@ -408,6 +452,10 @@ updateDocumentTitle(pageMeta)
max-width: unset;
}
.codex-editor__redactor {
padding-bottom: 0px !important;
}
.codeBoxHolder {
display: flex;
flex-direction: column;
@@ -497,4 +545,13 @@ updateDocumentTitle(pageMeta)
color: #383a42;
background-color: #fafafa;
}
.codeBoxTextArea {
line-height: 1.7;
}
iframe {
border-top: 3px solid theme('colors.gray.700');
border-bottom: 3px solid theme('colors.gray.700');
}
</style>

View File

@@ -43,7 +43,7 @@
<div
v-show="openInstructorEditor"
id="instructor-notes"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal mt-6 py-3"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal py-3"
></div>
</div>
</div>
@@ -54,7 +54,7 @@
</label>
<div
id="content"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal mt-6 py-3"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal py-3"
></div>
</div>
</div>
@@ -62,7 +62,7 @@
</div>
<div class="">
<div class="sticky top-0 p-5">
<LessonPlugins :editor="editor" :notesEditor="instructorEditor" />
<LessonHelp />
</div>
</div>
</div>
@@ -70,16 +70,25 @@
</template>
<script setup>
import { Breadcrumbs, FormControl, createResource, Button } from 'frappe-ui'
import { computed, reactive, onMounted, inject, ref, watch } from 'vue'
import {
computed,
reactive,
onMounted,
inject,
ref,
onBeforeUnmount,
} from 'vue'
import EditorJS from '@editorjs/editorjs'
import LessonPlugins from '@/components/LessonPlugins.vue'
import LessonHelp from '@/components/LessonHelp.vue'
import { ChevronRight } from 'lucide-vue-next'
import { updateDocumentTitle, createToast, getEditorTools } from '@/utils'
import { capture } from '@/telemetry'
const editor = ref(null)
const instructorEditor = ref(null)
const user = inject('$user')
const openInstructorEditor = ref(false)
let autoSaveInterval
const props = defineProps({
courseName: {
@@ -100,6 +109,7 @@ onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) {
window.location.href = '/login'
}
capture('lesson_form_opened')
editor.value = renderEditor('content')
instructorEditor.value = renderEditor('instructor-notes')
})
@@ -107,7 +117,7 @@ onMounted(() => {
const renderEditor = (holder) => {
return new EditorJS({
holder: holder,
tools: getEditorTools(),
tools: getEditorTools(true),
autofocus: true,
})
}
@@ -133,33 +143,52 @@ const lessonDetails = createResource({
Object.keys(data.lesson).forEach((key) => {
lesson[key] = data.lesson[key]
})
lesson.include_in_preview = data.include_in_preview ? true : false
editor.value.isReady.then(() => {
if (data.lesson.content) {
editor.value.render(JSON.parse(data.lesson.content))
} else if (data.lesson.body) {
let blocks = convertToJSON(data.lesson)
editor.value.render({
blocks: blocks,
})
}
})
instructorEditor.value.isReady.then(() => {
if (data.lesson.instructor_content) {
instructorEditor.value.render(
JSON.parse(data.lesson.instructor_content)
)
} else if (data.lesson.instructor_notes) {
let blocks = convertToJSON(data.lesson)
instructorEditor.value.render({
blocks: blocks,
})
}
})
lesson.include_in_preview = data?.lesson?.include_in_preview
? true
: false
addLessonContent(data)
addInstructorNotes(data)
enableAutoSave()
}
},
})
const addLessonContent = (data) => {
editor.value.isReady.then(() => {
if (data.lesson.content) {
editor.value.render(JSON.parse(data.lesson.content))
} else if (data.lesson.body) {
let blocks = convertToJSON(data.lesson)
editor.value.render({
blocks: blocks,
})
}
})
}
const addInstructorNotes = (data) => {
instructorEditor.value.isReady.then(() => {
if (data.lesson.instructor_content) {
instructorEditor.value.render(JSON.parse(data.lesson.instructor_content))
} else if (data.lesson.instructor_notes) {
let blocks = convertToJSON(data.lesson)
instructorEditor.value.render({
blocks: blocks,
})
}
})
}
const enableAutoSave = () => {
autoSaveInterval = setInterval(() => {
saveLesson()
}, 10000)
}
onBeforeUnmount(() => {
clearInterval(autoSaveInterval)
})
const newLessonResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
@@ -335,6 +364,7 @@ const createNewLesson = () => {
{ lesson: data.name },
{
onSuccess() {
capture('lesson_created')
showToast('Success', 'Lesson created successfully', 'check')
lessonDetails.reload()
},
@@ -357,9 +387,6 @@ const editCurrentLesson = () => {
validate() {
return validateLesson()
},
onSuccess() {
showToast('Success', 'Lesson updated successfully', 'check')
},
onError(err) {
showToast('Error', err.message, 'x')
},
@@ -398,7 +425,7 @@ const breadcrumbs = computed(() => {
},
{
label: lessonDetails.data?.course_title,
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
route: { name: 'CourseForm', params: { courseName: props.courseName } },
},
]
@@ -418,7 +445,7 @@ const breadcrumbs = computed(() => {
crumbs.push({
label: lessonDetails?.data?.lesson ? 'Edit Lesson' : 'Create Lesson',
route: {
name: 'CreateLesson',
name: 'LessonForm',
params: {
courseName: props.courseName,
chapterNumber: props.chapterNumber,
@@ -439,7 +466,8 @@ const pageMeta = computed(() => {
updateDocumentTitle(pageMeta)
</script>
<style>
.embed-tool__caption {
.embed-tool__caption,
.cdx-simple-image__caption {
display: none;
}
@@ -447,6 +475,10 @@ updateDocumentTitle(pageMeta)
max-width: none;
}
.codex-editor--narrow .ce-toolbar__actions {
right: 100%;
}
.ce-toolbar__content {
max-width: none;
}
@@ -519,10 +551,6 @@ updateDocumentTitle(pageMeta)
cursor: pointer;
}
.codeBoxSelectItem:hover {
opacity: 0.7;
}
.codeBoxSelectedItem {
background-color: lightblue !important;
}
@@ -540,4 +568,17 @@ updateDocumentTitle(pageMeta)
color: #383a42;
background-color: #fafafa;
}
.codeBoxTextArea {
line-height: 1.7;
}
.prose :where(pre):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
overflow-x: unset;
}
iframe {
border-top: 3px solid theme('colors.gray.700');
border-bottom: 3px solid theme('colors.gray.700');
}
</style>

View File

@@ -146,7 +146,7 @@ const coverImage = createResource({
const setActiveTab = () => {
let fragments = route.path.split('/')
let sections = ['certificates', 'roles', 'evaluations']
let sections = ['certificates', 'roles', 'slots', 'schedule']
sections.forEach((section) => {
if (fragments.includes(section)) {
activeTab.value = convertToTitleCase(section)
@@ -161,7 +161,8 @@ watchEffect(() => {
About: { name: 'ProfileAbout' },
Certificates: { name: 'ProfileCertificates' },
Roles: { name: 'ProfileRoles' },
Evaluations: { name: 'ProfileEvaluator' },
Slots: { name: 'ProfileEvaluator' },
Schedule: { name: 'ProfileEvaluationSchedule' },
}[activeTab.value]
router.push(route)
}
@@ -185,8 +186,13 @@ const isSessionUser = () => {
const getTabButtons = () => {
let buttons = [{ label: 'About' }, { label: 'Certificates' }]
if ($user.data?.is_moderator) buttons.push({ label: 'Roles' })
if (isSessionUser() && $user.data?.is_evaluator)
buttons.push({ label: 'Evaluations' })
if (
isSessionUser() &&
($user.data?.is_evaluator || $user.data?.is_moderator)
) {
buttons.push({ label: 'Slots' })
buttons.push({ label: 'Schedule' })
}
return buttons
}

View File

@@ -16,7 +16,7 @@
<h2 class="mb-3 text-lg font-semibold text-gray-900">
{{ __('Achievements') }}
</h2>
<div class="grid grid-cols-5 gap-4">
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<div v-for="badge in badges.data">
<Popover trigger="hover" :leaveDelay="Number(0.01)">
<template #target>
@@ -42,7 +42,7 @@
<img
:src="badge.badge_image"
:alt="badge.badge"
class="bg-gray-100 rounded-t-md"
class="bg-gray-100 rounded-t-md h-[200px] mx-auto"
/>
<div class="p-5">
<div class="text-2xl font-semibold mb-2">
@@ -142,7 +142,7 @@ const shareOnSocial = (badge, medium) => {
const summary = `I am happy to announce that I earned the ${
badge.badge
} badge on ${dayjs(badge.issued_on).format('DD MMM YYYY')} at ${
branding.data?.brand_name
branding.data?.app_name
}.`
if (medium == 'LinkedIn')

View File

@@ -0,0 +1,102 @@
<template>
<div class="mt-7 mb-20">
<div class="flex h-screen flex-col overflow-hidden">
<Calendar
v-if="evaluations.data?.length"
:config="{
defaultMode: 'Month',
disableModes: ['Day', 'Week'],
redundantCellHeight: 100,
enableShortcuts: false,
}"
:events="evaluations.data"
@click="(event) => openEvent(event)"
>
<template #header="{ currentMonthYear, decrement, increment }">
<div class="mb-2 flex justify-between">
<span class="text-lg font-semibold">
{{ currentMonthYear }}
</span>
<div class="flex gap-x-1">
<Button
@click="decrement()"
variant="ghost"
class="h-4 w-4"
icon="chevron-left"
/>
<Button
@click="increment()"
variant="ghost"
class="h-4 w-4"
icon="chevron-right"
/>
</div>
</div>
</template>
</Calendar>
</div>
</div>
<Event v-model="showEvent" :event="currentEvent" />
</template>
<script setup>
import { Calendar, createListResource, Button } from 'frappe-ui'
import { inject, ref } from 'vue'
import Event from '@/components/Modals/Event.vue'
const user = inject('$user')
const currentEvent = ref(null)
const showEvent = ref(false)
const props = defineProps({
profile: {
type: Object,
required: true,
},
})
const evaluations = createListResource({
doctype: 'LMS Certificate Request',
filters: {
evaluator: user.data?.name,
},
fields: [
'name',
'member_name',
'member',
'course',
'course_title',
'batch_name',
'batch_title',
'evaluator',
'evaluator_name',
'date',
'start_time',
'end_time',
'google_meet_link',
],
auto: true,
orderBy: 'creation desc',
limit: 100,
cache: ['schedule', user.data?.name],
transform(data) {
return data.map((d) => {
let mappedData = Object.assign({}, d)
mappedData.title = `${d.member_name}'s Evaluation`
mappedData.participant = d.member_name
mappedData.id = d.name
mappedData.venue = d.google_meet_link
mappedData.fromDate = `${d.date} ${d.start_time}`
mappedData.toDate = `${d.date} ${d.end_time}`
mappedData.color = 'green'
return mappedData
})
},
})
const openEvent = (event) => {
currentEvent.value = event.calendarEvent
showEvent.value = true
}
</script>

View File

@@ -0,0 +1,467 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<div class="space-x-2">
<router-link
v-if="quizDetails.data?.name"
:to="{
name: 'QuizPage',
params: {
quizID: quizDetails.data.name,
},
}"
>
<Button>
{{ __('Open') }}
</Button>
</router-link>
<router-link
v-if="quizDetails.data?.name"
:to="{
name: 'QuizSubmissionList',
params: {
quizID: quizDetails.data.name,
},
}"
>
<Button>
{{ __('Submission List') }}
</Button>
</router-link>
<Button variant="solid" @click="submitQuiz()">
{{ __('Save') }}
</Button>
</div>
</header>
<div class="w-3/4 mx-auto py-5">
<!-- Details -->
<div class="mb-8">
<div class="font-semibold mb-4">
{{ __('Details') }}
</div>
<FormControl
v-model="quiz.title"
:label="
quizDetails.data?.name
? __('Title')
: __('Enter a title and save the quiz to proceed')
"
/>
<div v-if="quizDetails.data?.name">
<div class="grid grid-cols-2 gap-5 mt-4 mb-8">
<FormControl
type="number"
v-model="quiz.max_attempts"
:label="__('Maximun Attempts')"
/>
<FormControl
type="number"
v-model="quiz.duration"
:label="__('Duration (in minutes)')"
/>
<FormControl
v-model="quiz.total_marks"
:label="__('Total Marks')"
disabled
/>
<FormControl
v-model="quiz.passing_percentage"
:label="__('Passing Percentage')"
/>
</div>
<!-- Settings -->
<div class="mb-8">
<div class="font-semibold mb-4">
{{ __('Settings') }}
</div>
<div class="grid grid-cols-3 gap-5 my-4">
<FormControl
v-model="quiz.show_answers"
type="checkbox"
:label="__('Show Answers')"
/>
<FormControl
v-model="quiz.show_submission_history"
type="checkbox"
:label="__('Show Submission History')"
/>
</div>
</div>
<div class="mb-8">
<div class="font-semibold mb-4">
{{ __('Shuffle Settings') }}
</div>
<div class="grid grid-cols-3">
<FormControl
v-model="quiz.shuffle_questions"
type="checkbox"
:label="__('Shuffle Questions')"
/>
<FormControl
v-if="quiz.shuffle_questions"
v-model="quiz.limit_questions_to"
:label="__('Limit Questions To')"
/>
</div>
</div>
<!-- Questions -->
<div>
<div class="flex items-center justify-between mb-4">
<div class="font-semibold">
{{ __('Questions') }}
</div>
<Button @click="openQuestionModal()">
<template #prefix>
<Plus class="w-4 h-4" />
</template>
{{ __('New Question') }}
</Button>
</div>
<ListView
:columns="questionColumns"
:rows="quiz.questions"
row-key="name"
:options="{
showTooltip: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
>
<ListHeaderItem :item="item" v-for="item in questionColumns" />
</ListHeader>
<ListRows>
<ListRow
:row="row"
v-slot="{ idx, column, item }"
v-for="row in quiz.questions"
@click="openQuestionModal(row)"
>
<ListRowItem :item="item">
<div
v-if="column.key == 'question_detail'"
class="text-xs truncate h-4"
v-html="item"
></div>
<div v-else class="text-xs">
{{ item }}
</div>
</ListRowItem>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="deleteQuestions(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
</div>
</div>
</div>
<Question
v-model="showQuestionModal"
:questionDetail="currentQuestion"
v-model:quiz="quizDetails"
:title="
currentQuestion.question
? __('Edit the question')
: __('Add a new question')
"
/>
</template>
<script setup>
import {
Breadcrumbs,
createResource,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
Button,
} from 'frappe-ui'
import {
computed,
reactive,
ref,
onMounted,
inject,
onBeforeUnmount,
watch,
isReactive,
} from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import Question from '@/components/Modals/Question.vue'
import { showToast, updateDocumentTitle } from '@/utils'
import { useRouter } from 'vue-router'
const showQuestionModal = ref(false)
const currentQuestion = reactive({
question: '',
marks: 0,
name: '',
})
const user = inject('$user')
const router = useRouter()
const props = defineProps({
quizID: {
type: String,
required: true,
},
})
const quiz = reactive({
title: '',
total_marks: 0,
passing_percentage: 0,
max_attempts: 0,
duration: 0,
limit_questions_to: 0,
show_answers: true,
show_submission_history: false,
shuffle_questions: false,
questions: [],
})
onMounted(() => {
if (
props.quizID == 'new' &&
!user.data?.is_moderator &&
!user.data?.is_instructor
) {
router.push({ name: 'Courses' })
}
if (props.quizID !== 'new') {
quizDetails.reload()
}
window.addEventListener('keydown', keyboardShortcut)
})
const keyboardShortcut = (e) => {
if (
e.key === 's' &&
(e.ctrlKey || e.metaKey) &&
!e.target.classList.contains('ProseMirror')
) {
submitQuiz()
e.preventDefault()
}
}
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
watch(
() => props.quizID !== 'new',
(newVal) => {
if (newVal) {
quizDetails.reload()
}
}
)
const quizDetails = createResource({
url: 'frappe.client.get',
makeParams(values) {
return { doctype: 'LMS Quiz', name: props.quizID }
},
auto: false,
onSuccess(data) {
Object.keys(data).forEach((key) => {
if (Object.hasOwn(quiz, key)) quiz[key] = data[key]
})
let checkboxes = [
'show_answers',
'show_submission_history',
'shuffle_questions',
]
for (let idx in checkboxes) {
let key = checkboxes[idx]
quiz[key] = quiz[key] ? true : false
}
},
})
const quizCreate = createResource({
url: 'frappe.client.insert',
auto: false,
makeParams(values) {
return {
doc: {
doctype: 'LMS Quiz',
...quiz,
},
}
},
})
const quizUpdate = createResource({
url: 'frappe.client.set_value',
auto: false,
makeParams(values) {
return {
doctype: 'LMS Quiz',
name: values.quizID,
fieldname: {
total_marks: calculateTotalMarks(),
...quiz,
},
}
},
})
const submitQuiz = () => {
if (quizDetails.data?.name) updateQuiz()
else createQuiz()
}
const createQuiz = () => {
quizCreate.submit(
{},
{
onSuccess(data) {
showToast(__('Success'), __('Quiz created successfully'), 'check')
router.push({
name: 'QuizForm',
params: { quizID: data.name },
})
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
}
)
}
const updateQuiz = () => {
quizUpdate.submit(
{ quizID: quizDetails.data?.name },
{
onSuccess(data) {
quiz.total_marks = data.total_marks
showToast(__('Success'), __('Quiz updated successfully'), 'check')
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
}
)
}
const calculateTotalMarks = () => {
let totalMarks = 0
if (quiz.limit_questions_to && quiz.questions.length > 0)
return quiz.questions[0].marks * quiz.limit_questions_to
quiz.questions.forEach((question) => {
totalMarks += question.marks
})
return totalMarks
}
const questionColumns = computed(() => {
return [
{
label: __('ID'),
key: 'question',
width: '10rem',
},
{
label: __('Question'),
key: __('question_detail'),
width: '40rem',
},
{
label: __('Marks'),
key: 'marks',
width: '5rem',
},
]
})
const openQuestionModal = (question = null) => {
if (question) {
currentQuestion.question = question.question
currentQuestion.marks = question.marks
currentQuestion.name = question.name
} else {
currentQuestion.question = ''
currentQuestion.marks = 0
currentQuestion.name = ''
}
showQuestionModal.value = true
}
const deleteQuestionResource = createResource({
url: 'lms.lms.api.delete_documents',
makeParams(values) {
return {
doctype: 'LMS Quiz Question',
documents: values.questions,
}
},
})
const deleteQuestions = (selections, unselectAll) => {
deleteQuestionResource.submit(
{
questions: Array.from(selections),
},
{
onSuccess() {
showToast(__('Success'), __('Questions deleted successfully'), 'check')
quizDetails.reload()
unselectAll()
},
}
)
}
const breadcrumbs = computed(() => {
let crumbs = [
{
label: __('Quizzes'),
route: {
name: 'Quizzes',
},
},
]
/* if (quizDetails.data) {
crumbs.push({
label: quiz.title,
})
} */
crumbs.push({
label: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title,
route: { name: 'QuizForm', params: { quizID: props.quizID } },
})
return crumbs
})
const pageMeta = computed(() => {
return {
title: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title,
description: __('Form to create and edit quizzes'),
}
})
updateDocumentTitle(pageMeta)
</script>

View File

@@ -0,0 +1,58 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
</header>
<div class="md:w-7/12 md:mx-auto mx-4 py-10">
<Quiz :quizName="quizID" />
</div>
</template>
<script setup>
import Quiz from '@/components/Quiz.vue'
import { createResource, Breadcrumbs } from 'frappe-ui'
import { computed, inject, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { updateDocumentTitle } from '@/utils'
const user = inject('$user')
const router = useRouter()
onMounted(() => {
if (!user.data) {
router.push({ name: 'Courses' })
}
})
const props = defineProps({
quizID: {
type: String,
required: true,
},
})
const title = createResource({
url: 'frappe.client.get_value',
params: {
doctype: 'LMS Quiz',
fieldname: 'title',
filters: {
name: props.quizID,
},
},
auto: true,
})
const breadcrumbs = computed(() => {
return [{ label: __('Quiz Submission') }, { label: title.data?.title }]
})
const pageMeta = computed(() => {
return {
title: title.data?.title,
description: __('Quiz Submission'),
}
})
updateDocumentTitle(pageMeta)
</script>

View File

@@ -0,0 +1,122 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs v-if="submisisonDetails.doc" :items="breadcrumbs" />
<div class="space-x-2">
<Badge
v-if="submisisonDetails.isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
<Button variant="solid" @click="saveSubmission()">
{{ __('Save') }}
</Button>
</div>
</header>
<div v-if="submisisonDetails.doc" class="w-1/2 mx-auto py-5 space-y-4">
<div class="grid grid-cols-2 gap-5">
<FormControl
v-model="submisisonDetails.doc.quiz_title"
:label="__('Quiz')"
:disabled="true"
/>
<FormControl
v-model="submisisonDetails.doc.member_name"
:label="__('Member')"
:disabled="true"
/>
</div>
<div class="grid grid-cols-2 gap-5">
<FormControl
v-model="submisisonDetails.doc.score"
:label="__('Score')"
:disabled="true"
/>
<FormControl
v-model="submisisonDetails.doc.percentage"
:label="__('Percentage')"
:disabled="true"
/>
</div>
<div
v-for="row in submisisonDetails.doc.result"
class="border p-5 rounded-md space-y-4"
>
<div class="font-semibold">{{ row.idx }}. {{ row.question }}</div>
<div v-html="row.answer" class="leading-5"></div>
<div class="grid grid-cols-2 gap-5">
<FormControl v-model="row.marks" :label="__('Marks')" />
<FormControl
v-model="row.marks_out_of"
:label="__('Marks out of')"
:disabled="true"
/>
</div>
</div>
</div>
</template>
<script setup>
import {
createDocumentResource,
Breadcrumbs,
FormControl,
Button,
Badge,
} from 'frappe-ui'
import { computed, onMounted, inject } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from '@/utils'
const router = useRouter()
const user = inject('$user')
onMounted(() => {
if (!user.data?.is_instructor && !user.data?.is_moderator)
router.push({ name: 'Courses' })
})
const props = defineProps({
submission: {
type: String,
required: true,
},
})
const submisisonDetails = createDocumentResource({
doctype: 'LMS Quiz Submission',
name: props.submission,
auto: true,
})
const breadcrumbs = computed(() => {
return [
{
label: __('Quiz Submissions'),
route: {
name: 'QuizSubmissionList',
params: {
quizID: submisisonDetails.doc.quiz,
},
},
},
{
label: submisisonDetails.doc.quiz_title,
},
]
})
const saveSubmission = () => {
submisisonDetails.save.submit(
{},
{
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
}
)
}
</script>

View File

@@ -0,0 +1,104 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
</header>
<div v-if="submissions.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5">
<ListView
:columns="quizColumns"
:rows="submissions.data"
row-key="name"
:options="{ showTooltip: false, selectable: false }"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
>
<ListHeaderItem :item="item" v-for="item in quizColumns">
</ListHeaderItem>
</ListHeader>
<ListRows>
<router-link
v-for="row in submissions.data"
:to="{
name: 'QuizSubmission',
params: {
submission: row.name,
},
}"
>
<ListRow :row="row" />
</router-link>
</ListRows>
</ListView>
</div>
</template>
<script setup>
import {
createListResource,
Breadcrumbs,
ListView,
ListRow,
ListRows,
ListHeader,
ListHeaderItem,
} from 'frappe-ui'
import { computed, onMounted, inject } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const user = inject('$user')
onMounted(() => {
if (!user.data?.is_instructor && !user.data?.is_moderator)
router.push({ name: 'Courses' })
})
const props = defineProps({
quizID: {
type: String,
required: true,
},
})
const submissions = createListResource({
doctype: 'LMS Quiz Submission',
filters: {
quiz: props.quizID,
},
fields: ['name', 'member_name', 'score', 'percentage', 'quiz_title'],
orderBy: 'creation desc',
auto: true,
})
const quizColumns = computed(() => {
return [
{
label: __('Member'),
key: 'member_name',
width: 2,
},
{
label: __('Quiz'),
key: 'quiz_title',
width: 2,
},
{
label: __('Score'),
key: 'score',
width: 1,
align: 'center',
},
{
label: __('Percentage'),
key: 'percentage',
width: 1,
align: 'center',
},
]
})
const breadcrumbs = computed(() => {
return [{ label: __('Quiz Submissions') }]
})
</script>

View File

@@ -0,0 +1,136 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<router-link
:to="{
name: 'QuizForm',
params: {
quizID: 'new',
},
}"
>
<Button variant="solid">
<template #prefix>
<Plus class="w-4 h-4" />
</template>
{{ __('New Quiz') }}
</Button>
</router-link>
</header>
<div v-if="quizzes.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5">
<ListView
:columns="quizColumns"
:rows="quizzes.data"
row-key="name"
:options="{ showTooltip: false, selectable: false }"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
>
<ListHeaderItem :item="item" v-for="item in quizColumns">
</ListHeaderItem>
</ListHeader>
<ListRows>
<router-link
v-for="row in quizzes.data"
:to="{
name: 'QuizForm',
params: {
quizID: row.name,
},
}"
>
<ListRow :row="row" />
</router-link>
</ListRows>
</ListView>
</div>
</template>
<script setup>
import {
Breadcrumbs,
createListResource,
ListView,
ListRows,
ListRow,
ListHeader,
ListHeaderItem,
Button,
} from 'frappe-ui'
import { useRouter } from 'vue-router'
import { computed, inject, onMounted } from 'vue'
import { Plus } from 'lucide-vue-next'
import { updateDocumentTitle } from '@/utils'
const user = inject('$user')
const router = useRouter()
onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) {
router.push({ name: 'Courses' })
}
})
const quizFilter = computed(() => {
if (user.data?.is_moderator) return {}
return {
owner: user.data?.name,
}
})
const quizzes = createListResource({
doctype: 'LMS Quiz',
filters: quizFilter,
fields: ['name', 'title', 'passing_percentage', 'total_marks'],
auto: true,
cache: ['quizzes', user.data?.name],
orderBy: 'modified desc',
onSuccess(data) {
data.forEach((row) => {})
},
})
const quizColumns = computed(() => {
return [
{
label: __('Title'),
key: 'title',
width: 2,
},
{
label: __('Total Marks'),
key: 'total_marks',
width: 1,
align: 'center',
},
{
label: __('Passing Percentage'),
key: 'passing_percentage',
width: 1,
align: 'center',
},
]
})
const breadcrumbs = computed(() => {
return [
{
label: __('Quizzes'),
route: {
name: 'Quizzes',
},
},
]
})
const pageMeta = computed(() => {
return {
title: __('Quizzes'),
description: __('List of quizzes'),
}
})
updateDocumentTitle(pageMeta)
</script>

View File

@@ -79,9 +79,15 @@ const routes = [
},
{
name: 'ProfileEvaluator',
path: 'evaluations',
path: 'slots',
component: () => import('@/pages/ProfileEvaluator.vue'),
},
{
name: 'ProfileEvaluationSchedule',
path: 'schedule',
component: () =>
import('@/pages/ProfileEvaluationSchedule.vue'),
},
],
},
{
@@ -97,20 +103,20 @@ const routes = [
},
{
path: '/courses/:courseName/edit',
name: 'CreateCourse',
component: () => import('@/pages/CreateCourse.vue'),
name: 'CourseForm',
component: () => import('@/pages/CourseForm.vue'),
props: true,
},
{
path: '/courses/:courseName/learn/:chapterNumber-:lessonNumber/edit',
name: 'CreateLesson',
component: () => import('@/pages/CreateLesson.vue'),
name: 'LessonForm',
component: () => import('@/pages/LessonForm.vue'),
props: true,
},
{
path: '/batches/:batchName/edit',
name: 'BatchCreation',
component: () => import('@/pages/BatchCreation.vue'),
name: 'BatchForm',
component: () => import('@/pages/BatchForm.vue'),
props: true,
},
{
@@ -141,6 +147,35 @@ const routes = [
component: () => import('@/pages/Badge.vue'),
props: true,
},
{
path: '/quizzes',
name: 'Quizzes',
component: () => import('@/pages/Quizzes.vue'),
},
{
path: '/quizzes/:quizID',
name: 'QuizForm',
component: () => import('@/pages/QuizForm.vue'),
props: true,
},
{
path: '/quiz/:quizID',
name: 'QuizPage',
component: () => import('@/pages/QuizPage.vue'),
props: true,
},
{
path: '/quiz-submissions/:quizID',
name: 'QuizSubmissionList',
component: () => import('@/pages/QuizSubmissionList.vue'),
props: true,
},
{
path: '/quiz-submission/:submission',
name: 'QuizSubmission',
component: () => import('@/pages/QuizSubmission.vue'),
props: true,
},
]
let router = createRouter({

View File

@@ -5,7 +5,7 @@ import router from '@/router'
import { ref, computed } from 'vue'
export const sessionStore = defineStore('lms-session', () => {
let { userResource } = usersStore()
let { userResource, allUsers } = usersStore()
function sessionUser() {
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
@@ -17,6 +17,9 @@ export const sessionStore = defineStore('lms-session', () => {
}
let user = ref(sessionUser())
if (user.value) {
allUsers.reload()
}
const isLoggedIn = computed(() => !!user.value)
const login = createResource({
@@ -50,11 +53,18 @@ export const sessionStore = defineStore('lms-session', () => {
},
})
const sidebarSettings = createResource({
url: 'lms.lms.api.get_sidebar_settings',
cache: 'Sidebar Settings',
auto: false,
})
return {
user,
isLoggedIn,
login,
logout,
branding,
sidebarSettings,
}
})

View File

@@ -0,0 +1,12 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useSettings = defineStore('settings', () => {
const isSettingsOpen = ref(false)
const activeTab = ref(null)
return {
isSettingsOpen,
activeTab,
}
})

View File

@@ -15,7 +15,6 @@ export const usersStore = defineStore('lms-users', () => {
const allUsers = createResource({
url: 'lms.lms.api.get_all_users',
cache: ['allUsers'],
auto: true,
})
return {

98
frontend/src/telemetry.ts Normal file
View File

@@ -0,0 +1,98 @@
import { useStorage } from "@vueuse/core";
import { call } from "frappe-ui";
import "../../../frappe/frappe/public/js/lib/posthog.js";
const APP = "lms";
const SITENAME = window.location.hostname;
declare global {
interface Window {
posthog: any;
}
}
const telemetry = useStorage("telemetry", {
enabled: false,
project_id: "",
host: "",
});
export async function init() {
await set_enabled();
if (!telemetry.value.enabled) return;
try {
await set_credentials();
window.posthog.init(telemetry.value.project_id, {
api_host: telemetry.value.host,
autocapture: false,
person_profiles: "always",
capture_pageview: true,
capture_pageleave: true,
disable_session_recording: false,
session_recording: {
maskAllInputs: false,
maskInputOptions: {
password: true,
},
},
loaded: (posthog) => {
window.posthog = posthog;
window.posthog.identify(SITENAME);
},
});
} catch (e) {
console.trace("Failed to initialize telemetry", e);
telemetry.value.enabled = false;
}
}
async function set_enabled() {
if (telemetry.value.enabled) return;
await call("lms.lms.telemetry.is_enabled").then((res) => {
telemetry.value.enabled = res;
});
}
async function set_credentials() {
if (!telemetry.value.enabled) return;
if (telemetry.value.project_id && telemetry.value.host) return;
await call("lms.lms.telemetry.get_credentials").then((res) => {
telemetry.value.project_id = res.project_id;
telemetry.value.host = res.telemetry_host;
});
}
interface CaptureOptions {
data: {
user: string;
[key: string]: string | number | boolean | object;
};
}
export function capture(
event: string,
options: CaptureOptions = { data: { user: "" } }
) {
if (!telemetry.value.enabled) return;
window.posthog.capture(`${APP}_${event}`, options);
}
export function recordSession() {
if (!telemetry.value.enabled) return;
if (window.posthog && window.posthog.__loaded) {
window.posthog.startSessionRecording();
}
}
export function stopSession() {
if (!telemetry.value.enabled) return;
if (
window.posthog &&
window.posthog.__loaded &&
window.posthog.sessionRecordingStarted()
) {
window.posthog.stopSessionRecording();
}
}

View File

@@ -2,6 +2,7 @@ import { createResource } from 'frappe-ui'
export default function translationPlugin(app) {
app.config.globalProperties.__ = translate
window.__ = translate
if (!window.translatedMessages) fetchTranslations()
}

View File

@@ -62,7 +62,7 @@ export class CodeBox {
static get toolbox() {
const app = createApp({
render: () => h(Code, { size: 24, strokeWidth: 2, color: 'black' }),
render: () => h(Code, { size: 18, strokeWidth: 1.5, color: 'black' }),
});
const div = document.createElement('div');

View File

@@ -1,32 +0,0 @@
import Embed from '@editorjs/embed'
import VideoBlock from '@/components/VideoBlock.vue'
import { createApp } from 'vue'
export class CustomEmbed extends Embed {
render() {
const container = super.render()
const { service, source, embed } = this.data
if (service === 'youtube' || service === 'vimeo') {
// Remove the iframe or existing embed content
container.innerHTML = ''
// Create a placeholder element for Vue component
const vueContainer = document.createElement('div')
vueContainer.setAttribute('data-service', service)
vueContainer.setAttribute('data-video-id', this.data.source)
// Append the Vue placeholder
container.appendChild(vueContainer)
console.log(source)
// Mount the Vue component (using a global Vue instance)
const app = createApp(VideoBlock, {
file: source,
type: 'video/youtube',
})
app.mount(vueContainer)
}
return container
}
}

View File

@@ -10,6 +10,7 @@ import InlineCode from '@editorjs/inline-code'
import { watch } from 'vue'
import dayjs from '@/utils/dayjs'
import Embed from '@editorjs/embed'
import SimpleImage from '@editorjs/simple-image'
export function createToast(options) {
toast({
@@ -79,15 +80,21 @@ export function getFileSize(file_size) {
return value
}
export function showToast(title, text, icon) {
export function showToast(title, text, icon, iconClasses = null) {
if (!iconClasses) {
if (icon == 'check') {
iconClasses = 'bg-green-600 text-white rounded-md p-px'
} else if (icon == 'circle-warn') {
iconClasses = 'bg-yellow-600 text-white rounded-md p-px'
} else {
iconClasses = 'bg-red-600 text-white rounded-md p-px'
}
}
createToast({
title: title,
text: htmlToText(text),
icon: icon,
iconClasses:
icon == 'check'
? 'bg-green-600 text-white rounded-md p-px'
: 'bg-red-600 text-white rounded-md p-px',
iconClasses: iconClasses,
position: icon == 'check' ? 'bottom-right' : 'top-center',
timeout: 5,
})
@@ -133,6 +140,7 @@ export function getEditorTools() {
header: Header,
quiz: Quiz,
upload: Upload,
image: SimpleImage,
paragraph: {
class: Paragraph,
inlineToolbar: true,
@@ -144,9 +152,9 @@ export function getEditorTools() {
class: CodeBox,
config: {
themeURL:
'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/styles/dracula.min.css', // Optional
themeName: 'atom-one-dark', // Optional
useDefaultTheme: 'dark', // Optional. This also determines the background color of the language select drop-down
'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/styles/atom-one-dark.min.css',
themeName: 'atom-one-dark',
useDefaultTheme: 'dark',
},
},
list: {
@@ -164,16 +172,74 @@ export function getEditorTools() {
inlineToolbar: false,
config: {
services: {
youtube: true,
youtube: {
regex: /(?:https?:\/\/)?(?:www\.)?(?:(?:youtu\.be\/)|(?:youtube\.com)\/(?:v\/|u\/\w\/|embed\/|watch))(?:(?:\?v=)?([^#&?=]*))?((?:[?&]\w*=\w*)*)/,
embedUrl:
'https://www.youtube.com/embed/<%= remote_id %>',
html: '<iframe style="width:100%; height: 30rem;" frameborder="0" allowfullscreen></iframe>',
height: 320,
width: 580,
id: ([id, params]) => {
if (!params && id) {
return id
}
const paramsMap = {
start: 'start',
end: 'end',
t: 'start',
// eslint-disable-next-line camelcase
time_continue: 'start',
list: 'list',
}
let newParams = params
.slice(1)
.split('&')
.map((param) => {
const [name, value] = param.split('=')
if (!id && name === 'v') {
id = value
return null
}
if (!paramsMap[name]) {
return null
}
if (
value === 'LL' ||
value.startsWith('RDMM') ||
value.startsWith('FL')
) {
return null
}
return `${paramsMap[name]}=${value}`
})
.filter((param) => !!param)
return id + '?' + newParams.join('&')
},
},
vimeo: true,
codepen: true,
aparat: true,
aparat: {
regex: /(?:http[s]?:\/\/)?(?:www.)?aparat\.com\/v\/([^\/\?\&]+)\/?/,
embedUrl:
'https://www.aparat.com/video/video/embed/videohash/<%= remote_id %>/vt/frame',
html: '<iframe style="margin: 0 auto; width: 100%; height: 25rem;" frameborder="0" scrolling="no" allowtransparency="true"></iframe>',
height: 300,
width: 600,
},
github: true,
slides: {
regex: /https:\/\/docs\.google\.com\/presentation\/d\/e\/([A-Za-z0-9_-]+)\/pub/,
regex: /https:\/\/docs\.google\.com\/presentation\/d\/([A-Za-z0-9_-]+)\/pub/,
embedUrl:
'https://docs.google.com/presentation/d/e/<%= remote_id %>/embed',
html: "<iframe style='width: 100%; height: 30rem; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>",
'https://docs.google.com/presentation/d/<%= remote_id %>/embed',
html: "<iframe style='width: 100%; height: 30rem; border: 1px solid #D3D3D3; border-radius: 12px; margin: 1rem 0' frameborder='0' allowfullscreen='true'></iframe>",
},
drive: {
regex: /https:\/\/drive\.google\.com\/file\/d\/([A-Za-z0-9_-]+)\/view(\?.+)?/,
@@ -197,7 +263,7 @@ export function getEditorTools() {
regex: /https:\/\/docs\.google\.com\/presentation\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?/,
embedUrl:
'https://docs.google.com/presentation/d/<%= remote_id %>/embed',
html: "<iframe style='width: 100%; height: 30rem; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>",
html: "<iframe style='width: 100%; height: 30rem; border: 1px solid #D3D3D3; border-radius: 12px; margin: 1rem 0;' frameborder='0' allowfullscreen='true'></iframe>",
},
codesandbox: {
regex: /^https:\/\/codesandbox\.io\/(?:embed\/)?([A-Za-z0-9_-]+)(?:\?[^\/]*)?$/,
@@ -361,15 +427,15 @@ export function getSidebarLinks() {
'Courses',
'CourseDetail',
'Lesson',
'CreateCourse',
'CreateLesson',
'CourseForm',
'LessonForm',
],
},
{
label: 'Batches',
icon: 'Users',
to: 'Batches',
activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchCreation'],
activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchForm'],
},
{
label: 'Certified Participants',
@@ -420,3 +486,26 @@ export function getLineStartPosition(string, position) {
return position
}
export function singularize(word) {
const endings = {
ves: 'fe',
ies: 'y',
i: 'us',
zes: 'ze',
ses: 's',
es: 'e',
s: '',
}
return word.replace(
new RegExp(`(${Object.keys(endings).join('|')})$`),
(r) => endings[r]
)
}
export const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
return __('Only image file is allowed.')
}
}

View File

@@ -1,7 +1,9 @@
import QuizBlock from '@/components/QuizBlock.vue'
import { createApp } from 'vue'
import QuizPlugin from '@/components/QuizPlugin.vue'
import { createApp, h } from 'vue'
import { usersStore } from '../stores/user'
import translationPlugin from '../translation'
import { CircleHelp } from 'lucide-vue-next'
export class Quiz {
constructor({ data, api, readOnly }) {
@@ -9,17 +11,31 @@ export class Quiz {
this.readOnly = readOnly
}
static get toolbox() {
const app = createApp({
render: () =>
h(CircleHelp, { size: 18, strokeWidth: 1.5, color: 'black' }),
})
const div = document.createElement('div')
app.mount(div)
return {
title: __('Quiz'),
icon: div.innerHTML,
}
}
static get isReadOnlySupported() {
return true
}
render() {
this.wrapper = document.createElement('div')
if (this.data) {
let renderedQuiz = this.renderQuiz(this.data.quiz)
if (!this.readOnly) {
this.wrapper.innerHTML = renderedQuiz
}
if (Object.keys(this.data).length) {
this.renderQuiz(this.data.quiz)
} else {
this.renderQuizModal()
}
return this.wrapper
}
@@ -27,7 +43,7 @@ export class Quiz {
renderQuiz(quiz) {
if (this.readOnly) {
const app = createApp(QuizBlock, {
quiz: quiz, // Pass quiz content as prop
quiz: quiz,
})
app.use(translationPlugin)
const { userResource } = usersStore()
@@ -35,11 +51,23 @@ export class Quiz {
app.mount(this.wrapper)
return
}
return `<div class='border rounded-md p-10 text-center mb-2'>
this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center mb-2'>
<span class="font-medium">
Quiz: ${quiz}
</span>
</div>`
return
}
renderQuizModal() {
const app = createApp(QuizPlugin, {
onQuizAddition: (quiz) => {
this.data.quiz = quiz
this.renderQuiz(quiz)
},
})
app.use(translationPlugin)
app.mount(this.wrapper)
}
save(blockContent) {

View File

@@ -1,6 +1,9 @@
import AudioBlock from '@/components/AudioBlock.vue'
import VideoBlock from '@/components/VideoBlock.vue'
import { createApp } from 'vue'
import UploadPlugin from '@/components/UploadPlugin.vue'
import { h, createApp } from 'vue'
import { Upload as UploadIcon } from 'lucide-vue-next'
import translationPlugin from '../translation'
export class Upload {
constructor({ data, api, readOnly }) {
@@ -8,17 +11,38 @@ export class Upload {
this.readOnly = readOnly
}
static get toolbox() {
const app = createApp({
render: () =>
h(UploadIcon, { size: 18, strokeWidth: 1.5, color: 'black' }),
})
const div = document.createElement('div')
app.mount(div)
return {
title: 'Upload',
icon: div.innerHTML,
}
}
static get isReadOnlySupported() {
return true
}
render() {
this.wrapper = document.createElement('div')
this.renderUpload(this.data)
if (this.data && this.data.file_url) {
this.renderFile(this.data)
} else {
this.renderFileUploader()
}
return this.wrapper
}
renderUpload(file) {
renderFile(file) {
if (this.isVideo(file.file_type)) {
const app = createApp(VideoBlock, {
file: file.file_url,
@@ -32,9 +56,11 @@ export class Upload {
app.mount(this.wrapper)
return
} else if (file.file_type == 'PDF') {
this.wrapper.innerHTML = `<iframe src="${encodeURI(
this.wrapper.innerHTML = `<iframe src="https://docs.google.com/viewer?url=${
window.location.origin
}${encodeURI(
file.file_url
)}#toolbar=0" width='100%' height='700px' class="mb-4"></iframe>`
)}&embedded=true" width='100%' height='700px' class="mb-4" type="application/pdf"></iframe>`
return
} else {
this.wrapper.innerHTML = `<img class="mb-4" src=${encodeURI(
@@ -44,6 +70,25 @@ export class Upload {
}
}
renderFileUploader() {
const app = createApp(UploadPlugin, {
onFileUploaded: (file) => {
this.data.file_url = file.file_url
this.data.file_type = file.file_type
this.renderFile(file)
},
})
app.use(translationPlugin)
app.mount(this.wrapper)
}
validate(savedData) {
if (!savedData.file_url || !savedData.file_type) {
return false
}
return true
}
save(blockContent) {
return {
file_url: this.data.file_url,

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1 @@
__version__ = "2.0.0"
__version__ = "2.9.0"

View File

View File

@@ -1,13 +0,0 @@
from frappe import _
def get_data():
return [
{
"module_name": "Community",
"color": "grey",
"icon": "octicon octicon-file-directory",
"type": "module",
"label": _("Community"),
}
]

View File

@@ -1,12 +0,0 @@
"""
Configuration for docs
"""
# source_link = "https://github.com/[org_name]/community"
# docs_base_url = "https://[org_name].github.io/community"
# headline = "App that does everything"
# sub_heading = "Yes, you got that right the first time, everything"
def get_context(context):
context.brand_html = "Community"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
[
{
"category": "Web Development",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2024-09-20 12:58:16.841571",
"name": "Web Development"
},
{
"category": "Business",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2024-09-20 12:58:32.304850",
"name": "Business"
},
{
"category": "Design",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2024-09-20 12:59:12.621022",
"name": "Design"
},
{
"category": "Personal Development",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2024-09-20 12:59:19.287404",
"name": "Personal Development"
},
{
"category": "Finance",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2024-09-20 12:58:28.579714",
"name": "Finance"
},
{
"category": "Frontend",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2024-05-08 14:05:16.979275",
"name": "Frontend"
},
{
"category": "Framework",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2023-06-15 18:01:41.598282",
"name": "Framework"
}
]

View File

@@ -4,9 +4,11 @@ app_name = "frappe_lms"
app_title = "Frappe LMS"
app_publisher = "Frappe"
app_description = "Frappe LMS App"
app_icon = "octicon octicon-file-directory"
app_icon_url = "/assets/lms/images/lms-logo.png"
app_icon_title = "Learning"
app_icon_route = "/lms"
app_color = "grey"
app_email = "school@frappe.io"
app_email = "jannat@frappe.io"
app_license = "AGPL"
# Includes in <head>
@@ -61,8 +63,6 @@ web_include_js = ["website.bundle.js"]
after_install = "lms.install.after_install"
after_sync = "lms.install.after_sync"
before_uninstall = "lms.install.before_uninstall"
setup_wizard_requires = "assets/lms/js/setup_wizard.js"
# Desk Notifications
@@ -115,7 +115,7 @@ scheduler_events = {
"daily": ["lms.job.doctype.job_opportunity.job_opportunity.update_job_openings"],
}
fixtures = ["Custom Field", "Function", "Industry"]
fixtures = ["Custom Field", "Function", "Industry", "LMS Category"]
# Testing
# -------
@@ -177,50 +177,15 @@ update_website_context = [
jinja = {
"methods": [
"lms.page_renderers.get_profile_url",
"lms.overrides.user.get_enrolled_courses",
"lms.overrides.user.get_course_membership",
"lms.overrides.user.get_authored_courses",
"lms.overrides.user.get_palette",
"lms.lms.utils.get_membership",
"lms.lms.utils.get_lessons",
"lms.lms.utils.get_signup_optin_checks",
"lms.lms.utils.get_tags",
"lms.lms.utils.get_lesson_count",
"lms.lms.utils.get_instructors",
"lms.lms.utils.get_students",
"lms.lms.utils.get_average_rating",
"lms.lms.utils.is_certified",
"lms.lms.utils.get_lesson_index",
"lms.lms.utils.get_lesson_url",
"lms.lms.utils.get_chapters",
"lms.lms.utils.get_slugified_chapter_title",
"lms.lms.utils.get_progress",
"lms.lms.utils.render_html",
"lms.lms.utils.is_mentor",
"lms.lms.utils.is_cohort_staff",
"lms.lms.utils.get_mentors",
"lms.lms.utils.get_reviews",
"lms.lms.utils.is_eligible_to_review",
"lms.lms.utils.get_initial_members",
"lms.lms.utils.get_sorted_reviews",
"lms.page_renderers.get_profile_url",
"lms.overrides.user.get_palette",
"lms.lms.utils.is_instructor",
"lms.lms.utils.convert_number_to_character",
"lms.lms.utils.get_signup_optin_checks",
"lms.lms.utils.get_popular_courses",
"lms.lms.utils.format_amount",
"lms.lms.utils.first_lesson_exists",
"lms.lms.utils.get_courses_under_review",
"lms.lms.utils.has_course_instructor_role",
"lms.lms.utils.has_course_moderator_role",
"lms.lms.utils.get_certificates",
"lms.lms.utils.format_number",
"lms.lms.utils.get_lesson_count",
"lms.lms.utils.get_all_memberships",
"lms.lms.utils.get_filtered_membership",
"lms.lms.utils.show_start_learing_cta",
"lms.lms.utils.can_create_courses",
"lms.lms.utils.get_telemetry_boot_info",
"lms.lms.utils.is_onboarding_complete",
"lms.www.utils.is_student",
],
"filters": [],
}
@@ -267,3 +232,13 @@ profile_url_prefix = "/users/"
signup_form_template = "lms.plugins.show_custom_signup"
on_session_creation = "lms.overrides.user.on_session_creation"
add_to_apps_screen = [
{
"name": "lms",
"logo": "/assets/lms/images/lms-logo.png",
"title": "Learning",
"route": "/lms",
"has_permission": "lms.lms.api.check_app_permission",
}
]

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