Compare commits

..

277 Commits

Author SHA1 Message Date
Frappe PR Bot
7fcf6a253d chore(release): Bumped to Version 2.30.0 2025-06-10 10:24:40 +00:00
Jannat Patel
be8d985d15 fix: removed duplicate import 2025-06-10 15:34:19 +05:30
Jannat Patel
974c90dddc Merge branch 'main' into develop 2025-06-10 15:29:39 +05:30
Jannat Patel
4811d395d2 Merge pull request #1568 from pateljannat/issues-114
fix: misc evaluation issues
2025-06-10 11:06:45 +05:30
Jannat Patel
132423d577 Merge pull request #1567 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-10 10:58:22 +05:30
Jannat Patel
10829e2f00 fix: misc evaluation issues 2025-06-10 10:58:03 +05:30
Jannat Patel
47b908c964 chore: Esperanto translations 2025-06-10 03:35:55 +05:30
Jannat Patel
0f8e471d5d chore: Chinese Simplified translations 2025-06-10 03:35:54 +05:30
Jannat Patel
2537119250 chore: Serbian (Latin) translations 2025-06-10 03:35:52 +05:30
Jannat Patel
977066d114 chore: Bosnian translations 2025-06-10 03:35:51 +05:30
Jannat Patel
46e956dc74 chore: Croatian translations 2025-06-10 03:35:50 +05:30
Jannat Patel
7afdd8d44f chore: Thai translations 2025-06-10 03:35:48 +05:30
Jannat Patel
6daf204b4f chore: Persian translations 2025-06-10 03:35:47 +05:30
Jannat Patel
2f4a550a4a chore: Portuguese, Brazilian translations 2025-06-10 03:35:46 +05:30
Jannat Patel
fe214f6b41 chore: Turkish translations 2025-06-10 03:35:44 +05:30
Jannat Patel
ca7de81888 chore: Swedish translations 2025-06-10 03:35:43 +05:30
Jannat Patel
17ce20355a chore: Russian translations 2025-06-10 03:35:42 +05:30
Jannat Patel
34981b4765 chore: Portuguese translations 2025-06-10 03:35:41 +05:30
Jannat Patel
21151a2e09 chore: Polish translations 2025-06-10 03:35:39 +05:30
Jannat Patel
1abb7f5b8c chore: Hungarian translations 2025-06-10 03:35:38 +05:30
Jannat Patel
05998549a4 chore: German translations 2025-06-10 03:35:37 +05:30
Jannat Patel
96283a3629 chore: Arabic translations 2025-06-10 03:35:35 +05:30
Jannat Patel
2bfc7abe9c chore: Spanish translations 2025-06-10 03:35:34 +05:30
Jannat Patel
4f389eca8d chore: French translations 2025-06-10 03:35:33 +05:30
Jannat Patel
1789479955 Merge pull request #1564 from frappe/pot_develop_2025-06-06
chore: update POT file
2025-06-09 19:44:09 +05:30
frappe-pr-bot
59316dbaf9 chore: update POT file 2025-06-06 16:04:34 +00:00
Jannat Patel
b726073a5b Merge pull request #1562 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-06 10:52:56 +05:30
Jannat Patel
adf897c812 chore: French translations 2025-06-06 03:03:37 +05:30
Jannat Patel
1fc4c2442c Merge pull request #1561 from pateljannat/evaluator-link-in-batch
fix batch instructor should be linked to evaluator
2025-06-05 15:06:46 +05:30
Jannat Patel
414643ee90 test: link evaluator as batch instructor 2025-06-05 14:46:09 +05:30
Jannat Patel
1a1cbd6ea1 fix: batch instructor should be linked to evaluator 2025-06-05 12:58:12 +05:30
Jannat Patel
9ae809a62f Merge pull request #1559 from pateljannat/issues-113
fix: dont allow enrollment is self learning is disabled from api
2025-06-05 12:53:42 +05:30
Jannat Patel
eb9b1c905d Merge pull request #1558 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-05 10:46:03 +05:30
Jannat Patel
fe9a8f49c1 fix: dont allow enrollment is self learning is disabled from api 2025-06-05 10:43:25 +05:30
Jannat Patel
f912c8fce3 chore: French translations 2025-06-05 02:22:54 +05:30
Jannat Patel
1d1ca43c35 chore: Serbian (Latin) translations 2025-06-04 01:58:16 +05:30
Jannat Patel
bce45f44e4 Merge pull request #1557 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-03 13:23:54 +05:30
Jannat Patel
07583fb563 fix: show error in toast when scheduling evaluations 2025-06-03 12:47:57 +05:30
Jannat Patel
775aa23992 chore: Esperanto translations 2025-06-03 02:00:23 +05:30
Jannat Patel
05ed6b7e73 chore: Chinese Simplified translations 2025-06-03 02:00:22 +05:30
Jannat Patel
d602694ea7 chore: Serbian (Latin) translations 2025-06-03 02:00:20 +05:30
Jannat Patel
18d71bc0d4 chore: Bosnian translations 2025-06-03 02:00:19 +05:30
Jannat Patel
3fa68643ba chore: Croatian translations 2025-06-03 02:00:18 +05:30
Jannat Patel
8904525c36 chore: Thai translations 2025-06-03 02:00:16 +05:30
Jannat Patel
3ce09a98f3 chore: Persian translations 2025-06-03 02:00:15 +05:30
Jannat Patel
b833768e71 chore: Portuguese, Brazilian translations 2025-06-03 02:00:14 +05:30
Jannat Patel
b9a6afd993 chore: Turkish translations 2025-06-03 02:00:12 +05:30
Jannat Patel
b5a81ea927 chore: Swedish translations 2025-06-03 02:00:11 +05:30
Jannat Patel
750e92cdde chore: Russian translations 2025-06-03 02:00:09 +05:30
Jannat Patel
da45f4c011 chore: Portuguese translations 2025-06-03 02:00:08 +05:30
Jannat Patel
544bb5c11c chore: Polish translations 2025-06-03 02:00:07 +05:30
Jannat Patel
1fc6f62f70 chore: Hungarian translations 2025-06-03 02:00:05 +05:30
Jannat Patel
8751ad27ec chore: German translations 2025-06-03 02:00:04 +05:30
Jannat Patel
159d3d5b87 chore: Arabic translations 2025-06-03 02:00:03 +05:30
Jannat Patel
34d6d99d8c chore: Spanish translations 2025-06-03 02:00:01 +05:30
Jannat Patel
6c46931b1a chore: French translations 2025-06-03 02:00:00 +05:30
Jannat Patel
2c3e2d9d08 Merge pull request #1554 from pateljannat/quiz-in-video
feat: show quiz in between videos
2025-06-02 19:35:55 +05:30
Jannat Patel
7be1562fa4 fix: simplified timestamp label 2025-06-02 19:18:27 +05:30
Jannat Patel
294389e7c7 Merge branch 'develop' of https://github.com/frappe/lms into quiz-in-video 2025-06-02 19:16:27 +05:30
Jannat Patel
2c8ce133f7 fix: quiz and time validation before linking to video 2025-06-02 19:12:13 +05:30
Ankush Menat
4f1d4d90d0 fix: remove invasive configs (#1555) 2025-06-02 19:04:55 +05:30
Jannat Patel
7b7484332b feat: quiz in videos 2025-06-02 18:18:13 +05:30
Jannat Patel
50e94b85aa chore: resolved conflicts 2025-06-02 12:24:16 +05:30
Jannat Patel
9b820594ef Merge pull request #1553 from pateljannat/batch-test
test: batch creation flow
2025-06-02 12:22:58 +05:30
Jannat Patel
ddcd45d56d test: don't add course to batch 2025-06-02 12:15:34 +05:30
Jannat Patel
c4a4c16516 test: batch creation flow 2025-06-02 10:48:54 +05:30
Jannat Patel
5ae9ad0762 Merge pull request #1552 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-02 10:38:49 +05:30
Jannat Patel
405f7d498e Merge pull request #1548 from frappe/pot_develop_2025-05-30
chore: update POT file
2025-06-02 10:38:39 +05:30
Jannat Patel
bcd6a5b1e7 chore: Persian translations 2025-06-02 01:08:03 +05:30
Jannat Patel
e5e5ac994c Merge pull request #1550 from pateljannat/issues-112
fix: misc issues
2025-05-31 12:49:16 +05:30
Jannat Patel
e1f8d6ec49 fix: count of jobs and certified members 2025-05-31 12:41:58 +05:30
Jannat Patel
6f50242f5a fix: misc issues 2025-05-31 11:52:25 +05:30
frappe-pr-bot
036f7ece05 chore: update POT file 2025-05-30 16:04:24 +00:00
Jannat Patel
622a2ff072 feat: display quiz when time is reached 2025-05-30 18:55:26 +05:30
Jannat Patel
60334ca04a feat: show quiz in between videos 2025-05-30 13:00:00 +05:30
Jannat Patel
ade47b4e83 Merge pull request #1547 from pateljannat/seo-in-forms
feat: seo tags and keywords for courses and batches
2025-05-30 10:19:29 +05:30
Jannat Patel
d7e550dfea feat: seo tags and keywords for courses and batches 2025-05-29 20:13:35 +05:30
Jannat Patel
c3cc0b9bf7 Merge pull request #1546 from pateljannat/issues-111
fix: misc issues
2025-05-29 16:29:57 +05:30
Jannat Patel
5ad89189c1 fix: changed certified members count based on filters 2025-05-29 16:09:51 +05:30
Jannat Patel
f1bbd4eb13 fix: settings ui cleanup 2025-05-29 16:09:14 +05:30
Jannat Patel
fba89dfacb feat: show unpushlished courses to admins on frontend 2025-05-29 12:50:25 +05:30
Jannat Patel
b93ed41215 fix: course and chapter permissions to moderators 2025-05-29 12:49:30 +05:30
Jannat Patel
13ff6a7304 chore: record sessions while creating courses and lessons 2025-05-29 12:48:58 +05:30
Jannat Patel
ad97405e55 Merge pull request #1544 from Rl0007/fix/edit-profile-escape-html
fix: Edit profile escape html
2025-05-28 20:20:53 +05:30
Rahul Agrawal
376e231d7b chore: remove unwanted line profile.bio = profile.bio 2025-05-28 16:00:14 +05:30
Rahul Agrawal
e16d76f6dd fix: remove escapeHtml from edit profile bio on save 2025-05-28 15:44:54 +05:30
Jannat Patel
ffd0fd92fc Merge pull request #1542 from pateljannat/zoom-refactor
feat: multiple zoom accounts and zoom attendance
2025-05-28 12:06:21 +05:30
Jannat Patel
933613d730 fix: jobs list header issue 2025-05-28 11:22:03 +05:30
Jannat Patel
9b0673bf92 feat: zoom attendance 2025-05-27 23:01:04 +05:30
Jannat Patel
7cba22aa28 Merge pull request #1539 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-05-27 12:02:06 +05:30
Jannat Patel
af05b614a9 feat: delete zoom accounts from settings 2025-05-27 11:40:22 +05:30
Jannat Patel
c0fa219a8b chore: Esperanto translations 2025-05-26 23:41:15 +05:30
Jannat Patel
4e3a47b0f4 chore: Chinese Simplified translations 2025-05-26 23:41:14 +05:30
Jannat Patel
161276b58a chore: Serbian (Latin) translations 2025-05-26 23:41:13 +05:30
Jannat Patel
47713019a5 chore: Bosnian translations 2025-05-26 23:41:11 +05:30
Jannat Patel
010632a21d chore: Croatian translations 2025-05-26 23:41:10 +05:30
Jannat Patel
e77fe550af chore: Thai translations 2025-05-26 23:41:09 +05:30
Jannat Patel
0a4233da14 chore: Persian translations 2025-05-26 23:41:08 +05:30
Jannat Patel
56fb70ab1e chore: Portuguese, Brazilian translations 2025-05-26 23:41:06 +05:30
Jannat Patel
4a1f2bc01d chore: Turkish translations 2025-05-26 23:41:05 +05:30
Jannat Patel
20292fbf16 chore: Swedish translations 2025-05-26 23:41:04 +05:30
Jannat Patel
1290cf8991 chore: Russian translations 2025-05-26 23:41:02 +05:30
Jannat Patel
b8b8af7cf1 chore: Portuguese translations 2025-05-26 23:41:01 +05:30
Jannat Patel
75f4f452d3 chore: Polish translations 2025-05-26 23:41:00 +05:30
Jannat Patel
9de492384f chore: Hungarian translations 2025-05-26 23:40:58 +05:30
Jannat Patel
14c4e161f2 chore: German translations 2025-05-26 23:40:57 +05:30
Jannat Patel
c55efbc0ba chore: Arabic translations 2025-05-26 23:40:56 +05:30
Jannat Patel
f0610222d9 chore: Spanish translations 2025-05-26 23:40:55 +05:30
Jannat Patel
302ee4a50f chore: French translations 2025-05-26 23:40:53 +05:30
Jannat Patel
2170819159 chore: telemetry fixes 2025-05-26 22:02:46 +05:30
Jannat Patel
0d1fac321a feat: zoom settings on frontend 2025-05-26 21:35:13 +05:30
Jannat Patel
dbbc1756dd Merge branch 'develop' of https://github.com/frappe/lms into zoom-refactor 2025-05-26 21:27:18 +05:30
Jannat Patel
d5b882d3f8 feat: multiple zoom accounts 2025-05-26 18:08:17 +05:30
Frappe PR Bot
3025ea9a7b chore(release): Bumped to Version 2.29.0 2025-05-26 10:05:36 +00:00
Jannat Patel
5dba4d1384 Merge pull request #1537 from frappe/develop
chore: merge 'develop' into 'main'
2025-05-26 15:33:03 +05:30
Jannat Patel
e4f1e7b093 Merge pull request #1536 from pateljannat/telemetry-fixes
chore: fix posthog init condition
2025-05-26 15:21:34 +05:30
Jannat Patel
d0a0597087 chore: removed unused imports 2025-05-26 15:08:41 +05:30
Jannat Patel
c9ccf9a1b5 chore: fix posthog init condition 2025-05-26 15:02:51 +05:30
Jannat Patel
69107d4441 Merge pull request #1535 from pateljannat/refactor-batch-charts
refactor: use frappe-ui for batch progress charts
2025-05-26 12:43:15 +05:30
Jannat Patel
e25afc1ef7 chore: fixed formating 2025-05-26 12:32:34 +05:30
Jannat Patel
9babfd150e fix: course count on batch dashboard 2025-05-26 12:25:16 +05:30
Jannat Patel
532dbbea4a fix: restricted minimum chart interval to 1 2025-05-26 11:34:52 +05:30
Jannat Patel
0d284d05d9 Merge pull request #1534 from pateljannat/issues-110
fix: misc issues
2025-05-26 11:22:16 +05:30
Jannat Patel
28fccae3ac Merge pull request #1532 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-05-26 11:05:01 +05:30
Jannat Patel
3a4a6da69c Merge pull request #1531 from frappe/pot_develop_2025-05-23
chore: update POT file
2025-05-26 11:04:50 +05:30
Jannat Patel
4ea07a95e7 fix: show batch CTA's on mobile 2025-05-26 11:04:00 +05:30
Jannat Patel
80ceb49358 fix: login menu now works on all browsers and devices 2025-05-26 10:58:17 +05:30
Jannat Patel
589337116a fix: added dependencies for onboarding steps 2025-05-26 09:59:05 +05:30
Jannat Patel
cb50067223 chore: Chinese Simplified translations 2025-05-24 23:16:12 +05:30
Jannat Patel
4d63266d88 chore: Serbian (Latin) translations 2025-05-24 23:16:11 +05:30
Jannat Patel
90dd33ce21 chore: Serbian (Latin) translations 2025-05-23 23:13:03 +05:30
frappe-pr-bot
763b849ddf chore: update POT file 2025-05-23 16:04:27 +00:00
Jannat Patel
9c76c54283 Merge pull request #1528 from pateljannat/email-template-list
feat: email template in settings
2025-05-23 15:35:11 +05:30
Md Hussain Nagaria
5cb17b3a36 Merge pull request #1529 from frappe/misc-fixes 2025-05-23 11:01:25 +02:00
Hussain Nagaria
2f7b5d1cbb fix: use unavailabilityMessage if set 2025-05-23 14:28:47 +05:30
Hussain Nagaria
4fe14eb2e9 fix: early return cleanup 2025-05-23 14:26:22 +05:30
Jannat Patel
eb089f2b58 fix: return payment fields data after transform 2025-05-23 14:25:29 +05:30
Hussain Nagaria
4f0ac98eea fix: toast used but not imported 2025-05-23 14:02:04 +05:30
Hussain Nagaria
af19940fa1 fix: some code semantics 2025-05-23 14:00:11 +05:30
Jannat Patel
5635d2a325 feat: email template update and deletion 2025-05-23 13:28:18 +05:30
Jannat Patel
5e2de35693 refactor: layout of payment fields 2025-05-22 21:39:49 +05:30
Jannat Patel
ef7180f23f Merge pull request #1523 from pateljannat/issues-109
refactor: misc enhancements
2025-05-22 12:12:57 +05:30
Jannat Patel
f939973d4f fix: don't validate number of students if seat count is 0 in batch 2025-05-22 12:03:07 +05:30
Jannat Patel
63f327733e refactor: category list in settings 2025-05-22 11:54:54 +05:30
Jannat Patel
c1fb807fe4 fix: show onboarding banner when redirected from other pages 2025-05-21 17:52:24 +05:30
Jannat Patel
b7ddf44267 test: close onboaring popover before creating course 2025-05-21 17:35:48 +05:30
Jannat Patel
6d4c72ea5e fix: rating input style on course details page 2025-05-21 16:28:04 +05:30
Jannat Patel
3db11b9372 refactor: moved batch feedback to sidebar 2025-05-21 16:08:49 +05:30
Jannat Patel
b8714f4abe refactor: batch progress chart will now use frappe-ui components 2025-05-21 13:13:42 +05:30
Jannat Patel
7ccbe74bbe chore: fixed conflicts 2025-05-20 19:09:19 +05:30
Jannat Patel
ea3ae3516b Merge pull request #1513 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-05-20 19:05:38 +05:30
Jannat Patel
d33af3ca52 chore: Esperanto translations 2025-05-19 21:33:54 +05:30
Jannat Patel
291c3fa908 chore: Serbian (Latin) translations 2025-05-19 21:33:53 +05:30
Jannat Patel
a51fa58122 chore: Bosnian translations 2025-05-19 21:33:52 +05:30
Jannat Patel
65a3967abd chore: Croatian translations 2025-05-19 21:33:50 +05:30
Jannat Patel
e1e5c94a43 chore: Thai translations 2025-05-19 21:33:49 +05:30
Jannat Patel
f15127eceb chore: Chinese Simplified translations 2025-05-19 21:33:47 +05:30
Jannat Patel
071a238b71 chore: Persian translations 2025-05-19 21:33:46 +05:30
Jannat Patel
050b052156 chore: Portuguese, Brazilian translations 2025-05-19 21:33:44 +05:30
Jannat Patel
8f65cca776 chore: Turkish translations 2025-05-19 21:33:43 +05:30
Jannat Patel
66624a8c47 chore: Swedish translations 2025-05-19 21:33:41 +05:30
Jannat Patel
c8b9a415e6 chore: Russian translations 2025-05-19 21:33:40 +05:30
Jannat Patel
a1dcb4c203 chore: Portuguese translations 2025-05-19 21:33:39 +05:30
Jannat Patel
d4edc3e622 chore: Polish translations 2025-05-19 21:33:37 +05:30
Jannat Patel
e2b8c3ee0e chore: Hungarian translations 2025-05-19 21:33:35 +05:30
Jannat Patel
c37816e90d chore: German translations 2025-05-19 21:33:34 +05:30
Jannat Patel
a35cfcdca7 chore: Arabic translations 2025-05-19 21:33:32 +05:30
Jannat Patel
d381646226 chore: Spanish translations 2025-05-19 21:33:31 +05:30
Jannat Patel
285e7afec2 chore: French translations 2025-05-19 21:33:30 +05:30
Jannat Patel
df7d678c32 Merge pull request #1510 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-05-19 14:24:21 +05:30
Jannat Patel
f36f7e58de Merge pull request #1511 from frappe/pot_develop_2025-05-16
chore: update POT file
2025-05-19 14:24:10 +05:30
Jannat Patel
0e16c834d8 chore: Serbian (Latin) translations 2025-05-18 21:37:27 +05:30
frappe-pr-bot
31a3256128 chore: update POT file 2025-05-16 16:04:20 +00:00
Jannat Patel
aa8f70da28 chore: Swedish translations 2025-05-16 21:09:45 +05:30
Frappe PR Bot
f375ffb8f8 chore(release): Bumped to Version 2.28.1 2025-05-16 06:36:08 +00:00
Jannat Patel
de240e40a5 Merge pull request #1507 from frappe/develop
chore: merge 'develop' into 'main'
2025-05-16 12:01:07 +05:30
Jannat Patel
7d30aea07f Merge pull request #1509 from pateljannat/issues-108
fix: misc issues
2025-05-16 11:55:31 +05:30
Jannat Patel
04a7361d0d fix: verify if score_out_of is not 0 before calculating percentage 2025-05-16 11:40:03 +05:30
Jannat Patel
7b19618eca fix: basic cleanup of quiz submission form 2025-05-16 11:25:43 +05:30
Jannat Patel
bd9600cc08 fix: list index error on quiz submission 2025-05-16 11:07:47 +05:30
Jannat Patel
32172bc791 chore: fixed redis issue faced during docker setup 2025-05-16 09:53:45 +05:30
Jannat Patel
c92f57fb07 Merge pull request #1508 from pateljannat/issues-107
fix: ask for role in persona form
2025-05-15 21:51:05 +05:30
Jannat Patel
8fbdea7f36 fix: ask for role in persona form 2025-05-15 19:57:43 +05:30
Jannat Patel
df15da5145 Merge branch 'develop' of https://github.com/frappe/lms into develop 2025-05-15 09:40:09 +05:30
Jannat Patel
846fe53c0f fix: show persona form after course count has been fetched 2025-05-15 09:38:22 +05:30
Jannat Patel
3bbdc828d9 Merge pull request #1506 from frappe/develop
chore: merge 'develop' into 'main'
2025-05-14 17:48:42 +05:30
Jannat Patel
c454c3f0f2 Merge pull request #1505 from pateljannat/issues-106
fix: plyr will now work on all videos of a lesson
2025-05-14 17:33:14 +05:30
Jannat Patel
77b1a546e8 fix: plyr will now work on all videos of a lesson 2025-05-14 17:12:20 +05:30
Jannat Patel
7c7f063204 Merge pull request #1502 from pateljannat/issues-105
fix: misc fixes
2025-05-14 14:10:16 +05:30
Jannat Patel
0a0fcb305c fix: settings modal size 2025-05-14 13:19:03 +05:30
Jannat Patel
da8028784d chore: changed cypress config to esm 2025-05-14 11:52:48 +05:30
Jannat Patel
48edd888a6 chore: changed file to esm 2025-05-14 11:30:11 +05:30
Jannat Patel
da4f134095 fix: misc ui issues 2025-05-13 20:04:39 +05:30
Jannat Patel
0a71620046 fix: misc ui issues 2025-05-13 20:04:06 +05:30
Jannat Patel
1b5a762578 Merge pull request #1500 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-05-13 14:10:58 +05:30
Jannat Patel
d9d031ed2b refactor: new toast api 2025-05-13 14:08:04 +05:30
Jannat Patel
403e56b4ef fix: misc batch issues 2025-05-13 11:46:00 +05:30
Jannat Patel
499b06e300 chore: Esperanto translations 2025-05-12 20:56:10 +05:30
Jannat Patel
cb69540bdd chore: Chinese Simplified translations 2025-05-12 20:56:08 +05:30
Jannat Patel
1f27fa419a chore: Serbian (Latin) translations 2025-05-12 20:56:06 +05:30
Jannat Patel
a561b2bd91 chore: Bosnian translations 2025-05-12 20:56:05 +05:30
Jannat Patel
eeec85d1de chore: Croatian translations 2025-05-12 20:56:04 +05:30
Jannat Patel
e01484f854 chore: Thai translations 2025-05-12 20:56:02 +05:30
Jannat Patel
fb996ded88 chore: Persian translations 2025-05-12 20:56:01 +05:30
Jannat Patel
a11bfca15a chore: Portuguese, Brazilian translations 2025-05-12 20:55:59 +05:30
Jannat Patel
6262e1c9e6 chore: Turkish translations 2025-05-12 20:55:57 +05:30
Jannat Patel
4e318af7cc chore: Swedish translations 2025-05-12 20:55:55 +05:30
Jannat Patel
d587b7867e chore: Russian translations 2025-05-12 20:55:54 +05:30
Jannat Patel
bd03ead9c3 chore: Portuguese translations 2025-05-12 20:55:53 +05:30
Jannat Patel
c1685b7128 chore: Polish translations 2025-05-12 20:55:51 +05:30
Jannat Patel
7625e79574 chore: Hungarian translations 2025-05-12 20:55:50 +05:30
Jannat Patel
c5bf7875b9 chore: German translations 2025-05-12 20:55:48 +05:30
Jannat Patel
da026293bc chore: Arabic translations 2025-05-12 20:55:46 +05:30
Jannat Patel
86e5677574 chore: Spanish translations 2025-05-12 20:55:45 +05:30
Jannat Patel
a48636604f chore: French translations 2025-05-12 20:55:43 +05:30
Jannat Patel
e6945ac076 fix: all empty states now come from a common component 2025-05-12 17:46:23 +05:30
Jannat Patel
9107d76522 fix: batch form cleanup 2025-05-12 15:04:35 +05:30
Jannat Patel
52b925b306 fix: course form cleanup 2025-05-12 13:07:06 +05:30
Jannat Patel
49d3dc0aa0 Merge pull request #1498 from frappe/pot_develop_2025-05-09
chore: update POT file
2025-05-12 10:53:20 +05:30
Jannat Patel
0d41a1ae70 refactor: use frappe-ui for batch progress charts 2025-05-12 10:37:26 +05:30
frappe-pr-bot
49e22d790a chore: update POT file 2025-05-09 16:04:15 +00:00
Jannat Patel
12e5eedd6b Merge pull request #1494 from pateljannat/issues-104
fix: misc issues
2025-05-08 16:01:14 +05:30
Jannat Patel
159b651871 fix: dark mode for upcoming evaluations 2025-05-08 15:29:04 +05:30
Jannat Patel
080be7a885 fix: tooltips for number cards on statistics page 2025-05-08 15:10:51 +05:30
Jannat Patel
e526627eb9 fix: only show published certificate on the statistics page 2025-05-08 15:05:07 +05:30
Jannat Patel
67fc37c76c fix: ui of job details 2025-05-08 14:53:34 +05:30
Jannat Patel
b2b92aea31 chore: merged upstream 2025-05-07 22:04:53 +05:30
Jannat Patel
e0680d9612 chore: merged upstream 2025-05-07 22:03:57 +05:30
Jannat Patel
d54ac37403 Merge pull request #1491 from pateljannat/issues-103
fix: only assign lms roles to admin
2025-05-07 21:49:38 +05:30
Jannat Patel
eedb3d3dd8 fix: only assign lms roles to admin 2025-05-07 21:40:43 +05:30
Jannat Patel
d286df649e Merge pull request #1490 from frappe/develop
chore: merge 'develop' into 'main'
2025-05-07 12:56:56 +05:30
Jannat Patel
e0cbc247b2 Merge pull request #1485 from pateljannat/release-conflicts
chore: merge to 'main'
2025-05-06 13:10:08 +05:30
Jannat Patel
a2c8a82559 chore: merged conflicts 2025-05-06 12:59:22 +05:30
Jannat Patel
8b91323705 Merge pull request #1457 from pateljannat/vimeo
fix: allow fullscreen on vimeo
2025-04-21 17:32:12 +05:30
Jannat Patel
89fdbf5660 test: find the course image label and attach course image to its sibling input 2025-04-21 17:10:33 +05:30
Jannat Patel
7ed5dfdb8f fix: allow fullscreen on video and adjust video height on mobile devices 2025-04-21 16:34:34 +05:30
Jannat Patel
824c65eb38 Merge pull request #1440 from frappe/develop
chore: merge 'develop' into 'main'
2025-04-16 18:55:21 +05:30
Jannat Patel
e43eeeba4a Merge pull request #1423 from frappe/develop
chore: merge 'develop' into 'main'
2025-04-10 16:01:54 +05:30
Jannat Patel
9e2c7cc145 Merge pull request #1417 from frappe/develop
chore: merge 'develop' into 'main'
2025-04-09 15:17:25 +05:30
Jannat Patel
989598b9cd Merge pull request #1398 from frappe/develop
chore: merge 'develop' into 'main'
2025-03-27 09:44:00 +05:30
Jannat Patel
6a41942de6 Merge pull request #1384 from frappe/develop
chore: merge 'develop' into 'main'
2025-03-20 13:04:16 +05:30
Jannat Patel
d263072aca Merge pull request #1373 from frappe/develop
chore: merge 'develop' into 'main'
2025-03-12 11:10:48 +05:30
Jannat Patel
78c8467bf6 Merge pull request #1361 from frappe/develop
chore: merge 'develop' into 'main'
2025-03-05 18:33:33 +05:30
Jannat Patel
084908bd04 Merge pull request #1352 from frappe/develop
chore: merge 'develop' into 'main'
2025-03-03 15:38:08 +05:30
Jannat Patel
039a775ce4 Merge pull request #1340 from frappe/develop
chore: merge 'develop' into 'main'
2025-02-26 10:18:26 +05:30
Jannat Patel
dd9e80f067 Merge pull request #1326 from frappe/develop
chore: merge 'develop' into 'main'
2025-02-19 11:06:43 +05:30
Jannat Patel
a3a2af948e Merge pull request #1303 from frappe/develop
chore: merge 'develop' into 'main'
2025-02-14 18:07:10 +05:30
Jannat Patel
0bedf3ea59 Merge pull request #1264 from frappe/develop
chore: merge 'develop' into 'main'
2025-01-22 12:59:34 +05:30
Jannat Patel
1775ac4803 Merge pull request #1247 from frappe/develop
chore: merge 'develop' into 'main'
2025-01-15 11:24:49 +05:30
Jannat Patel
ae1a615863 Merge pull request #1237 from frappe/develop
chore: merge 'develop' into 'main'
2025-01-09 11:01:37 +05:30
Jannat Patel
a6ef1b8902 Merge pull request #1220 from frappe/develop
chore: merge 'develop' into 'main'
2025-01-02 20:24:58 +05:30
Jannat Patel
94d17b81d4 Merge pull request #1197 from frappe/develop
chore: merge 'develop' into 'main'
2024-12-18 20:30:52 +05:30
Jannat Patel
44a63d9cec Merge pull request #1180 from frappe/develop
chore: merge 'develop' into 'main'
2024-12-13 12:27:07 +05:30
Jannat Patel
e2b4b5a57e Merge pull request #1164 from frappe/develop
chore: merge 'develop' into 'main'
2024-12-06 11:05:49 +05:30
Jannat Patel
ec30aa323e Merge pull request #1155 from frappe/develop
chore: merge 'develop' into 'main'
2024-11-29 17:05:30 +05:30
Jannat Patel
95e9087c6e Merge pull request #1151 from frappe/develop
chore: merge 'develop' into 'main'
2024-11-25 15:06:00 +05:30
Jannat Patel
db38099557 Merge pull request #1143 from frappe/develop
chore: merge 'develop' into 'main'
2024-11-20 13:30:06 +05:30
Jannat Patel
164d5cdec9 Merge pull request #1130 from frappe/develop
chore: merge 'develop' into 'main'
2024-11-13 11:53:57 +05:30
Jannat Patel
c6b1076092 Merge pull request #1099 from frappe/develop
chore: merge 'develop' into 'main'
2024-11-07 09:51:07 +05:30
Jannat Patel
6aebe856da Merge pull request #1087 from frappe/develop
chore: merge 'develop' into 'main'
2024-10-31 12:15:12 +05:30
Jannat Patel
4737551918 Merge pull request #1075 from frappe/develop
chore: merge 'develop' into 'main'
2024-10-23 13:00:37 +05:30
Jannat Patel
c2cb79f700 Merge pull request #1067 from frappe/develop
chore: merge 'develop' into 'main'
2024-10-17 12:33:35 +05:30
Jannat Patel
d7c05984be Merge pull request #1048 from frappe/develop
chore: merge 'develop' into 'main'
2024-10-09 22:11:23 +05:30
Jannat Patel
55429e2f03 Merge pull request #1036 from frappe/develop
chore: merge 'develop' into 'main'
2024-10-02 12:30:46 +05:30
Jannat Patel
25ffe8b0e4 Merge pull request #1029 from frappe/develop
chore: merge 'develop' into 'main'
2024-09-25 11:47:14 +05:30
Jannat Patel
303a9d1110 Merge pull request #1020 from frappe/develop
chore: merge 'develop' into 'main'
2024-09-18 10:21:11 +05:30
Jannat Patel
de8c907c51 Merge pull request #1013 from frappe/develop
chore: merge 'develop' into 'main'
2024-09-11 11:09:55 +05:30
Jannat Patel
0fd1cabd60 Merge pull request #1003 from frappe/develop
chore: merge 'develop' into 'main'
2024-09-04 10:36:05 +05:30
Jannat Patel
8dd480735c Merge pull request #996 from frappe/develop
chore: merge 'develop' into 'main'
2024-08-28 11:24:59 +05:30
Jannat Patel
676f1a1f0e Merge pull request #984 from frappe/develop
chore: merge 'develop' into 'main'
2024-08-21 10:48:23 +05:30
Jannat Patel
ce75422126 Merge pull request #966 from frappe/develop
chore: merge 'develop' into 'main'
2024-08-14 11:24:10 +05:30
Jannat Patel
3a097d6b15 Merge pull request #956 from frappe/develop
chore: Merge develop into main
2024-08-06 11:27:00 +05:30
Jannat Patel
9de1bf1020 Merge pull request #954 from frappe/develop
chore: Merge develop into main
2024-08-05 14:47:45 +05:30
Jannat Patel
93e5cf1c25 Merge pull request #952 from frappe/develop
chore: Merge develop to main
2024-08-05 12:22:05 +05:30
Jannat Patel
6e2376570b Merge pull request #949 from frappe/develop
chore: Merge develop to main
2024-08-01 17:16:22 +05:30
Jannat Patel
b20c4bf197 Merge pull request #948 from frappe/develop
chore: Merge develop to main
2024-07-31 16:33:43 +05:30
Jannat Patel
6ae1d92033 Merge pull request #925 from frappe/develop
chore: merge `develop` into `main`
2024-07-11 09:11:50 +05:30
166 changed files with 22602 additions and 17983 deletions

View File

@@ -105,7 +105,7 @@ jobs:
- name: cypress pre-requisites
run: |
cd ~/frappe-bench/apps/lms
yarn add cypress@^10 --no-lockfile
yarn add cypress@^10 --no-lockfile -W
- name: UI Tests
run: cd ~/frappe-bench/ && bench --site lms.test run-ui-tests lms --headless

View File

@@ -1,4 +1,4 @@
module.exports = {
export default {
parserPreset: "conventional-changelog-conventionalcommits",
rules: {
"subject-empty": [2, "never"],

View File

@@ -1,6 +1,6 @@
const { defineConfig } = require("cypress");
import { defineConfig } from "cypress";
module.exports = defineConfig({
export default defineConfig({
projectId: "vandxn",
adminPassword: "admin",
testUser: "frappe@example.com",

View File

@@ -0,0 +1,180 @@
describe("Batch Creation", () => {
it("creates a new batch", () => {
cy.login();
cy.wait(500);
cy.visit("/lms/batches");
cy.closeOnboardingModal();
// Open Settings
cy.get("span").contains("Learning").click();
cy.get("span").contains("Settings").click();
// Add a new member
cy.get('[id^="headlessui-dialog-panel-v-"]')
.find("span")
.contains(/^Members$/)
.click();
cy.get('[id^="headlessui-dialog-panel-v-"]')
.find("button")
.contains("New")
.click();
const dateNow = Date.now();
const randomEmail = `testuser_${dateNow}@example.com`;
const randomName = `Test User ${dateNow}`;
cy.get("input[placeholder='Email']").type(randomEmail);
cy.get("input[placeholder='First Name']").type(randomName);
cy.get("button").contains("Add").click();
// Add evaluator
cy.get('[id^="headlessui-dialog-panel-v-"]')
.find("span")
.contains(/^Evaluators$/)
.click();
cy.get('[id^="headlessui-dialog-panel-v-"]')
.find("button")
.contains("New")
.click();
const randomEvaluator = `evaluator${dateNow}@example.com`;
cy.get("input[placeholder='Email']").type(randomEvaluator);
cy.get("button").contains("Add").click();
cy.get("div").contains(randomEvaluator).should("be.visible").click();
cy.visit("/lms/batches");
cy.closeOnboardingModal();
// Create a batch
cy.get("button").contains("New").click();
cy.wait(500);
cy.url().should("include", "/batches/new/edit");
cy.get("label").contains("Title").type("Test Batch");
cy.get("label").contains("Start Date").type("2030-10-01");
cy.get("label").contains("End Date").type("2030-10-31");
cy.get("label").contains("Start Time").type("10:00");
cy.get("label").contains("End Time").type("11:00");
cy.get("label").contains("Timezone").type("IST");
cy.get("label").contains("Seat Count").type("10");
cy.get("label").contains("Published").click();
cy.get("label")
.contains("Short Description")
.type("Test Batch Short Description to test the UI");
cy.get("div[contenteditable=true").invoke(
"text",
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
);
/* Instructor */
cy.get("label")
.contains("Instructors")
.parent()
.within(() => {
cy.get("input").click().type("evaluator");
cy.get("input")
.invoke("attr", "aria-controls")
.as("instructor_list_id");
});
cy.get("@instructor_list_id").then((instructor_list_id) => {
cy.get(`[id^=${instructor_list_id}`)
.should("be.visible")
.within(() => {
cy.get("[id^=headlessui-combobox-option-").first().click();
});
});
cy.button("Save").click();
cy.wait(1000);
let batchName;
cy.url().then((url) => {
console.log(url);
batchName = url.split("/").pop();
cy.wrap(batchName).as("batchName");
});
cy.wait(500);
// View Batch
cy.wait(1000);
cy.visit("/lms/batches");
cy.closeOnboardingModal();
cy.url().should("include", "/lms/batches");
cy.get('[id^="headlessui-radiogroup-v-"]')
.find("span")
.contains("Upcoming")
.should("be.visible")
.click();
cy.get("@batchName").then((batchName) => {
cy.get(`a[href='/lms/batches/details/${batchName}'`).within(() => {
cy.get("div").contains("Test Batch").should("be.visible");
cy.get("div")
.contains("Test Batch Short Description to test the UI")
.should("be.visible");
cy.get("span")
.contains("01 Oct 2030 - 31 Oct 2030")
.should("be.visible");
cy.get("span")
.contains("10:00 AM - 11:00 AM")
.should("be.visible");
cy.get("span").contains("IST").should("be.visible");
cy.get("a").contains("Evaluator").should("be.visible");
cy.get("div")
.contains("10")
.should("be.visible")
.get("span")
.contains("Seats Left")
.should("be.visible");
});
cy.get(`a[href='/lms/batches/details/${batchName}'`).click();
});
cy.get("div").contains("Test Batch").should("be.visible");
cy.get("div")
.contains("Test Batch Short Description to test the UI")
.should("be.visible");
cy.get("a").contains("Evaluator").should("be.visible");
cy.get("span")
.contains("01 Oct 2030 - 31 Oct 2030")
.should("be.visible");
cy.get("span").contains("10:00 AM - 11:00 AM").should("be.visible");
cy.get("span").contains("IST").should("be.visible");
cy.get("div")
.contains("10")
.should("be.visible")
.get("span")
.contains("Seats Left")
.should("be.visible");
cy.get("p")
.contains(
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
)
.should("be.visible");
cy.get("button").contains("Manage Batch").click();
/* Add student to batch */
cy.get("button").contains("Add").click();
cy.get('div[id^="headlessui-dialog-panel-v-"]')
.first()
.find("button")
.eq(1)
.click();
cy.get("input[id^='headlessui-combobox-input-v-']").type(randomEmail);
cy.get("div").contains(randomEmail).click();
cy.get("button").contains("Submit").click();
// Verify Seat Count
cy.get("span").contains("Details").click();
cy.get("div")
.contains("9")
.should("be.visible")
.get("span")
.contains("Seats Left")
.should("be.visible");
});
});

View File

@@ -1,12 +1,15 @@
describe("Course Creation", () => {
it("creates a new course", () => {
cy.login();
cy.wait(1000);
cy.wait(500);
cy.visit("/lms/courses");
// Close onboarding modal
cy.closeOnboardingModal();
// Create a course
cy.get("button").contains("New").click();
cy.wait(1000);
cy.wait(500);
cy.url().should("include", "/courses/new/edit");
cy.get("label").contains("Title").type("Test Course");
@@ -96,7 +99,8 @@ describe("Course Creation", () => {
// View Course
cy.wait(1000);
cy.visit("/lms");
cy.wait(500);
cy.closeOnboardingModal();
cy.url().should("include", "/lms/courses");
cy.get(".grid a:first").within(() => {
cy.get("div").contains("Test Course");

View File

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

View File

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

1
frappe-ui Submodule

Submodule frappe-ui added at fd5252663b

View File

@@ -27,9 +27,9 @@ declare module 'vue' {
BatchOverlay: typeof import('./src/components/BatchOverlay.vue')['default']
BatchStudentProgress: typeof import('./src/components/Modals/BatchStudentProgress.vue')['default']
BatchStudents: typeof import('./src/components/BatchStudents.vue')['default']
BrandSettings: typeof import('./src/components/BrandSettings.vue')['default']
BrandSettings: typeof import('./src/components/Settings/BrandSettings.vue')['default']
BulkCertificates: typeof import('./src/components/Modals/BulkCertificates.vue')['default']
Categories: typeof import('./src/components/Categories.vue')['default']
Categories: typeof import('./src/components/Settings/Categories.vue')['default']
CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default']
ChapterModal: typeof import('./src/components/Modals/ChapterModal.vue')['default']
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
@@ -47,10 +47,14 @@ declare module 'vue' {
Discussions: typeof import('./src/components/Discussions.vue')['default']
EditCoverImage: typeof import('./src/components/Modals/EditCoverImage.vue')['default']
EditProfile: typeof import('./src/components/Modals/EditProfile.vue')['default']
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
EmailTemplates: typeof import('./src/components/Settings/EmailTemplates.vue')['default']
EmptyState: typeof import('./src/components/EmptyState.vue')['default']
EvaluationModal: typeof import('./src/components/Modals/EvaluationModal.vue')['default']
Evaluators: typeof import('./src/components/Evaluators.vue')['default']
Evaluators: typeof import('./src/components/Settings/Evaluators.vue')['default']
Event: typeof import('./src/components/Modals/Event.vue')['default']
ExplanationVideos: typeof import('./src/components/Modals/ExplanationVideos.vue')['default']
FeedbackModal: typeof import('./src/components/Modals/FeedbackModal.vue')['default']
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
IconPicker: typeof import('./src/components/Controls/IconPicker.vue')['default']
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
@@ -61,28 +65,30 @@ declare module 'vue' {
LessonHelp: typeof import('./src/components/LessonHelp.vue')['default']
Link: typeof import('./src/components/Controls/Link.vue')['default']
LiveClass: typeof import('./src/components/LiveClass.vue')['default']
LiveClassAttendance: typeof import('./src/components/Modals/LiveClassAttendance.vue')['default']
LiveClassModal: typeof import('./src/components/Modals/LiveClassModal.vue')['default']
LMSLogo: typeof import('./src/components/Icons/LMSLogo.vue')['default']
Members: typeof import('./src/components/Members.vue')['default']
Members: typeof import('./src/components/Settings/Members.vue')['default']
MobileLayout: typeof import('./src/components/MobileLayout.vue')['default']
MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default']
NoPermission: typeof import('./src/components/NoPermission.vue')['default']
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
PaymentSettings: typeof import('./src/components/PaymentSettings.vue')['default']
PaymentSettings: typeof import('./src/components/Settings/PaymentSettings.vue')['default']
Play: typeof import('./src/components/Icons/Play.vue')['default']
ProgressBar: typeof import('./src/components/ProgressBar.vue')['default']
Question: typeof import('./src/components/Modals/Question.vue')['default']
Quiz: typeof import('./src/components/Quiz.vue')['default']
QuizBlock: typeof import('./src/components/QuizBlock.vue')['default']
QuizInVideo: typeof import('./src/components/Modals/QuizInVideo.vue')['default']
Rating: typeof import('./src/components/Controls/Rating.vue')['default']
ReviewModal: typeof import('./src/components/Modals/ReviewModal.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SettingDetails: typeof import('./src/components/SettingDetails.vue')['default']
SettingFields: typeof import('./src/components/SettingFields.vue')['default']
Settings: typeof import('./src/components/Modals/Settings.vue')['default']
SettingDetails: typeof import('./src/components/Settings/SettingDetails.vue')['default']
SettingFields: typeof import('./src/components/Settings/SettingFields.vue')['default']
Settings: typeof import('./src/components/Settings/Settings.vue')['default']
SidebarLink: typeof import('./src/components/SidebarLink.vue')['default']
StudentHeatmap: typeof import('./src/components/StudentHeatmap.vue')['default']
StudentModal: typeof import('./src/components/Modals/StudentModal.vue')['default']
@@ -93,5 +99,7 @@ declare module 'vue' {
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
VideoBlock: typeof import('./src/components/VideoBlock.vue')['default']
ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default']
ZoomSettings: typeof import('./src/components/Settings/ZoomSettings.vue')['default']
}
}

View File

@@ -2,6 +2,7 @@
"name": "frappe-ui-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"serve": "vite preview",
@@ -26,7 +27,7 @@
"codemirror-editor-vue3": "^2.8.0",
"dayjs": "^1.11.6",
"feather-icons": "^4.28.0",
"frappe-ui": "^0.1.134",
"frappe-ui": "^0.1.147",
"highlight.js": "^11.11.1",
"lucide-vue-next": "^0.383.0",
"markdown-it": "^14.0.0",

View File

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

View File

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

View File

@@ -125,7 +125,7 @@
@click="redirectToWebsite()"
/>
</Tooltip>
<Tooltip :text="__('Help')">
<Tooltip v-if="showOnboarding" :text="__('Help')">
<CircleHelp
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
@click="
@@ -181,8 +181,16 @@
import UserDropdown from '@/components/UserDropdown.vue'
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
import SidebarLink from '@/components/SidebarLink.vue'
import { useStorage } from '@vueuse/core'
import { ref, onMounted, inject, watch, reactive, markRaw, h } from 'vue'
import {
ref,
onMounted,
inject,
watch,
reactive,
markRaw,
h,
onUnmounted,
} from 'vue'
import { getSidebarLinks } from '../utils'
import { usersStore } from '@/stores/user'
import { sessionStore } from '@/stores/session'
@@ -244,6 +252,7 @@ const iconProps = {
onMounted(() => {
addNotifications()
setSidebarLinks()
setUpOnboarding()
socket.on('publish_lms_notifications', (data) => {
unreadNotifications.reload()
})
@@ -388,10 +397,6 @@ const deletePage = (link) => {
)
}
const getSidebarFromStorage = () => {
return useStorage('sidebar_is_collapsed', false)
}
const toggleSidebar = () => {
sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed
localStorage.setItem(
@@ -438,6 +443,7 @@ const steps = reactive([
title: __('Add your first chapter'),
icon: markRaw(h(FolderTree, iconProps)),
completed: false,
dependsOn: 'create_first_course',
onClick: async () => {
minimize.value = true
let course = await getFirstCourse()
@@ -453,6 +459,7 @@ const steps = reactive([
title: __('Add your first lesson'),
icon: markRaw(h(FileText, iconProps)),
completed: false,
dependsOn: 'create_first_chapter',
onClick: async () => {
minimize.value = true
let course = await getFirstCourse()
@@ -471,6 +478,7 @@ const steps = reactive([
title: __('Create your first quiz'),
icon: markRaw(h(CircleHelp, iconProps)),
completed: false,
dependsOn: 'create_first_course',
onClick: () => {
minimize.value = true
router.push({ name: 'Quizzes' })
@@ -502,6 +510,7 @@ const steps = reactive([
title: __('Add students to your batch'),
icon: markRaw(h(UserPlus, iconProps)),
completed: false,
dependsOn: 'create_first_batch',
onClick: async () => {
minimize.value = true
let batch = await getFirstBatch()
@@ -522,6 +531,7 @@ const steps = reactive([
title: __('Add courses to your batch'),
icon: markRaw(h(BookText, iconProps)),
completed: false,
dependsOn: 'create_first_batch',
onClick: async () => {
minimize.value = true
let batch = await getFirstBatch()
@@ -625,4 +635,8 @@ watch(userResource, () => {
const redirectToWebsite = () => {
window.open('https://frappe.io/learning', '_blank')
}
onUnmounted(() => {
socket.off('publish_lms_notifications')
})
</script>

View File

@@ -191,10 +191,11 @@ import {
FileUploader,
FormControl,
TextEditor,
toast,
} from 'frappe-ui'
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { FileText, X } from 'lucide-vue-next'
import { showToast, getFileSize } from '@/utils'
import { getFileSize } from '@/utils'
import { useRouter } from 'vue-router'
const submissionFile = ref(null)
@@ -284,7 +285,7 @@ const submissionResource = createDocumentResource({
doctype: 'LMS Assignment Submission',
name: props.submissionName,
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
toast.error(err.messages?.[0] || err)
},
auto: false,
cache: [user.data?.name, props.assignmentID],
@@ -338,7 +339,7 @@ const submitAssignment = () => {
},
{
onSuccess(data) {
showToast(__('Success'), __('Changes saved successfully'), 'check')
toast.success(__('Changes saved successfully'))
},
}
)
@@ -352,7 +353,7 @@ const addNewSubmission = () => {
{},
{
onSuccess(data) {
showToast('Success', 'Assignment submitted successfully.', 'check')
toast.success(__('Assignment submitted successfully'))
if (router.currentRoute.value.name == 'AssignmentSubmission') {
router.push({
name: 'AssignmentSubmission',
@@ -370,7 +371,7 @@ const addNewSubmission = () => {
submissionResource.reload()
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex flex-col border hover:border-outline-gray-4 rounded-md p-4 h-full"
class="flex flex-col border hover:border-outline-gray-3 rounded-md p-4 h-full"
style="min-height: 150px"
>
<div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9">

View File

@@ -86,9 +86,9 @@ import {
ListRows,
ListView,
ListRowItem,
toast,
} from 'frappe-ui'
import { Plus, Trash2 } from 'lucide-vue-next'
import { showToast } from '@/utils'
const readOnlyMode = window.read_only_mode
const showCourseModal = ref(false)
@@ -106,7 +106,6 @@ const courses = createResource({
params: {
batch: props.batch,
},
cache: ['batchCourses', props.batchName],
auto: true,
})
@@ -152,7 +151,7 @@ const removeCourses = (selections, unselectAll) => {
{
onSuccess(data) {
courses.reload()
showToast(__('Success'), __('Courses deleted successfully'), 'check')
toast.success(__('Courses deleted successfully'))
unselectAll()
},
}

View File

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

View File

@@ -2,7 +2,12 @@
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72">
<div
v-if="batch.data.seat_count && seats_left > 0"
class="text-xs bg-green-100 text-green-700 float-right px-2 py-0.5 rounded-md"
class="text-sm bg-green-100 text-green-700 px-2 py-1 rounded-md"
:class="
batch.data.amount || batch.data.courses.length
? 'float-right'
: 'w-fit mb-4'
"
>
{{ seats_left }}
<span v-if="seats_left > 1">
@@ -117,9 +122,9 @@
</template>
<script setup>
import { inject, computed } from 'vue'
import { Badge, Button, createResource } from 'frappe-ui'
import { Badge, Button, createResource, toast } from 'frappe-ui'
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
import { formatNumberIntoCurrency, formatTime, showToast } from '@/utils'
import { formatNumberIntoCurrency, formatTime } from '@/utils'
import DateRange from '@/components/Common/DateRange.vue'
import { useRouter } from 'vue-router'
@@ -151,11 +156,7 @@ const enrollInBatch = () => {
{},
{
onSuccess(data) {
showToast(
__('Success'),
__('You have been enrolled in this batch'),
'check'
)
toast.success(__('You have been enrolled in this batch'))
router.push({
name: 'Batch',
params: {

View File

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

View File

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

View File

@@ -92,7 +92,10 @@
{{ option.label }}
</div>
<div
v-if="option.description"
v-if="
option.description &&
option.description != option.label
"
class="text-xs text-ink-gray-7"
v-html="option.description"
></div>

View File

@@ -34,7 +34,7 @@
<Button
variant="ghost"
class="w-full !justify-start"
label="Create New"
:label="__('Create New')"
@click="attrs.onCreate(value, close)"
>
<template #prefix>

View File

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

View File

@@ -116,7 +116,7 @@
v-if="parseInt(course.data.rating) > 0"
class="flex items-center text-ink-gray-9"
>
<Star class="h-4 w-4 stroke-1.5 fill-orange-500 text-gray-50" />
<Star class="size-4 stroke-1.5 fill-yellow-500 text-transparent" />
<span class="ml-2">
{{ course.data.rating }} {{ __('Rating') }}
</span>
@@ -146,8 +146,8 @@
<script setup>
import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next'
import { computed, inject } from 'vue'
import { Badge, Button, createResource } from 'frappe-ui'
import { showToast, formatAmount } from '@/utils/'
import { Badge, Button, call, createResource, toast } from 'frappe-ui'
import { formatAmount } from '@/utils/'
import { capture } from '@/telemetry'
import { useRouter } from 'vue-router'
import CertificationLinks from '@/components/CertificationLinks.vue'
@@ -172,31 +172,19 @@ const video_link = computed(() => {
function enrollStudent() {
if (!user.data) {
showToast(
__('Please Login'),
__('You need to login first to enroll for this course'),
'alert-circle'
)
toast.success(__('You need to login first to enroll for this course'))
setTimeout(() => {
window.location.href = `/login?redirect-to=${window.location.pathname}`
}, 1000)
}, 500)
} else {
const enrollStudentResource = createResource({
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
call('lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership', {
course: props.course.data.name,
})
enrollStudentResource
.submit({
course: props.course.data.name,
})
.then(() => {
capture('enrolled_in_course', {
course: props.course.data.name,
})
showToast(
__('Success'),
__('You have been enrolled in this course'),
'check'
)
toast.success(__('You have been enrolled in this course'))
setTimeout(() => {
router.push({
name: 'Lesson',
@@ -206,7 +194,11 @@ function enrollStudent() {
lessonNumber: 1,
},
})
}, 2000)
}, 1000)
})
.catch((err) => {
toast.warning(__(err.messages?.[0] || err))
console.error(err)
})
}
}

View File

@@ -147,7 +147,7 @@
/>
</template>
<script setup>
import { Button, createResource, Tooltip } from 'frappe-ui'
import { Button, createResource, Tooltip, toast } from 'frappe-ui'
import { getCurrentInstance, inject, ref } from 'vue'
import Draggable from 'vuedraggable'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
@@ -162,7 +162,6 @@ import {
} from 'lucide-vue-next'
import { useRoute, useRouter } from 'vue-router'
import ChapterModal from '@/components/Modals/ChapterModal.vue'
import { showToast } from '@/utils'
const route = useRoute()
const router = useRouter()
@@ -215,7 +214,7 @@ const deleteLesson = createResource({
},
onSuccess() {
outline.reload()
showToast('Success', 'Lesson deleted successfully', 'check')
toast.success(__('Lesson deleted successfully'))
},
})
@@ -230,7 +229,7 @@ const updateLessonIndex = createResource({
}
},
onSuccess() {
showToast('Success', 'Lesson moved successfully', 'check')
toast.success(__('Lesson moved successfully'))
},
})
@@ -288,7 +287,7 @@ const deleteChapter = createResource({
},
onSuccess() {
outline.reload()
showToast('Success', 'Chapter deleted successfully', 'check')
toast.success(__('Chapter deleted successfully'))
},
})
@@ -317,11 +316,7 @@ const redirectToChapter = (chapter) => {
event.preventDefault()
if (props.allowEdit) return
if (!user.data) {
showToast(
__('You are not enrolled'),
__('Please enroll for this course to view this lesson'),
'alert-circle'
)
toast.success(__('Please enroll for this course to view this lesson'))
return
}

View File

@@ -35,14 +35,14 @@
<span class="text-ink-gray-7">
{{ review.creation }}
</span>
<div class="flex mt-2">
<div class="flex mt-2 space-x-1">
<Star
v-for="index in 5"
class="h-5 w-5 text-ink-gray-1 rounded-sm mr-2"
class="size-4 text-transparent rounded-sm"
:class="
index <= Math.ceil(review.rating)
? 'fill-orange-500'
: 'fill-gray-600'
? 'fill-yellow-500'
: 'fill-gray-300'
"
/>
</div>

View File

@@ -93,12 +93,11 @@
</div>
</template>
<script setup>
import { createResource, TextEditor, Button, Dropdown } from 'frappe-ui'
import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
import { timeAgo } from '../utils'
import UserAvatar from '@/components/UserAvatar.vue'
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
import { ref, inject, onMounted } from 'vue'
import { createToast } from '../utils'
import { ref, inject, onMounted, onUnmounted } from 'vue'
const showTopics = defineModel('showTopics')
const newReply = ref('')
@@ -192,14 +191,7 @@ const postReply = () => {
replies.reload()
},
onError(err) {
createToast({
title: 'Error',
text: err.messages?.[0] || err,
icon: 'x',
iconClasses: 'bg-surface-red-5 text-ink-white rounded-md p-px',
position: 'top-center',
timeout: 10,
})
toast.error(err.messages?.[0] || err)
},
}
)
@@ -259,4 +251,10 @@ const deleteReply = (reply) => {
}
)
}
onUnmounted(() => {
socket.off('publish_message')
socket.off('update_message')
socket.off('delete_message')
})
</script>

View File

@@ -70,7 +70,7 @@
import { createResource, Button } from 'frappe-ui'
import UserAvatar from '@/components/UserAvatar.vue'
import { singularize, timeAgo } from '../utils'
import { ref, onMounted, inject } from 'vue'
import { ref, onMounted, inject, onUnmounted } from 'vue'
import DiscussionReplies from '@/components/DiscussionReplies.vue'
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
import { MessageSquareText } from 'lucide-vue-next'
@@ -153,4 +153,8 @@ const showReplies = (topic) => {
const openTopicModal = () => {
showTopicModal.value = true
}
onUnmounted(() => {
socket.off('new_discussion_topic')
})
</script>

View File

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

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-4"
class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-3"
>
<div class="flex space-x-4 mb-4">
<div class="flex flex-col space-y-2 flex-1">

View File

@@ -1,5 +1,15 @@
<template>
<div class="flex items-center justify-between mb-5">
<div
v-if="hasPermission() && !props.zoomAccount"
class="flex items-center space-x-2 mb-5 bg-surface-amber-1 py-1 px-2 rounded-md text-ink-amber-3"
>
<AlertCircle class="size-4 stroke-1.5" />
<span>
{{ __('Please add a zoom account to the batch to create live classes.') }}
</span>
</div>
<div class="flex items-center justify-between">
<div class="text-lg font-semibold text-ink-gray-9">
{{ __('Live Class') }}
</div>
@@ -12,10 +22,18 @@
</span>
</Button>
</div>
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
<div v-if="liveClasses.data?.length" class="grid grid-cols-3 gap-5 mt-5">
<div
v-for="cls in liveClasses.data"
class="flex flex-col border rounded-md h-full text-ink-gray-7 p-3"
class="flex flex-col border rounded-md h-full text-ink-gray-7 hover:border-outline-gray-3 p-3"
:class="{
'cursor-pointer': hasPermission() && cls.attendees > 0,
}"
@click="
() => {
openAttendanceModal(cls)
}
"
>
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
{{ cls.title }}
@@ -23,7 +41,7 @@
<div class="short-introduction">
{{ cls.description }}
</div>
<div class="space-y-3">
<div class="mt-auto space-y-3">
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span>
@@ -33,18 +51,20 @@
<div class="flex items-center space-x-2">
<Clock class="w-4 h-4 stroke-1.5" />
<span>
{{ formatTime(cls.time) }}
{{ formatTime(cls.time) }} -
{{ dayjs(getClassEnd(cls)).format('HH:mm') }}
</span>
</div>
<div
v-if="cls.date >= dayjs().format('YYYY-MM-DD')"
v-if="canAccessClass(cls)"
class="flex items-center space-x-2 text-ink-gray-9 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-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
:class="cls.join_url ? 'w-full' : 'w-1/2'"
>
<Monitor class="h-4 w-4 stroke-1.5" />
{{ __('Start') }}
@@ -58,42 +78,63 @@
{{ __('Join') }}
</a>
</div>
<div v-else class="flex items-center space-x-2 text-yellow-700">
<Info class="w-4 h-4 stroke-1.5" />
<span>
{{ __('This class has ended') }}
</span>
</div>
<Tooltip
v-else-if="hasClassEnded(cls)"
:text="__('This class has ended')"
placement="right"
>
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
<Info class="w-4 h-4 stroke-1.5" />
<span>
{{ __('Ended') }}
</span>
</div>
</Tooltip>
</div>
</div>
</div>
<div v-else class="text-sm italic text-ink-gray-5">
<div v-else class="text-sm italic text-ink-gray-5 mt-2">
{{ __('No live classes scheduled') }}
</div>
<LiveClassModal
:batch="props.batch"
:zoomAccount="props.zoomAccount"
v-model="showLiveClassModal"
v-model:reloadLiveClasses="liveClasses"
/>
<LiveClassAttendance v-model="showAttendance" :live_class="attendanceFor" />
</template>
<script setup>
import { createListResource, Button } from 'frappe-ui'
import { Plus, Clock, Calendar, Video, Monitor, Info } from 'lucide-vue-next'
import { inject } from 'vue'
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
import { ref } from 'vue'
import { createListResource, Button, Tooltip } from 'frappe-ui'
import {
Plus,
Clock,
Calendar,
Video,
Monitor,
Info,
AlertCircle,
} from 'lucide-vue-next'
import { inject, ref } from 'vue'
import { formatTime } from '@/utils/'
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
import LiveClassAttendance from '@/components/Modals/LiveClassAttendance.vue'
const user = inject('$user')
const showLiveClassModal = ref(false)
const dayjs = inject('$dayjs')
const readOnlyMode = window.read_only_mode
const showAttendance = ref(false)
const attendanceFor = ref(null)
const props = defineProps({
batch: {
type: String,
required: true,
},
zoomAccount: String,
})
const liveClasses = createListResource({
@@ -106,6 +147,8 @@ const liveClasses = createListResource({
'description',
'time',
'date',
'duration',
'attendees',
'start_url',
'join_url',
'owner',
@@ -120,8 +163,38 @@ const openLiveClassModal = () => {
const canCreateClass = () => {
if (readOnlyMode) return false
if (!props.zoomAccount) return false
return hasPermission()
}
const hasPermission = () => {
return user.data?.is_moderator || user.data?.is_evaluator
}
const canAccessClass = (cls) => {
if (cls.date < dayjs().format('YYYY-MM-DD')) return false
if (cls.date > dayjs().format('YYYY-MM-DD')) return false
if (hasClassEnded(cls)) return false
return true
}
const getClassEnd = (cls) => {
const classStart = new Date(`${cls.date}T${cls.time}`)
return new Date(classStart.getTime() + cls.duration * 60000)
}
const hasClassEnded = (cls) => {
const classEnd = getClassEnd(cls)
const now = new Date()
return now > classEnd
}
const openAttendanceModal = (cls) => {
if (!hasPermission()) return
if (cls.attendees <= 0) return
showAttendance.value = true
attendanceFor.value = cls
}
</script>
<style>
.short-introduction {

View File

@@ -1,60 +1,55 @@
<template>
<div class="flex h-full flex-col">
<div class="flex h-full flex-col relative">
<div class="h-full pb-10" id="scrollContainer">
<slot />
</div>
<div
v-if="sidebarSettings.data"
class="fixed flex items-center justify-around border-t border-outline-gray-2 bottom-0 z-10 w-full bg-surface-white standalone:pb-4"
:style="{
gridTemplateColumns: `repeat(${
sidebarLinks.length + 1
}, minmax(0, 1fr))`,
}"
>
<button
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"
@click="handleClick(tab)"
<div class="relative z-20">
<!-- Dropdown menu -->
<div
class="fixed bottom-16 right-2 w-[80%] rounded-md bg-surface-white text-base p-5 space-y-4 shadow-md"
v-if="showMenu"
ref="menu"
>
<component
:is="icons[tab.icon]"
class="h-6 w-6 stroke-1.5"
:class="[isActive(tab) ? 'text-ink-gray-9' : 'text-ink-gray-5']"
/>
</button>
<Popover
trigger="hover"
popoverClass="bottom-28 mx-2"
placement="top-start"
<div
v-for="link in otherLinks"
:key="link.label"
class="flex items-center space-x-2 cursor-pointer"
@click="handleClick(link)"
>
<component
:is="icons[link.icon]"
class="h-4 w-4 stroke-1.5 text-ink-gray-5"
/>
<div>{{ link.label }}</div>
</div>
</div>
<!-- Fixed menu -->
<div
v-if="sidebarSettings.data"
class="fixed bottom-0 left-0 w-full flex items-center justify-around border-t border-outline-gray-2 bg-surface-white standalone:pb-4 z-10"
>
<template #target>
<button
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"
@click="handleClick(tab)"
>
<component
:is="icons[tab.icon]"
class="h-6 w-6 stroke-1.5"
:class="[isActive(tab) ? 'text-ink-gray-9' : 'text-ink-gray-5']"
/>
</button>
<button @click="toggleMenu">
<component
:is="icons['List']"
class="h-6 w-6 stroke-1.5 text-ink-gray-5"
/>
</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-ink-gray-5"
/>
<div>
{{ link.label }}
</div>
</div>
</div>
</template>
</Popover>
</button>
</div>
</div>
</div>
</template>
@@ -64,7 +59,6 @@ import { useRouter } from 'vue-router'
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, sidebarSettings } = sessionStore()
@@ -73,26 +67,47 @@ const router = useRouter()
let { userResource } = usersStore()
const sidebarLinks = ref(getSidebarLinks())
const otherLinks = ref([])
const showMenu = ref(false)
const menu = ref(null)
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
)
}
})
filterLinksToShow(data)
addOtherLinks()
},
}
)
})
const handleOutsideClick = (e) => {
if (menu.value && !menu.value.contains(e.target)) {
showMenu.value = false
}
}
watch(showMenu, (val) => {
if (val) {
setTimeout(() => {
document.addEventListener('click', handleOutsideClick)
}, 0)
} else {
document.removeEventListener('click', handleOutsideClick)
}
})
const filterLinksToShow = (data) => {
Object.keys(data).forEach((key) => {
if (!parseInt(data[key])) {
sidebarLinks.value = sidebarLinks.value.filter(
(link) => link.label.toLowerCase().split(' ').join('_') !== key
)
}
})
}
const addOtherLinks = () => {
if (user) {
otherLinks.value.push({
@@ -122,6 +137,7 @@ watch(userResource, () => {
(userResource.data.is_moderator || userResource.data.is_instructor)
) {
addQuizzes()
addAssignments()
}
})
@@ -133,6 +149,14 @@ const addQuizzes = () => {
})
}
const addAssignments = () => {
otherLinks.value.push({
label: 'Assignments',
icon: 'Pencil',
to: 'Assignments',
})
}
let isActive = (tab) => {
return tab.activeFor?.includes(router.currentRoute.value.name)
}
@@ -158,4 +182,8 @@ const isVisible = (tab) => {
else if (tab.label == 'Log out') return isLoggedIn
else return true
}
const toggleMenu = () => {
showMenu.value = !showMenu.value
}
</script>

View File

@@ -31,6 +31,7 @@
<div class="mb-4">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Announcement') }}
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:fixedMenu="true"
@@ -43,9 +44,8 @@
</Dialog>
</template>
<script setup>
import { Dialog, Input, TextEditor, createResource } from 'frappe-ui'
import { Dialog, Input, TextEditor, createResource, toast } from 'frappe-ui'
import { reactive } from 'vue'
import { showToast } from '@/utils/'
const show = defineModel()
@@ -87,22 +87,21 @@ const makeAnnouncement = (close) => {
{
validate() {
if (!props.students.length) {
return 'No students in this batch'
return __('No students in this batch')
}
if (!announcement.subject) {
return 'Subject is required'
return __('Subject is required')
}
if (!announcement.announcement) {
return __('Announcement is required')
}
},
onSuccess() {
close()
showToast(
__('Success'),
__('Announcement has been sent successfully'),
'check'
)
toast.success(__('Announcement has been sent successfully'))
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'alert-circle')
toast.error(__(err.messages?.[0] || err))
},
}
)

View File

@@ -25,21 +25,39 @@
v-model="assessment"
:doctype="assessmentType"
:label="__('Assessment')"
:onCreate="
(value, close) => {
close()
if (assessmentType === 'LMS Quiz') {
router.push({
name: 'QuizForm',
params: {
quizID: 'new',
},
})
} else if (assessmentType === 'LMS Assignment') {
router.push({
name: 'Assignments',
})
}
}
"
/>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, FormControl, createResource } from 'frappe-ui'
import { Dialog, FormControl, createResource, toast } from 'frappe-ui'
import Link from '@/components/Controls/Link.vue'
import { computed, ref } from 'vue'
import { showToast } from '@/utils'
import { useRouter } from 'vue-router'
const show = defineModel()
const assessmentType = ref(null)
const assessment = ref(null)
const assessments = defineModel('assessments')
const router = useRouter()
const props = defineProps({
batch: {
@@ -70,7 +88,7 @@ const addAssessment = (close) => {
{
onSuccess(data) {
assessments.value.reload()
showToast(__('Success'), __('Assessment added successfully'), 'check')
toast.success(__('Assessment added successfully'))
close()
},
}

View File

@@ -6,7 +6,7 @@
}"
>
<template #body>
<div class="p-5 text-base max-h-[75vh] overflow-y-auto">
<div class="p-5 text-base">
<div class="text-lg text-ink-gray-9 font-semibold mb-5">
{{
assignmentID === 'new'
@@ -14,7 +14,7 @@
: __('Edit Assignment')
}}
</div>
<div class="space-y-4">
<div class="space-y-4 max-h-[75vh] overflow-y-auto">
<FormControl
v-model="assignment.title"
:label="__('Title')"
@@ -37,7 +37,7 @@
@change="(val) => (assignment.question = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
/>
</div>
</div>
@@ -64,9 +64,8 @@
</Dialog>
</template>
<script setup lang="ts">
import { Button, Dialog, FormControl, TextEditor } from 'frappe-ui'
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { computed, reactive, watch } from 'vue'
import { showToast } from '@/utils'
const show = defineModel()
const assignments = defineModel<Assignments>('assignments')
@@ -123,11 +122,7 @@ const saveAssignment = () => {
{
onSuccess() {
show.value = false
showToast(
__('Success'),
__('Assignment created successfully'),
'check'
)
toast.success(__('Assignment created successfully'))
},
}
)
@@ -140,11 +135,7 @@ const saveAssignment = () => {
{
onSuccess() {
show.value = false
showToast(
__('Success'),
__('Assignment updated successfully'),
'check'
)
toast.success(__('Assignment updated successfully'))
},
}
)

View File

@@ -19,32 +19,43 @@
v-model="course"
:label="__('Course')"
:required="true"
:onCreate="
(value, close) => {
close()
router.push({
name: 'CourseForm',
params: {
courseName: 'new',
},
})
}
"
/>
<Link
doctype="Course Evaluator"
v-model="evaluator"
:label="__('Evaluator')"
:onCreate="(value, close) => openSettings(close)"
:onCreate="(value, close) => openSettings('Evaluators', close)"
class="mt-4"
/>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, createResource } from 'frappe-ui'
import { Dialog, createResource, toast } from 'frappe-ui'
import { ref, inject } from 'vue'
import Link from '@/components/Controls/Link.vue'
import { showToast } from '@/utils'
import { useOnboarding } from 'frappe-ui/frappe'
import { useSettings } from '@/stores/settings'
import { openSettings } from '@/utils'
import { useRouter } from 'vue-router'
const show = defineModel()
const course = ref(null)
const evaluator = ref(null)
const user = inject('$user')
const courses = defineModel('courses')
const router = useRouter()
const { updateOnboardingStep } = useOnboarding('learning')
const settingsStore = useSettings()
const props = defineProps({
batch: {
@@ -83,15 +94,9 @@ const addCourse = (close) => {
evaluator.value = null
},
onError(err) {
showToast('Error', err.message[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)
}
const openSettings = (close) => {
close()
settingsStore.activeTab = 'Evaluators'
settingsStore.isSettingsOpen = true
}
</script>

View File

@@ -14,7 +14,13 @@
<div class="text-xl font-semibold">
{{ student.full_name }}
</div>
<Badge :theme="student.progress === 100 ? 'green' : 'red'">
<Badge
v-if="
Object.keys(student.assessments).length ||
Object.keys(student.courses).length
"
:theme="student.progress === 100 ? 'green' : 'red'"
>
{{ student.progress }}% {{ __('Complete') }}
</Badge>
</div>
@@ -26,7 +32,10 @@
<div class="space-y-8">
<!-- Assessments -->
<div class="space-y-2 text-sm">
<div
v-if="Object.keys(student.assessments).length"
class="space-y-2 text-sm"
>
<div class="flex items-center border-b pb-1 font-medium">
<span class="flex-1">
{{ __('Assessment') }}
@@ -73,7 +82,10 @@
</div>
<!-- Courses -->
<div class="space-y-2 text-sm">
<div
v-if="Object.keys(student.courses).length"
class="space-y-2 text-sm"
>
<div class="flex items-center border-b pb-1 font-medium">
<span class="flex-1">
{{ __('Courses') }}

View File

@@ -62,9 +62,8 @@
</template>
<script setup>
import { inject, reactive } from 'vue'
import { createResource, Dialog, FormControl, Switch } from 'frappe-ui'
import { createResource, Dialog, FormControl, Switch, toast } from 'frappe-ui'
import Link from '@/components/Controls/Link.vue'
import { showToast } from '@/utils'
const show = defineModel()
const dayjs = inject('$dayjs')
@@ -112,13 +111,13 @@ const generateCertificates = (close) => {
},
{
onError(err) {
showToast(__('Error'), err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)
})
close()
showToast(__('Success'), __('Certificates generated successfully'), 'check')
toast.success(__('Certificates generated successfully'))
}
const getCourses = () => {

View File

@@ -76,9 +76,10 @@ import {
FileUploader,
FormControl,
Switch,
toast,
} from 'frappe-ui'
import { reactive, watch, inject } from 'vue'
import { showToast, getFileSize } from '@/utils/'
import { getFileSize } from '@/utils/'
import { capture } from '@/telemetry'
import { FileText, X } from 'lucide-vue-next'
import { useOnboarding } from 'frappe-ui/frappe'
@@ -150,21 +151,17 @@ const addChapter = async (close) => {
onSuccess(data) {
cleanChapter()
outline.value.reload()
showToast(
__('Success'),
__('Chapter added successfully'),
'check'
)
toast.success(__('Chapter added successfully'))
},
onError(err) {
showToast(__('Error'), err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)
close()
},
onError(err) {
showToast(__('Error'), err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)
@@ -196,11 +193,11 @@ const editChapter = (close) => {
},
onSuccess() {
outline.value.reload()
showToast(__('Success'), __('Chapter updated successfully'), 'check')
toast.success(__('Chapter updated successfully'))
close()
},
onError(err) {
showToast(__('Error'), err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)

View File

@@ -34,9 +34,15 @@
</Dialog>
</template>
<script setup>
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui'
import {
Dialog,
FormControl,
TextEditor,
createResource,
toast,
} from 'frappe-ui'
import { reactive } from 'vue'
import { showToast, singularize } from '@/utils'
import { singularize } from '@/utils'
const topics = defineModel('reloadTopics')
@@ -115,7 +121,7 @@ const submitTopic = (close) => {
)
},
onError(err) {
showToast('Error', err.message, 'x')
toast.error(err.messages?.[0] || err)
},
}
)

View File

@@ -93,10 +93,11 @@ import {
Button,
createResource,
TextEditor,
toast,
} from 'frappe-ui'
import { reactive, watch } from 'vue'
import { FileText, X } from 'lucide-vue-next'
import { getFileSize, showToast, escapeHTML } from '@/utils'
import { getFileSize } from '@/utils'
const reloadProfile = defineModel('reloadProfile')
@@ -131,7 +132,6 @@ const imageResource = createResource({
const updateProfile = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
profile.bio = escapeHTML(profile.bio)
return {
doctype: 'User',
name: props.profile.data.name,
@@ -155,7 +155,7 @@ const saveProfile = (close) => {
reloadProfile.value.reload()
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)

View File

@@ -0,0 +1,192 @@
<template>
<Dialog
v-model="show"
:options="{
title:
templateID == 'new'
? __('New Email Template')
: __('Edit Email Template'),
size: 'lg',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick: ({ close }) => {
saveTemplate(close)
},
},
],
}"
>
<template #body-content>
<div class="space-y-4">
<FormControl
:label="__('Name')"
v-model="template.name"
type="text"
:required="true"
:placeholder="__('Batch Enrollment Confirmation')"
/>
<FormControl
:label="__('Subject')"
v-model="template.subject"
type="text"
:required="true"
:placeholder="__('Your enrollment in {{ batch_name }} is confirmed')"
/>
<FormControl
:label="__('Use HTML')"
v-model="template.use_html"
type="checkbox"
/>
<FormControl
v-if="template.use_html"
:label="__('Content')"
v-model="template.response_html"
type="textarea"
:required="true"
:rows="10"
:placeholder="
__(
'<p>Dear {{ member_name }},</p>\n\n<p>You have been enrolled in our upcoming batch {{ batch_name }}.</p>\n\n<p>Thanks,</p>\n<p>Frappe Learning</p>'
)
"
/>
<div v-else>
<div class="text-xs text-ink-gray-5 mb-2">
{{ __('Content') }}
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:content="template.response"
@change="(val) => (template.response = val)"
:editable="true"
:fixedMenu="true"
:placeholder="
__(
'Dear {{ member_name }},\n\nYou have been enrolled in our upcoming batch {{ batch_name }}.\n\nThanks,\nFrappe Learning'
)
"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { call, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { reactive, watch } from 'vue'
import { cleanError } from '@/utils'
const props = defineProps({
templateID: {
type: String,
default: 'new',
},
})
const show = defineModel()
const emailTemplates = defineModel('emailTemplates')
const template = reactive({
name: '',
subject: '',
use_html: false,
response: '',
response_html: '',
})
const saveTemplate = (close) => {
if (props.templateID == 'new') {
createNewTemplate(close)
} else {
updateTemplate(close)
}
}
const createNewTemplate = (close) => {
emailTemplates.value.insert.submit(
{
__newname: template.name,
...template,
},
{
onSuccess() {
emailTemplates.value.reload()
refreshForm(close)
toast.success(__('Email Template created successfully'))
},
onError(err) {
refreshForm(close)
toast.error(
cleanError(err.messages[0]) || __('Error creating email template')
)
},
}
)
}
const updateTemplate = async (close) => {
if (props.templateID != template.name) {
await renameDoc()
}
setValue(close)
}
const setValue = (close) => {
emailTemplates.value.setValue.submit(
{
...template,
name: template.name,
},
{
onSuccess() {
emailTemplates.value.reload()
refreshForm(close)
toast.success(__('Email Template updated successfully'))
},
onError(err) {
refreshForm(close)
toast.error(
cleanError(err.messages[0]) || __('Error updating email template')
)
},
}
)
}
const renameDoc = async () => {
await call('frappe.client.rename_doc', {
doctype: 'Email Template',
old_name: props.templateID,
new_name: template.name,
})
}
watch(
() => props.templateID,
(val) => {
if (val !== 'new') {
emailTemplates.value?.data.forEach((row) => {
if (row.name === val) {
template.name = row.name
template.subject = row.subject
template.use_html = row.use_html
template.response = row.response
template.response_html = row.response_html
}
})
}
},
{ flush: 'post' }
)
const refreshForm = (close) => {
close()
template.name = ''
template.subject = ''
template.use_html = false
template.response = ''
template.response_html = ''
}
</script>

View File

@@ -42,10 +42,11 @@
<div class="grid grid-cols-2 gap-2">
<div v-for="slot in slots.data">
<div
class="text-base text-center border rounded-md bg-surface-gray-3 p-2 cursor-pointer"
class="text-base text-center border rounded-md text-ink-gray-8 bg-surface-gray-3 p-2 cursor-pointer"
@click="saveSlot(slot)"
:class="{
'border-gray-900': evaluation.start_time == slot.start_time,
'border-outline-gray-4':
evaluation.start_time == slot.start_time,
}"
>
{{ formatTime(slot.start_time) }} -
@@ -65,9 +66,9 @@
</Dialog>
</template>
<script setup>
import { Dialog, createResource, Select, FormControl } from 'frappe-ui'
import { Dialog, createResource, Select, FormControl, toast } from 'frappe-ui'
import { reactive, watch, inject } from 'vue'
import { createToast, formatTime } from '@/utils/'
import { formatTime } from '@/utils/'
const user = inject('$user')
const dayjs = inject('$dayjs')
@@ -89,7 +90,7 @@ const props = defineProps({
},
})
let evaluation = reactive({
const evaluation = reactive({
course: '',
date: '',
start_time: '',
@@ -138,29 +139,13 @@ 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: unavailabilityMessage ? __('Evaluator is Unavailable') : '',
text: message,
icon: unavailabilityMessage ? 'alert-circle' : 'x',
iconClasses: 'bg-yellow-600 text-ink-white rounded-md p-px',
position: 'top-center',
timeout: 10,
})
toast.warning(__(err.messages?.[0] || err))
},
})
}
const getCourses = () => {
let courses = []
const courses = []
for (const course of props.courses) {
if (course.evaluator) {
courses.push({
@@ -170,7 +155,7 @@ const getCourses = () => {
}
}
if (courses.length == 1) {
if (courses.length === 1) {
evaluation.course = courses[0].value
}

View File

@@ -76,8 +76,8 @@
</Button>
</div>
</div>
<Tabs :tabs="tabs" v-model="tabIndex" class="border-l w-1/2">
<template #default="{ tab }">
<Tabs :tabs="tabs" as="div" v-model="tabIndex" class="border-l w-1/2">
<template #tab-panel="{ tab }">
<div
v-if="tab.label == 'Evaluation'"
class="flex flex-col space-y-4 p-5"
@@ -144,6 +144,7 @@ import {
Tabs,
Tooltip,
Textarea,
toast,
} from 'frappe-ui'
import {
User,
@@ -157,7 +158,7 @@ import {
ClipboardList,
} from 'lucide-vue-next'
import { inject, reactive, watch, ref, computed } from 'vue'
import { formatTime, showToast } from '@/utils'
import { formatTime } from '@/utils'
import Rating from '@/components/Controls/Rating.vue'
import Link from '@/components/Controls/Link.vue'
@@ -252,7 +253,7 @@ const saveEvaluation = () => {
} else {
show.value = false
}
showToast(__('Success'), __('Evaluation saved successfully'), 'check')
toast.success(__('Evaluation saved successfully'))
},
}
)
@@ -307,7 +308,7 @@ const saveCertificate = () => {
{},
{
onSuccess: () => {
showToast(__('Success'), __('Certificate saved successfully'), 'check')
toast.success(__('Certificate saved successfully'))
},
}
)

View File

@@ -0,0 +1,115 @@
<template>
<Dialog
v-model="show"
:options="{
size: '4xl',
}"
>
<template #body>
<div class="p-5 min-h-[300px]">
<div class="text-lg font-semibold mb-4">
{{ __('Training Feedback') }}
</div>
<ListView
:columns="feedbackColumns"
:rows="feedbackList"
row-key="name"
:options="{
showTooltip: false,
rowHeight: 'h-16',
selectable: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
></ListHeader>
<ListRows>
<ListRow
:row="row"
v-for="row in feedbackList"
class="group feedback-list"
>
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="text-sm"
>
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div v-if="ratingKeys.includes(column.key)">
<Rating v-model="row[column.key]" :readonly="true" />
</div>
<div v-else class="leading-5">
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
</ListView>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Dialog,
ListView,
Avatar,
ListHeader,
ListRows,
ListRow,
ListRowItem,
Rating,
} from 'frappe-ui'
import { reactive, computed } from 'vue'
const show = defineModel()
const ratingKeys = ['content', 'instructors', 'value']
const props = defineProps({
feedbackList: {
type: Array,
required: true,
},
})
const feedbackColumns = computed(() => {
return [
{
label: 'Member',
key: 'member_name',
width: '10rem',
},
{
label: 'Feedback',
key: 'feedback',
width: '15rem',
},
{
label: 'Content',
key: 'content',
width: '9rem',
},
{
label: 'Instructors',
key: 'instructors',
width: '9rem',
},
{
label: 'Value',
key: 'value',
width: '9rem',
},
]
})
</script>

View File

@@ -64,10 +64,10 @@
</Dialog>
</template>
<script setup>
import { Dialog, FileUploader, Button, createResource } from 'frappe-ui'
import { Dialog, FileUploader, Button, createResource, toast } from 'frappe-ui'
import { FileText } from 'lucide-vue-next'
import { ref, inject } from 'vue'
import { createToast, getFileSize } from '@/utils/'
import { getFileSize } from '@/utils/'
const resume = ref(null)
const show = defineModel()
@@ -112,24 +112,12 @@ const submitResume = (close) => {
}
},
onSuccess() {
createToast({
title: 'Success',
text: 'Your application has been submitted',
icon: 'check',
iconClasses: 'bg-surface-green-3 text-ink-white rounded-md p-px',
})
toast.success('Your application has been submitted successfully')
application.value.reload()
close()
},
onError(err) {
createToast({
title: 'Error',
text: err.messages?.[0] || err,
icon: 'x',
iconClasses: 'bg-surface-red-5 text-ink-white rounded-md p-px',
position: 'top-center',
timeout: 10,
})
toast.error(err.messages?.[0] || err)
},
}
)

View File

@@ -0,0 +1,91 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Attendance for Class - {0}').format(live_class?.title),
size: 'xl',
}"
>
<template #body-content>
<div class="space-y-5">
<div
v-for="participant in participants.data"
@click="redirectToProfile(participant.member_username)"
class="cursor-pointer text-base w-fit"
>
<Tooltip placement="right">
<div class="flex items-center space-x-2">
<Avatar
:image="participant.member_image"
:label="participant.member_name"
size="xl"
/>
<div class="space-y-1">
<div class="font-medium">
{{ participant.member_name }}
</div>
<div>
{{ participant.member }}
</div>
</div>
</div>
<template #body>
<div
class="max-w-[30ch] rounded bg-surface-gray-7 px-2 py-1 text-p-xs text-ink-white leading-5 shadow-xl"
>
{{ dayjs(participant.joined_at).format('HH:mm a') }} -
{{ dayjs(participant.left_at).format('HH:mm a') }}
<br />
{{ __('attended for') }} {{ participant.duration }}
{{ __('minutes') }}
</div>
</template>
</Tooltip>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Avatar, createListResource, Dialog, Tooltip } from 'frappe-ui'
import { useRouter } from 'vue-router'
import { inject } from 'vue'
const show = defineModel()
const router = useRouter()
const dayjs = inject('$dayjs')
interface LiveClass {
name: String
title: String
}
const props = defineProps<{
live_class: LiveClass | null
}>()
const participants = createListResource({
doctype: 'LMS Live Class Participant',
filter: {
live_class: props.live_class?.name,
},
fields: [
'name',
'member',
'member_name',
'member_image',
'member_username',
'joined_at',
'left_at',
'duration',
],
auto: true,
})
const redirectToProfile = (username: string) => {
router.push({
name: 'Profile',
params: { username },
})
}
</script>

View File

@@ -8,7 +8,7 @@
{
label: 'Submit',
variant: 'solid',
onClick: (close) => submitLiveClass(close),
onClick: ({ close }) => submitLiveClass(close),
},
],
}"
@@ -16,14 +16,29 @@
<template #body-content>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-2 gap-4">
<div>
<div class="space-y-4">
<FormControl
type="text"
v-model="liveClass.title"
:label="__('Title')"
class="mb-4"
:required="true"
/>
<FormControl
v-model="liveClass.date"
type="date"
:label="__('Date')"
:required="true"
/>
<Tooltip :text="__('Duration of the live class in minutes')">
<FormControl
type="number"
v-model="liveClass.duration"
:label="__('Duration')"
:required="true"
/>
</Tooltip>
</div>
<div class="space-y-4">
<Tooltip
:text="
__(
@@ -35,7 +50,6 @@
v-model="liveClass.time"
type="time"
:label="__('Time')"
class="mb-4"
:required="true"
/>
</Tooltip>
@@ -52,24 +66,6 @@
:required="true"
/>
</div>
</div>
<div>
<FormControl
v-model="liveClass.date"
type="date"
class="mb-4"
:label="__('Date')"
:required="true"
/>
<Tooltip :text="__('Duration of the live class in minutes')">
<FormControl
type="number"
v-model="liveClass.duration"
:label="__('Duration')"
class="mb-4"
:required="true"
/>
</Tooltip>
<FormControl
v-model="liveClass.auto_recording"
type="select"
@@ -94,9 +90,10 @@ import {
Tooltip,
FormControl,
Autocomplete,
toast,
} from 'frappe-ui'
import { reactive, inject, onMounted } from 'vue'
import { getTimezones, createToast, getUserTimezone } from '@/utils/'
import { getTimezones, getUserTimezone } from '@/utils/'
const liveClasses = defineModel('reloadLiveClasses')
const show = defineModel()
@@ -106,7 +103,11 @@ const dayjs = inject('$dayjs')
const props = defineProps({
batch: {
type: String,
default: null,
required: true,
},
zoomAccount: {
type: String,
required: true,
},
})
@@ -158,6 +159,7 @@ const createLiveClass = createResource({
return {
doctype: 'LMS Live Class',
batch_name: values.batch,
zoom_account: props.zoomAccount,
...values,
}
},
@@ -166,54 +168,52 @@ const createLiveClass = createResource({
const submitLiveClass = (close) => {
return createLiveClass.submit(liveClass, {
validate() {
if (!liveClass.title) {
return __('Please enter a title.')
}
if (!liveClass.date) {
return __('Please select a date.')
}
if (!liveClass.time) {
return __('Please select a time.')
}
if (!liveClass.timezone) {
return __('Please select a timezone.')
}
if (!valideTime()) {
return __('Please enter a valid time in the format HH:mm.')
}
const liveClassDateTime = dayjs(`${liveClass.date}T${liveClass.time}`).tz(
liveClass.timezone,
true
)
if (
liveClassDateTime.isSameOrBefore(
dayjs().tz(liveClass.timezone, false),
'minute'
)
) {
return __('Please select a future date and time.')
}
if (!liveClass.duration) {
return __('Please select a duration.')
}
validateFormFields()
},
onSuccess() {
liveClasses.value.reload()
refreshForm()
close()
},
onError(err) {
createToast({
title: 'Error',
text: err.messages?.[0] || err,
icon: 'x',
iconClasses: 'bg-surface-red-5 text-ink-white rounded-md p-px',
position: 'top-center',
timeout: 10,
})
toast.error(err.messages?.[0] || err)
},
})
}
const validateFormFields = () => {
if (!liveClass.title) {
return __('Please enter a title.')
}
if (!liveClass.date) {
return __('Please select a date.')
}
if (!liveClass.time) {
return __('Please select a time.')
}
if (!liveClass.timezone) {
return __('Please select a timezone.')
}
if (!valideTime()) {
return __('Please enter a valid time in the format HH:mm.')
}
const liveClassDateTime = dayjs(`${liveClass.date}T${liveClass.time}`).tz(
liveClass.timezone,
true
)
if (
liveClassDateTime.isSameOrBefore(
dayjs().tz(liveClass.timezone, false),
'minute'
)
) {
return __('Please select a future date and time.')
}
if (!liveClass.duration) {
return __('Please select a duration.')
}
}
const valideTime = () => {
let time = liveClass.time.split(':')
if (time.length != 2) {
@@ -227,4 +227,14 @@ const valideTime = () => {
}
return true
}
const refreshForm = () => {
liveClass.title = ''
liveClass.description = ''
liveClass.date = ''
liveClass.time = ''
liveClass.duration = ''
liveClass.timezone = getUserTimezone()
liveClass.auto_recording = 'No Recording'
}
</script>

View File

@@ -30,11 +30,10 @@
</Dialog>
</template>
<script setup>
import { Dialog, createResource } from 'frappe-ui'
import { Dialog, createResource, toast } from 'frappe-ui'
import Link from '@/components/Controls/Link.vue'
import { reactive, watch } from 'vue'
import IconPicker from '@/components/Controls/IconPicker.vue'
import { showToast } from '@/utils'
const sidebar = defineModel('reloadSidebar')
const show = defineModel()
@@ -78,10 +77,10 @@ const addWebPage = (close) => {
onSuccess() {
sidebar.value.reload()
close()
showToast('Success', 'Web page added to sidebar', 'check')
toast.success(__('Web page added to sidebar'))
},
onError(err) {
showToast('Error', err.message[0] || err, 'x')
toast.error(err.message[0] || err)
close()
},
}

View File

@@ -121,10 +121,10 @@ import {
createResource,
Switch,
Button,
toast,
} from 'frappe-ui'
import { computed, watch, reactive, ref, inject } from 'vue'
import Link from '@/components/Controls/Link.vue'
import { showToast } from '@/utils'
import { useOnboarding } from 'frappe-ui/frappe'
const show = defineModel()
@@ -260,7 +260,7 @@ const addQuestion = () => {
})
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
toast.error(err.messages?.[0] || err)
},
}
)
@@ -278,12 +278,12 @@ const addQuestionRow = (question) => {
updateOnboardingStep('create_first_quiz')
show.value = false
showToast(__('Success'), __('Question added successfully'), 'check')
toast.success(__('Question added successfully'))
quiz.value.reload()
show.value = false
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
toast.error(err.messages?.[0] || err)
show.value = false
},
}
@@ -328,18 +328,14 @@ const updateQuestion = () => {
{
onSuccess() {
show.value = false
showToast(
__('Success'),
__('Question updated successfully'),
'check'
)
toast.success(__('Question updated successfully'))
quiz.value.reload()
},
}
)
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
toast.error(err.messages?.[0] || err)
},
}
)

View File

@@ -0,0 +1,225 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Add quiz to this video'),
size: '2xl',
}"
>
<template #body-content>
<div class="text-base">
<div class="flex items-end gap-4">
<FormControl
:label="__('Time in Video')"
v-model="quiz.time"
type="text"
placeholder="2:15"
class="flex-1"
/>
<Link
v-model="quiz.quiz"
:label="__('Quiz')"
doctype="LMS Quiz"
class="flex-1"
/>
<Button @click="addQuiz()" variant="solid">
<template #prefix>
<Plus class="w-4 h-4 stroke-1.5" />
</template>
{{ __('Add') }}
</Button>
</div>
<div class="mt-10 mb-5">
<div class="font-medium mb-4">
{{ __('Quizzes in this video') }}
</div>
<ListView
v-if="allQuizzes.length"
:columns="columns"
:rows="allQuizzes"
row-key="quiz"
:options="{
showTooltip: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns">
<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 allQuizzes">
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key as keyof Quiz]"
:align="column.align"
>
<div v-if="column.key == 'time'" class="leading-5 text-sm">
{{ formatTimestamp(row[column.key as keyof Quiz]) }}
</div>
<div v-else class="leading-5 text-sm">
{{ row[column.key as keyof Quiz] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeQuiz(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
<div v-else class="text-ink-gray-5 italic text-xs">
{{ __('No quizzes added yet.') }}
</div>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Dialog,
Button,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
toast,
} from 'frappe-ui'
import { computed, reactive, ref, watch } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import { formatTimestamp } from '@/utils'
import Link from '@/components/Controls/Link.vue'
type Quiz = {
time: string
quiz: string
}
const show = defineModel()
const allQuizzes = ref<Quiz[]>([])
const quiz = reactive<Quiz>({
time: '',
quiz: '',
})
const props = defineProps({
quizzes: {
type: Array as () => Quiz[],
default: () => [],
},
saveQuizzes: {
type: Function,
required: true,
},
duration: {
type: Number,
default: 0,
},
})
const addQuiz = () => {
quiz.time = `${getTimeInSeconds()}`
if (!isTimeValid() || !isFormComplete()) return
allQuizzes.value.push({
time: quiz.time,
quiz: quiz.quiz,
})
props.saveQuizzes(allQuizzes.value)
quiz.time = ''
quiz.quiz = ''
}
const getTimeInSeconds = () => {
if (quiz.time && !quiz.time.includes(':')) {
quiz.time = `${quiz.time}:00`
}
const timeParts = quiz.time.split(':')
const timeInSeconds = parseInt(timeParts[0]) * 60 + parseInt(timeParts[1])
return timeInSeconds
}
const isTimeValid = () => {
if (parseInt(quiz.time) > props.duration) {
toast.error(__('Time in video exceeds the total duration of the video.'))
return false
}
return true
}
const isFormComplete = () => {
if (!quiz.time) {
toast.error(__('Please enter a valid timestamp'))
return false
}
if (!quiz.quiz) {
toast.error(__('Please select a quiz'))
return false
}
return true
}
const removeQuiz = (selections: string, unselectAll: () => void) => {
Array.from(selections).forEach((selection) => {
const index = allQuizzes.value.findIndex((q) => q.quiz === selection)
if (index !== -1) {
allQuizzes.value.splice(index, 1)
}
unselectAll()
})
props.saveQuizzes(allQuizzes.value)
}
watch(
() => props.quizzes,
(newQuizzes) => {
allQuizzes.value = newQuizzes
},
{ immediate: true }
)
const columns = computed(() => {
return [
{
key: 'quiz',
label: __('Quiz'),
},
{
key: 'time',
label: __('Time in Video (minutes)'),
align: 'center',
},
]
})
</script>

View File

@@ -15,27 +15,20 @@
>
<template #body-content>
<div class="flex flex-col gap-4">
<div>
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Rating') }}
</div>
<Rating v-model="review.rating" />
</div>
<div>
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Review') }}
</div>
<Textarea type="text" size="md" rows="5" v-model="review.review" />
</div>
<Rating v-model="review.rating" :label="__('Rating')" />
<FormControl
:label="__('Review')"
type="textarea"
v-model="review.review"
:rows="5"
/>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, Textarea, createResource } from 'frappe-ui'
import { Dialog, FormControl, createResource, toast, Rating } from 'frappe-ui'
import { reactive } from 'vue'
import Rating from '@/components/Controls/Rating.vue'
import { createToast } from '@/utils/'
const show = defineModel()
const reviews = defineModel('reloadReviews')
@@ -78,11 +71,7 @@ function submitReview(close) {
hasReviewed.value.reload()
},
onError(err) {
createToast({
text: err.messages?.[0] || err,
icon: 'x',
iconClasses: 'text-ink-red-4 bg-surface-red-4',
})
toast.error(err.messages?.[0] || err)
},
})
close()

View File

@@ -19,19 +19,25 @@
doctype="User"
v-model="student"
:filters="{ ignore_user_type: 1 }"
:onCreate="
(value, close) => {
openSettings('Members', close)
}
"
/>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, createResource } from 'frappe-ui'
import { Dialog, createResource, toast } from 'frappe-ui'
import { ref, inject } from 'vue'
import Link from '@/components/Controls/Link.vue'
import { showToast } from '@/utils'
import { useOnboarding } from 'frappe-ui/frappe'
import { openSettings } from '@/utils'
const students = defineModel('reloadStudents')
const batchModal = defineModel('batchModal')
const student = ref()
const user = inject('$user')
const { updateOnboardingStep } = useOnboarding('learning')
@@ -66,11 +72,12 @@ const addStudent = (close) => {
updateOnboardingStep('add_batch_student')
students.value.reload()
batchModal.value.reload()
student.value = null
close()
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
toast.error(err.messages?.[0] || err)
},
}
)

View File

@@ -0,0 +1,206 @@
<template>
<Dialog
v-model="show"
:options="{
title:
accountID === 'new' ? __('New Zoom Account') : __('Edit Zoom Account'),
size: 'xl',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick: ({ close }) => {
saveAccount(close)
},
},
],
}"
>
<template #body-content>
<div class="mb-4">
<FormControl
v-model="account.enabled"
:label="__('Enabled')"
type="checkbox"
/>
</div>
<div class="grid grid-cols-2 gap-5">
<FormControl
v-model="account.name"
:label="__('Account Name')"
type="text"
:required="true"
/>
<FormControl
v-model="account.client_id"
:label="__('Client ID')"
type="text"
:required="true"
/>
<Link
v-model="account.member"
:label="__('Member')"
doctype="Course Evaluator"
:onCreate="(value, close) => openSettings('Members', close)"
:required="true"
/>
<FormControl
v-model="account.client_secret"
:label="__('Client Secret')"
type="password"
:required="true"
/>
<FormControl
v-model="account.account_id"
:label="__('Account ID')"
type="text"
:required="true"
/>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { call, Dialog, FormControl, toast } from 'frappe-ui'
import { inject, reactive, watch } from 'vue'
import { User } from '@/components/Settings/types'
import { openSettings, cleanError } from '@/utils'
import Link from '@/components/Controls/Link.vue'
interface ZoomAccount {
name: string
account_name: string
enabled: boolean
member: string
account_id: string
client_id: string
client_secret: string
}
interface ZoomAccounts {
data: ZoomAccount[]
reload: () => void
insert: {
submit: (
data: ZoomAccount,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
}
}
const show = defineModel('show')
const user = inject<User | null>('$user')
const zoomAccounts = defineModel<ZoomAccounts>('zoomAccounts')
const account = reactive({
name: '',
enabled: false,
member: user?.data?.name || '',
account_id: '',
client_id: '',
client_secret: '',
})
const props = defineProps({
accountID: {
type: String,
default: 'new',
},
})
watch(
() => props.accountID,
(val) => {
if (val != 'new') {
zoomAccounts.value?.data.forEach((acc) => {
if (acc.name === val) {
account.name = acc.name
account.enabled = acc.enabled || false
account.member = acc.member
account.account_id = acc.account_id
account.client_id = acc.client_id
account.client_secret = acc.client_secret
}
})
}
}
)
watch(show, (val) => {
if (!val) {
account.name = ''
account.enabled = false
account.member = user?.data?.name || ''
account.account_id = ''
account.client_id = ''
account.client_secret = ''
}
})
const saveAccount = (close) => {
if (props.accountID == 'new') {
createAccount(close)
} else {
updateAccount(close)
}
}
const createAccount = (close) => {
zoomAccounts.value?.insert.submit(
{
account_name: account.name,
...account,
},
{
onSuccess() {
zoomAccounts.value?.reload()
close()
toast.success(__('Zoom Account created successfully'))
},
onError(err) {
close()
toast.error(
cleanError(err.messages[0]) || __('Error creating Zoom Account')
)
},
}
)
}
const updateAccount = async (close) => {
if (props.accountID != account.name) {
await renameDoc()
}
setValue(close)
}
const renameDoc = async () => {
await call('frappe.client.rename_doc', {
doctype: 'LMS Zoom Settings',
old_name: props.accountID,
new_name: account.name,
})
}
const setValue = (close) => {
zoomAccounts.value?.setValue.submit(
{
...account,
name: account.name,
},
{
onSuccess() {
zoomAccounts.value?.reload()
close()
toast.success(__('Zoom Account updated successfully'))
},
onError(err) {
close()
toast.error(
cleanError(err.messages[0]) || __('Error updating Zoom Account')
)
},
}
)
}
</script>

View File

@@ -1,8 +1,11 @@
<template>
<div v-if="quiz.data">
<div
class="bg-surface-blue-2 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-ink-blue-3"
class="bg-surface-blue-2 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-ink-blue-3 leading-5"
>
<div v-if="inVideo">
{{ __('You will have to complete the quiz to continue the video') }}
</div>
<div class="leading-5">
{{
__('This quiz consists of {0} questions.').format(questions.length)
@@ -55,19 +58,30 @@
<div class="font-semibold text-lg text-ink-gray-9">
{{ quiz.data.title }}
</div>
<Button
<div class="flex items-center justify-center space-x-2 mt-4">
<Button
v-if="
!quiz.data.max_attempts ||
attempts.data?.length < quiz.data.max_attempts
"
variant="solid"
@click="startQuiz"
>
<span>
{{ inVideo ? __('Start the Quiz') : __('Start') }}
</span>
</Button>
<Button v-if="inVideo" @click="props.backToVideo()">
{{ __('Resume Video') }}
</Button>
</div>
<div
v-if="
!quiz.data.max_attempts ||
attempts.data?.length < quiz.data.max_attempts
quiz.data.max_attempts &&
attempts.data?.length >= quiz.data.max_attempts
"
@click="startQuiz"
class="mt-2"
class="leading-5 text-ink-gray-7"
>
<span>
{{ __('Start') }}
</span>
</Button>
<div v-else class="leading-5 text-ink-gray-7">
{{
__(
'You have already exceeded the maximum number of attempts allowed for this quiz.'
@@ -247,18 +261,23 @@
)
}}
</div>
<Button
@click="resetQuiz()"
class="mt-2"
v-if="
!quiz.data.max_attempts ||
attempts?.data.length < quiz.data.max_attempts
"
>
<span>
{{ __('Try Again') }}
</span>
</Button>
<div class="space-x-2">
<Button
@click="resetQuiz()"
class="mt-2"
v-if="
!quiz.data.max_attempts ||
attempts?.data.length < quiz.data.max_attempts
"
>
<span>
{{ __('Try Again') }}
</span>
</Button>
<Button v-if="inVideo" @click="props.backToVideo()">
{{ __('Resume Video') }}
</Button>
</div>
</div>
<div
v-if="
@@ -291,9 +310,9 @@ import {
ListView,
TextEditor,
FormControl,
toast,
} from 'frappe-ui'
import { ref, watch, reactive, inject, computed } from 'vue'
import { createToast, showToast } from '@/utils/'
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
import { timeAgo } from '@/utils'
import { useRouter } from 'vue-router'
@@ -308,13 +327,20 @@ let questions = reactive([])
const possibleAnswer = ref(null)
const timer = ref(0)
let timerInterval = null
const router = useRouter()
const props = defineProps({
quizName: {
type: String,
required: true,
},
inVideo: {
type: Boolean,
default: false,
},
backToVideo: {
type: Function,
default: () => {},
},
})
const quiz = createResource({
@@ -494,12 +520,7 @@ const getAnswers = () => {
const checkAnswer = () => {
let answers = getAnswers()
if (!answers.length) {
createToast({
title: 'Please select an option',
icon: 'alert-circle',
iconClasses: 'text-yellow-600 bg-yellow-100 rounded-full',
position: 'top-center',
})
toast.warning(__('Please select an option'))
return
}
@@ -589,7 +610,7 @@ const createSubmission = () => {
const errorTitle = err?.message || ''
if (errorTitle.includes('MaximumAttemptsExceededError')) {
const errorMessage = err.messages?.[0] || err
showToast(__('Error'), __(errorMessage), 'x')
toast.error(__(errorMessage))
setTimeout(() => {
window.location.reload()
}, 3000)
@@ -616,11 +637,15 @@ const getInstructions = (question) => {
}
const markLessonProgress = () => {
if (router.currentRoute.value.name == 'Lesson') {
let pathname = window.location.pathname.split('/')
if (pathname[2] != 'courses') return
let lessonIndex = pathname.pop().split('-')
if (lessonIndex.length == 2) {
call('lms.lms.api.mark_lesson_progress', {
course: router.currentRoute.value.params.courseName,
chapter_number: router.currentRoute.value.params.chapterNumber,
lesson_number: router.currentRoute.value.params.lessonNumber,
course: pathname[3],
chapter_number: lessonIndex[0],
lesson_number: lessonIndex[1],
})
}
}

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col justify-between min-h-0">
<div class="flex flex-col justify-between h-full">
<div>
<div class="flex items-center justify-between">
<div class="font-semibold mb-1 text-ink-gray-9">
@@ -18,17 +18,17 @@
</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 class="flex flex-row-reverse mt-auto">
<Button variant="solid" :loading="saveSettings.loading" @click="update">
{{ __('Update') }}
</Button>
</div>
</div>
</template>
<script setup>
import { createResource, Button, Badge } from 'frappe-ui'
import SettingFields from '@/components/SettingFields.vue'
import SettingFields from '@/components/Settings/SettingFields.vue'
import { watch, ref } from 'vue'
const isDirty = ref(false)

View File

@@ -0,0 +1,216 @@
<template>
<div class="flex flex-col min-h-0 text-base">
<div class="flex items-center justify-between mb-5">
<div class="flex flex-col space-y-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ label }}
</div>
<div class="text-xs text-ink-gray-5">
{{ __(description) }}
</div>
</div>
<div class="flex items-center space-x-5">
<div
class="flex items-center space-x-1 text-ink-amber-3 border border-outline-amber-1 bg-surface-amber-1 rounded-lg px-2 py-1"
v-if="saving"
>
<LoadingIndicator class="size-2" />
<span class="text-xs">
{{ __('saving...') }}
</span>
</div>
<Button @click="() => showCategoryForm()">
<template #prefix>
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
<X v-else class="h-3 w-3 stroke-1.5" />
</template>
{{ showForm ? __('Close') : __('New') }}
</Button>
</div>
</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="divide-y space-y-2">
<div
v-for="(cat, index) in categories.data"
:key="cat.name"
class="pt-2"
>
<div
v-if="editing?.name !== cat.name"
class="flex items-center justify-between group text-sm"
>
<div @dblclick="allowEdit(cat, index)">
{{ cat.category }}
</div>
<Button
variant="ghost"
theme="red"
class="invisible group-hover:visible"
@click="deleteCategory(cat.name)"
>
<template #icon>
<Trash2 class="size-4 stroke-1.5 text-ink-red-4" />
</template>
</Button>
</div>
<FormControl
v-else
:ref="(el) => (editInputRef[index] = el)"
v-model="editedValue"
type="text"
class="w-full"
@keyup.enter="saveChanges(cat.name, editedValue)"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
Button,
FormControl,
LoadingIndicator,
createListResource,
createResource,
toast,
} from 'frappe-ui'
import { Plus, Trash2, X } from 'lucide-vue-next'
import { ref } from 'vue'
import { cleanError } from '@/utils'
const showForm = ref(false)
const category = ref(null)
const categoryInput = ref(null)
const saving = ref(false)
const editing = ref(null)
const editedValue = ref('')
const editInputRef = ref([])
const props = defineProps({
label: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
})
const categories = createListResource({
doctype: 'LMS Category',
fields: ['name', 'category'],
auto: true,
})
const addCategory = () => {
categories.insert.submit(
{
category: category.value,
},
{
onSuccess(data) {
categories.reload()
category.value = null
showForm.value = false
toast.success(__('Category added successfully'))
},
onError(err) {
toast.error(__(cleanError(err.messages[0]) || 'Unable to add category'))
},
}
)
}
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) => {
saving.value = true
updateCategory.submit(
{
name: name,
category: value,
},
{
onSuccess() {
saving.value = false
categories.reload()
editing.value = null
editedValue.value = ''
toast.success(__('Category updated successfully'))
},
onError(err) {
saving.value = false
editing.value = null
editedValue.value = ''
toast.error(
__(cleanError(err.messages[0]) || 'Unable to update category')
)
},
}
)
}
const deleteCategory = (name) => {
saving.value = true
categories.delete.submit(name, {
onSuccess() {
saving.value = false
categories.reload()
toast.success(__('Category deleted successfully'))
},
onError(err) {
saving.value = false
toast.error(
__(cleanError(err.messages[0]) || 'Unable to delete category')
)
},
})
}
const saveChanges = (name, value) => {
saving.value = true
update(name, value)
}
const allowEdit = (cat, index) => {
editing.value = cat
editedValue.value = cat.category
setTimeout(() => {
editInputRef.value[index].$el.querySelector('input').focus()
}, 0)
}
</script>

View File

@@ -0,0 +1,160 @@
<template>
<div class="flex flex-col min-h-0 text-base">
<div class="flex items-center justify-between mb-5">
<div class="flex flex-col space-y-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ label }}
</div>
<!-- <div class="text-xs text-ink-gray-5">
{{ __(description) }}
</div> -->
</div>
<div class="flex items-center space-x-5">
<Button @click="openTemplateForm('new')">
<template #prefix>
<Plus class="h-3 w-3 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</div>
</div>
<div v-if="emailTemplates.data?.length" class="overflow-y-scroll">
<ListView
:columns="columns"
:rows="emailTemplates.data"
row-key="name"
:options="{
showTooltip: false,
onRowClick: (row) => {
openTemplateForm(row.name)
},
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns">
<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 emailTemplates.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div class="leading-5 text-sm">
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeTemplate(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
</div>
<EmailTemplateModal
v-model="showForm"
v-model:emailTemplates="emailTemplates"
:templateID="selectedTemplate"
/>
</template>
<script setup lang="ts">
import {
Button,
call,
createListResource,
ListView,
ListHeader,
ListHeaderItem,
ListSelectBanner,
ListRows,
ListRow,
ListRowItem,
toast,
} from 'frappe-ui'
import { computed, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import EmailTemplateModal from '@/components/Modals/EmailTemplateModal.vue'
const props = defineProps({
label: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
})
const showForm = ref(false)
const readOnlyMode = window.read_only_mode
const selectedTemplate = ref(null)
const emailTemplates = createListResource({
doctype: 'Email Template',
fields: ['name', 'subject', 'use_html', 'response', 'response_html'],
auto: true,
orderBy: 'modified desc',
cache: 'email-templates',
})
const removeTemplate = (selections, unselectAll) => {
call('lms.lms.api.delete_documents', {
doctype: 'Email Template',
documents: Array.from(selections),
})
.then(() => {
emailTemplates.reload()
toast.success(__('Email Templates deleted successfully'))
unselectAll()
})
.catch((err) => {
toast.error(
cleanError(err.messages[0]) || __('Error deleting email templates')
)
})
}
const openTemplateForm = (templateID) => {
if (readOnlyMode) {
return
}
selectedTemplate.value = templateID
showForm.value = true
}
const columns = computed(() => {
return [
{
label: 'Name',
key: 'name',
width: '20rem',
},
{
label: 'Subject',
key: 'subject',
width: '25rem',
},
]
})
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div>
<div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between mb-4">
<div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
@@ -17,10 +17,11 @@
: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 #prefix>
<Plus v-if="!showForm" class="size-4 stroke-1.5" />
<X v-else class="size-4 stroke-1.5" />
</template>
{{ showForm ? __('Close') : __('New') }}
</Button>
</div>
</div>
@@ -32,31 +33,34 @@
:placeholder="__('Email')"
type="email"
class="w-full"
@keydown.enter="addEvaluator"
/>
<Button @click="addEvaluator()" variant="subtle">
{{ __('Add') }}
</Button>
</div>
<div class="divide-y">
<div
v-for="evaluator in evaluators.data"
@click="openProfile(evaluator.username)"
class="cursor-pointer"
>
<div class="flex items-center justify-between py-3">
<div class="flex items-center space-x-3">
<Avatar
:image="evaluator.user_image"
:label="evaluator.full_name"
size="lg"
/>
<div>
<div class="text-base font-semibold text-ink-gray-9">
{{ evaluator.full_name }}
</div>
<div class="text-xs text-ink-gray-5">
{{ evaluator.evaluator }}
<div class="overflow-y-scroll">
<div class="divide-y">
<div
v-for="evaluator in evaluators.data"
@click="openProfile(evaluator.username)"
class="cursor-pointer"
>
<div class="flex items-center justify-between py-3">
<div class="flex items-center space-x-3">
<Avatar
:image="evaluator.user_image"
:label="evaluator.full_name"
size="lg"
/>
<div>
<div class="text-base font-semibold text-ink-gray-9">
{{ evaluator.full_name }}
</div>
<div class="text-xs text-ink-gray-5">
{{ evaluator.evaluator }}
</div>
</div>
</div>
</div>

View File

@@ -17,10 +17,11 @@
: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 #prefix>
<Plus v-if="!showForm" class="size-4 stroke-1.5" />
<X v-else class="size-4 stroke-1.5" />
</template>
{{ showForm ? __('Close') : __('New') }}
</Button>
</div>
</div>
@@ -117,23 +118,7 @@ import { useRouter } from 'vue-router'
import { ref, watch, reactive, inject } from 'vue'
import { RefreshCw, Plus, X } from 'lucide-vue-next'
import { useOnboarding } from 'frappe-ui/frappe'
interface User {
data: {
email: string
name: string
enabled: boolean
user_image: string
full_name: string
user_type: ['System User', 'Website User']
username: string
is_moderator: boolean
is_system_manager: boolean
is_evaluator: boolean
is_instructor: boolean
is_fc_site: boolean
}
}
import type { User } from '@/components/Settings/types'
const router = useRouter()
const show = defineModel('show')

View File

@@ -12,13 +12,13 @@
/> -->
</div>
<div class="overflow-y-scroll">
<div class="flex space-x-4">
<SettingFields :fields="fields" :data="data.doc" class="w-1/2" />
<div class="flex flex-col divide-y">
<SettingFields :fields="fields" :data="data.doc" />
<SettingFields
v-if="paymentGateway.data"
:fields="paymentGateway.data.fields"
:data="paymentGateway.data.data"
class="w-1/2"
class="pt-5 my-0"
/>
</div>
</div>
@@ -30,9 +30,9 @@
</div>
</template>
<script setup>
import SettingFields from '@/components/SettingFields.vue'
import SettingFields from '@/components/Settings/SettingFields.vue'
import { createResource, Badge, Button } from 'frappe-ui'
import { watch, ref } from 'vue'
import { watch } from 'vue'
const props = defineProps({
label: {
@@ -60,9 +60,28 @@ const paymentGateway = createResource({
payment_gateway: props.data.doc.payment_gateway,
}
},
transform(data) {
arrangeFields(data.fields)
return data
},
auto: true,
})
const arrangeFields = (fields) => {
fields = fields.sort((a, b) => {
if (a.type === 'Upload' && b.type !== 'Upload') {
return 1
} else if (a.type !== 'Upload' && b.type === 'Upload') {
return -1
}
return 0
})
fields.splice(3, 0, {
type: 'Column Break',
})
}
const saveSettings = createResource({
url: 'frappe.client.set_value',
makeParams(values) {

View File

@@ -27,9 +27,8 @@
</template>
<script setup>
import { Button, Badge } from 'frappe-ui'
import SettingFields from '@/components/SettingFields.vue'
import { showToast } from '@/utils'
import { Button, Badge, toast } from 'frappe-ui'
import SettingFields from '@/components/Settings/SettingFields.vue'
const props = defineProps({
fields: {
@@ -61,7 +60,7 @@ const update = () => {
{},
{
onError(err) {
showToast(__('Error'), err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)

View File

@@ -6,7 +6,7 @@
<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'"
:class="columns.length > 1 ? 'w-[21rem]' : 'w-full'"
>
<div v-for="field in column">
<Link
@@ -14,6 +14,7 @@
v-model="data[field.name]"
:doctype="field.doctype"
:label="__(field.label)"
:description="__(field.description)"
/>
<div v-else-if="field.type == 'Code'">
@@ -54,11 +55,13 @@
<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-modals bg-white w-[10rem] py-2"
class="flex items-center justify-center rounded border border-outline-gray-1 bg-surface-gray-2"
:class="field.size == 'lg' ? 'px-5 py-5' : 'px-20 py-8'"
>
<img
:src="data[field.name]?.file_url || data[field.name]"
class="w-[80%] rounded"
class="rounded"
:class="field.size == 'lg' ? 'w-36' : 'size-6'"
/>
</div>
<div class="flex flex-col flex-wrap">
@@ -100,6 +103,7 @@
:rows="field.rows"
:options="field.options"
:description="field.description"
:class="columns.length > 1 ? 'w-full' : 'w-1/2'"
/>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<Dialog v-model="show" :options="{ size: '4xl' }">
<Dialog v-model="show" :options="{ size: '5xl' }">
<template #body>
<div class="flex h-[calc(100vh_-_8rem)]">
<div class="flex w-52 shrink-0 flex-col bg-surface-gray-2 p-2">
@@ -51,6 +51,16 @@
:label="activeTab.label"
:description="activeTab.description"
/>
<EmailTemplates
v-else-if="activeTab.label === 'Email Templates'"
:label="activeTab.label"
:description="activeTab.description"
/>
<ZoomSettings
v-else-if="activeTab.label === 'Zoom Accounts'"
:label="activeTab.label"
:description="activeTab.description"
/>
<PaymentSettings
v-else-if="activeTab.label === 'Payment Gateway'"
:label="activeTab.label"
@@ -81,13 +91,15 @@
import { Dialog, createDocumentResource, createResource } from 'frappe-ui'
import { ref, computed, watch } from 'vue'
import { useSettings } from '@/stores/settings'
import SettingDetails from '../SettingDetails.vue'
import SettingDetails from '@/components/Settings/SettingDetails.vue'
import SidebarLink from '@/components/SidebarLink.vue'
import Members from '@/components/Members.vue'
import Evaluators from '@/components/Evaluators.vue'
import Categories from '@/components/Categories.vue'
import BrandSettings from '@/components/BrandSettings.vue'
import PaymentSettings from '@/components/PaymentSettings.vue'
import Members from '@/components/Settings/Members.vue'
import Evaluators from '@/components/Settings/Evaluators.vue'
import Categories from '@/components/Settings/Categories.vue'
import EmailTemplates from '@/components/Settings/EmailTemplates.vue'
import BrandSettings from '@/components/Settings/BrandSettings.vue'
import PaymentSettings from '@/components/Settings/PaymentSettings.vue'
import ZoomSettings from '@/components/Settings/ZoomSettings.vue'
const show = defineModel()
const doctype = ref('LMS Settings')
@@ -122,7 +134,7 @@ const tabsStructure = computed(() => {
label: 'Enable Learning Paths',
name: 'enable_learning_paths',
description:
'This will enforce students to go through programs assigned to them in the correct order.',
'This will ensure students follow the assigned programs in order.',
type: 'checkbox',
},
{
@@ -139,11 +151,26 @@ const tabsStructure = computed(() => {
'If enabled, it sends google calendar invite to the student for evaluations.',
type: 'checkbox',
},
{
type: 'Column Break',
},
{
label: 'Batch Confirmation Email Template',
name: 'batch_confirmation_template',
doctype: 'Email Template',
type: 'Link',
},
{
label: 'Certification Email Template',
name: 'certification_template',
doctype: 'Email Template',
type: 'Link',
},
{
label: 'Unsplash Access Key',
name: 'unsplash_access_key',
description:
'Optional. If this is set, students can pick a cover image from the unsplash library for their profile page. https://unsplash.com/documentation#getting-started.',
'Allows users to pick a profile cover image from Unsplash. https://unsplash.com/documentation#getting-started.',
type: 'password',
},
],
@@ -160,6 +187,12 @@ const tabsStructure = computed(() => {
description:
'Configure the payment gateway and other payment related settings',
fields: [
{
label: 'Default Currency',
name: 'default_currency',
type: 'Link',
doctype: 'Currency',
},
{
label: 'Payment Gateway',
name: 'payment_gateway',
@@ -167,10 +200,7 @@ const tabsStructure = computed(() => {
doctype: 'Payment Gateway',
},
{
label: 'Default Currency',
name: 'default_currency',
type: 'Link',
doctype: 'Currency',
type: 'Column Break',
},
{
label: 'Apply GST for India',
@@ -207,9 +237,19 @@ const tabsStructure = computed(() => {
},
{
label: 'Categories',
description: 'Manage the members of your learning system',
description: 'Double click to edit the category',
icon: 'Network',
},
{
label: 'Email Templates',
description: 'Manage the email templates for your learning system',
icon: 'MailPlus',
},
{
label: 'Zoom Accounts',
description: 'Manage the Zoom accounts for your learning system',
icon: 'Video',
},
],
},
{
@@ -235,28 +275,6 @@ const tabsStructure = computed(() => {
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',
},
],
},
{
@@ -275,8 +293,8 @@ const tabsStructure = computed(() => {
type: 'checkbox',
},
{
label: 'Certified Participants',
name: 'certified_participants',
label: 'Certified Members',
name: 'certified_members',
type: 'checkbox',
},
{
@@ -299,24 +317,6 @@ const tabsStructure = computed(() => {
},
],
},
{
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: 'Signup',
icon: 'LogIn',
@@ -335,6 +335,9 @@ const tabsStructure = computed(() => {
description:
'New users will have to be manually registered by Admins.',
},
{
type: 'Column Break',
},
{
label: 'Signup Consent HTML',
name: 'custom_signup_content',
@@ -362,12 +365,16 @@ const tabsStructure = computed(() => {
type: 'textarea',
rows: 4,
description:
'Keywords for search engines to find your website. Separated by commas.',
'Comma separated keywords for search engines to find your website.',
},
{
type: 'Column Break',
},
{
label: 'Meta Image',
name: 'meta_image',
type: 'Upload',
size: 'lg',
},
],
},

View File

@@ -0,0 +1,188 @@
<template>
<div class="flex flex-col min-h-0 text-base">
<div class="flex items-center justify-between mb-5">
<div class="flex flex-col space-y-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ label }}
</div>
<!-- <div class="text-xs text-ink-gray-5">
{{ __(description) }}
</div> -->
</div>
<div class="flex items-center space-x-5">
<Button @click="openForm('new')">
<template #prefix>
<Plus class="h-3 w-3 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</div>
</div>
<div v-if="zoomAccounts.data?.length" class="overflow-y-scroll">
<ListView
:columns="columns"
:rows="zoomAccounts.data"
row-key="name"
:options="{
showTooltip: false,
onRowClick: (row) => {
openForm(row.name)
},
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns">
<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 zoomAccounts.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key == 'enabled'">
<Badge v-if="row[column.key]" theme="blue">
{{ __('Enabled') }}
</Badge>
<Badge v-else theme="gray">
{{ __('Disabled') }}
</Badge>
</div>
<div v-else class="leading-5 text-sm">
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeAccount(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
</div>
<ZoomAccountModal
v-model="showForm"
v-model:zoomAccounts="zoomAccounts"
:accountID="currentAccount"
/>
</template>
<script setup lang="ts">
import {
Button,
Badge,
call,
createListResource,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
toast,
} from 'frappe-ui'
import { computed, inject, onMounted, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import { cleanError } from '@/utils'
import { User } from '@/components/Settings/types'
import ZoomAccountModal from '@/components/Modals/ZoomAccountModal.vue'
const user = inject<User | null>('$user')
const showForm = ref(false)
const currentAccount = ref<string | null>(null)
const props = defineProps({
label: String,
description: String,
})
const zoomAccounts = createListResource({
doctype: 'LMS Zoom Settings',
fields: [
'name',
'enabled',
'member',
'member_name',
'account_id',
'client_id',
'client_secret',
],
cache: ['zoomAccounts'],
})
onMounted(() => {
fetchZoomAccounts()
})
const fetchZoomAccounts = () => {
if (!user?.data?.is_moderator && !user?.data?.is_evaluator) return
if (!user?.data?.is_moderator) {
zoomAccounts.update({
filters: {
member: user.data.name,
},
})
}
zoomAccounts.reload()
}
const openForm = (accountID: string) => {
currentAccount.value = accountID
showForm.value = true
}
const removeAccount = (selections, unselectAll) => {
call('lms.lms.api.delete_documents', {
doctype: 'LMS Zoom Settings',
documents: Array.from(selections),
})
.then(() => {
zoomAccounts.reload()
toast.success(__('Email Templates deleted successfully'))
unselectAll()
})
.catch((err) => {
toast.error(
cleanError(err.messages[0]) || __('Error deleting email templates')
)
})
}
const columns = computed(() => {
return [
{
label: __('Account'),
key: 'name',
},
{
label: __('Member'),
key: 'member_name',
},
{
label: __('Status'),
key: 'enabled',
align: 'center',
},
]
})
</script>

View File

@@ -0,0 +1,16 @@
export interface User {
data: {
email: string
name: string
enabled: boolean
user_image: string
full_name: string
user_type: ['System User', 'Website User']
username: string
is_moderator: boolean
is_system_manager: boolean
is_evaluator: boolean
is_instructor: boolean
is_fc_site: boolean
}
}

View File

@@ -61,7 +61,7 @@
</button>
</template>
<script setup>
import { Tooltip, Button } from 'frappe-ui'
import { Tooltip } from 'frappe-ui'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import * as icons from 'lucide-vue-next'

View File

@@ -1,14 +1,11 @@
<template>
<div>
<div class="flex items-center justify-between mb-4">
<div class="text-lg font-semibold">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Upcoming Evaluations') }}
</div>
<Button
v-if="
!upcoming_evals.data?.length ||
upcoming_evals.length == courses.length
"
v-if="upcoming_evals.data?.length != evaluationCourses.length"
@click="openEvalModal"
>
{{ __('Schedule Evaluation') }}
@@ -17,9 +14,9 @@
<div v-if="upcoming_evals.data?.length">
<div class="grid grid-cols-3 gap-4">
<div v-for="evl in upcoming_evals.data">
<div class="border rounded-md p-3">
<div class="border text-ink-gray-7 rounded-md p-3">
<div class="flex justify-between mb-3">
<span class="font-semibold leading-5">
<span class="font-semibold text-ink-gray-9 leading-5">
{{ evl.course_title }}
</span>
<Menu
@@ -42,7 +39,7 @@
leave-to-class="transform scale-95 opacity-0"
>
<MenuItems
class="absolute mt-2 w-32 rounded-md bg-white shadow-lg p-1.5"
class="absolute mt-2 w-32 rounded-md bg-surface-white border p-1.5"
>
<MenuItem v-slot="{ active }">
<Button
@@ -82,12 +79,11 @@
{{ evl.evaluator_name }}
</span>
</div>
<div class="flex items-center justify-between space-x-2 mt-4">
<Button
v-if="evl.google_meet_link"
@click="openEvalCall(evl)"
class="w-full"
>
<div
v-if="evl.google_meet_link"
class="flex items-center justify-between space-x-2 mt-4"
>
<Button @click="openEvalCall(evl)" class="w-full">
<template #prefix>
<HeadsetIcon class="w-4 h-4 stroke-1.5" />
</template>
@@ -119,7 +115,7 @@ import {
HeadsetIcon,
EllipsisVertical,
} from 'lucide-vue-next'
import { inject, ref, getCurrentInstance } from 'vue'
import { inject, ref, getCurrentInstance, computed } from 'vue'
import { formatTime } from '../utils'
import { Button, createResource, call } from 'frappe-ui'
import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
@@ -164,6 +160,12 @@ const openEvalCall = (evl) => {
window.open(evl.google_meet_link, '_blank')
}
const evaluationCourses = computed(() => {
return props.courses.filter((course) => {
return course.evaluator != ''
})
})
const cancelEvaluation = (evl) => {
$dialog({
title: __('Cancel this evaluation?'),

View File

@@ -72,7 +72,7 @@ import { usersStore } from '@/stores/user'
import { useSettings } from '@/stores/settings'
import { markRaw, watch, ref, onMounted, computed } from 'vue'
import { createDialog } from '@/utils/dialogs'
import SettingsModal from '@/components/Modals/Settings.vue'
import SettingsModal from '@/components/Settings/Settings.vue'
import FrappeCloudIcon from '@/components/Icons/FrappeCloudIcon.vue'
import {
ChevronDown,

View File

@@ -1,80 +1,129 @@
<template>
<div ref="videoContainer" class="video-block relative group">
<video
@timeupdate="updateTime"
@ended="videoEnded"
@click="togglePlay"
oncontextmenu="return false"
class="rounded-md border border-gray-100 cursor-pointer"
ref="videoRef"
>
<source :src="fileURL" :type="type" />
</video>
<div>
<div
v-if="!playing"
class="absolute inset-0 flex items-center justify-center cursor-pointer"
@click="playVideo"
v-if="quizzes.length && !showQuiz && readOnly"
class="bg-surface-blue-2 space-y-1 py-3 px-4 rounded-md text-sm text-ink-blue-3 leading-5"
>
<div
class="rounded-full p-4 pl-4.5"
style="
background: radial-gradient(
circle,
rgba(0, 0, 0, 0.3) 0%,
rgba(0, 0, 0, 0.4) 50%
);
"
>
<Play />
{{
__('This video contains {0} {1}:').format(
quizzes.length,
quizzes.length == 1 ? 'quiz' : 'quizzes'
)
}}
<div v-for="(quiz, index) in quizzes" class="pl-3 mt-1">
<span> {{ index + 1 }}. {{ quiz.quiz }} </span>
{{ __('at {0}').format(formatTimestamp(quiz.time)) }}
</div>
</div>
<div
class="flex items-center space-x-2 py-2 px-1 text-ink-white bg-gradient-to-b from-transparent to-black/75 absolute bottom-0 left-0 right-0 mx-auto rounded-md"
:class="{
'invisible group-hover:visible': playing,
}"
v-if="!showQuiz"
ref="videoContainer"
class="video-block relative group"
>
<Button variant="ghost">
<template #icon>
<Play
v-if="!playing"
@click="playVideo"
class="size-4 text-ink-gray-9"
/>
<Pause v-else @click="pauseVideo" class="size-5 text-ink-white" />
</template>
</Button>
<Button variant="ghost" @click="toggleMute">
<template #icon>
<Volume2 v-if="!muted" class="size-5 text-ink-white" />
<VolumeX v-else class="size-5 text-ink-white" />
</template>
</Button>
<input
type="range"
min="0"
:max="duration"
step="0.1"
v-model="currentTime"
@input="changeCurrentTime"
class="duration-slider w-full h-1"
/>
<span class="text-sm font-semibold">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</span>
<Button variant="ghost" @click="toggleFullscreen">
<template #icon>
<Maximize class="size-5 text-ink-white" />
</template>
<video
@timeupdate="updateTime"
@ended="videoEnded"
@click="togglePlay"
oncontextmenu="return false"
class="rounded-md border border-gray-100 cursor-pointer"
ref="videoRef"
>
<source :src="fileURL" :type="type" />
</video>
<div
v-if="!playing"
class="absolute inset-0 flex items-center justify-center cursor-pointer"
@click="playVideo"
>
<div
class="rounded-full p-4 pl-4.5"
style="
background: radial-gradient(
circle,
rgba(0, 0, 0, 0.3) 0%,
rgba(0, 0, 0, 0.4) 50%
);
"
>
<Play />
</div>
</div>
<div
class="flex items-center space-x-2 py-2 px-1 text-ink-white bg-gradient-to-b from-transparent to-black/75 absolute bottom-0 left-0 right-0 mx-auto rounded-md"
:class="{
'invisible group-hover:visible': playing,
}"
>
<Button variant="ghost" class="hover:bg-transparent">
<template #icon>
<Play
v-if="!playing"
@click="playVideo"
class="size-4 text-ink-gray-9"
/>
<Pause v-else @click="pauseVideo" class="size-5 text-ink-white" />
</template>
</Button>
<input
type="range"
min="0"
:max="duration"
step="0.1"
v-model="currentTime"
@input="changeCurrentTime"
class="duration-slider w-full h-1"
/>
<span class="text-sm font-medium">
{{ formatSeconds(currentTime) }} / {{ formatSeconds(duration) }}
</span>
<Button
variant="ghost"
@click="toggleMute"
class="hover:bg-transparent"
>
<template #icon>
<Volume2 v-if="!muted" class="size-5 text-ink-white" />
<VolumeX v-else class="size-5 text-ink-white" />
</template>
</Button>
<Button
variant="ghost"
@click="toggleFullscreen"
class="hover:bg-transparent"
>
<template #icon>
<Maximize class="size-5 text-ink-white" />
</template>
</Button>
</div>
</div>
<Quiz
v-if="showQuiz"
:quizName="currentQuiz"
:inVideo="true"
:backToVideo="resumeVideo"
/>
<div v-if="!readOnly" @click="showQuizModal = true">
<Button>
{{ __('Add Quiz to Video') }}
</Button>
</div>
</div>
<QuizInVideo
v-model="showQuizModal"
:quizzes="quizzes"
:saveQuizzes="saveQuizzes"
:duration="duration"
/>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
import { Button } from 'frappe-ui'
import { formatSeconds, formatTimestamp } from '@/utils'
import Play from '@/components/Icons/Play.vue'
import QuizInVideo from '@/components/Modals/QuizInVideo.vue'
const videoRef = ref(null)
const videoContainer = ref(null)
@@ -82,6 +131,10 @@ let playing = ref(false)
let currentTime = ref(0)
let duration = ref(0)
let muted = ref(false)
const showQuizModal = ref(false)
const showQuiz = ref(false)
const currentQuiz = ref(null)
const nextQuiz = ref({})
const props = defineProps({
file: {
@@ -92,34 +145,81 @@ const props = defineProps({
type: String,
default: 'video/mp4',
},
readOnly: {
type: String,
default: true,
},
quizzes: {
type: Array,
default: () => [],
},
saveQuizzes: {
type: Function,
},
})
onMounted(() => {
updateCurrentTime()
updateNextQuiz()
})
const updateCurrentTime = () => {
setTimeout(() => {
videoRef.value.onloadedmetadata = () => {
duration.value = videoRef.value.duration
}
videoRef.value.ontimeupdate = () => {
currentTime.value = videoRef.value.currentTime
currentTime.value = videoRef.value?.currentTime || currentTime.value
if (currentTime.value >= nextQuiz.value.time) {
videoRef.value.pause()
playing.value = false
videoRef.value.onTimeupdate = null
currentQuiz.value = nextQuiz.value.quiz
showQuiz.value = true
}
}
}, 0)
})
}
const resumeVideo = (restart = false) => {
showQuiz.value = false
currentQuiz.value = null
updateCurrentTime()
setTimeout(() => {
videoRef.value.currentTime = restart ? 0 : currentTime.value
videoRef.value.play()
playing.value = true
updateNextQuiz()
}, 0)
}
const updateNextQuiz = () => {
if (!props.quizzes.length) return
props.quizzes.forEach((quiz) => {
if (typeof quiz.time == 'string' && quiz.time.includes(':')) {
let time = quiz.time.split(':')
let timeInSeconds = parseInt(time[0]) * 60 + parseInt(time[1])
quiz.time = timeInSeconds
}
})
props.quizzes.sort((a, b) => a.time - b.time)
const nextQuizIndex = props.quizzes.findIndex(
(quiz) => quiz.time > currentTime.value
)
if (nextQuizIndex !== -1) {
nextQuiz.value = props.quizzes[nextQuizIndex]
} else {
nextQuiz.value = {}
}
}
const fileURL = computed(() => {
if (isYoutube) {
let url = props.file
if (url.includes('watch?v=')) {
url = url.replace('watch?v=', 'embed/')
}
return `${url}?autoplay=0&controls=0&disablekb=1&playsinline=1&cc_load_policy=1&cc_lang_pref=auto`
}
return props.file
})
const isYoutube = computed(() => {
return props.type == 'video/youtube'
})
const playVideo = () => {
videoRef.value.play()
playing.value = true
@@ -149,12 +249,7 @@ const toggleMute = () => {
const changeCurrentTime = () => {
videoRef.value.currentTime = currentTime.value
}
const formatTime = (time) => {
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
updateNextQuiz()
}
const toggleFullscreen = () => {

View File

@@ -21,14 +21,28 @@
</header>
<div class="md:w-3/4 md:mx-auto py-5 mx-5">
<div class="grid grid-cols-3 gap-5 mb-5">
<FormControl v-model="titleFilter" :placeholder="__('Search by title')" />
<FormControl
v-model="typeFilter"
type="select"
:options="assignmentTypes"
:placeholder="__('Type')"
/>
<div class="flex items-center justify-between mb-5">
<div
v-if="assignmentCount"
class="text-xl font-semibold text-ink-gray-7 mb-4"
>
{{ __('{0} Assignments').format(assignmentCount) }}
</div>
<div
v-if="assignments.data?.length || assigmentCount > 0"
class="grid grid-cols-2 gap-5"
>
<FormControl
v-model="titleFilter"
:placeholder="__('Search by title')"
/>
<FormControl
v-model="typeFilter"
type="select"
:options="assignmentTypes"
:placeholder="__('Type')"
/>
</div>
</div>
<ListView
v-if="assignments.data?.length"
@@ -46,22 +60,7 @@
}"
>
</ListView>
<div
v-else
class="text-center p-5 text-ink-gray-5 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
>
<Pencil class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-xl font-medium">
{{ __('No assignments found') }}
</div>
<div class="leading-5">
{{
__(
'You have not created any assignments yet. To create a new assignment, click on the "New" button above.'
)
}}
</div>
</div>
<EmptyState v-else type="Assignments" />
<div
v-if="assignments.data && assignments.hasNextPage"
class="flex justify-center my-5"
@@ -81,16 +80,18 @@
import {
Breadcrumbs,
Button,
call,
createListResource,
FormControl,
ListView,
usePageMeta,
} from 'frappe-ui'
import { computed, inject, onMounted, ref, watch } from 'vue'
import { Plus, Pencil } from 'lucide-vue-next'
import { Plus } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { sessionStore } from '../stores/session'
import AssignmentForm from '@/components/Modals/AssignmentForm.vue'
import EmptyState from '@/components/EmptyState.vue'
const user = inject('$user')
const dayjs = inject('$dayjs')
@@ -98,6 +99,7 @@ const titleFilter = ref('')
const typeFilter = ref('')
const showAssignmentForm = ref(false)
const assignmentID = ref('new')
const assignmentCount = ref(0)
const { brand } = sessionStore()
const router = useRouter()
const readOnlyMode = window.read_only_mode
@@ -106,7 +108,7 @@ onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) {
router.push({ name: 'Courses' })
}
getAssignmentCount()
titleFilter.value = router.currentRoute.value.query.title
typeFilter.value = router.currentRoute.value.query.type
})
@@ -179,6 +181,14 @@ const assignmentColumns = computed(() => {
]
})
const getAssignmentCount = () => {
call('frappe.client.get_count', {
doctype: 'LMS Assignment',
}).then((data) => {
assignmentCount.value = data
})
}
const assignmentTypes = computed(() => {
let types = ['', 'Document', 'Image', 'PDF', 'URL', 'Text']
return types.map((type) => {

View File

@@ -67,10 +67,13 @@
<BatchDashboard :batch="batch" :isStudent="isStudent" />
</div>
<div v-else-if="tab.label == 'Dashboard'">
<BatchStudents :batch="batch.data" />
<BatchStudents :batch="batch" />
</div>
<div v-else-if="tab.label == 'Classes'">
<LiveClass :batch="batch.data.name" />
<LiveClass
:batch="batch.data.name"
:zoomAccount="batch.data.zoom_account"
/>
</div>
<div v-else-if="tab.label == 'Assessments'">
<Assessments :batch="batch.data.name" />
@@ -88,56 +91,61 @@
:scrollToBottom="false"
/>
</div>
<div v-else-if="tab.label == 'Feedback'">
<BatchFeedback :batch="batch.data.name" />
</div>
</div>
</template>
</Tabs>
</div>
<div class="p-5">
<div class="text-ink-gray-7 font-semibold mb-4">
{{ __('About this batch') }}:
</div>
<div
v-html="batch.data.description"
class="leading-5 mb-4 text-ink-gray-7"
></div>
<div class="flex items-center avatar-group overlap mb-5">
<div
class="h-6 mr-1"
:class="{
'avatar-group overlap': batch.data.instructors.length > 1,
}"
>
<UserAvatar
v-for="instructor in batch.data.instructors"
:user="instructor"
/>
<div class="mb-10">
<div class="text-ink-gray-7 font-semibold mb-2">
{{ __('About this batch') }}
</div>
<div
v-html="batch.data.description"
class="leading-5 mb-4 text-ink-gray-7"
></div>
<div class="flex items-center avatar-group overlap mb-5">
<div
class="h-6 mr-1"
:class="{
'avatar-group overlap': batch.data.instructors.length > 1,
}"
>
<UserAvatar
v-for="instructor in batch.data.instructors"
:user="instructor"
/>
</div>
<CourseInstructors :instructors="batch.data.instructors" />
</div>
<DateRange
:startDate="batch.data.start_date"
:endDate="batch.data.end_date"
class="mb-3"
/>
<div class="flex items-center mb-3 text-ink-gray-7">
<Clock class="h-4 w-4 stroke-1.5 mr-2" />
<span>
{{ formatTime(batch.data.start_time) }} -
{{ formatTime(batch.data.end_time) }}
</span>
</div>
<div
v-if="batch.data.timezone"
class="flex items-center mb-3 text-ink-gray-7"
>
<Globe class="h-4 w-4 stroke-1.5 mr-2" />
<span>
{{ batch.data.timezone }}
</span>
</div>
<CourseInstructors :instructors="batch.data.instructors" />
</div>
<DateRange
:startDate="batch.data.start_date"
:endDate="batch.data.end_date"
class="mb-3"
/>
<div class="flex items-center mb-4 text-ink-gray-7">
<Clock class="h-4 w-4 stroke-1.5 mr-2" />
<span>
{{ formatTime(batch.data.start_time) }} -
{{ formatTime(batch.data.end_time) }}
</span>
</div>
<div
v-if="batch.data.timezone"
class="flex items-center mb-4 text-ink-gray-7"
>
<Globe class="h-4 w-4 stroke-1.5 mr-2" />
<span>
{{ batch.data.timezone }}
</span>
<div v-if="dayjs().isSameOrAfter(dayjs(batch.data.start_date))">
<div class="text-ink-gray-7 font-semibold mb-2">
{{ __('Feedback') }}
</div>
<BatchFeedback :batch="batch.data?.name" />
</div>
</div>
<AnnouncementModal
@@ -234,6 +242,7 @@ import Discussions from '@/components/Discussions.vue'
import DateRange from '@/components/Common/DateRange.vue'
import BulkCertificates from '@/components/Modals/BulkCertificates.vue'
import BatchFeedback from '@/components/BatchFeedback.vue'
import dayjs from 'dayjs/esm'
const user = inject('$user')
const showAnnouncementModal = ref(false)
@@ -277,11 +286,6 @@ const tabs = computed(() => {
label: 'Discussions',
icon: MessageCircle,
})
batchTabs.push({
label: 'Feedback',
icon: ClipboardPen,
})
return batchTabs
})
@@ -357,6 +361,9 @@ watch(tabIndex, () => {
const canMakeAnnouncement = () => {
if (readOnlyMode) return false
if (!batch.data?.students?.length) return false
return user.data?.is_moderator || user.data?.is_evaluator
}

View File

@@ -6,67 +6,38 @@
<Breadcrumbs :items="breadcrumbs" />
</header>
<div class="m-5 pb-10">
<div>
<div class="text-3xl font-semibold text-ink-gray-9">
{{ batch.data.title }}
</div>
<div class="my-3 leading-6 text-ink-gray-7">
{{ batch.data.description }}
</div>
<div
class="flex flex-col gap-2 lg:gap-0 lg:flex-row lg:items-center space-x-0 md:space-x-5 lg:w-1/2"
>
<div
v-if="batch.data?.courses?.length"
class="flex items-center text-ink-gray-7"
>
<BookOpen class="h-4 w-4 mr-2 stroke-1.5" />
<span> {{ batch.data?.courses?.length }} {{ __('Courses') }} </span>
<div class="flex justify-between w-full">
<div class="md:w-2/3">
<div class="text-3xl font-semibold text-ink-gray-9">
{{ batch.data.title }}
</div>
<span v-if="batch.data?.courses?.length" class="hidden lg:block"
>&middot;</span
>
<DateRange
:startDate="batch.data.start_date"
:endDate="batch.data.end_date"
/>
<span class="hidden lg:block" v-if="batch.data.start_date"
>&middot;</span
>
<div class="flex items-center text-ink-gray-7">
<Clock class="h-4 w-4 mr-2 stroke-1.5" />
<span>
{{ formatTime(batch.data.start_time) }} -
{{ formatTime(batch.data.end_time) }}
</span>
<div class="my-3 leading-6 text-ink-gray-7">
{{ batch.data.description }}
</div>
</div>
<div class="flex avatar-group overlap mt-3">
<div
class="h-6 mr-1"
:class="{
'avatar-group overlap': batch.data.instructors.length > 1,
}"
>
<UserAvatar
v-for="instructor in batch.data.instructors"
:user="instructor"
/>
<div class="flex avatar-group overlap">
<div
class="h-6 mr-1"
:class="{
'avatar-group overlap': batch.data.instructors.length > 1,
}"
>
<UserAvatar
v-for="instructor in batch.data.instructors"
:user="instructor"
/>
</div>
<CourseInstructors :instructors="batch.data.instructors" />
</div>
<CourseInstructors :instructors="batch.data.instructors" />
</div>
</div>
<div class="grid lg:grid-cols-[60%,20%] gap-4 lg:gap-20 mt-10">
<div class="order-2 lg:order-none">
<div
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-6"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-10"
v-html="batch.data.batch_details"
></div>
</div>
<div class="order-1 lg:order-none">
<div class="hidden md:block">
<BatchOverlay :batch="batch" />
</div>
</div>
<BatchOverlay :batch="batch" class="md:hidden mt-5" />
<div v-if="batch.data.courses.length">
<div class="flex items-center mt-10">
<div class="text-2xl font-semibold">

View File

@@ -8,13 +8,13 @@
{{ __('Save') }}
</Button>
</header>
<div class="w-3/4 mx-auto py-5">
<div class="">
<div class="py-5">
<div class="px-20 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Details') }}
</div>
<div class="space-y-10 mb-4">
<div class="space-y-4">
<div class="grid grid-cols-2 gap-5">
<div class="space-y-5">
<FormControl
v-model="batch.title"
:label="__('Title')"
@@ -23,34 +23,180 @@
/>
<MultiSelect
v-model="instructors"
doctype="User"
doctype="Course Evaluator"
:label="__('Instructors')"
:required="true"
:onCreate="(close) => openSettings('Evaluators', close)"
:filters="{ ignore_user_type: 1 }"
/>
</div>
<FormControl
v-model="batch.description"
:label="__('Short Description')"
type="textarea"
:rows="8"
:placeholder="__('Short description of the batch')"
:required="true"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-10">
<div class="flex flex-col space-y-5">
<FormControl
v-model="batch.published"
type="checkbox"
:label="__('Published')"
/>
<FormControl
v-model="batch.allow_self_enrollment"
type="checkbox"
:label="__('Allow self enrollment')"
/>
<FormControl
v-model="batch.certification"
type="checkbox"
:label="__('Certification')"
/>
</div>
<div class="px-20 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Settings') }}
</div>
<div class="grid grid-cols-3 gap-5">
<FormControl
v-model="batch.published"
type="checkbox"
:label="__('Published')"
/>
<FormControl
v-model="batch.allow_self_enrollment"
type="checkbox"
:label="__('Allow self enrollment')"
/>
<FormControl
v-model="batch.certification"
type="checkbox"
:label="__('Certification')"
/>
</div>
</div>
<div class="px-20 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Date and Time') }}
</div>
<div class="grid grid-cols-3 gap-10">
<div class="space-y-5">
<FormControl
v-model="batch.start_date"
:label="__('Start Date')"
type="date"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batch.end_date"
:label="__('End Date')"
type="date"
class="mb-4"
:required="true"
/>
</div>
<div class="space-y-5">
<FormControl
v-model="batch.start_time"
:label="__('Start Time')"
type="time"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batch.end_time"
:label="__('End Time')"
type="time"
class="mb-4"
:required="true"
/>
</div>
<div class="space-y-5">
<FormControl
v-model="batch.timezone"
:label="__('Timezone')"
type="text"
:placeholder="__('Example: IST (+5:30)')"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batch.evaluation_end_date"
:label="__('Evaluation End Date')"
type="date"
class="mb-4"
/>
</div>
</div>
</div>
<div class="px-20 pb-5 space-y-5 border-b mb-5">
<div>
<label class="block text-sm text-ink-gray-5 mb-1">
{{ __('Batch Details') }}
<span class="text-ink-red-3">*</span>
</label>
<TextEditor
:content="batch.batch_details"
@change="(val) => (batch.batch_details = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[20rem] overflow-y-scroll mb-4"
/>
</div>
</div>
<div class="px-20 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Configurations') }}
</div>
<div class="grid grid-cols-3 gap-10">
<div class="space-y-5">
<FormControl
v-model="batch.seat_count"
:label="__('Seat Count')"
type="number"
class="mb-4"
:placeholder="__('Number of seats available')"
/>
<Link
doctype="Email Template"
:label="__('Email Template')"
v-model="batch.confirmation_email_template"
:onCreate="
(value, close) => {
openSettings('Email Templates', close)
}
"
/>
<Link
doctype="LMS Zoom Settings"
:label="__('Zoom Account')"
v-model="batch.zoom_account"
:onCreate="
(value, close) => {
openSettings('Zoom Accounts', close)
}
"
/>
</div>
<div class="space-y-5">
<FormControl
v-model="batch.medium"
type="select"
:options="[
{
label: 'Online',
value: 'Online',
},
{
label: 'Offline',
value: 'Offline',
},
]"
:label="__('Medium')"
class="mb-4"
/>
<Link
doctype="LMS Category"
:label="__('Category')"
v-model="batch.category"
:onCreate="(value, close) => openSettings('Categories', close)"
/>
</div>
<div class="space-y-5">
<div>
<div class="text-xs text-ink-gray-5 mb-2">
<div class="text-xs text-ink-gray-5">
{{ __('Meta Image') }}
</div>
<FileUploader
@@ -70,11 +216,9 @@
<Button @click="openFileSelector">
{{ __('Upload') }}
</Button>
<div class="mt-2 text-ink-gray-5 text-sm">
<div class="mt-1 text-ink-gray-5 text-sm leading-5">
{{
__(
'Appears when the batch URL is shared on any online platform'
)
__('Appears when the batch URL is shared on socials')
}}
</div>
</div>
@@ -106,119 +250,16 @@
</div>
</div>
<div class="my-10">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Date and Time') }}
</div>
<div class="grid grid-cols-3 gap-10">
<div>
<FormControl
v-model="batch.start_date"
:label="__('Start Date')"
type="date"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batch.end_date"
:label="__('End Date')"
type="date"
class="mb-4"
:required="true"
/>
</div>
<div>
<FormControl
v-model="batch.start_time"
:label="__('Start Time')"
type="time"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batch.end_time"
:label="__('End Time')"
type="time"
class="mb-4"
:required="true"
/>
</div>
<div>
<FormControl
v-model="batch.timezone"
:label="__('Timezone')"
type="text"
:placeholder="__('Example: IST (+5:30)')"
class="mb-4"
:required="true"
/>
</div>
</div>
</div>
<div class="mb-10">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Settings') }}
</div>
<div class="grid grid-cols-3 gap-10">
<div>
<FormControl
v-model="batch.seat_count"
:label="__('Seat Count')"
type="number"
class="mb-4"
:placeholder="__('Number of seats available')"
/>
<FormControl
v-model="batch.evaluation_end_date"
:label="__('Evaluation End Date')"
type="date"
class="mb-4"
/>
</div>
<div>
<FormControl
v-model="batch.medium"
type="select"
:options="[
{
label: 'Online',
value: 'Online',
},
{
label: 'Offline',
value: 'Offline',
},
]"
:label="__('Medium')"
class="mb-4"
/>
<Link
doctype="LMS Category"
:label="__('Category')"
v-model="batch.category"
/>
</div>
<div>
<Link
doctype="Email Template"
:label="__('Email Template')"
v-model="batch.confirmation_email_template"
/>
</div>
</div>
</div>
<div class="">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Payment') }}
<div class="px-20 pb-5 space-y-5">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Pricing') }}
</div>
<FormControl
v-model="batch.paid_batch"
type="checkbox"
:label="__('Paid Batch')"
/>
<div class="grid grid-cols-3 gap-10 mt-4">
<div v-if="batch.paid_batch" class="grid grid-cols-3 gap-5">
<FormControl
v-model="batch.amount"
:label="__('Amount')"
@@ -233,29 +274,23 @@
</div>
</div>
<div class="my-10">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Description') }}
<div class="px-20 pb-5 space-y-5 border-b">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Meta Tags') }}
</div>
<FormControl
v-model="batch.description"
:label="__('Short Description')"
type="textarea"
class="my-4"
:placeholder="__('Short description of the batch')"
:required="true"
/>
<div>
<label class="block text-sm text-ink-gray-5 mb-1">
{{ __('Batch Details') }}
<span class="text-ink-red-3">*</span>
</label>
<TextEditor
:content="batch.batch_details"
@change="(val) => (batch.batch_details = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] mb-4"
<div class="space-y-5">
<FormControl
v-model="meta.description"
:label="__('Meta Description')"
type="textarea"
:rows="7"
/>
<FormControl
v-model="meta.keywords"
:label="__('Meta Keywords')"
type="textarea"
:rows="7"
:placeholder="__('Comma separated keywords for SEO')"
/>
</div>
</div>
@@ -279,20 +314,22 @@ import {
TextEditor,
createResource,
usePageMeta,
toast,
} from 'frappe-ui'
import { useRouter } from 'vue-router'
import { showToast } from '@/utils'
import { Image } from 'lucide-vue-next'
import { capture } from '@/telemetry'
import { useOnboarding } from 'frappe-ui/frappe'
import { sessionStore } from '../stores/session'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
import Link from '@/components/Controls/Link.vue'
import { openSettings, getMetaInfo, updateMetaInfo } from '@/utils'
const router = useRouter()
const user = inject('$user')
const { brand } = sessionStore()
const { updateOnboardingStep } = useOnboarding('learning')
const instructors = ref([])
const props = defineProps({
batchName: {
@@ -322,20 +359,29 @@ const batch = reactive({
paid_batch: false,
currency: '',
amount: 0,
zoom_account: '',
})
const instructors = ref([])
const meta = reactive({
description: '',
keywords: '',
})
onMounted(() => {
if (!user.data) window.location.href = '/login'
if (props.batchName != 'new') {
batchDetail.reload()
fetchBatchInfo()
} else {
capture('batch_form_opened')
}
window.addEventListener('keydown', keyboardShortcut)
})
const fetchBatchInfo = () => {
batchDetail.reload()
getMetaInfo('batches', props.batchName, meta)
}
const keyboardShortcut = (e) => {
if (
e.key === 's' &&
@@ -449,7 +495,7 @@ const createNewBatch = () => {
localStorage.setItem('firstBatch', data.name)
})
}
updateMetaInfo('batches', data.name, meta)
capture('batch_created')
router.push({
name: 'BatchDetail',
@@ -459,7 +505,7 @@ const createNewBatch = () => {
})
},
onError(err) {
showToast('Message', err.messages?.[0] || err, 'alert-circle')
toast.error(err.messages?.[0] || err)
},
}
)
@@ -470,6 +516,7 @@ const editBatchDetails = () => {
{},
{
onSuccess(data) {
updateMetaInfo('batches', data.name, meta)
router.push({
name: 'BatchDetail',
params: {
@@ -478,7 +525,7 @@ const editBatchDetails = () => {
})
},
onError(err) {
showToast('Message', err.messages?.[0] || err, 'alert-circle')
toast.error(err.messages?.[0] || err)
},
}
)

View File

@@ -70,22 +70,8 @@
<BatchCard :batch="batch" />
</router-link>
</div>
<div
v-else-if="!batches.list.loading"
class="flex flex-col items-center justify-center text-sm text-ink-gray-5 mt-48"
>
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-lg font-medium mb-1">
{{ __('No batches found') }}
</div>
<div class="leading-5 w-2/5 text-center">
{{
__(
'There are no batches matching the criteria. Keep an eye out, fresh learning experiences are on the way soon!'
)
}}
</div>
</div>
<EmptyState v-else-if="!batches.list.loading" type="Batches" />
<div
v-if="!batches.list.loading && batches.hasNextPage"
class="flex justify-center mt-5"
@@ -100,6 +86,7 @@
import {
Breadcrumbs,
Button,
call,
createListResource,
FormControl,
Select,
@@ -107,9 +94,10 @@ import {
usePageMeta,
} from 'frappe-ui'
import { computed, inject, onMounted, ref, watch } from 'vue'
import { BookOpen, Plus } from 'lucide-vue-next'
import { Plus } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import BatchCard from '@/components/BatchCard.vue'
import EmptyState from '@/components/EmptyState.vue'
const user = inject('$user')
const dayjs = inject('$dayjs')

View File

@@ -156,9 +156,9 @@ import {
FormControl,
Breadcrumbs,
usePageMeta,
toast,
} from 'frappe-ui'
import { reactive, inject, onMounted, computed } from 'vue'
import { showToast } from '@/utils/'
import { sessionStore } from '../stores/session'
import Link from '@/components/Controls/Link.vue'
import NotPermitted from '@/components/NotPermitted.vue'
@@ -259,7 +259,7 @@ const generatePaymentLink = () => {
window.location.href = data
},
onError(err) {
showToast(__('Error'), err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)
@@ -333,14 +333,7 @@ const validateAddress = () => {
}
const showError = (err) => {
createToast({
title: 'Error',
text: err.messages?.[0] || err,
icon: 'x',
iconClasses: 'bg-surface-red-5 text-ink-white rounded-md p-px',
position: 'top-center',
timeout: 10,
})
toast.error(err.messages?.[0] || err)
}
const changeCurrency = (country) => {

View File

@@ -3,7 +3,7 @@
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<router-link :to="{ name: 'Batches' }">
<router-link :to="{ name: 'Batches', query: { certification: true } }">
<Button>
<template #prefix>
<GraduationCap class="h-4 w-4 stroke-1.5" />
@@ -12,10 +12,7 @@
</Button>
</router-link>
</header>
<div
v-if="participants.data?.length"
class="mx-auto w-full max-w-4xl pt-6 pb-10"
>
<div class="mx-auto w-full max-w-4xl pt-6 pb-10">
<div class="flex flex-col md:flex-row justify-between mb-4 px-3">
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
{{ memberCount }} {{ __('certified members') }}
@@ -41,7 +38,7 @@
</div>
</div>
</div>
<div class="divide-y">
<div v-if="participants.data?.length" class="divide-y">
<template v-for="participant in participants.data">
<router-link
:to="{
@@ -92,6 +89,7 @@
</router-link>
</template>
</div>
<EmptyState v-else type="Certified Members" />
<div
v-if="!participants.list.loading && participants.hasNextPage"
class="flex justify-center mt-5"
@@ -101,22 +99,6 @@
</Button>
</div>
</div>
<div
v-else
class="flex flex-col items-center justify-center text-sm text-ink-gray-5 mt-48"
>
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-lg font-medium mb-1">
{{ __('No certified members') }}
</div>
<div class="leading-5 w-2/5 text-center">
{{
__(
'No certified members found. Please check again later or get certified yourself.'
)
}}
</div>
</div>
</template>
<script setup>
import {
@@ -130,8 +112,9 @@ import {
usePageMeta,
} from 'frappe-ui'
import { computed, inject, onMounted, ref } from 'vue'
import { BookOpen, GraduationCap } from 'lucide-vue-next'
import { GraduationCap } from 'lucide-vue-next'
import { sessionStore } from '../stores/session'
import EmptyState from '@/components/EmptyState.vue'
const currentCategory = ref('')
const filters = ref({})
@@ -141,22 +124,24 @@ const memberCount = ref(0)
const dayjs = inject('$dayjs')
onMounted(() => {
getMemberCount()
updateParticipants()
})
const participants = createListResource({
doctype: 'LMS Certificate',
url: 'lms.lms.api.get_certified_participants',
cache: ['certified_participants'],
start: 0,
pageLength: 30,
pageLength: 100,
})
const count = call('lms.lms.api.get_count_of_certified_members').then(
(data) => {
const getMemberCount = () => {
call('lms.lms.api.get_count_of_certified_members', {
filters: filters.value,
}).then((data) => {
memberCount.value = data
}
)
})
}
const categories = createListResource({
doctype: 'LMS Certificate',
@@ -171,6 +156,7 @@ const categories = createListResource({
const updateParticipants = () => {
updateFilters()
getMemberCount()
participants.update({
filters: filters.value,
})

View File

@@ -20,7 +20,7 @@
:text="__('Average Rating')"
class="flex items-center"
>
<Star class="h-5 w-5 text-gray-100 fill-orange-500" />
<Star class="size-4 text-transparent fill-yellow-500" />
<span class="ml-1 text-ink-gray-7">
{{ course.data.rating }}
</span>

View File

@@ -19,62 +19,112 @@
</Button>
</div>
</header>
<div class="mt-5 mb-10">
<div class="container mb-5">
<div class="mt-5 mb-5">
<div class="px-10 pb-5 mb-5 space-y-5 border-b">
<div class="text-lg font-semibold mb-4">
{{ __('Details') }}
</div>
<FormControl
v-model="course.title"
:label="__('Title')"
class="mb-4"
:required="true"
/>
<FormControl
v-model="course.short_introduction"
:label="__('Short Introduction')"
:placeholder="
__(
'A one line introduction to the course that appears on the course card'
)
"
class="mb-4"
:required="true"
/>
<div class="mb-4">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Course Description') }}
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:content="course.description"
@change="(val) => (course.description = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
<div class="grid grid-cols-2 gap-5">
<FormControl
v-model="course.title"
:label="__('Title')"
:required="true"
/>
<Link
doctype="LMS Category"
v-model="course.category"
:label="__('Category')"
:onCreate="(value, close) => openSettings('Categories', close)"
/>
</div>
<div class="mb-4">
<div class="text-xs text-ink-gray-5 mb-2">
{{ __('Course Image') }}
<span class="text-ink-red-3">*</span>
<div class="grid grid-cols-2 gap-5">
<MultiSelect
v-model="instructors"
doctype="User"
:label="__('Instructors')"
:filters="{ ignore_user_type: 1 }"
:onCreate="(close) => openSettings('Members', close)"
:required="true"
/>
<div>
<div class="mb-1.5 text-xs text-ink-gray-5">
{{ __('Tags') }}
</div>
<div class="flex items-center">
<div
v-if="course.tags"
v-for="tag in course.tags?.split(', ')"
class="flex items-center bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md mr-2"
>
{{ tag }}
<X
class="stroke-1.5 w-3 h-3 ml-2 cursor-pointer"
@click="removeTag(tag)"
/>
</div>
<FormControl
v-model="newTag"
:placeholder="__('Add a keyword and then press enter')"
class="w-full"
@keyup.enter="updateTags()"
id="tags"
/>
</div>
</div>
<FileUploader
v-if="!course.course_image"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<template
v-slot="{ file, progress, uploading, openFileSelector }"
</div>
<div class="grid grid-cols-2 gap-5">
<FormControl
v-model="course.short_introduction"
type="textarea"
:rows="5"
:label="__('Short Introduction')"
:placeholder="
__(
'A one line introduction to the course that appears on the course card'
)
"
:required="true"
/>
<div class="mb-4">
<div class="text-xs text-ink-gray-5 mb-2">
{{ __('Course Image') }}
<span class="text-ink-red-3">*</span>
</div>
<FileUploader
v-if="!course.course_image"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<div class="flex items-center">
<div class="border rounded-md w-fit py-5 px-20">
<Image class="size-5 stroke-1 text-ink-gray-7" />
<template
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="flex items-center">
<div class="border rounded-md w-fit py-5 px-20">
<Image class="size-5 stroke-1 text-ink-gray-7" />
</div>
<div class="ml-4">
<Button @click="openFileSelector">
{{ __('Upload') }}
</Button>
<div class="mt-1 text-ink-gray-5 text-sm leading-5">
{{
__('Appears on the course card in the course list')
}}
</div>
</div>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="flex items-center">
<img
:src="course.course_image.file_url"
class="border rounded-md w-40"
/>
<div class="ml-4">
<Button @click="openFileSelector">
{{ __('Upload') }}
<Button @click="removeImage()">
{{ __('Remove') }}
</Button>
<div class="mt-2 text-ink-gray-5 text-sm">
{{
@@ -83,85 +133,17 @@
</div>
</div>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="flex items-center">
<img
:src="course.course_image.file_url"
class="border rounded-md w-40"
/>
<div class="ml-4">
<Button @click="removeImage()">
{{ __('Remove') }}
</Button>
<div class="mt-2 text-ink-gray-5 text-sm">
{{ __('Appears on the course card in the course list') }}
</div>
</div>
</div>
</div>
</div>
<FormControl
v-model="course.video_link"
:label="__('Preview Video')"
:placeholder="
__(
'Paste the youtube link of a short video introducing the course'
)
"
class="mb-4"
/>
<div class="mb-4">
<div class="mb-1.5 text-xs text-ink-gray-5">
{{ __('Tags') }}
</div>
<div class="flex items-center">
<div
v-if="course.tags"
v-for="tag in course.tags?.split(', ')"
class="flex items-center bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md mr-2"
>
{{ tag }}
<X
class="stroke-1.5 w-3 h-3 ml-2 cursor-pointer"
@click="removeTag(tag)"
/>
</div>
<FormControl
v-model="newTag"
:placeholder="__('Add a keyword and then press enter')"
class="w-72"
@keyup.enter="updateTags()"
id="tags"
/>
</div>
</div>
<div class="w-1/2 mb-4">
<Link
doctype="LMS Category"
v-model="course.category"
:label="__('Category')"
:onCreate="(value, close) => openSettings(close)"
/>
</div>
<MultiSelect
v-model="instructors"
doctype="User"
:label="__('Instructors')"
:filters="{ ignore_user_type: 1 }"
:required="true"
/>
</div>
<div class="container border-t">
<div class="text-lg font-semibold mt-5 mb-4">
<div class="px-10 pb-5 mb-5 space-y-5 border-b">
<div class="text-lg font-semibold">
{{ __('Settings') }}
</div>
<div class="grid grid-cols-2 gap-10 mb-4">
<div
v-if="user.data?.is_moderator"
class="flex flex-col space-y-4"
>
<div class="grid grid-cols-2 gap-5">
<div class="flex flex-col space-y-5">
<FormControl
type="checkbox"
v-model="course.published"
@@ -171,10 +153,9 @@
v-model="course.published_on"
:label="__('Published On')"
type="date"
class="mb-5"
/>
</div>
<div class="flex flex-col space-y-3">
<div class="flex flex-col space-y-5">
<FormControl
type="checkbox"
v-model="course.upcoming"
@@ -193,7 +174,34 @@
</div>
</div>
</div>
<div class="container border-t space-y-4">
<div class="px-10 pb-5 mb-5 space-y-5 border-b">
<div class="">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Course Description') }}
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:content="course.description"
@change="(val) => (course.description = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
<FormControl
v-model="course.video_link"
:label="__('Preview Video')"
:placeholder="
__(
'Paste the youtube link of a short video introducing the course'
)
"
/>
</div>
<div class="px-10 pb-5 space-y-5 border-b">
<div class="text-lg font-semibold mt-5">
{{ __('Pricing and Certification') }}
</div>
@@ -214,19 +222,52 @@
:label="__('Paid Certificate')"
/>
</div>
<FormControl v-model="course.course_price" :label="__('Amount')" />
<Link
doctype="Currency"
v-model="course.currency"
:filters="{ enabled: 1 }"
:label="__('Currency')"
/>
<Link
v-if="course.paid_certificate"
doctype="Course Evaluator"
v-model="course.evaluator"
:label="__('Evaluator')"
/>
<div class="grid grid-cols-2 gap-5">
<div class="space-y-5">
<FormControl
v-if="course.paid_course || course.paid_certificate"
v-model="course.course_price"
:label="__('Amount')"
/>
<Link
v-if="course.paid_certificate"
doctype="Course Evaluator"
v-model="course.evaluator"
:label="__('Evaluator')"
:onCreate="
(value, close) => openSettings('Evaluators', close)
"
/>
</div>
<Link
v-if="course.paid_course || course.paid_certificate"
doctype="Currency"
v-model="course.currency"
:filters="{ enabled: 1 }"
:label="__('Currency')"
/>
</div>
</div>
<div class="px-10 pb-5 space-y-5">
<div class="text-lg font-semibold mt-5">
{{ __('Meta Tags') }}
</div>
<div class="space-y-5">
<FormControl
v-model="meta.description"
:label="__('Meta Description')"
type="textarea"
:rows="7"
/>
<FormControl
v-model="meta.keywords"
:label="__('Meta Keywords')"
type="textarea"
:rows="7"
:placeholder="__('Comma separated keywords for SEO')"
/>
</div>
</div>
</div>
</div>
@@ -244,12 +285,14 @@
<script setup>
import {
Breadcrumbs,
call,
TextEditor,
Button,
createResource,
FormControl,
FileUploader,
usePageMeta,
toast,
} from 'frappe-ui'
import {
inject,
@@ -261,13 +304,12 @@ import {
watch,
getCurrentInstance,
} from 'vue'
import { showToast } from '@/utils'
import { Image, Trash2, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { capture } from '@/telemetry'
import { capture, startRecording, stopRecording } from '@/telemetry'
import { useOnboarding } from 'frappe-ui/frappe'
import { sessionStore } from '../stores/session'
import { useSettings } from '@/stores/settings'
import { openSettings, getMetaInfo, updateMetaInfo } from '@/utils'
import Link from '@/components/Controls/Link.vue'
import CourseOutline from '@/components/CourseOutline.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
@@ -277,7 +319,6 @@ const newTag = ref('')
const { brand } = sessionStore()
const router = useRouter()
const instructors = ref([])
const settingsStore = useSettings()
const app = getCurrentInstance()
const { updateOnboardingStep } = useOnboarding('learning')
const { $dialog } = app.appContext.config.globalProperties
@@ -309,19 +350,30 @@ const course = reactive({
evaluator: '',
})
const meta = reactive({
description: '',
keywords: '',
})
onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) {
router.push({ name: 'Courses' })
}
if (props.courseName !== 'new') {
courseResource.reload()
fetchCourseInfo()
} else {
capture('course_form_opened')
startRecording()
}
window.addEventListener('keydown', keyboardShortcut)
})
const fetchCourseInfo = () => {
courseResource.reload()
getMetaInfo('courses', props.courseName, meta)
}
const keyboardShortcut = (e) => {
if (
e.key === 's' &&
@@ -335,6 +387,7 @@ const keyboardShortcut = (e) => {
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
stopRecording()
})
const courseCreationResource = createResource({
@@ -423,40 +476,50 @@ const imageResource = createResource({
const submitCourse = () => {
if (courseResource.data) {
courseEditResource.submit(
{
course: courseResource.data.name,
},
{
onSuccess() {
showToast('Success', 'Course updated successfully', 'check')
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
},
}
)
editCourse()
} else {
courseCreationResource.submit(course, {
onSuccess(data) {
if (user.data?.is_system_manager) {
updateOnboardingStep('create_first_course', true, false, () => {
localStorage.setItem('firstCourse', data.name)
})
}
createCourse()
}
}
capture('course_created')
showToast('Success', 'Course created successfully', 'check')
router.push({
name: 'CourseForm',
params: { courseName: data.name },
const createCourse = () => {
courseCreationResource.submit(course, {
onSuccess(data) {
updateMetaInfo('courses', data.name, meta)
if (user.data?.is_system_manager) {
updateOnboardingStep('create_first_course', true, false, () => {
localStorage.setItem('firstCourse', data.name)
})
}
capture('course_created')
toast.success(__('Course created successfully'))
router.push({
name: 'CourseForm',
params: { courseName: data.name },
})
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
})
}
const editCourse = () => {
courseEditResource.submit(
{
course: courseResource.data.name,
},
{
onSuccess() {
updateMetaInfo('courses', props.courseName, meta)
toast.success(__('Course updated successfully'))
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
})
}
}
)
}
const deleteCourse = createResource({
@@ -467,7 +530,7 @@ const deleteCourse = createResource({
}
},
onSuccess() {
showToast(__('Success'), __('Course deleted successfully'), 'check')
toast.success(__('Course deleted successfully'))
router.push({ name: 'Courses' })
},
})
@@ -496,7 +559,7 @@ watch(
() => props.courseName !== 'new',
(newVal) => {
if (newVal) {
courseResource.reload()
fetchCourseInfo()
}
}
)
@@ -531,12 +594,6 @@ 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

View File

@@ -66,22 +66,7 @@
<CourseCard :course="course" />
</router-link>
</div>
<div
v-else-if="!courses.list.loading"
class="flex flex-col items-center justify-center text-sm text-ink-gray-5 italic mt-48"
>
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-lg font-medium mb-1">
{{ __('No courses found') }}
</div>
<div class="leading-5 w-2/5 text-center">
{{
__(
'There are no courses matching the criteria. Keep an eye out, fresh learning experiences are on the way soon!'
)
}}
</div>
</div>
<EmptyState v-else-if="!courses.list.loading" type="Courses" />
<div
v-if="!courses.list.loading && courses.hasNextPage"
class="flex justify-center mt-5"
@@ -104,10 +89,11 @@ import {
usePageMeta,
} from 'frappe-ui'
import { computed, inject, onMounted, ref, watch } from 'vue'
import { BookOpen, Plus } from 'lucide-vue-next'
import { Plus } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import { canCreateCourse } from '@/utils'
import CourseCard from '@/components/CourseCard.vue'
import EmptyState from '@/components/EmptyState.vue'
import router from '../router'
const user = inject('$user')
@@ -121,12 +107,12 @@ const certification = ref(false)
const filters = ref({})
const currentTab = ref('Live')
const { brand } = sessionStore()
const readOnlyMode = window.read_only_mode
const courseCount = ref(0)
onMounted(() => {
identifyUserPersona()
setFiltersFromQuery()
updateCourses()
getCourseCount()
categories.value = [
{
label: '',
@@ -175,19 +161,25 @@ const identifyUserPersona = async () => {
if (user.data?.is_system_manager && !user.data?.developer_mode) {
let personaCaptured = await isPersonaCaptured()
if (personaCaptured) return
call('frappe.client.get_count', {
doctype: 'LMS Course',
}).then((data) => {
if (!data) {
router.push({
name: 'PersonaForm',
})
}
})
if (!courseCount.value) {
router.push({
name: 'PersonaForm',
})
}
}
}
const getCourseCount = () => {
if (!user.data) return
call('frappe.client.get_count', {
doctype: 'LMS Course',
}).then((data) => {
courseCount.value = data
identifyUserPersona()
})
}
const updateCourses = () => {
updateFilters()
courses.update({
@@ -248,7 +240,6 @@ const updateTabFilter = () => {
filters.value['live'] = 1
} else if (currentTab.value == 'Upcoming') {
filters.value['upcoming'] = 1
filters.value['published'] = 1
} else if (currentTab.value == 'New') {
filters.value['published'] = 1
filters.value['published_on'] = [
@@ -257,6 +248,8 @@ const updateTabFilter = () => {
]
} else if (currentTab.value == 'Created') {
filters.value['created'] = 1
} else if (currentTab.value == 'Unpublished') {
filters.value['published'] = 0
}
}
}
@@ -326,6 +319,7 @@ const courseTabs = computed(() => {
user.data?.is_evaluator
) {
tabs.push({ label: __('Created') })
tabs.push({ label: __('Unpublished') })
} else if (user.data) {
tabs.push({ label: __('Enrolled') })
}

View File

@@ -67,86 +67,61 @@
</header>
<div v-if="job.data" class="max-w-3xl mx-auto pt-5">
<div class="p-4">
<div class="space-y-5 mb-10">
<div class="flex items-center">
<div class="space-y-5 mb-12">
<div class="flex">
<img
:src="job.data.company_logo"
class="size-10 rounded-lg object-contain cursor-pointer mr-4"
:alt="job.data.company_name"
@click="redirectToWebsite(job.data.company_website)"
/>
<div class="text-2xl text-ink-gray-9 font-semibold">
{{ job.data.job_title }}
<div class="">
<div class="text-2xl text-ink-gray-9 font-semibold mb-1">
{{ job.data.job_title }}
</div>
<div class="text-sm text-ink-gray-5 font-semibold">
{{ job.data.company_name }} - {{ job.data.location }},
{{ job.data.country }}
</div>
</div>
</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-4">
<Building2 class="size-4 stroke-1.5 text-ink-gray-7" />
<div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs text-ink-gray-5 font-medium uppercase">
{{ __('Organisation') }}
</span>
<span class="text-sm font-semibold">
{{ job.data.company_name }}
</span>
</div>
</div>
<div class="flex items-center space-x-4">
<MapPin class="size-4 stroke-1.5 text-ink-gray-7" />
<div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs text-ink-gray-5 font-medium uppercase">
{{ __('Location') }}
</span>
<span class="text-sm font-semibold">
{{ job.data.location }}, {{ job.data.country }}
</span>
</div>
</div>
<div class="flex items-center space-x-4">
<ClipboardType class="size-4 stroke-1.5 text-ink-gray-7" />
<div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs text-ink-gray-5 font-medium uppercase">
{{ __('Category') }}
</span>
<span class="text-sm font-semibold">
{{ job.data.type }}
</span>
</div>
</div>
<div class="flex items-center space-x-4">
<CalendarDays class="size-4 stroke-1.5 text-ink-gray-7" />
<div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs text-ink-gray-5 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-4"
>
<SquareUserRound class="size-4 stroke-1.5 text-ink-gray-7" />
<div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs text-ink-gray-5 font-medium uppercase">
{{ __('Applications Received') }}
</span>
<span class="text-sm font-semibold">
{{ applicationCount.data }}
</span>
</div>
</div>
</div>
<div class="space-x-5">
<Badge size="lg">
<template #prefix>
<CalendarDays class="size-3 stroke-2 text-ink-gray-7" />
</template>
{{ dayjs(job.data.creation).fromNow() }}
</Badge>
<Badge size="lg">
<template #prefix>
<ClipboardType class="size-3 stroke-2 text-ink-gray-7" />
</template>
{{ job.data.type }}
</Badge>
<Badge v-if="applicationCount.data" size="lg">
<template #prefix>
<SquareUserRound class="size-3 stroke-2 text-ink-gray-7" />
</template>
{{ applicationCount.data }}
{{
applicationCount.data == 1 ? __('applicant') : __('applicants')
}}
</Badge>
</div>
</div>
<div class="flex items-center justify-between">
<div class="bg-surface-gray-2 h-px m-1 w-1/2"></div>
<div>
<FileText class="size-3 stroke-1 text-ink-gray-5" />
</div>
<div class="bg-surface-gray-2 h-px m-1 w-1/2"></div>
</div>
<p
v-html="job.data.description"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-6"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-12"
></p>
</div>
<JobApplicationModal
@@ -169,15 +144,14 @@ import { inject, ref } from 'vue'
import { sessionStore } from '../stores/session'
import JobApplicationModal from '@/components/Modals/JobApplicationModal.vue'
import {
MapPin,
Check,
SendHorizonal,
Pencil,
Building2,
CalendarDays,
ClipboardType,
SquareUserRound,
SquareArrowOutUpRight,
FileText,
ClipboardType,
} from 'lucide-vue-next'
const user = inject('$user')
@@ -252,3 +226,12 @@ usePageMeta(() => {
}
})
</script>
<style>
p {
margin-bottom: 0.5rem !important;
line-height: 1.5;
}
p span {
line-height: 1.5;
}
</style>

View File

@@ -9,7 +9,7 @@
</Button>
</header>
<div class="py-5">
<div class="container border-b mb-4 pb-4">
<div class="container border-b mb-4 pb-5">
<div class="text-lg font-semibold mb-4">
{{ __('Job Details') }}
</div>
@@ -20,6 +20,15 @@
:label="__('Title')"
:required="true"
/>
<FormControl
v-model="job.type"
:label="__('Type')"
type="select"
:options="jobTypes"
:required="true"
/>
</div>
<div class="space-y-4">
<FormControl
v-model="job.location"
:label="__('City')"
@@ -31,17 +40,8 @@
:label="__('Country')"
:required="true"
/>
</div>
<div>
<FormControl
v-model="job.type"
:label="__('Type')"
type="select"
:options="jobTypes"
class="mb-4"
:required="true"
/>
<FormControl
v-if="jobName != 'new'"
v-model="job.status"
:label="__('Status')"
type="select"
@@ -51,7 +51,7 @@
</div>
</div>
</div>
<div class="container border-b mb-4 pb-4">
<div class="container border-b mb-4 pb-5">
<div class="text-lg font-semibold mb-4">
{{ __('Company Details') }}
</div>
@@ -145,12 +145,13 @@ import {
TextEditor,
FileUploader,
usePageMeta,
toast,
} from 'frappe-ui'
import { computed, onMounted, reactive, inject } from 'vue'
import { FileText, X } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import { useRouter } from 'vue-router'
import { getFileSize, showToast } from '../utils'
import { getFileSize } from '@/utils'
const user = inject('$user')
const router = useRouter()
@@ -259,7 +260,7 @@ const createNewJob = () => {
})
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)
@@ -278,7 +279,7 @@ const editJobDetails = () => {
})
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)

View File

@@ -28,13 +28,14 @@
<div
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between w-full md:w-4/5 mx-auto p-5"
>
<div
v-if="jobCount"
class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0"
>
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
{{ __('{0} Open Jobs').format(jobCount) }}
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
<div
class="grid grid-cols-1 gap-2"
:class="user.data ? 'md:grid-cols-3' : 'md:grid-cols-2'"
>
<FormControl
type="text"
:placeholder="__('Search')"
@@ -50,6 +51,7 @@
</template>
</FormControl>
<Link
v-if="user.data"
doctype="Country"
v-model="country"
:placeholder="__('Country')"
@@ -79,21 +81,7 @@
</router-link>
</div>
</div>
<div
v-else
class="flex flex-col items-center justify-center text-sm text-ink-gray-5 mt-56"
>
<Laptop class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-lg font-medium mb-1">
{{ __('No jobs found') }}
</div>
<div class="leading-5 w-2/5 text-center">
{{ __('There are no jobs available at the moment.') }}
</div>
<div class="leading-5 w-1/5 text-center">
{{ __('Post a new job or check again later.') }}
</div>
</div>
<EmptyState v-else type="Job Openings" />
</div>
</div>
</template>
@@ -106,11 +94,12 @@ import {
FormControl,
usePageMeta,
} from 'frappe-ui'
import { Laptop, Plus, Search } from 'lucide-vue-next'
import { Plus, Search } from 'lucide-vue-next'
import { sessionStore } from '../stores/session'
import { inject, computed, ref, onMounted, watch } from 'vue'
import JobCard from '@/components/JobCard.vue'
import Link from '@/components/Controls/Link.vue'
import EmptyState from '@/components/EmptyState.vue'
const user = inject('$user')
const jobType = ref(null)
@@ -128,12 +117,14 @@ onMounted(() => {
jobType.value = queries.get('type')
}
updateJobs()
getJobCount()
})
const jobs = createResource({
url: 'lms.lms.api.get_job_opportunities',
cache: ['jobs'],
onSuccess(data) {
jobCount.value = data.length
},
})
const updateJobs = () => {
@@ -174,18 +165,6 @@ const updateFilters = () => {
}
}
const getJobCount = () => {
call('frappe.client.get_count', {
doctype: 'Job Opportunity',
filters: {
status: 'Open',
disabled: 0,
},
}).then((data) => {
jobCount.value = data
})
}
watch(country, (val) => {
updateJobs()
})

View File

@@ -303,6 +303,7 @@ import ProgressBar from '@/components/ProgressBar.vue'
import CertificationLinks from '@/components/CertificationLinks.vue'
const user = inject('$user')
const socket = inject('$socket')
const router = useRouter()
const route = useRoute()
const allowDiscussions = ref(false)
@@ -334,8 +335,12 @@ const props = defineProps({
onMounted(() => {
startTimer()
enablePlyr()
document.addEventListener('fullscreenchange', attachFullscreenEvent)
socket.on('update_lesson_progress', (data) => {
if (data.course === props.courseName) {
lessonProgress.value = data.progress
}
})
})
const attachFullscreenEvent = () => {
@@ -473,6 +478,7 @@ watch(
() => lesson.data,
(data) => {
setupLesson(data)
enablePlyr()
}
)

View File

@@ -84,6 +84,7 @@ import {
createResource,
FormControl,
usePageMeta,
toast,
} from 'frappe-ui'
import {
computed,
@@ -97,8 +98,8 @@ import { sessionStore } from '../stores/session'
import EditorJS from '@editorjs/editorjs'
import LessonHelp from '@/components/LessonHelp.vue'
import { ChevronRight } from 'lucide-vue-next'
import { createToast, getEditorTools, enablePlyr } from '@/utils'
import { capture } from '@/telemetry'
import { getEditorTools, enablePlyr } from '@/utils'
import { capture, startRecording, stopRecording } from '@/telemetry'
import { useOnboarding } from 'frappe-ui/frappe'
const { brand } = sessionStore()
@@ -130,6 +131,7 @@ onMounted(() => {
window.location.href = '/login'
}
capture('lesson_form_opened')
startRecording()
editor.value = renderEditor('content')
instructorEditor.value = renderEditor('instructor-notes')
window.addEventListener('keydown', keyboardShortcut)
@@ -208,7 +210,7 @@ const addInstructorNotes = (data) => {
const enableAutoSave = () => {
autoSaveInterval = setInterval(() => {
saveLesson({ showSuccessMessage: false })
}, 10000)
}, 5000)
}
const keyboardShortcut = (e) => {
@@ -225,6 +227,7 @@ const keyboardShortcut = (e) => {
onBeforeUnmount(() => {
clearInterval(autoSaveInterval)
window.removeEventListener('keydown', keyboardShortcut)
stopRecording()
})
const newLessonResource = createResource({
@@ -410,14 +413,14 @@ const createNewLesson = () => {
updateOnboardingStep('create_first_lesson')
capture('lesson_created')
showToast('Success', 'Lesson created successfully', 'check')
toast.success(__('Lesson created successfully'))
lessonDetails.reload()
},
}
)
},
onError(err) {
showToast('Error', err.message, 'x')
toast.error(err.messages?.[0] || err)
},
}
)
@@ -434,11 +437,11 @@ const editCurrentLesson = () => {
},
onSuccess() {
showSuccessMessage
? showToast('Success', 'Lesson updated successfully', 'check')
? toast.success(__('Lesson updated successfully'))
: ''
},
onError(err) {
showToast('Error', err.message, 'x')
toast.error(err.message)
},
}
)
@@ -453,20 +456,6 @@ const validateLesson = () => {
}
}
const showToast = (title, text, icon) => {
createToast({
title: title,
text: text,
icon: icon,
iconClasses:
icon == 'check'
? 'bg-surface-green-3 text-ink-white rounded-md p-px'
: 'bg-surface-red-5 text-ink-white rounded-md p-px',
position: icon == 'check' ? 'bottom-right' : 'top-center',
timeout: icon == 'check' ? 5 : 10,
})
}
const breadcrumbs = computed(() => {
let crumbs = [
{

View File

@@ -22,6 +22,7 @@
<div
v-if="notifications?.length"
v-for="log in notifications"
:key="log.name"
class="flex items-center py-2 justify-between"
>
<div class="flex items-center">
@@ -32,22 +33,20 @@
<Link
v-if="log.link"
:to="log.link"
@click="markAsRead.submit({ name: log.name })"
@click="(e) => handleMarkAsRead(e, log.name)"
class="text-ink-gray-5 font-medium text-sm hover:text-ink-gray-7"
>
{{ __('View') }}
</Link>
<Tooltip :text="__('Mark as read')">
<Button
variant="ghost"
v-if="!log.read"
@click="markAsRead.submit({ name: log.name })"
>
<template #icon>
<X class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
</template>
</Button>
</Tooltip>
<Button
variant="ghost"
v-if="!log.read"
@click.stop="(e) => handleMarkAsRead(e, log.name)"
>
<template #icon>
<X class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
</template>
</Button>
</div>
</div>
<div v-else class="text-ink-gray-5">
@@ -64,11 +63,10 @@ import {
Link,
TabButtons,
Button,
Tooltip,
usePageMeta,
} from 'frappe-ui'
import { sessionStore } from '../stores/session'
import { computed, inject, ref, onMounted } from 'vue'
import { computed, inject, ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { X } from 'lucide-vue-next'
@@ -135,6 +133,14 @@ const markAllAsRead = createResource({
},
})
const handleMarkAsRead = (e, logName) => {
markAsRead.submit({ name: logName })
}
onUnmounted(() => {
socket.off('publish_lms_notifications')
})
const breadcrumbs = computed(() => {
let crumbs = [
{

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex h-screen overflow-hidden sm:bg-gray-50">
<div class="relative h-full z-10 mx-auto pt-8 sm:w-max sm:pt-32">
<div class="relative h-full z-10 mx-auto sm:w-max pt-40">
<div class="mx-auto flex items-center justify-center space-x-2">
<LMSLogo class="size-7" />
<span
@@ -18,7 +18,7 @@
<div class="mb-5">
<div class="text-sm text-gray-700 mb-2">
{{ __('What is your main use case for Frappe Learning?') }}
{{ __('What is your use case for Frappe Learning?') }}
</div>
<FormControl
v-model="persona.useCase"
@@ -29,12 +29,12 @@
<div class="mb-5">
<div class="text-sm text-gray-700 mb-2">
{{ __('How many students are you planning to teach?') }}
{{ __('What best describes your role?') }}
</div>
<FormControl
v-model="persona.noOfStudents"
v-model="persona.role"
type="select"
:options="noOfStudentsOptions"
:options="roleOptions"
/>
</div>
@@ -65,7 +65,7 @@ const router = useRouter()
const { brand } = sessionStore()
const persona = reactive({
noOfStudents: null,
role: null,
useCase: null,
})
@@ -97,6 +97,24 @@ const skipPersonaForm = () => {
})
}
const roleOptions = computed(() => {
const options = [
'Trainer / Instructor',
'Freelancer / Consultant',
'HR / L&D Professional',
'School / University Admin',
'Software Developer',
'Community Manager',
'Business Owner / Team Lead',
'Other',
]
return options.map((option) => ({
label: option,
value: option,
}))
})
const noOfStudentsOptions = computed(() => {
const options = [
'Less than 50',

View File

@@ -58,6 +58,7 @@ const evaluations = createListResource({
doctype: 'LMS Certificate Request',
filters: {
evaluator: user.data?.name,
status: ['!=', 'Cancelled'],
},
fields: [
'name',

View File

@@ -141,9 +141,9 @@
</div>
</template>
<script setup>
import { createResource, FormControl, Button, Badge } from 'frappe-ui'
import { createResource, FormControl, Button, Badge, toast } from 'frappe-ui'
import { computed, reactive, ref, onMounted, inject } from 'vue'
import { showToast, convertToTitleCase } from '@/utils'
import { convertToTitleCase } from '@/utils'
import { Plus, X, Check, CircleAlert } from 'lucide-vue-next'
const user = inject('$user')
@@ -198,7 +198,7 @@ const createSlot = createResource({
}
},
onSuccess() {
showToast('Success', 'Slot added successfully', 'check')
toast.success(__('Slot added successfully'))
evaluator.reload()
showSlotsTemplate.value = 0
newSlot.day = ''
@@ -206,7 +206,7 @@ const createSlot = createResource({
newSlot.end_time = ''
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
})
@@ -221,10 +221,10 @@ const updateSlot = createResource({
}
},
onSuccess() {
showToast('Success', 'Availability updated successfully', 'check')
toast.success(__('Availability updated successfully'))
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
})
@@ -237,11 +237,11 @@ const deleteSlot = createResource({
}
},
onSuccess() {
showToast('Success', 'Slot deleted successfully', 'check')
toast.success(__('Slot deleted successfully'))
evaluator.reload()
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
})
@@ -256,10 +256,10 @@ const updateUnavailability = createResource({
}
},
onSuccess() {
showToast('Success', 'Unavailability updated successfully', 'check')
toast.success(__('Unavailability updated successfully'))
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
})

View File

@@ -44,9 +44,9 @@
</div>
</template>
<script setup>
import { FormControl, createResource } from 'frappe-ui'
import { FormControl, createResource, toast } from 'frappe-ui'
import { ref } from 'vue'
import { showToast, convertToTitleCase } from '@/utils'
import { convertToTitleCase } from '@/utils'
import { CircleAlert } from 'lucide-vue-next'
const moderator = ref(false)
@@ -102,7 +102,7 @@ const changeRole = (role) => {
},
{
onSuccess(data) {
showToast('Success', 'Role updated successfully', 'check')
toast.success(__('Role updated successfully'))
},
}
)

View File

@@ -168,6 +168,7 @@
ignore_user_type: 1,
}"
:label="__('Program Member')"
:onCreate="(value, close) => openSettings('Members', close)"
/>
</template>
</Dialog>
@@ -187,12 +188,13 @@ import {
ListHeaderItem,
ListSelectBanner,
usePageMeta,
toast,
} from 'frappe-ui'
import { computed, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import { showToast } from '@/utils/'
import { useRouter } from 'vue-router'
import { sessionStore } from '../stores/session'
import { sessionStore } from '@/stores/session'
import { openSettings } from '@/utils'
import Draggable from 'vuedraggable'
import Link from '@/components/Controls/Link.vue'
@@ -229,11 +231,11 @@ const addProgramCourse = () => {
onSuccess(data) {
showDialog.value = false
course.value = null
showToast(__('Success'), __('Course added to program'), 'check')
toast.success(__('Course added to program'))
program.reload()
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)
@@ -251,11 +253,11 @@ const addProgramMember = () => {
onSuccess(data) {
showDialog.value = false
member.value = null
showToast(__('Success'), __('Member added to program'), 'check')
toast.success(__('Member added to program'))
program.reload()
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)
@@ -272,11 +274,11 @@ const remove = (selections, unselectAll, doctype) => {
{
onSuccess(data) {
unselectAll()
showToast(__('Success'), __('Items removed successfully'), 'check')
toast.success(__('Items removed successfully'))
program.reload()
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)
@@ -298,11 +300,11 @@ const updateOrder = (e) => {
},
{
onSuccess(data) {
showToast(__('Success'), __('Course moved successfully'), 'check')
toast.success(__('Course moved successfully'))
program.reload()
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)

View File

@@ -82,22 +82,7 @@
</div>
</div>
</div>
<div
v-else
class="text-center p-5 text-ink-gray-5 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
>
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-xl font-medium">
{{ __('No programs found') }}
</div>
<div class="leading-5">
{{
__(
'There are no programs available at the moment. Keep an eye out, fresh learning experiences are on the way soon!'
)
}}
</div>
</div>
<EmptyState v-else type="Programs" />
<Dialog
v-model="showDialog"
@@ -127,13 +112,14 @@ import {
Dialog,
FormControl,
usePageMeta,
toast,
} from 'frappe-ui'
import { computed, inject, onMounted, ref } from 'vue'
import { BookOpen, Edit, Plus, LockKeyhole } from 'lucide-vue-next'
import { Edit, Plus, LockKeyhole } from 'lucide-vue-next'
import CourseCard from '@/components/CourseCard.vue'
import EmptyState from '@/components/EmptyState.vue'
import { useRouter } from 'vue-router'
import { sessionStore } from '../stores/session'
import { showToast } from '@/utils'
import { useSettings } from '@/stores/settings'
const { brand } = sessionStore()
@@ -198,7 +184,7 @@ const enrollMember = (program, course) => {
}
})
.catch((err) => {
showToast('Error', err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
})
}

View File

@@ -198,6 +198,7 @@ import {
ListSelectBanner,
Button,
usePageMeta,
toast,
} from 'frappe-ui'
import {
computed,
@@ -210,7 +211,7 @@ import {
} from 'vue'
import { sessionStore } from '../stores/session'
import { Plus, Trash2 } from 'lucide-vue-next'
import { showToast, updateDocumentTitle } from '@/utils'
import { updateDocumentTitle } from '@/utils'
import { useRouter } from 'vue-router'
import Question from '@/components/Modals/Question.vue'
@@ -340,14 +341,14 @@ const createQuiz = () => {
{},
{
onSuccess(data) {
showToast(__('Success'), __('Quiz created successfully'), 'check')
toast.success(__('Quiz created successfully'))
router.push({
name: 'QuizForm',
params: { quizID: data.name },
})
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
toast.error(err.messages?.[0] || err)
},
}
)
@@ -359,10 +360,10 @@ const updateQuiz = () => {
{
onSuccess(data) {
quiz.total_marks = data.total_marks
showToast(__('Success'), __('Quiz updated successfully'), 'check')
toast.success(__('Quiz updated successfully'))
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
toast.error(err.messages?.[0] || err)
},
}
)
@@ -428,7 +429,7 @@ const deleteQuestions = (selections, unselectAll) => {
},
{
onSuccess() {
showToast(__('Success'), __('Questions deleted successfully'), 'check')
toast.success(__('Questions deleted successfully'))
quizDetails.reload()
unselectAll()
},

View File

@@ -2,10 +2,10 @@
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs v-if="submisisonDetails.doc" :items="breadcrumbs" />
<Breadcrumbs v-if="submissionDetails.doc" :items="breadcrumbs" />
<div class="space-x-2">
<Badge
v-if="submisisonDetails.isDirty"
v-if="submissionDetails.isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
@@ -15,19 +15,19 @@
</Button>
</div>
</header>
<div v-if="submisisonDetails.doc" class="w-1/2 mx-auto py-5 space-y-5">
<div class="text-xl font-semibold text-ink-gray-9">
{{ submisisonDetails.doc.member_name }}
<div v-if="submissionDetails.doc" class="w-2/3 border-x mx-auto py-5">
<div class="text-xl px-10 font-semibold text-ink-gray-9 mb-5">
{{ submissionDetails.doc.member_name }}
</div>
<div class="space-y-4 border p-5 rounded-md">
<div class="space-y-4 border-b pb-5 px-10">
<div class="grid grid-cols-2 gap-5">
<FormControl
v-model="submisisonDetails.doc.quiz_title"
v-model="submissionDetails.doc.quiz_title"
:label="__('Quiz')"
:disabled="true"
/>
<FormControl
v-model="submisisonDetails.doc.member_name"
v-model="submissionDetails.doc.member_name"
:label="__('Member')"
:disabled="true"
/>
@@ -35,39 +35,39 @@
<div class="grid grid-cols-2 gap-5">
<FormControl
v-model="submisisonDetails.doc.score"
v-model="submissionDetails.doc.score"
:label="__('Score')"
:disabled="true"
/>
<FormControl
v-model="submisisonDetails.doc.percentage"
v-model="submissionDetails.doc.percentage"
:label="__('Percentage')"
:disabled="true"
/>
</div>
</div>
<div
v-for="(row, index) in submisisonDetails.doc.result"
class="border p-5 rounded-md space-y-4"
>
<div class="flex items-start space-x-1 font-semibold text-ink-gray-9">
<!-- <span>
{{ index + 1 }}.
</span> -->
<span class="leading-5" v-html="row.question"> </span>
</div>
<div class="leading-5 text-ink-gray-7 space-x-1">
<span> {{ __('Answer') }}: </span>
<span v-html="row.answer"></span>
</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 class="divide-y">
<div
v-for="(row, index) in submissionDetails.doc.result"
class="py-5 px-10 space-y-4"
>
<div class="text-ink-gray-9">
<span class="font-semibold"> {{ __('Question') }}: </span>
<span class="leading-5" v-html="row.question"> </span>
</div>
<div class="">
<span class="font-semibold"> {{ __('Answer') }} </span>
<span class="leading-5" v-html="row.answer"></span>
</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>
</div>
@@ -80,10 +80,10 @@ import {
Button,
Badge,
usePageMeta,
toast,
} from 'frappe-ui'
import { computed, onBeforeUnmount, onMounted, inject } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from '@/utils'
import { sessionStore } from '@/stores/session'
const { brand } = sessionStore()
@@ -119,7 +119,7 @@ const props = defineProps({
},
})
const submisisonDetails = createDocumentResource({
const submissionDetails = createDocumentResource({
doctype: 'LMS Quiz Submission',
name: props.submission,
auto: true,
@@ -132,22 +132,22 @@ const breadcrumbs = computed(() => {
route: {
name: 'QuizSubmissionList',
params: {
quizID: submisisonDetails.doc.quiz,
quizID: submissionDetails.doc.quiz,
},
},
},
{
label: submisisonDetails.doc.quiz_title,
label: submissionDetails.doc.quiz_title,
},
]
})
const saveSubmission = () => {
submisisonDetails.save.submit(
submissionDetails.save.submit(
{},
{
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
toast.error(err.messages?.[0] || err)
},
}
)
@@ -155,7 +155,7 @@ const saveSubmission = () => {
usePageMeta(() => {
return {
title: `${submisisonDetails.doc.quiz_title}`,
title: `${submissionDetails.doc?.quiz_title}`,
icon: brand.favicon,
}
})

View File

@@ -40,18 +40,7 @@
</Button>
</div>
</div>
<div
v-else
class="text-center p-5 text-ink-gray-5 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
>
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-xl font-medium">
{{ __('No submissions') }}
</div>
<div class="leading-5">
{{ __('No quiz submissions found. Please check again later.') }}
</div>
</div>
<EmptyState v-else />
</template>
<script setup>
import {
@@ -65,10 +54,10 @@ import {
ListHeaderItem,
usePageMeta,
} from 'frappe-ui'
import { BookOpen } from 'lucide-vue-next'
import { computed, onMounted, inject } from 'vue'
import { sessionStore } from '../stores/session'
import { useRouter } from 'vue-router'
import EmptyState from '@/components/EmptyState.vue'
const { brand } = sessionStore()
const router = useRouter()

View File

@@ -21,6 +21,9 @@
</router-link>
</header>
<div v-if="quizzes.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5">
<div v-if="quizCount" class="text-xl font-semibold text-ink-gray-7 mb-4">
{{ __('{0} Quizzes').format(quizCount) }}
</div>
<ListView
:columns="quizColumns"
:rows="quizzes.data"
@@ -53,27 +56,13 @@
</Button>
</div>
</div>
<div
v-else
class="text-center p-5 text-ink-gray-5 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
>
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-xl font-medium">
{{ __('No quizzes found') }}
</div>
<div class="leading-5">
{{
__(
'You have not created any quizzes yet. To create a new quiz, click on the "New Quiz" button above.'
)
}}
</div>
</div>
<EmptyState v-else type="Quizzes" />
</template>
<script setup>
import {
Breadcrumbs,
Button,
call,
createListResource,
ListView,
ListRows,
@@ -83,19 +72,22 @@ import {
usePageMeta,
} from 'frappe-ui'
import { useRouter } from 'vue-router'
import { computed, inject, onMounted } from 'vue'
import { BookOpen, Plus } from 'lucide-vue-next'
import { computed, inject, onMounted, ref } from 'vue'
import { Plus } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import EmptyState from '@/components/EmptyState.vue'
const { brand } = sessionStore()
const user = inject('$user')
const router = useRouter()
const quizCount = ref(0)
const readOnlyMode = window.read_only_mode
onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) {
router.push({ name: 'Courses' })
}
getQuizCount()
})
const quizFilter = computed(() => {
@@ -114,6 +106,14 @@ const quizzes = createListResource({
orderBy: 'modified desc',
})
const getQuizCount = () => {
call('frappe.client.get_count', {
doctype: 'LMS Quiz',
}).then((data) => {
quizCount.value = data
})
}
const quizColumns = computed(() => {
return [
{

View File

@@ -7,35 +7,45 @@
</header>
<div v-if="chartDetails.data" class="p-5">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<NumberChart
class="border rounded-md"
:config="{ title: 'Courses', value: chartDetails.data.courses }"
/>
<NumberChart
class="border rounded-md"
:config="{ title: 'Signups', value: chartDetails.data.users }"
/>
<NumberChart
class="border rounded-md"
:config="{
title: 'Enrollments',
value: chartDetails.data.enrollments,
}"
/>
<NumberChart
class="border rounded-md"
:config="{
title: 'Completions',
value: chartDetails.data.completions,
}"
/>
<NumberChart
class="border rounded-md"
:config="{
title: 'Certifications',
value: chartDetails.data.certifications,
}"
/>
<Tooltip :text="__('Published Courses')">
<NumberChart
class="border rounded-md"
:config="{ title: 'Courses', value: chartDetails.data.courses }"
/>
</Tooltip>
<Tooltip :text="__('Active Members')">
<NumberChart
class="border rounded-md"
:config="{ title: 'Signups', value: chartDetails.data.users }"
/>
</Tooltip>
<Tooltip :text="__('Course Enrollments')">
<NumberChart
class="border rounded-md"
:config="{
title: 'Enrollments',
value: chartDetails.data.enrollments,
}"
/>
</Tooltip>
<Tooltip :text="__('Course Completions')">
<NumberChart
class="border rounded-md"
:config="{
title: 'Completions',
value: chartDetails.data.completions,
}"
/>
</Tooltip>
<Tooltip :text="__('Certified Members')">
<NumberChart
class="border rounded-md"
:config="{
title: 'Certifications',
value: chartDetails.data.certifications,
}"
/>
</Tooltip>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
<div class="border rounded-md min-h-72">
@@ -129,6 +139,7 @@ import {
createResource,
DonutChart,
NumberChart,
Tooltip,
usePageMeta,
} from 'frappe-ui'
import { computed } from 'vue'

View File

@@ -1,98 +1,90 @@
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;
import '../../../frappe/frappe/public/js/lib/posthog.js'
import { createResource } from 'frappe-ui'
declare global {
interface Window {
posthog: any;
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;
});
type PosthogSettings = {
posthog_project_id: string
posthog_host: string
enable_telemetry: boolean
telemetry_site_age: number
}
interface CaptureOptions {
data: {
user: string;
[key: string]: string | number | boolean | object;
};
user: string
[key: string]: string | number | boolean | object
}
}
export function capture(
let posthog: typeof window.posthog = window.posthog
// Posthog Settings
let posthogSettings = createResource({
url: 'lms.lms.telemetry.get_posthog_settings',
cache: 'posthog_settings',
onSuccess: (ps: PosthogSettings) => initPosthog(ps),
})
let isTelemetryEnabled = () => {
if (!posthogSettings.data) return false
return (
posthogSettings.data.enable_telemetry &&
posthogSettings.data.posthog_project_id &&
posthogSettings.data.posthog_host
)
}
// Posthog Initialization
function initPosthog(ps: PosthogSettings) {
if (!isTelemetryEnabled()) return
posthog.init(ps.posthog_project_id, {
api_host: ps.posthog_host,
person_profiles: 'identified_only',
autocapture: false,
capture_pageview: true,
capture_pageleave: true,
enable_heatmaps: false,
disable_session_recording: false,
loaded: (ph: typeof posthog) => {
window.posthog = ph
ph.identify(window.location.hostname)
},
})
}
// Posthog Functions
function capture(
event: string,
options: CaptureOptions = { data: { user: "" } }
options: CaptureOptions = { data: { user: '' } },
) {
if (!telemetry.value.enabled) return;
window.posthog.capture(`${APP}_${event}`, options);
if (!isTelemetryEnabled()) return
window.posthog.capture(`lms_${event}`, options)
}
export function recordSession() {
if (!telemetry.value.enabled) return;
if (window.posthog && window.posthog.__loaded) {
window.posthog.startSessionRecording();
}
function startRecording() {
}
export function stopSession() {
if (!telemetry.value.enabled) return;
if (
window.posthog &&
window.posthog.__loaded &&
window.posthog.sessionRecordingStarted()
) {
window.posthog.stopSessionRecording();
}
function stopRecording() {
}
// Posthog Plugin
function posthogPlugin(app: any) {
app.config.globalProperties.posthog = posthog
if (!window.posthog?.length) posthogSettings.fetch()
}
export {
posthog,
posthogSettings,
posthogPlugin,
capture,
startRecording,
stopRecording,
}

View File

@@ -1,32 +1,26 @@
import { toast } from 'frappe-ui'
import { watch } from 'vue'
import { call, toast } from 'frappe-ui'
import { useTimeAgo } from '@vueuse/core'
import { Quiz } from '@/utils/quiz'
import { Assignment } from '@/utils/assignment'
import { Upload } from '@/utils/upload'
import { Markdown } from '@/utils/markdownParser'
import { useSettings } from '@/stores/settings'
import { usersStore } from '@/stores/user'
import Header from '@editorjs/header'
import Paragraph from '@editorjs/paragraph'
import { CodeBox } from '@/utils/code'
import NestedList from '@editorjs/nested-list'
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'
import Table from '@editorjs/table'
import { usersStore } from '../stores/user'
import Plyr from 'plyr'
import 'plyr/dist/plyr.css'
const readOnlyMode = window.read_only_mode
export function createToast(options) {
toast({
position: 'bottom-right',
...options,
})
}
export function timeAgo(date) {
return useTimeAgo(date).value
}
@@ -34,20 +28,21 @@ export function timeAgo(date) {
export function formatTime(timeString) {
if (!timeString) return ''
const [hour, minute] = timeString.split(':').map(Number)
// Create a Date object with dummy values for day, month, and year
const dummyDate = new Date(0, 0, 0, hour, minute)
// Use Intl.DateTimeFormat to format the time in 12-hour format
const formattedTime = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: 'numeric',
hour12: true,
}).format(dummyDate)
return formattedTime
}
export const formatSeconds = (time) => {
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
}
export function formatNumber(number) {
return number.toLocaleString('en-IN', {
maximumFractionDigits: 0,
@@ -97,26 +92,6 @@ export function getFileSize(file_size) {
return value
}
export function showToast(title, text, icon, iconClasses = null) {
if (!iconClasses) {
if (icon == 'check') {
iconClasses = 'bg-surface-green-3 text-ink-white rounded-md p-px'
} else if (icon == 'alert-circle') {
iconClasses = 'bg-yellow-600 text-ink-white rounded-md p-px'
} else {
iconClasses = 'bg-surface-red-5 text-ink-white rounded-md p-px'
}
}
createToast({
title: title,
text: htmlToText(text),
icon: icon,
iconClasses: iconClasses,
position: icon == 'check' ? 'bottom-right' : 'top-center',
timeout: icon != 'check' ? 10 : 5,
})
}
export function getImgDimensions(imgSrc) {
return new Promise((resolve) => {
let img = new Image()
@@ -558,24 +533,92 @@ export const enablePlyr = () => {
const videoElement = document.getElementsByClassName('video-player')
if (videoElement.length === 0) return
const src = videoElement[0].getAttribute('src')
if (src) {
let videoID = src.split('/').pop()
videoElement[0].setAttribute('data-plyr-embed-id', videoID)
}
new Plyr('.video-player', {
youtube: {
noCookie: true,
},
controls: [
'play-large',
'play',
'progress',
'current-time',
'mute',
'volume',
'fullscreen',
],
})
}, 500)
Array.from(videoElement).forEach((video) => {
const src = video.getAttribute('src')
if (src) {
let videoID = src.split('/').pop()
video.setAttribute('data-plyr-embed-id', videoID)
}
new Plyr(video, {
youtube: {
noCookie: true,
},
controls: [
'play-large',
'play',
'progress',
'current-time',
'mute',
'volume',
'fullscreen',
],
})
}, 500)
})
}
export const openSettings = (category, close) => {
const settingsStore = useSettings()
close()
settingsStore.activeTab = category
settingsStore.isSettingsOpen = true
}
export const cleanError = (message) => {
// Remove HTML tags but keep the text within the tags
const cleanMessage = message.replace(/<[^>]+>/g, (match) => {
return match.replace(/<\/?[^>]+(>|$)/g, '')
})
return cleanMessage
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
.replace(/&#x60;/g, '`')
.replace(/&#x3D;/g, '=')
.replace(/&#x2F;/g, '/')
.replace(/&#x2C;/g, ',')
.replace(/&#x3B;/g, ';')
.replace(/&#x3A;/g, ':')
}
export const getMetaInfo = (type, route, meta) => {
call('lms.lms.api.get_meta_info', {
type: type,
route: route,
}).then((data) => {
if (data.length) {
data.forEach((row) => {
if (row.key == 'description') {
meta.description = row.value
} else if (row.key == 'keywords') {
meta.keywords = row.value
}
})
}
})
}
export const updateMetaInfo = (type, route, meta) => {
call('lms.lms.api.update_meta_info', {
type: type,
route: route,
meta_tags: [
{ key: 'description', value: meta.description },
{ key: 'keywords', value: meta.keywords },
],
}).catch((error) => {
toast.error(__('Failed to update meta tags {0}').format(error))
console.error(error)
})
}
export const formatTimestamp = (seconds) => {
const date = new Date(seconds * 1000)
const minutes = String(date.getUTCMinutes()).padStart(2, '0')
const secs = String(date.getUTCSeconds()).padStart(2, '0')
return `${minutes}:${secs}`
}

View File

@@ -46,7 +46,14 @@ export class Upload {
if (this.isVideo(file.file_type)) {
const app = createApp(VideoBlock, {
file: file.file_url,
readOnly: this.readOnly,
quizzes: file.quizzes || [],
saveQuizzes: (quizzes) => {
if (this.readOnly) return
this.data.quizzes = quizzes
},
})
app.use(translationPlugin)
app.mount(this.wrapper)
return
} else if (this.isAudio(file.file_type)) {
@@ -93,6 +100,7 @@ export class Upload {
return {
file_url: this.data.file_url,
file_type: this.data.file_type,
quizzes: this.data.quizzes || [],
}
}

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