Compare commits

...

499 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
Frappe PR Bot
015aff9c4b chore(release): Bumped to Version 2.28.0 2025-05-07 07:48:16 +00:00
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
567bfc41e0 Merge pull request #1489 from pateljannat/issues-102
fix: misc fixes
2025-05-07 12:51:11 +05:30
Jannat Patel
90d77e9ffb fix: improved question form for quiz 2025-05-07 12:34:38 +05:30
Jannat Patel
2b33ba1984 fix: dark mode issues 2025-05-07 11:50:34 +05:30
Jannat Patel
1918f0c5d5 Merge branch 'develop' of https://github.com/frappe/lms into issues-102 2025-05-07 11:50:07 +05:30
Jannat Patel
91d79de723 fix: submission list access from assignment form 2025-05-06 20:02:34 +05:30
Jannat Patel
62b05f2377 fix: route to course from the course card widget 2025-05-06 19:25:53 +05:30
Jannat Patel
b628ec4c57 Merge pull request #1488 from pateljannat/simplify-persona-form
chore: simplified the persona form
2025-05-06 19:23:28 +05:30
Jannat Patel
494394f084 Merge pull request #1487 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-05-06 19:15:24 +05:30
Jannat Patel
e99b4b183c chore: simplified the persona form 2025-05-06 19:14:41 +05:30
Jannat Patel
9186353654 chore: Esperanto translations 2025-05-06 18:24:57 +05:30
Jannat Patel
bd2a7b9095 chore: Chinese Simplified translations 2025-05-06 18:24:55 +05:30
Jannat Patel
42b70e7a94 chore: Serbian (Latin) translations 2025-05-06 18:24:54 +05:30
Jannat Patel
7f913203a1 chore: Bosnian translations 2025-05-06 18:24:53 +05:30
Jannat Patel
9b94958840 chore: Croatian translations 2025-05-06 18:24:51 +05:30
Jannat Patel
2070e93379 chore: Thai translations 2025-05-06 18:24:50 +05:30
Jannat Patel
772f4d938f chore: Persian translations 2025-05-06 18:24:48 +05:30
Jannat Patel
531f3af203 chore: Portuguese, Brazilian translations 2025-05-06 18:24:47 +05:30
Jannat Patel
ed522341c1 chore: Turkish translations 2025-05-06 18:24:46 +05:30
Jannat Patel
ee59c5068e chore: Swedish translations 2025-05-06 18:24:44 +05:30
Jannat Patel
ebe3abd05b chore: Russian translations 2025-05-06 18:24:43 +05:30
Jannat Patel
358dd4dddc chore: Portuguese translations 2025-05-06 18:24:41 +05:30
Jannat Patel
3d924d3631 chore: Polish translations 2025-05-06 18:24:40 +05:30
Jannat Patel
0bed316a40 chore: Hungarian translations 2025-05-06 18:24:39 +05:30
Jannat Patel
24b5937793 chore: German translations 2025-05-06 18:24:37 +05:30
Jannat Patel
c5b5876700 chore: Arabic translations 2025-05-06 18:24:36 +05:30
Jannat Patel
0f969e952d chore: Spanish translations 2025-05-06 18:24:34 +05:30
Jannat Patel
43ba512fd5 chore: French translations 2025-05-06 18:24:33 +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
8aadbffe8c Merge pull request #1483 from pateljannat/issues-101
fix: misc fixes
2025-05-06 10:49:47 +05:30
Jannat Patel
be7e7bc6fd refactor: extracted function that enables plyr as a utility 2025-05-06 10:35:05 +05:30
Jannat Patel
3a10d4bdc0 fix: alignment of information on batch details 2025-05-06 10:17:07 +05:30
Jannat Patel
fc03ecd1b3 fix: made programs breadcrumb translatable 2025-05-06 09:27:48 +05:30
Jannat Patel
c7b10f0e83 Merge pull request #1482 from frappe/pot_develop_2025-05-02
chore: update POT file
2025-05-06 08:50:56 +05:30
Jannat Patel
6a94ce5e1c Merge pull request #1480 from pateljannat/evaluator-list-issue
fix: evaluator list in settings
2025-05-06 08:50:44 +05:30
frappe-pr-bot
59859a8e2f chore: update POT file 2025-05-02 16:04:11 +00:00
Jannat Patel
f51a8aae39 fix: evaluator list in settings 2025-05-02 15:03:54 +05:30
Jannat Patel
bd5b8c5e0e Merge pull request #1478 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-05-02 15:02:35 +05:30
Jannat Patel
67e7744566 Merge pull request #1479 from pateljannat/read-only
feat: read only mode
2025-04-30 18:33:22 +05:30
Jannat Patel
65a6663c31 fix: when moving between lessons in zen mode, ensure discussions remain hidden 2025-04-30 18:19:45 +05:30
Jannat Patel
603e80fd26 feat: read only mode 2025-04-30 18:03:00 +05:30
Jannat Patel
de4ee6bbe6 chore: Persian translations 2025-04-30 15:28:31 +05:30
Jannat Patel
a8aa242280 chore: Portuguese, Brazilian translations 2025-04-30 15:28:30 +05:30
Jannat Patel
0d32c2a9d9 Merge branch 'develop' of https://github.com/frappe/lms into read-only 2025-04-29 18:40:42 +05:30
Jannat Patel
6d5a02e2a8 feat: read only mode 2025-04-29 18:39:22 +05:30
Jannat Patel
67f3cbaaa8 Merge pull request #1475 from pateljannat/issues-100
fix: hide start learning button if self enrollment is disabled
2025-04-29 16:23:51 +05:30
Jannat Patel
f17504e1a0 fix: hide start learning button if self enrollment is disabled 2025-04-29 15:51:03 +05:30
Jannat Patel
b1a9af5de8 Merge pull request #1474 from pateljannat/issues-99
fix: check parenttype when fetching instructors
2025-04-29 11:27:56 +05:30
Jannat Patel
913bf553ae refactor: simplified the condition to check is user is instructor 2025-04-29 11:03:58 +05:30
Jannat Patel
356dcc42bf fix: check parenttype when fetching instructors 2025-04-29 10:48:52 +05:30
Jannat Patel
8c006f24ce Merge pull request #1465 from nextchamp-saqib/refactor-charts
refactor: use charts from `frappe-ui`
2025-04-28 18:19:16 +05:30
Jannat Patel
6f2f0092f0 refactor: dynamic data for statistics charts 2025-04-28 18:05:43 +05:30
Jannat Patel
56afc4c614 Merge pull request #1471 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-04-28 15:34:43 +05:30
Jannat Patel
0a3b9f8f9a chore: Esperanto translations 2025-04-28 14:01:56 +05:30
Jannat Patel
9b0623f4a4 chore: Bosnian translations 2025-04-28 14:01:55 +05:30
Jannat Patel
c13ef17a86 chore: Croatian translations 2025-04-28 14:01:54 +05:30
Jannat Patel
d5ac2f521f chore: Thai translations 2025-04-28 14:01:52 +05:30
Jannat Patel
037af18114 chore: Persian translations 2025-04-28 14:01:51 +05:30
Jannat Patel
92299458f5 chore: Portuguese, Brazilian translations 2025-04-28 14:01:49 +05:30
Jannat Patel
3272f2a4cf chore: Turkish translations 2025-04-28 14:01:48 +05:30
Jannat Patel
6a6dfdd82c chore: Swedish translations 2025-04-28 14:01:46 +05:30
Jannat Patel
fa27452983 chore: Russian translations 2025-04-28 14:01:45 +05:30
Jannat Patel
8df5ec41d5 chore: Portuguese translations 2025-04-28 14:01:43 +05:30
Jannat Patel
55aad3a742 chore: Polish translations 2025-04-28 14:01:42 +05:30
Jannat Patel
e46890d87e chore: Hungarian translations 2025-04-28 14:01:41 +05:30
Jannat Patel
3a36e10fce chore: German translations 2025-04-28 14:01:39 +05:30
Jannat Patel
cc30c6d271 chore: Arabic translations 2025-04-28 14:01:38 +05:30
Jannat Patel
5e75ff7fb7 chore: Spanish translations 2025-04-28 14:01:36 +05:30
Jannat Patel
80681a1f8b chore: French translations 2025-04-28 14:01:34 +05:30
Jannat Patel
5954e10155 chore: Serbian (Latin) translations 2025-04-28 14:01:32 +05:30
Jannat Patel
78c43b7a10 chore: Chinese Simplified translations 2025-04-28 14:01:31 +05:30
Jannat Patel
8c6f8bf97b Merge pull request #1470 from pateljannat/issues-98
fix: don't allow billing page access if batch has started
2025-04-28 12:38:13 +05:30
Jannat Patel
f220438257 fix: remove borders for iframe on lesson form 2025-04-28 11:16:04 +05:30
Jannat Patel
bbd06752d3 Merge pull request #1468 from frappe/pot_develop_2025-04-25
chore: update POT file
2025-04-28 10:36:31 +05:30
Jannat Patel
e34df2ce95 fix: don't allow billing page access if batch has started 2025-04-28 10:35:14 +05:30
frappe-pr-bot
b197c08716 chore: update POT file 2025-04-25 16:04:29 +00:00
Jannat Patel
aeb6c0f433 Merge branch 'develop' of https://github.com/frappe/lms into refactor-charts 2025-04-25 18:27:50 +05:30
Jannat Patel
8f32767267 Merge pull request #1461 from frappe/zen-mode
feat: Zen Mode
2025-04-25 18:21:31 +05:30
Jannat Patel
afd43b9a9a Merge branch 'develop' of https://github.com/frappe/lms into zen-mode 2025-04-25 17:49:13 +05:30
Jannat Patel
5893e02c48 fix: reduced the size of play button in video block 2025-04-25 17:49:07 +05:30
Jannat Patel
66d3325e3c Merge pull request #1467 from harshpwctech/develop
feat: Embedding for CloudflareStream
2025-04-25 17:29:35 +05:30
Jannat Patel
e513993a0d feat: show and hide discussions in zen mode 2025-04-25 17:28:54 +05:30
safe user
ddbdf42265 feat: Embedding Cloudflare Stream 2025-04-25 09:28:44 +00:00
safe user
badaa33ddb feat: Embedding for CloudflareStream 2025-04-25 08:38:00 +00:00
safe user
befa3d7a6d feat: Added embedding for CloudflareStream 2025-04-25 08:38:00 +00:00
Jannat Patel
513f1e8b86 fix: improved lesson locked state 2025-04-25 08:38:00 +00:00
Jannat Patel
4128f0fb73 chore: fixed settings 2025-04-25 08:38:00 +00:00
Jannat Patel
3d81a63410 ci: skip persona form for ui tests 2025-04-25 08:38:00 +00:00
Jannat Patel
c0ba44cacc fix: check persona_captured after details get saved 2025-04-25 08:38:00 +00:00
Jannat Patel
deba027457 chore: identify user persona 2025-04-25 08:38:00 +00:00
Jannat Patel
47089d286e chore: Serbian (Latin) translations 2025-04-25 08:38:00 +00:00
Jannat Patel
6c50292a66 fix: tags spacing on course cards 2025-04-25 08:38:00 +00:00
Jannat Patel
1f23f06926 fix: allow fullscreen on vimeo and adjust video height on mobile devices 2025-04-25 08:38:00 +00:00
Jannat Patel
63319d32e8 fix: detect editor change to enable plyr on newly added videos 2025-04-25 11:18:19 +05:30
Jannat Patel
66f28ef7a6 Merge branch 'develop' of https://github.com/frappe/lms into zen-mode 2025-04-25 10:20:38 +05:30
Jannat Patel
4e4eccd909 Merge pull request #1466 from pateljannat/issues-97
fix: country details in job page and form
2025-04-25 10:19:32 +05:30
Jannat Patel
c21fe99368 fix: country details in job page and form 2025-04-25 10:13:17 +05:30
Jannat Patel
53ea91e945 feat: plyr for vimeo 2025-04-25 10:05:32 +05:30
Jannat Patel
7cde05b58a Merge branch 'develop' of https://github.com/frappe/lms into zen-mode 2025-04-24 18:42:44 +05:30
Jannat Patel
0fc9b35307 Merge pull request #1464 from pateljannat/issues-96
feat: redesigned job list
2025-04-24 18:35:20 +05:30
Jannat Patel
4a36826af0 fix: if student applied for a job show that on the details page 2025-04-24 18:28:06 +05:30
Jannat Patel
26a278c5f4 feat: country filter in job list 2025-04-24 18:22:00 +05:30
Saqib Ansari
66a4d79730 refactor: use charts from frappe-ui 2025-04-24 16:19:12 +05:30
Jannat Patel
097d541391 feat: redesigned job list 2025-04-24 14:20:51 +05:30
Jannat Patel
788ef9b106 Merge pull request #1463 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-04-24 14:12:16 +05:30
Jannat Patel
a38e1163af chore: Chinese Simplified translations 2025-04-24 13:37:52 +05:30
Jannat Patel
a633ff5174 fix: check if youtube or vimeo video exists before enabling plyr 2025-04-24 12:06:57 +05:30
Jannat Patel
6b412106de feat: redesigned video block 2025-04-23 17:06:29 +05:30
Jannat Patel
93b5cb6161 feat: zen mode 2025-04-23 11:44:39 +05:30
Jannat Patel
4b80fbe5eb Merge pull request #1460 from pateljannat/issues-95
fix: improved lesson locked state
2025-04-22 18:13:01 +05:30
Jannat Patel
52775aae60 fix: improved lesson locked state 2025-04-22 18:05:07 +05:30
Jannat Patel
0430178b3e Merge pull request #1459 from pateljannat/user-persona
chore: identify user persona
2025-04-22 17:47:20 +05:30
Jannat Patel
470123c77a chore: fixed settings 2025-04-22 16:17:29 +05:30
Jannat Patel
66d4798db3 Merge branch 'develop' of https://github.com/frappe/lms into user-persona 2025-04-22 15:54:55 +05:30
Jannat Patel
cc39395a12 Merge pull request #1458 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-04-22 15:54:42 +05:30
Jannat Patel
3aeb9cf0b1 ci: skip persona form for ui tests 2025-04-22 15:32:01 +05:30
Jannat Patel
f1b383f0b7 fix: check persona_captured after details get saved 2025-04-22 15:02:21 +05:30
Jannat Patel
e2896b7bf0 chore: Serbian (Latin) translations 2025-04-22 12:52:49 +05:30
Jannat Patel
780dfb8966 Merge branch 'develop' of https://github.com/frappe/lms into user-persona 2025-04-21 18:07:12 +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
ac47ab3f8a Merge pull request #1456 from pateljannat/issues-94
fix: allow fullscreen on vimeo and adjust video height on mobile devices
2025-04-21 16:36:06 +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
bfc1488860 fix: tags spacing on course cards 2025-04-21 16:16:54 +05:30
Jannat Patel
726f733434 fix: allow fullscreen on vimeo and adjust video height on mobile devices 2025-04-21 15:41:51 +05:30
Jannat Patel
0c97e31101 Merge pull request #1455 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-04-21 15:07:47 +05:30
Jannat Patel
ec2b0718e6 chore: Portuguese translations 2025-04-21 12:09:04 +05:30
Jannat Patel
720056268c chore: Serbian (Latin) translations 2025-04-21 12:09:03 +05:30
Jannat Patel
345992eda4 chore: Esperanto translations 2025-04-21 12:09:02 +05:30
Jannat Patel
e3e6b35eb7 chore: Croatian translations 2025-04-21 12:09:00 +05:30
Jannat Patel
701ea950de chore: Thai translations 2025-04-21 12:08:59 +05:30
Jannat Patel
4b78865823 chore: Portuguese, Brazilian translations 2025-04-21 12:08:58 +05:30
Jannat Patel
5b2bdf4cf6 chore: Bosnian translations 2025-04-21 12:08:57 +05:30
Jannat Patel
a677b7fd3a chore: Persian translations 2025-04-21 12:08:55 +05:30
Jannat Patel
9cbd3db022 chore: Chinese Simplified translations 2025-04-21 12:08:54 +05:30
Jannat Patel
5f52d2c2c7 chore: Turkish translations 2025-04-21 12:08:53 +05:30
Jannat Patel
b8c403aa5d chore: Swedish translations 2025-04-21 12:08:52 +05:30
Jannat Patel
2c6863e18e chore: Russian translations 2025-04-21 12:08:50 +05:30
Jannat Patel
e7a462c685 chore: Polish translations 2025-04-21 12:08:49 +05:30
Jannat Patel
0cf671ae3b chore: Hungarian translations 2025-04-21 12:08:48 +05:30
Jannat Patel
dfc6f5bfb4 chore: German translations 2025-04-21 12:08:47 +05:30
Jannat Patel
64b9be7e42 chore: Arabic translations 2025-04-21 12:08:45 +05:30
Jannat Patel
7412a8761c chore: Spanish translations 2025-04-21 12:08:44 +05:30
Jannat Patel
65cdeabc77 chore: French translations 2025-04-21 12:08:42 +05:30
Jannat Patel
a507d4464d Merge pull request #1454 from pateljannat/issues-93
feat: meta image and keywords from settings
2025-04-21 11:05:39 +05:30
Jannat Patel
9143cc39d9 test: find the course image label and attach course image to its sibling input 2025-04-21 10:53:22 +05:30
Jannat Patel
e821755721 test: attach course image if selector is hidden 2025-04-21 10:33:24 +05:30
Jannat Patel
d081688fc9 Merge pull request #1453 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-04-21 10:16:30 +05:30
Jannat Patel
cdc7ee698c Merge pull request #1452 from frappe/pot_develop_2025-04-18
chore: update POT file
2025-04-21 09:55:00 +05:30
Jannat Patel
0d0a9c872c chore: Chinese Simplified translations 2025-04-20 11:58:27 +05:30
Jannat Patel
30953cce66 chore: German translations 2025-04-20 11:58:22 +05:30
Jannat Patel
f6008cf46a fix: don't show course count on batch details if there are no courses 2025-04-19 12:46:31 +05:30
Jannat Patel
eb0587f726 feat: meta image and keywords from settings 2025-04-19 12:37:24 +05:30
frappe-pr-bot
ba56ac87c5 chore: update POT file 2025-04-18 16:04:27 +00:00
Jannat Patel
5800ac67c4 chore: identify user persona 2025-04-18 18:05:57 +05:30
Jannat Patel
73941a159a Merge pull request #1451 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-04-18 16:16:35 +05:30
Jannat Patel
d1fe8b203a chore: Thai translations 2025-04-18 11:52:30 +05:30
Jannat Patel
8b8dbc1053 chore: Chinese Simplified translations 2025-04-18 11:52:28 +05:30
Jannat Patel
57e477b17c Merge pull request #1450 from pateljannat/issues-92
fix: moved powered by learning to the bottom of sidebar
2025-04-17 22:41:59 +05:30
Jannat Patel
1a1924de3e fix: removed styles on attachments as they were overriding the styles on webform 2025-04-17 22:32:25 +05:30
Jannat Patel
3bea19c8ad fix: moved powered by learning to the bottom of sidebar 2025-04-17 22:31:47 +05:30
Jannat Patel
cd47b62765 Merge pull request #1449 from pateljannat/assignment-issues
refactor: enhanced assignment form
2025-04-17 22:09:01 +05:30
Jannat Patel
ffeaad324e fix: removed assignment submission email notification 2025-04-17 22:03:07 +05:30
Jannat Patel
4504dd810d fix: make assignment modal scrollable if the questions is very long 2025-04-17 21:52:55 +05:30
Jannat Patel
60ad86f79c refactor: enhanced assignment form 2025-04-17 21:42:43 +05:30
Jannat Patel
f63294699a Merge pull request #1446 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-04-17 17:37:12 +05:30
Jannat Patel
650594d9ea Merge pull request #1447 from pateljannat/certification-redesign
fix: improved UI of certified participants page
2025-04-17 12:48:27 +05:30
Jannat Patel
7c22d5c774 chore: changed release workflow to run on the 15th of every month 2025-04-17 12:38:24 +05:30
Jannat Patel
73a501908d fix: mobile view for certified members page 2025-04-17 12:34:38 +05:30
Jannat Patel
31836e5c9e chore: Persian translations 2025-04-17 11:49:28 +05:30
Jannat Patel
31adab94b3 chore: merged conflicts 2025-04-16 21:38:00 +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
4e02044eb4 Merge pull request #1441 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-04-16 18:47:37 +05:30
Jannat Patel
f245cf2c5d fix: meta title for pages that don't have a default title 2025-04-16 17:09:09 +05:30
Jannat Patel
1b49cc1408 fix: meta title for pages that don't have a title 2025-04-16 17:02:52 +05:30
Jannat Patel
bd384a9b59 chore: Portuguese translations 2025-04-16 11:50:58 +05:30
Jannat Patel
48eb2ff405 fix: registration button should be visible if batch starts today but a few hours are left 2025-04-16 11:18:17 +05:30
Jannat Patel
dcacda984f fix: mobile view for certified member list 2025-04-16 10:59:06 +05:30
Jannat Patel
8186e9e1d2 Merge pull request #1438 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-04-15 20:14:15 +05:30
Jannat Patel
b5b93917d1 chore: Croatian translations 2025-04-15 11:50:27 +05:30
Jannat Patel
1ffdadbde3 chore: Bosnian translations 2025-04-15 11:50:25 +05:30
Jannat Patel
4506603ea1 chore: Swedish translations 2025-04-15 11:50:23 +05:30
Jannat Patel
fdf8b85f88 Merge branch 'develop' of https://github.com/frappe/lms into certification-redesign 2025-04-14 22:50:47 +05:30
Jannat Patel
340264ce41 Merge pull request #1436 from pateljannat/issues-91
fix: misc issues
2025-04-14 22:49:25 +05:30
Jannat Patel
d6187b3d63 fix: corrected grammar in payments app error message 2025-04-14 22:39:07 +05:30
Jannat Patel
b6577133a9 fix: misc issues 2025-04-14 22:28:06 +05:30
Jannat Patel
2d410eac37 Merge pull request #1430 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-04-14 16:24:43 +05:30
Jannat Patel
e63e71f2bf chore: Portuguese translations 2025-04-14 11:31:24 +05:30
Jannat Patel
ba743e0480 chore: Serbian (Latin) translations 2025-04-14 11:31:23 +05:30
Jannat Patel
2f26b15524 chore: Esperanto translations 2025-04-14 11:31:21 +05:30
Jannat Patel
5841ed0e70 chore: Croatian translations 2025-04-14 11:31:20 +05:30
Jannat Patel
d217dff4b9 chore: Thai translations 2025-04-14 11:31:18 +05:30
Jannat Patel
2746606db1 chore: Portuguese, Brazilian translations 2025-04-14 11:31:17 +05:30
Jannat Patel
2d321780d0 chore: Bosnian translations 2025-04-14 11:31:16 +05:30
Jannat Patel
c26108586f chore: Persian translations 2025-04-14 11:31:14 +05:30
Jannat Patel
7f30d9c3dc chore: Chinese Simplified translations 2025-04-14 11:31:12 +05:30
Jannat Patel
816b40bdc6 chore: Turkish translations 2025-04-14 11:31:11 +05:30
Jannat Patel
09688315cb chore: Swedish translations 2025-04-14 11:31:10 +05:30
Jannat Patel
c709535442 chore: Russian translations 2025-04-14 11:31:08 +05:30
Jannat Patel
08e2d804fa chore: Polish translations 2025-04-14 11:31:07 +05:30
Jannat Patel
b4fb07b435 chore: Hungarian translations 2025-04-14 11:31:06 +05:30
Jannat Patel
d119ae6409 chore: German translations 2025-04-14 11:31:04 +05:30
Jannat Patel
cf26fc4530 chore: Arabic translations 2025-04-14 11:31:03 +05:30
Jannat Patel
f50a7704c9 chore: Spanish translations 2025-04-14 11:31:02 +05:30
Jannat Patel
facec8393c chore: French translations 2025-04-14 11:31:00 +05:30
Jannat Patel
172e8872ef Merge pull request #1428 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-04-14 10:57:06 +05:30
Jannat Patel
b7755b844a Merge pull request #1427 from frappe/pot_develop_2025-04-11
chore: update POT file
2025-04-14 10:56:54 +05:30
Jannat Patel
7e77d29edb chore: Portuguese translations 2025-04-13 11:08:49 +05:30
Jannat Patel
3b84ef6968 fix: seo description for job openings 2025-04-13 10:26:59 +05:30
Jannat Patel
2dd8192dcb feat: list of certified participants 2025-04-13 10:14:00 +05:30
Jannat Patel
cafb499a79 chore: Portuguese translations 2025-04-12 10:51:50 +05:30
Jannat Patel
f952267396 chore: Arabic translations 2025-04-12 10:51:38 +05:30
frappe-pr-bot
6913b71c69 chore: update POT file 2025-04-11 16:04:23 +00:00
Jannat Patel
c485b03b83 chore: merged conflicts 2025-04-11 19:01:46 +05:30
Jannat Patel
e1f35c86db fix: empty meta info issue 2025-04-11 18:43:34 +05:30
Jannat Patel
cfbe60b731 fix: improved UI of certified participants page 2025-04-11 18:36:12 +05:30
Jannat Patel
a21020e226 Merge pull request #1425 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-04-11 13:21:11 +05:30
Jannat Patel
28d18102f0 chore: Serbian (Latin) translations 2025-04-11 10:37:08 +05:30
Jannat Patel
f5e78b7fdb chore: Persian translations 2025-04-11 10:37:04 +05:30
Jannat Patel
d420b2dae5 chore: Turkish translations 2025-04-11 10:37:01 +05:30
Jannat Patel
3cce9107d0 chore: Polish translations 2025-04-11 10:36:59 +05:30
Jannat Patel
a5248eb92b chore: Hungarian translations 2025-04-11 10:36:57 +05:30
Jannat Patel
1acf734229 chore: German translations 2025-04-11 10:36:56 +05:30
Jannat Patel
cc170ecb20 chore: French translations 2025-04-11 10:36:53 +05:30
Jannat Patel
b7f40d16a4 Merge pull request #1424 from pateljannat/seo-description
feat: SEO Meta Description
2025-04-10 17:42:58 +05:30
Jannat Patel
7e6cb727bd feat: seo description field 2025-04-10 17:19:38 +05:30
Jannat Patel
eeaa835bef fix: redirect to course list from course from if user is not moderator and instructor 2025-04-10 16:36:22 +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
189 changed files with 34307 additions and 17660 deletions

View File

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

View File

@@ -100,11 +100,12 @@ jobs:
bench --site lms.test execute frappe.utils.install.complete_setup_wizard
bench --site lms.test execute frappe.tests.ui_test_helpers.create_test_user
bench --site lms.test set-password frappe@example.com admin
bench --site lms.test execute lms.lms.utils.persona_captured
- name: cypress pre-requisites
run: |
cd ~/frappe-bench/apps/lms
yarn add cypress@^10 --no-lockfile
yarn add cypress@^10 --no-lockfile -W
- name: UI Tests
run: cd ~/frappe-bench/ && bench --site lms.test run-ui-tests lms --headless

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",
@@ -13,6 +13,6 @@ module.exports = defineConfig({
openMode: 0,
},
e2e: {
baseUrl: "http://testui:8000",
baseUrl: "http://pertest:8000",
},
});

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");
@@ -19,12 +22,16 @@ describe("Course Creation", () => {
);
cy.fixture("profile.png", "base64").then((fileContent) => {
cy.get('input[type="file"]').attachFile({
fileContent,
fileName: "profile.png",
mimeType: "image/png",
encoding: "base64",
});
cy.get("div")
.contains("Course Image")
.siblings("div")
.children('input[type="file"]')
.attachFile({
fileContent,
fileName: "profile.png",
mimeType: "image/png",
encoding: "base64",
});
});
cy.get("label")
@@ -92,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

View File

@@ -16,6 +16,7 @@ declare module 'vue' {
AssessmentPlugin: typeof import('./src/components/AssessmentPlugin.vue')['default']
Assessments: typeof import('./src/components/Assessments.vue')['default']
Assignment: typeof import('./src/components/Assignment.vue')['default']
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
Autocomplete: typeof import('./src/components/Controls/Autocomplete.vue')['default']
BatchCard: typeof import('./src/components/BatchCard.vue')['default']
@@ -26,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']
@@ -46,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']
@@ -60,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']
OnboardingBanner: typeof import('./src/components/OnboardingBanner.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']
@@ -92,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

@@ -26,12 +26,8 @@
<a href="{{ meta.link }}">Know More</a>
</div>
</div>
<div id="modals"></div>
<div id="popovers"></div>
<script>
document.getElementById('seo-content').style.display = 'none';
window.csrf_token = '{{ csrf_token }}'
</script>
<script type="module" src="/src/main.js"></script>
</body>

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,11 +27,12 @@
"codemirror-editor-vue3": "^2.8.0",
"dayjs": "^1.11.6",
"feather-icons": "^4.28.0",
"frappe-ui": "^0.1.122",
"frappe-ui": "^0.1.147",
"highlight.js": "^11.11.1",
"lucide-vue-next": "^0.383.0",
"markdown-it": "^14.0.0",
"pinia": "^2.0.33",
"plyr": "^3.7.8",
"socket.io-client": "^4.7.2",
"tailwindcss": "3.4.15",
"typescript": "^5.7.2",

View File

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

View File

@@ -1,30 +1,30 @@
<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) {
if (to.query.fromLesson || to.path === '/persona') {
noSidebar.value = true
} else {
noSidebar.value = false
@@ -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

@@ -39,7 +39,11 @@
{{ __('More') }}
</span>
</div>
<Button v-if="isModerator" variant="ghost" @click="openPageModal()">
<Button
v-if="isModerator && !readOnlyMode"
variant="ghost"
@click="openPageModal()"
>
<template #icon>
<Plus class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
</template>
@@ -63,6 +67,16 @@
</div>
</div>
<div class="m-2 flex flex-col gap-1">
<div
v-if="readOnlyMode && !sidebarStore.isSidebarCollapsed"
class="z-10 m-2 bg-surface-modal py-2.5 px-3 text-xs text-ink-gray-7 leading-5 rounded-md"
>
{{
__(
'This site is being updated. You will not be able to make any changes. Full access will be restored shortly.'
)
}}
</div>
<TrialBanner
v-if="
userResource.data?.is_system_manager && userResource.data?.is_fc_site
@@ -74,43 +88,69 @@
:isSidebarCollapsed="sidebarStore.isSidebarCollapsed"
appName="learning"
/>
<SidebarLink
v-if="isOnboardingStepsCompleted"
:link="{
label: __('Help'),
}"
:isCollapsed="sidebarStore.isSidebarCollapsed"
@click="
() => {
showHelpModal = minimize ? true : !showHelpModal
minimize = !showHelpModal
}
<div
class="flex items-center mt-4"
:class="
sidebarStore.isSidebarCollapsed ? 'flex-col space-y-3' : 'flex-row'
"
>
<template #icon>
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<CircleHelp class="h-4 w-4 stroke-1.5" />
</span>
</template>
</SidebarLink>
<SidebarLink
:link="{
label: sidebarStore.isSidebarCollapsed ? 'Expand' : 'Collapse',
}"
:isCollapsed="sidebarStore.isSidebarCollapsed"
@click="toggleSidebar()"
>
<template #icon>
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<CollapseSidebar
class="h-4 w-4 text-ink-gray-7 duration-300 ease-in-out"
:class="{
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed,
}"
<div
class="flex items-center flex-1"
:class="
sidebarStore.isSidebarCollapsed
? 'flex-col space-y-3'
: 'flex-row space-x-3'
"
>
<Tooltip v-if="readOnlyMode && sidebarStore.isSidebarCollapsed">
<CircleAlert
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
/>
</span>
</template>
</SidebarLink>
<template #body>
<div
class="max-w-[30ch] rounded bg-surface-gray-7 px-2 py-1 text-center text-p-xs text-ink-white shadow-xl"
>
{{
__(
'This site is being updated. You will not be able to make any changes. Full access will be restored shortly.'
)
}}
</div>
</template>
</Tooltip>
<Tooltip :text="__('Powered by Learning')">
<Zap
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
@click="redirectToWebsite()"
/>
</Tooltip>
<Tooltip v-if="showOnboarding" :text="__('Help')">
<CircleHelp
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
@click="
() => {
showHelpModal = minimize ? true : !showHelpModal
minimize = !showHelpModal
}
"
/>
</Tooltip>
</div>
<Tooltip
:text="
sidebarStore.isSidebarCollapsed ? __('Expand') : __('Collapse')
"
>
<CollapseSidebar
class="size-4 text-ink-gray-7 duration-300 stroke-1.5 ease-in-out cursor-pointer"
:class="{
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed,
}"
@click="toggleSidebar()"
/>
</Tooltip>
</div>
</div>
<HelpModal
v-if="showOnboarding && showHelpModal"
@@ -141,14 +181,22 @@
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'
import { useSidebar } from '@/stores/sidebar'
import { useSettings } from '@/stores/settings'
import { Button, createResource } from 'frappe-ui'
import { Button, createResource, Tooltip } from 'frappe-ui'
import PageModal from '@/components/Modals/PageModal.vue'
import { capture } from '@/telemetry'
import LMSLogo from '@/components/Icons/LMSLogo.vue'
@@ -156,6 +204,7 @@ import { useRouter } from 'vue-router'
import InviteIcon from './Icons/InviteIcon.vue'
import {
BookOpen,
CircleAlert,
ChevronRight,
Plus,
CircleHelp,
@@ -164,6 +213,7 @@ import {
UserPlus,
Users,
BookText,
Zap,
} from 'lucide-vue-next'
import {
TrialBanner,
@@ -192,6 +242,7 @@ const currentStep = ref({})
const router = useRouter()
let onboardingDetails
let isOnboardingStepsCompleted = false
const readOnlyMode = window.read_only_mode
const iconProps = {
strokeWidth: 1.5,
width: 16,
@@ -201,6 +252,7 @@ const iconProps = {
onMounted(() => {
addNotifications()
setSidebarLinks()
setUpOnboarding()
socket.on('publish_lms_notifications', (data) => {
unreadNotifications.reload()
})
@@ -345,10 +397,6 @@ const deletePage = (link) => {
)
}
const getSidebarFromStorage = () => {
return useStorage('sidebar_is_collapsed', false)
}
const toggleSidebar = () => {
sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed
localStorage.setItem(
@@ -395,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()
@@ -410,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()
@@ -428,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' })
@@ -459,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()
@@ -479,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()
@@ -578,4 +631,12 @@ watch(userResource, () => {
setUpOnboarding()
}
})
const redirectToWebsite = () => {
window.open('https://frappe.io/learning', '_blank')
}
onUnmounted(() => {
socket.off('publish_lms_notifications')
})
</script>

View File

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

View File

@@ -4,7 +4,7 @@
<div class="text-lg font-semibold text-ink-gray-9">
{{ __('Assessments') }}
</div>
<Button v-if="canSeeAddButton()" @click="showModal = true">
<Button v-if="canAddAssessments()" @click="showModal = true">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
@@ -100,6 +100,7 @@ import { Plus, Trash2 } from 'lucide-vue-next'
const user = inject('$user')
const showModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({
batch: {
@@ -181,7 +182,8 @@ const getRowRoute = (row) => {
}
}
const canSeeAddButton = () => {
const canAddAssessments = () => {
if (readOnlyMode) return false
return user.data?.is_moderator || user.data?.is_evaluator
}

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-2 hover:bg-surface-gray-2 rounded-md p-4 h-full"
class="flex flex-col border hover:border-outline-gray-3 rounded-md p-4 h-full"
style="min-height: 150px"
>
<div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9">

View File

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

View File

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

View File

@@ -2,7 +2,12 @@
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72">
<div
v-if="batch.data.seat_count && seats_left > 0"
class="text-xs bg-green-100 text-green-700 float-right px-2 py-0.5 rounded-md"
class="text-sm bg-green-100 text-green-700 px-2 py-1 rounded-md"
:class="
batch.data.amount || batch.data.courses.length
? 'float-right'
: 'w-fit mb-4'
"
>
{{ seats_left }}
<span v-if="seats_left > 1">
@@ -24,7 +29,10 @@
>
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
</div>
<div class="flex items-center mb-3 text-ink-gray-7">
<div
v-if="batch.data.courses.length"
class="flex items-center mb-3 text-ink-gray-7"
>
<BookOpen class="h-4 w-4 stroke-1.5 mr-2" />
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
</div>
@@ -46,81 +54,83 @@
{{ batch.data.timezone }}
</span>
</div>
<router-link
v-if="isModerator || isStudent"
:to="{
name: 'Batch',
params: {
batchName: batch.data.name,
},
}"
>
<Button variant="solid" class="w-full mt-4">
<span>
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
</span>
<div v-if="!readOnlyMode">
<router-link
v-if="isModerator || isStudent"
:to="{
name: 'Batch',
params: {
batchName: batch.data.name,
},
}"
>
<Button variant="solid" class="w-full mt-4">
<span>
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
</span>
</Button>
</router-link>
<router-link
:to="{
name: 'Billing',
params: {
type: 'batch',
name: batch.data.name,
},
}"
v-else-if="
batch.data.paid_batch &&
batch.data.seats_left > 0 &&
batch.data.accept_enrollments
"
>
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
<span>
{{ __('Register Now') }}
</span>
</Button>
</router-link>
<Button
variant="solid"
class="w-full mt-2"
v-else-if="
batch.data.allow_self_enrollment &&
batch.data.seats_left &&
batch.data.accept_enrollments
"
@click="enrollInBatch()"
>
{{ __('Enroll Now') }}
</Button>
</router-link>
<router-link
:to="{
name: 'Billing',
params: {
type: 'batch',
name: batch.data.name,
},
}"
v-else-if="
batch.data.paid_batch &&
batch.data.seats_left > 0 &&
batch.data.accept_enrollments
"
>
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
<span>
{{ __('Register Now') }}
</span>
</Button>
</router-link>
<Button
variant="solid"
class="w-full mt-2"
v-else-if="
batch.data.allow_self_enrollment &&
batch.data.seats_left &&
batch.data.accept_enrollments
"
@click="enrollInBatch()"
>
{{ __('Enroll Now') }}
</Button>
<router-link
v-if="isModerator"
:to="{
name: 'BatchForm',
params: {
batchName: batch.data.name,
},
}"
>
<Button class="w-full mt-2">
<span>
{{ __('Edit') }}
</span>
</Button>
</router-link>
<router-link
v-if="isModerator"
:to="{
name: 'BatchForm',
params: {
batchName: batch.data.name,
},
}"
>
<Button class="w-full mt-2">
<span>
{{ __('Edit') }}
</span>
</Button>
</router-link>
</div>
</div>
</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'
const router = useRouter()
const user = inject('$user')
const dayjs = inject('$dayjs')
const readOnlyMode = window.read_only_mode
const props = defineProps({
batch: {
@@ -146,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>
@@ -110,7 +66,7 @@
<div class="text-ink-gray-7 font-medium">
{{ __('Students') }}
</div>
<Button @click="openStudentModal()">
<Button v-if="!readOnlyMode" @click="openStudentModal()">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
@@ -201,9 +157,10 @@
</div>
<StudentModal
:batch="props.batch.name"
:batch="props.batch.data.name"
v-model="showStudentModal"
v-model:reloadStudents="students"
v-model:batchModal="props.batch"
/>
<BatchStudentProgress
:student="selectedStudent"
@@ -213,6 +170,7 @@
<script setup>
import {
Avatar,
AxisChart,
Button,
createResource,
FeatherIcon,
@@ -223,6 +181,8 @@ import {
ListRows,
ListView,
ListRowItem,
NumberChart,
toast,
} from 'frappe-ui'
import {
BookOpen,
@@ -234,7 +194,6 @@ import {
} from 'lucide-vue-next'
import { ref, watch } from 'vue'
import StudentModal from '@/components/Modals/StudentModal.vue'
import { showToast } from '@/utils'
import ProgressBar from '@/components/ProgressBar.vue'
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
import ApexChart from 'vue3-apexcharts'
@@ -244,9 +203,9 @@ const showStudentModal = ref(false)
const showStudentProgressModal = ref(false)
const selectedStudent = ref(null)
const chartData = ref(null)
const chartOptions = ref(null)
const showProgressChart = ref(false)
const assessmentCount = ref(0)
const readOnlyMode = window.read_only_mode
const props = defineProps({
batch: {
@@ -257,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)
},
})
@@ -322,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()
},
}
@@ -330,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, () => {
@@ -433,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

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

View File

@@ -9,16 +9,20 @@
:class="{ 'default-image': !course.image }"
:style="{ backgroundImage: 'url(\'' + encodeURI(course.image) + '\')' }"
>
<div
class="flex items-center flex-wrap space-x-1 relative top-4 px-2 w-fit"
>
<Badge v-if="course.featured" variant="subtle" theme="green" size="md">
<div class="flex items-center flex-wrap relative top-4 px-2 w-fit">
<Badge
v-if="course.featured"
variant="subtle"
theme="green"
size="md"
class="mb-1 mr-1"
>
{{ __('Featured') }}
</Badge>
<div
v-if="course.tags"
v-for="tag in course.tags?.split(', ')"
class="text-xs bg-white text-gray-800 px-2 py-0.5 rounded-md"
class="text-xs bg-white text-gray-800 px-2 py-0.5 rounded-md mb-1 mr-1"
>
{{ tag }}
</div>

View File

@@ -9,88 +9,94 @@
<div v-if="course.data.paid_course" class="text-2xl font-semibold mb-3">
{{ course.data.price }}
</div>
<div v-if="course.data.membership" class="space-y-2">
<div v-if="!readOnlyMode">
<div v-if="course.data.membership" class="space-y-2">
<router-link
:to="{
name: 'Lesson',
params: {
courseName: course.name,
chapterNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[0]
: 1,
lessonNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[1]
: 1,
},
}"
>
<Button variant="solid" size="md" class="w-full">
<span>
{{ __('Continue Learning') }}
</span>
</Button>
</router-link>
<CertificationLinks :courseName="course.data.name" class="w-full" />
</div>
<router-link
v-else-if="course.data.paid_course"
:to="{
name: 'Lesson',
name: 'Billing',
params: {
courseName: course.name,
chapterNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[0]
: 1,
lessonNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[1]
: 1,
type: 'course',
name: course.data.name,
},
}"
>
<Button variant="solid" size="md" class="w-full">
<span>
{{ __('Continue Learning') }}
{{ __('Buy this course') }}
</span>
</Button>
</router-link>
<CertificationLinks :courseName="course.data.name" class="w-full" />
</div>
<router-link
v-else-if="course.data.paid_course"
:to="{
name: 'Billing',
params: {
type: 'course',
name: course.data.name,
},
}"
>
<Button variant="solid" size="md" class="w-full">
<Badge
v-else-if="course.data.disable_self_learning"
theme="blue"
size="lg"
>
{{ __('Contact the Administrator to enroll for this course.') }}
</Badge>
<Button
v-else
@click="enrollStudent()"
variant="solid"
class="w-full"
size="md"
>
<span>
{{ __('Buy this course') }}
{{ __('Start Learning') }}
</span>
</Button>
</router-link>
<div
v-else-if="course.data.disable_self_learning"
class="bg-surface-blue-2 text-blue-900 text-sm rounded-md py-1 px-3"
>
{{ __('Contact the Administrator to enroll for this course.') }}
</div>
<Button
v-else
@click="enrollStudent()"
variant="solid"
class="w-full"
size="md"
>
<span>
{{ __('Start Learning') }}
</span>
</Button>
<Button
v-if="canGetCertificate"
@click="fetchCertificate()"
variant="subtle"
class="w-full mt-2"
size="md"
>
{{ __('Get Certificate') }}
</Button>
<router-link
v-if="user?.data?.is_moderator || is_instructor()"
:to="{
name: 'CourseForm',
params: {
courseName: course.data.name,
},
}"
>
<Button variant="subtle" class="w-full mt-2" size="md">
<span>
{{ __('Edit') }}
</span>
<Button
v-if="canGetCertificate"
@click="fetchCertificate()"
variant="subtle"
class="w-full mt-2"
size="md"
>
{{ __('Get Certificate') }}
</Button>
</router-link>
<router-link
v-if="user?.data?.is_moderator || is_instructor()"
:to="{
name: 'CourseForm',
params: {
courseName: course.data.name,
},
}"
>
<Button variant="subtle" class="w-full mt-2" size="md">
<span>
{{ __('Edit') }}
</span>
</Button>
</router-link>
</div>
<div class="space-y-4">
<div class="mt-8 font-medium text-ink-gray-9">
<div
class="font-medium text-ink-gray-9"
:class="{ 'mt-8': !readOnlyMode }"
>
{{ __('This course has:') }}
</div>
<div class="flex items-center text-ink-gray-9">
@@ -110,7 +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>
@@ -140,14 +146,15 @@
<script setup>
import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next'
import { computed, inject } from 'vue'
import { Button, createResource, Tooltip } from 'frappe-ui'
import { showToast, formatAmount } from '@/utils/'
import { 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'
const router = useRouter()
const user = inject('$user')
const readOnlyMode = window.read_only_mode
const props = defineProps({
course: {
@@ -165,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}`
}, 2000)
}, 500)
} else {
const enrollStudentResource = createResource({
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
call('lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership', {
course: props.course.data.name,
})
enrollStudentResource
.submit({
course: props.course.data.name,
})
.then(() => {
capture('enrolled_in_course', {
course: props.course.data.name,
})
showToast(
__('Success'),
__('You have been enrolled in this course'),
'check'
)
toast.success(__('You have been enrolled in this course'))
setTimeout(() => {
router.push({
name: 'Lesson',
@@ -199,7 +194,11 @@ function enrollStudent() {
lessonNumber: 1,
},
})
}, 2000)
}, 1000)
})
.catch((err) => {
toast.warning(__(err.messages?.[0] || err))
console.error(err)
})
}
}

View File

@@ -1,5 +1,5 @@
<template>
<div class="h-full">
<div class="">
<div
v-if="title && (outline.data?.length || allowEdit)"
class="flex items-center justify-between space-x-2 mb-4 px-2"
@@ -17,9 +17,6 @@
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
{{ __('Add Chapter') }}
</Button>
<!-- <span class="font-medium cursor-pointer" @click="expandAllChapters()">
{{ expandAll ? __("Collapse all chapters") : __("Expand all chapters") }}
</span> -->
</div>
<div
:class="{
@@ -150,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'
@@ -165,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()
@@ -218,7 +214,7 @@ const deleteLesson = createResource({
},
onSuccess() {
outline.reload()
showToast('Success', 'Lesson deleted successfully', 'check')
toast.success(__('Lesson deleted successfully'))
},
})
@@ -233,7 +229,7 @@ const updateLessonIndex = createResource({
}
},
onSuccess() {
showToast('Success', 'Lesson moved successfully', 'check')
toast.success(__('Lesson moved successfully'))
},
})
@@ -291,7 +287,7 @@ const deleteChapter = createResource({
},
onSuccess() {
outline.reload()
showToast('Success', 'Chapter deleted successfully', 'check')
toast.success(__('Chapter deleted successfully'))
},
})
@@ -320,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

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

View File

@@ -1,6 +1,10 @@
<template>
<div>
<Button v-if="!singleThread" class="float-right" @click="openTopicModal()">
<Button
v-if="!singleThread && !readOnlyMode"
class="float-right"
@click="openTopicModal()"
>
{{ __('New {0}').format(singularize(title)) }}
</Button>
<div class="text-xl font-semibold text-ink-gray-9">
@@ -66,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'
@@ -77,6 +81,7 @@ const currentTopic = ref(null)
const socket = inject('$socket')
const user = inject('$user')
const showTopicModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({
title: {
@@ -148,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

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

View File

@@ -1,25 +1,35 @@
<template>
<div class="border rounded-md p-4">
<div class="flex space-x-4">
<img
:src="job.company_logo"
class="size-10 rounded-full object-contain"
/>
<div class="flex flex-col space-y-1 flex-1">
<div class="flex items-center justify-between">
<span class="text-lg font-semibold text-ink-gray-9">
{{ job.job_title }}
</span>
</div>
<div class="text-xs text-ink-gray-5">
<div
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">
<div class="text-lg font-semibold text-ink-gray-9">
{{ job.company_name }}
</div>
<span class="font-medium text-ink-gray-7 leading-5">
{{ job.job_title }}
</span>
<div class="flex items-center space-x-1 text-sm text-ink-gray-7">
<MapPin class="size-3" />
<span>
{{ job.location }}{{ job.country ? `, ${job.country}` : '' }}
</span>
</div>
<div
v-if="job.applicants"
class="flex items-center space-x-1 text-sm text-ink-gray-7"
>
<User class="size-3" />
<span>
{{ job.applicants }}
{{ job.applicants > 1 ? __('applicants') : __('applicant') }}
</span>
</div>
</div>
<!-- <img :src="job.company_logo" alt="Company Logo" class="size-8 rounded-full object-contain bg-white" /> -->
</div>
<div class="space-x-4 mt-2">
<Badge>
{{ job.location }}
</Badge>
<div class="space-x-2 mt-auto">
<Badge>
{{ job.type }}
</Badge>
@@ -27,11 +37,16 @@
{{ dayjs(job.creation).fromNow() }}
</Badge>
</div>
<!-- <div
class="description text-ink-gray-9 text-sm"
v-html="job.description"
></div> -->
</div>
</template>
<script setup>
import { inject } from 'vue'
import { Badge } from 'frappe-ui'
import { MapPin, User } from 'lucide-vue-next'
const dayjs = inject('$dayjs')
const props = defineProps({
@@ -41,3 +56,15 @@ const props = defineProps({
},
})
</script>
<style>
.description {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
width: 100%;
overflow: hidden;
margin-top: auto;
line-height: 1.5;
}
</style>

View File

@@ -1,9 +1,19 @@
<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>
<Button v-if="user.data.is_moderator" @click="openLiveClassModal">
<Button v-if="canCreateClass()" @click="openLiveClassModal">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
@@ -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,41 +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({
@@ -105,6 +147,8 @@ const liveClasses = createListResource({
'description',
'time',
'date',
'duration',
'attendees',
'start_url',
'join_url',
'owner',
@@ -116,6 +160,41 @@ const liveClasses = createListResource({
const openLiveClassModal = () => {
showLiveClassModal.value = true
}
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

@@ -0,0 +1,154 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'lg',
}"
>
<template #body>
<div class="p-5 text-base">
<div class="text-lg text-ink-gray-9 font-semibold mb-5">
{{
assignmentID === 'new'
? __('Create an Assignment')
: __('Edit Assignment')
}}
</div>
<div class="space-y-4 max-h-[75vh] overflow-y-auto">
<FormControl
v-model="assignment.title"
:label="__('Title')"
:required="true"
/>
<FormControl
v-model="assignment.type"
type="select"
:options="assignmentOptions"
:label="__('Submission Type')"
:required="true"
/>
<div>
<div class="text-xs text-ink-gray-5 mb-2">
{{ __('Question') }}
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:content="assignment.question"
@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] max-h-[18rem] overflow-y-auto"
/>
</div>
</div>
<div class="flex justify-end space-x-2 mt-5">
<router-link
:to="{
name: 'AssignmentSubmissionList',
query: {
assignmentID: assignmentID,
},
}"
>
<Button v-if="assignmentID !== 'new'" variant="subtle">
{{ __('Check Submissions') }}
</Button>
</router-link>
<Button variant="solid" @click="saveAssignment">
{{ __('Save') }}
</Button>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { computed, reactive, watch } from 'vue'
const show = defineModel()
const assignments = defineModel<Assignments>('assignments')
interface Assignment {
title: string
type: string
question: string
}
interface Assignments {
data: Assignment[]
get: (params: { doctype: string; name: string }) => Promise<Assignment>
insert: {
submit: (params: Assignment, options: { onSuccess: () => void }) => void
}
}
const assignment = reactive({
title: '',
type: '',
question: '',
})
const props = defineProps({
assignmentID: {
type: String,
default: 'new',
},
})
watch(
() => props.assignmentID,
(val) => {
if (val !== 'new') {
assignments.value?.data.forEach((row) => {
if (row.name === val) {
assignment.title = row.title
assignment.type = row.type
assignment.question = row.question
}
})
}
},
{ flush: 'post' }
)
const saveAssignment = () => {
if (props.assignmentID == 'new') {
assignments.value.insert.submit(
{
...assignment,
},
{
onSuccess() {
show.value = false
toast.success(__('Assignment created successfully'))
},
}
)
} else {
assignments.value.setValue.submit(
{
...assignment,
name: props.assignmentID,
},
{
onSuccess() {
show.value = false
toast.success(__('Assignment updated successfully'))
},
}
)
}
}
const assignmentOptions = computed(() => {
return [
{ label: 'PDF', value: 'PDF' },
{ label: 'Image', value: 'Image' },
{ label: 'Document', value: 'Document' },
{ label: 'Text', value: 'Text' },
{ label: 'URL', value: 'URL' },
]
})
</script>

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

@@ -38,7 +38,7 @@
<div class="mb-4">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading ? `Uploading ${progress}%` : 'Upload an zip file'
uploading ? `Uploading ${progress}%` : 'Upload an ZIP file'
}}
</Button>
</div>
@@ -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

@@ -1,38 +1,27 @@
<template>
<Dialog v-model="show" :options="dialogOptions">
<template #body-content>
<div class="space-y-4">
<Dialog
v-model="show"
:options="{
size: '3xl',
}"
>
<template #body>
<div class="p-5 space-y-5">
<div class="text-lg font-semibold text-ink-gray-9 mb-5">
{{ __(props.title) }}
</div>
<div
v-if="!editMode"
class="flex items-center text-xs text-ink-gray-7 space-x-5"
>
<div class="flex items-center space-x-2">
<input
type="radio"
id="existing"
value="existing"
v-model="questionType"
class="w-3 h-3 cursor-pointer"
/>
<label for="existing" class="cursor-pointer">
{{ __('Add an existing question') }}
</label>
</div>
<div class="flex items-center space-x-2">
<input
type="radio"
id="new"
value="new"
v-model="questionType"
class="w-3 h-3 cursor-pointer"
/>
<label for="new" class="cursor-pointer">
{{ __('Create a new question') }}
</label>
</div>
<Switch
size="sm"
:label="__('Choose an existing question')"
v-model="chooseFromExisting"
class="!p-0"
/>
</div>
<div v-if="questionType == 'new' || editMode" class="space-y-2">
<div v-if="!chooseFromExisting || editMode" class="space-y-2">
<div>
<label class="block text-xs text-ink-gray-5 mb-1">
{{ __('Question') }}
@@ -45,20 +34,34 @@
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="question.marks"
:label="__('Marks')"
type="number"
/>
<FormControl
:label="__('Type')"
v-model="question.type"
type="select"
:options="['Choices', 'User Input', 'Open Ended']"
class="pb-2"
:required="true"
/>
<div v-if="question.type == 'Choices'" class="divide-y border-t">
<div class="grid grid-cols-2 gap-4">
<FormControl
v-model="question.marks"
:label="__('Marks')"
type="number"
/>
<FormControl
:label="__('Type')"
v-model="question.type"
type="select"
:options="['Choices', 'User Input', 'Open Ended']"
class="pb-2"
:required="true"
/>
</div>
<div
v-if="question.type == 'Choices'"
class="text-base font-semibold text-ink-gray-9 mb-5 mt-5"
>
{{ __('Options') }}
</div>
<div
v-else-if="question.type == 'User Input'"
class="text-base font-semibold text-ink-gray-9 mb-5 mt-5"
>
{{ __('Possibilities') }}
</div>
<div v-if="question.type == 'Choices'" class="grid grid-cols-2 gap-4">
<div v-for="n in 4" class="space-y-4 py-2">
<FormControl
:label="__('Option') + ' ' + n"
@@ -78,17 +81,18 @@
</div>
<div
v-else-if="question.type == 'User Input'"
v-for="n in 4"
class="space-y-2"
class="grid grid-cols-2 gap-4 py-2"
>
<FormControl
:label="__('Possibility') + ' ' + n"
v-model="question[`possibility_${n}`]"
:required="n == 1 ? true : false"
/>
<div v-for="n in 4">
<FormControl
:label="__('Possibility') + ' ' + n"
v-model="question[`possibility_${n}`]"
:required="n == 1 ? true : false"
/>
</div>
</div>
</div>
<div v-else-if="questionType == 'existing'" class="space-y-2">
<div v-else-if="chooseFromExisting" class="space-y-2">
<Link
v-model="existingQuestion.question"
:label="__('Select a question')"
@@ -100,20 +104,32 @@
type="number"
/>
</div>
<div class="flex items-center justify-end space-x-2 mt-5">
<Button variant="solid" @click="submitQuestion()">
{{ __('Submit') }}
</Button>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui'
import {
Dialog,
FormControl,
TextEditor,
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()
const quiz = defineModel('quiz')
const questionType = ref(null)
const chooseFromExisting = ref(false)
const editMode = ref(false)
const user = inject('$user')
const { updateOnboardingStep } = useOnboarding('learning')
@@ -182,11 +198,12 @@ watch(show, () => {
editMode.value = false
if (props.questionDetail.question) questionData.fetch()
else {
;(question.question = ''), (question.marks = 0)
question.question = ''
question.marks = 1
question.type = 'Choices'
existingQuestion.question = ''
existingQuestion.marks = 0
questionType.value = null
existingQuestion.marks = 1
chooseFromExisting.value = false
populateFields()
}
@@ -221,42 +238,36 @@ const questionCreation = createResource({
},
})
const submitQuestion = (close) => {
if (props.questionDetail?.question) updateQuestion(close)
else addQuestion(close)
const submitQuestion = () => {
if (props.questionDetail?.question) updateQuestion()
else addQuestion()
}
const addQuestion = (close) => {
if (questionType.value == 'existing') {
addQuestionRow(
{
question: existingQuestion.question,
marks: existingQuestion.marks,
},
close
)
const addQuestion = () => {
if (chooseFromExisting.value) {
addQuestionRow({
question: existingQuestion.question,
marks: existingQuestion.marks,
})
} else {
questionCreation.submit(
{},
{
onSuccess(data) {
addQuestionRow(
{
question: data.name,
marks: question.marks,
},
close
)
addQuestionRow({
question: data.name,
marks: question.marks,
})
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
toast.error(err.messages?.[0] || err)
},
}
)
}
}
const addQuestionRow = (question, close) => {
const addQuestionRow = (question) => {
questionRow.submit(
{
...question,
@@ -267,13 +278,13 @@ const addQuestionRow = (question, close) => {
updateOnboardingStep('create_first_quiz')
show.value = false
showToast(__('Success'), __('Question added successfully'), 'check')
toast.success(__('Question added successfully'))
quiz.value.reload()
close()
show.value = false
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
close()
toast.error(err.messages?.[0] || err)
show.value = false
},
}
)
@@ -307,7 +318,7 @@ const marksUpdate = createResource({
},
})
const updateQuestion = (close) => {
const updateQuestion = () => {
questionUpdate.submit(
{},
{
@@ -317,39 +328,18 @@ const updateQuestion = (close) => {
{
onSuccess() {
show.value = false
showToast(
__('Success'),
__('Question updated successfully'),
'check'
)
toast.success(__('Question updated successfully'))
quiz.value.reload()
close()
},
}
)
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
toast.error(err.messages?.[0] || err)
},
}
)
}
const dialogOptions = computed(() => {
return {
title: __(props.title),
size: 'xl',
actions: [
{
label: __('Submit'),
variant: 'solid',
onClick: (close) => {
submitQuestion(close)
},
},
],
}
})
</script>
<style>
input[type='radio']:checked {

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,159 +0,0 @@
<template>
<div v-if="showOnboardingBanner && onboardingDetails.data">
<Tooltip :text="__('Skip Onboarding')" placement="left">
<X
class="w-4 h-4 stroke-1 absolute top-2 right-2 cursor-pointer mr-1"
@click="skipOnboarding.reload()"
/>
</Tooltip>
<div class="flex items-center justify-evenly bg-surface-gray-2 p-10">
<div
@click="redirectToCourseForm()"
class="flex items-center space-x-2"
:class="{
'cursor-pointer': !onboardingDetails.data.course_created?.length,
}"
>
<span
v-if="onboardingDetails.data.course_created?.length"
class="py-1 px-1 bg-surface-white rounded-full"
>
<Check class="h-4 w-4 stroke-2 text-ink-green-3" />
</span>
<span
v-else
class="font-semibold bg-surface-white px-2 py-1 rounded-full"
>
1
</span>
<span class="text-lg font-semibold">
{{ __('Create a course') }}
</span>
</div>
<div
@click="redirectToChapterForm()"
class="flex items-center space-x-2"
:class="{
'cursor-pointer':
onboardingDetails.data.course_created?.length &&
!onboardingDetails.data.chapter_created?.length,
'text-ink-gray-3': !onboardingDetails.data.course_created?.length,
}"
>
<span
v-if="onboardingDetails.data.chapter_created?.length"
class="py-1 px-1 bg-surface-white rounded-full"
>
<Check class="h-4 w-4 stroke-2 text-ink-green-3" />
</span>
<span
v-else
class="font-semibold bg-surface-white px-2 py-1 rounded-full"
>
2
</span>
<span class="text-lg font-semibold">
{{ __('Add a chapter') }}
</span>
</div>
<div
@click="redirectToLessonForm()"
class="flex items-center space-x-2"
:class="{
'cursor-pointer':
onboardingDetails.data.course_created?.length &&
onboardingDetails.data.chapter_created?.length,
'text-ink-gray-3':
!onboardingDetails.data.course_created?.length ||
!onboardingDetails.data.chapter_created?.length,
}"
>
<span
v-if="onboardingDetails.data.lesson_created?.length"
class="py-1 px-1 bg-surface-white rounded-full"
>
<Check class="h-4 w-4 stroke-2 text-ink-green-3" />
</span>
<span class="font-semibold bg-surface-white px-2 py-1 rounded-full">
3
</span>
<span class="text-lg font-semibold">
{{ __('Add a lesson') }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Check, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { useSettings } from '@/stores/settings'
import { createResource, Tooltip } from 'frappe-ui'
const showOnboardingBanner = ref(false)
const settings = useSettings()
const onboardingDetails = settings.onboardingDetails
const router = useRouter()
watch(onboardingDetails, () => {
if (!onboardingDetails.data?.is_onboarded) {
showOnboardingBanner.value = true
} else {
showOnboardingBanner.value = false
}
})
const redirectToCourseForm = () => {
if (onboardingDetails.data?.course_created.length) {
return
} else {
router.push({ name: 'CourseForm', params: { courseName: 'new' } })
}
}
const redirectToChapterForm = () => {
if (!onboardingDetails.data?.course_created.length) {
return
} else {
router.push({
name: 'CourseForm',
params: {
courseName: onboardingDetails.data?.first_course,
},
})
}
}
const redirectToLessonForm = () => {
if (!onboardingDetails.data?.course_created.length) {
return
} else if (!onboardingDetails.data?.chapter_created.length) {
return
} else {
router.push({
name: 'LessonForm',
params: {
courseName: onboardingDetails.data?.first_course,
chapterNumber: 1,
lessonNumber: 1,
},
})
}
}
const skipOnboarding = createResource({
url: 'frappe.client.set_value',
makeParams() {
return {
doctype: 'LMS Settings',
name: 'LMS Settings',
fieldname: 'is_onboarding_complete',
value: 1,
}
},
onSuccess(data) {
onboardingDetails.reload()
},
})
</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],
})
}
}
@@ -653,3 +678,8 @@ const getSubmissionColumns = () => {
]
}
</script>
<style>
p {
line-height: 1.5rem;
}
</style>

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>
@@ -97,7 +101,7 @@ const evaluators = createResource({
return {
doctype: 'Course Evaluator',
fields: ['evaluator', 'full_name', 'user_image', 'username'],
filters: search.value ? [['evaluator', 'like', search.value]] : [],
filters: search.value ? { evaluator: ['like', `%${search.value}%`] } : {},
}
},
auto: true,

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: {
@@ -51,7 +50,9 @@ const props = defineProps({
const update = () => {
props.fields.forEach((f) => {
if (f.type != 'Column Break') {
if (f.type == 'Upload') {
props.data.doc[f.name] = f.value ? f.value.file_url : null
} else if (f.type != 'Column Break') {
props.data.doc[f.name] = f.value
}
})
@@ -59,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,21 +55,32 @@
<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 w-[10rem] py-5"
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" class="h-6 rounded" />
<img
:src="data[field.name]?.file_url || data[field.name]"
class="rounded"
:class="field.size == 'lg' ? 'w-36' : 'size-6'"
/>
</div>
<div class="flex flex-col flex-wrap">
<span class="break-all text-ink-gray-9">
{{ data[field.name]?.file_name }}
{{
data[field.name]?.file_name ||
data[field.name].split('/').pop()
}}
</span>
<span class="text-sm text-ink-gray-5 mt-1">
<span
v-if="data[field.name]?.file_size"
class="text-sm text-ink-gray-5 mt-1"
>
{{ getFileSize(data[field.name]?.file_size) }}
</span>
</div>
<X
@click="data[field.name] = null"
class="bg-surface-gray-5 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
class="border text-ink-gray-7 border-outline-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
</div>
@@ -91,6 +103,7 @@
:rows="field.rows"
:options="field.options"
:description="field.description"
:class="columns.length > 1 ? 'w-full' : 'w-1/2'"
/>
</div>
</div>
@@ -99,7 +112,7 @@
</template>
<script setup>
import { FormControl, FileUploader, Button, Switch } from 'frappe-ui'
import { computed, onMounted } from 'vue'
import { computed } from 'vue'
import { getFileSize, validateFile } from '@/utils'
import { X } from 'lucide-vue-next'
import Link from '@/components/Controls/Link.vue'

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,40 +317,16 @@ 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: 'Assignment Submission Template',
name: 'assignment_submission_template',
doctype: 'Email Template',
type: 'Link',
},
],
},
{
label: 'Signup',
icon: 'LogIn',
fields: [
{
label: 'Identify User Persona',
label: 'Identify User Category',
name: 'user_category',
type: 'checkbox',
description:
'Enable this option to identify the user persona during signup.',
'Enable this option to identify the user category during signup.',
},
{
label: 'Disable signup',
@@ -341,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',
@@ -350,6 +347,37 @@ const tabsStructure = computed(() => {
},
],
},
{
label: 'SEO',
icon: 'Search',
fields: [
{
label: 'Meta Description',
name: 'meta_description',
type: 'textarea',
rows: 4,
description:
"This description will be shown on lists and pages that don't have meta description",
},
{
label: 'Meta Keywords',
name: 'meta_keywords',
type: 'textarea',
rows: 4,
description:
'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,
@@ -194,18 +194,6 @@ const userDropdownOptions = computed(() => {
)
},
},
],
},
{
group: '',
items: [
{
icon: Zap,
label: 'Powered by Learning',
onClick: () => {
window.open('https://frappe.io/learning', '_blank')
},
},
{
icon: LogOut,
label: 'Log out',

View File

@@ -1,58 +1,129 @@
<template>
<div ref="videoContainer" class="video-block group relative">
<video
@timeupdate="updateTime"
@ended="videoEnded"
@click="togglePlay"
oncontextmenu="return false"
class="rounded-lg border border-gray-100 group cursor-pointer"
ref="videoRef"
>
<source :src="fileURL" :type="type" />
</video>
<div>
<div
class="flex items-center space-x-2 bg-surface-gray-3 rounded-md p-0.5 absolute bottom-3 w-[98%] left-0 right-0 mx-auto invisible group-hover:visible"
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"
>
<Button variant="ghost">
<template #icon>
<Play
v-if="!playing"
@click="playVideo"
class="w-4 h-4 text-ink-gray-9"
/>
<Pause v-else @click="pauseVideo" class="w-4 h-4 text-ink-gray-9" />
</template>
</Button>
<Button variant="ghost" @click="toggleMute">
<template #icon>
<Volume2 v-if="!muted" class="w-4 h-4 text-ink-gray-9" />
<VolumeX v-else class="w-4 h-4 text-ink-gray-9" />
</template>
</Button>
<input
type="range"
min="0"
:max="duration"
step="0.1"
v-model="currentTime"
@input="changeCurrentTime"
class="duration-slider w-full h-1"
/>
<span class="text-xs font-medium">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</span>
<Button variant="ghost" @click="toggleFullscreen">
<template #icon>
<Maximize class="w-4 h-4 text-ink-gray-9" />
</template>
{{
__('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
v-if="!showQuiz"
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
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 { Play, Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
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)
@@ -60,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: {
@@ -70,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
@@ -127,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 = () => {
@@ -147,7 +264,6 @@ const toggleFullscreen = () => {
<style scoped>
.video-block {
width: 100%;
max-width: 900px;
margin: 0 auto;
}
@@ -165,15 +281,16 @@ iframe {
flex: 1;
-webkit-appearance: none;
appearance: none;
background-color: theme('colors.gray.400');
border-radius: 10px;
background-color: theme('colors.gray.100');
cursor: pointer;
}
.duration-slider::-webkit-slider-thumb {
height: 10px;
width: 10px;
width: 2px;
border-radius: 50%;
-webkit-appearance: none;
background-color: theme('colors.gray.900');
background-color: theme('colors.gray.500');
}
@media screen and (-webkit-min-device-pixel-ratio: 0) {
@@ -186,7 +303,7 @@ iframe {
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
cursor: pointer;
box-shadow: -500px 0 0 500px theme('colors.gray.900');
box-shadow: -500px 0 0 500px theme('colors.gray.600');
}
}
</style>

View File

@@ -26,5 +26,6 @@ app.mount('#app')
const { userResource, allUsers } = usersStore()
app.provide('$user', userResource)
app.provide('$allUsers', allUsers)
app.config.globalProperties.$user = userResource
app.config.globalProperties.$dialog = createDialog

View File

@@ -1,201 +0,0 @@
<template>
<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 :items="breadcrumbs" />
<div class="space-x-2">
<router-link
v-if="assignment.doc?.name"
:to="{
name: 'AssignmentSubmissionList',
query: {
assignmentID: assignment.doc.name,
},
}"
>
<Button>
{{ __('Submission List') }}
</Button>
</router-link>
<Button variant="solid" @click="saveAssignment()">
{{ __('Save') }}
</Button>
</div>
</header>
<div class="w-3/4 mx-auto py-5">
<div class="font-semibold mb-4">
{{ __('Details') }}
</div>
<div class="grid grid-cols-2 gap-5 mt-4 mb-8">
<FormControl
v-model="model.title"
:label="__('Title')"
:required="true"
/>
<FormControl
v-model="model.type"
type="select"
:options="assignmentOptions"
:label="__('Type')"
:required="true"
/>
</div>
<div>
<div class="text-xs text-ink-gray-5 mb-2">
{{ __('Question') }}
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:content="model.question"
@change="(val) => (model.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]"
/>
</div>
</div>
</template>
<script setup>
import {
Breadcrumbs,
Button,
createDocumentResource,
createResource,
FormControl,
TextEditor,
usePageMeta,
} from 'frappe-ui'
import {
computed,
inject,
onMounted,
onBeforeUnmount,
reactive,
watch,
} from 'vue'
import { sessionStore } from '../stores/session'
import { showToast } from '@/utils'
import { useRouter } from 'vue-router'
const user = inject('$user')
const router = useRouter()
const { brand } = sessionStore()
const props = defineProps({
assignmentID: {
type: String,
required: true,
},
})
const model = reactive({
title: '',
type: 'PDF',
question: '',
})
onMounted(() => {
if (
props.assignmentID == 'new' &&
!user.data?.is_moderator &&
!user.data?.is_instructor
) {
router.push({ name: 'Courses' })
}
if (props.assignmentID !== 'new') {
assignment.reload()
}
window.addEventListener('keydown', keyboardShortcut)
})
const keyboardShortcut = (e) => {
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
saveAssignment()
e.preventDefault()
}
}
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
const assignment = createDocumentResource({
doctype: 'LMS Assignment',
name: props.assignmentID,
auto: false,
})
const newAssignment = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Assignment',
...values,
},
}
},
onSuccess(data) {
router.push({ name: 'AssignmentForm', params: { assignmentID: data.name } })
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
})
const saveAssignment = () => {
if (props.assignmentID == 'new') {
newAssignment.submit({
...model,
})
} else {
assignment.setValue.submit(
{
...model,
},
{
onSuccess(data) {
showToast(__('Success'), __('Assignment saved successfully'), 'check')
assignment.reload()
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
}
)
}
}
watch(assignment, () => {
Object.keys(assignment.doc).forEach((key) => {
model[key] = assignment.doc[key]
})
})
const breadcrumbs = computed(() => [
{
label: __('Assignments'),
route: { name: 'Assignments' },
},
{
label: assignment.doc ? assignment.doc.title : __('New Assignment'),
},
])
const assignmentOptions = computed(() => {
return [
{ label: 'PDF', value: 'PDF' },
{ label: 'Image', value: 'Image' },
{ label: 'Document', value: 'Document' },
{ label: 'Text', value: 'Text' },
{ label: 'URL', value: 'URL' },
]
})
usePageMeta(() => {
return {
title: assignment.doc ? assignment.doc.title : __('New Assignment'),
icon: brand.favicon,
}
})
</script>

View File

@@ -3,32 +3,46 @@
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 :items="breadcrumbs" />
<router-link
:to="{
name: 'AssignmentForm',
params: {
assignmentID: 'new',
},
}"
<Button
v-if="!readOnlyMode"
variant="solid"
@click="
() => {
assignmentID = 'new'
showAssignmentForm = true
}
"
>
<Button variant="solid">
<template #prefix>
<Plus class="w-4 h-4" />
</template>
{{ __('New') }}
</Button>
</router-link>
<template #prefix>
<Plus class="w-4 h-4" />
</template>
{{ __('New') }}
</Button>
</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"
@@ -38,31 +52,15 @@
:options="{
showTooltip: false,
selectable: false,
getRowRoute: (row) => ({
name: 'AssignmentForm',
params: {
assignmentID: row.name,
},
}),
onRowClick: (row) => {
if (readOnlyMode) return
assignmentID = row.name
showAssignmentForm = true
},
}"
>
</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"
@@ -72,33 +70,45 @@
</Button>
</div>
</div>
<AssignmentForm
v-model="showAssignmentForm"
v-model:assignments="assignments"
:assignmentID="assignmentID"
/>
</template>
<script setup>
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')
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
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
})
@@ -136,7 +146,7 @@ const assignmentFilter = computed(() => {
const assignments = createListResource({
doctype: 'LMS Assignment',
fields: ['name', 'title', 'type', 'creation'],
fields: ['name', 'title', 'type', 'creation', 'question'],
orderBy: 'modified desc',
cache: ['assignments'],
transform(data) {
@@ -166,11 +176,19 @@ const assignmentColumns = computed(() => {
label: __('Created'),
key: 'creation',
width: 1,
align: 'center',
align: 'right',
},
]
})
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

@@ -11,7 +11,7 @@
>
{{ __('Generate Certificates') }}
</Button>
<Button v-if="user.data?.is_moderator" @click="openAnnouncementModal()">
<Button v-if="canMakeAnnouncement()" @click="openAnnouncementModal()">
<span>
{{ __('Make an Announcement') }}
</span>
@@ -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)
@@ -242,6 +251,7 @@ const route = useRoute()
const router = useRouter()
const { brand } = sessionStore()
const tabIndex = ref(0)
const readOnlyMode = window.read_only_mode
const tabs = computed(() => {
let batchTabs = []
@@ -276,11 +286,6 @@ const tabs = computed(() => {
label: 'Discussions',
icon: MessageCircle,
})
batchTabs.push({
label: 'Feedback',
icon: ClipboardPen,
})
return batchTabs
})
@@ -354,6 +359,14 @@ watch(tabIndex, () => {
}
})
const canMakeAnnouncement = () => {
if (readOnlyMode) return false
if (!batch.data?.students?.length) return false
return user.data?.is_moderator || user.data?.is_evaluator
}
usePageMeta(() => {
return {
title: batch?.data?.title,

View File

@@ -6,64 +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 justify-between lg:w-1/2"
>
<div class="flex items-center text-ink-gray-7">
<BookOpen class="h-4 w-4 mr-2" />
<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 class="hidden lg:block" v-if="batch.data.courses"
>&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" />
<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 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>
</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>
<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,99 +8,68 @@
{{ __('Save') }}
</Button>
</header>
<div class="w-1/2 mx-auto py-5">
<div class="">
<div class="text-lg font-semibold mb-4">
<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-4 mb-4">
<div class="grid grid-cols-2 gap-5">
<div class="space-y-5">
<FormControl
v-model="batch.title"
:label="__('Title')"
:required="true"
class="w-full"
/>
<MultiSelect
v-model="instructors"
doctype="Course Evaluator"
:label="__('Instructors')"
:required="true"
:onCreate="(close) => openSettings('Evaluators', close)"
:filters="{ ignore_user_type: 1 }"
/>
</div>
<FormControl
v-model="batch.title"
:label="__('Title')"
v-model="batch.description"
:label="__('Short Description')"
type="textarea"
:rows="8"
:placeholder="__('Short description of the batch')"
:required="true"
class="w-full"
/>
<div class="flex items-center space-x-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>
<div class="mb-4">
<div class="text-xs text-ink-gray-5 mb-2">
{{ __('Meta Image') }}
</div>
<FileUploader
v-if="!batch.image"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<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-2 text-ink-gray-5 text-sm">
{{
__(
'Appears when the batch URL is shared on any online platform'
)
}}
</div>
</div>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="flex items-center">
<img :src="batch.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 when the batch URL is shared on any online platform'
)
}}
</div>
</div>
</div>
</div>
</div>
<MultiSelect
v-model="instructors"
doctype="User"
:label="__('Instructors')"
:required="true"
:filters="{ ignore_user_type: 1 }"
/>
<div class="my-10">
<div class="text-lg font-semibold mb-4">
<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-2 gap-10">
<div>
<div class="grid grid-cols-3 gap-10">
<div class="space-y-5">
<FormControl
v-model="batch.start_date"
:label="__('Start Date')"
@@ -115,16 +84,8 @@
class="mb-4"
:required="true"
/>
<FormControl
v-model="batch.timezone"
:label="__('Timezone')"
type="text"
:placeholder="__('Example: IST (+5:30)')"
class="mb-4"
:required="true"
/>
</div>
<div>
<div class="space-y-5">
<FormControl
v-model="batch.start_time"
:label="__('Start Time')"
@@ -140,21 +101,14 @@
:required="true"
/>
</div>
</div>
</div>
<div class="mb-10">
<div class="text-lg font-semibold mb-4">
{{ __('Settings') }}
</div>
<div class="grid grid-cols-2 gap-10">
<div>
<div class="space-y-5">
<FormControl
v-model="batch.seat_count"
:label="__('Seat Count')"
type="number"
v-model="batch.timezone"
:label="__('Timezone')"
type="text"
:placeholder="__('Example: IST (+5:30)')"
class="mb-4"
:placeholder="__('Number of seats available')"
:required="true"
/>
<FormControl
v-model="batch.evaluation_end_date"
@@ -162,13 +116,61 @@
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>
<div class="space-y-5">
<FormControl
v-model="batch.medium"
type="select"
@@ -189,26 +191,79 @@
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">
{{ __('Meta Image') }}
</div>
<FileUploader
v-if="!batch.image"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<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 when the batch URL is shared on socials')
}}
</div>
</div>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="flex items-center">
<img
:src="batch.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 when the batch URL is shared on any online platform'
)
}}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="">
<div class="text-lg 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>
<div>
<FormControl
v-model="batch.paid_batch"
type="checkbox"
:label="__('Paid Batch')"
/>
<FormControl
v-model="batch.paid_batch"
type="checkbox"
:label="__('Paid Batch')"
/>
<div v-if="batch.paid_batch" class="grid grid-cols-3 gap-5">
<FormControl
v-model="batch.amount"
:label="__('Amount')"
type="number"
class="my-4"
/>
<Link
doctype="Currency"
@@ -219,29 +274,23 @@
</div>
</div>
<div class="my-10">
<div class="text-lg 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>
@@ -265,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: {
@@ -308,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' &&
@@ -435,7 +495,7 @@ const createNewBatch = () => {
localStorage.setItem('firstBatch', data.name)
})
}
updateMetaInfo('batches', data.name, meta)
capture('batch_created')
router.push({
name: 'BatchDetail',
@@ -445,7 +505,7 @@ const createNewBatch = () => {
})
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)
@@ -456,6 +516,7 @@ const editBatchDetails = () => {
{},
{
onSuccess(data) {
updateMetaInfo('batches', data.name, meta)
router.push({
name: 'BatchDetail',
params: {
@@ -464,7 +525,7 @@ const editBatchDetails = () => {
})
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)

View File

@@ -4,7 +4,7 @@
>
<Breadcrumbs :items="breadcrumbs" />
<router-link
v-if="user.data?.is_moderator"
v-if="canCreateBatch()"
:to="{
name: 'BatchForm',
params: { batchName: 'new' },
@@ -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')
@@ -124,6 +112,7 @@ const filters = ref({})
const is_student = computed(() => user.data?.is_student)
const currentTab = ref(is_student.value ? 'All' : 'Upcoming')
const orderBy = ref('start_date')
const readOnlyMode = window.read_only_mode
onMounted(() => {
setFiltersFromQuery()
@@ -299,6 +288,12 @@ const batchTabs = computed(() => {
return tabs
})
const canCreateBatch = () => {
if (readOnlyMode) return false
if (user.data?.is_moderator || user.data?.is_instructor) return true
return false
}
const breadcrumbs = computed(() => [
{
label: __('Batches'),

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,12 +12,10 @@
</Button>
</router-link>
</header>
<div class="p-5 lg:w-3/4 mx-auto">
<div
class="flex flex-col lg:flex-row lg:items-center space-y-4 lg:space-y-0 justify-between mb-5"
>
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('All Certified Participants') }}
<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') }}
</div>
<div class="grid grid-cols-2 gap-2">
<FormControl
@@ -40,57 +38,65 @@
</div>
</div>
</div>
<div v-if="participants.data?.length">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<div v-if="participants.data?.length" class="divide-y">
<template v-for="participant in participants.data">
<router-link
v-for="participant in participants.data"
:to="{
name: 'ProfileCertificates',
params: { username: participant.username },
params: {
username: participant.username,
},
}"
class="flex sm:rounded px-3 py-2 sm:h-15 hover:bg-surface-gray-2"
>
<div
class="flex items-center space-x-2 border rounded-md hover:bg-surface-menu-bar p-2 text-ink-gray-7"
>
<div class="flex items-center w-full space-x-3">
<Avatar
:image="participant.user_image"
class="size-8 rounded-full object-contain"
:label="participant.full_name"
size="2xl"
/>
<div class="flex flex-col space-y-2">
<div class="font-medium">
{{ participant.full_name }}
<div class="flex flex-col md:flex-row w-full">
<div class="flex-1">
<div class="text-base font-medium text-ink-gray-8">
{{ participant.full_name }}
</div>
<div
v-if="participant.headline"
class="mt-1.5 text-base text-ink-gray-5"
>
{{ participant.headline }}
</div>
</div>
<div
v-if="participant.headline"
class="headline text-sm text-ink-gray-7"
class="flex items-center space-x-3 md:space-x-24 text-sm md:text-base mt-1.5"
>
{{ participant.headline }}
<div class="text-ink-gray-5">
{{ participant.certificate_count }}
{{
participant.certificate_count > 1
? __('certificates')
: __('certificate')
}}
</div>
<span class="text-ink-gray-4 md:hidden">·</span>
<div class="text-ink-gray-5">
{{ dayjs(participant.issue_date).format('DD MMM YYYY') }}
</div>
</div>
</div>
</div>
</router-link>
</div>
<div
v-if="!participants.list.loading && participants.hasNextPage"
class="flex justify-center mt-5"
>
<Button @click="participants.next()">
{{ __('Load More') }}
</Button>
</div>
</template>
</div>
<EmptyState v-else type="Certified Members" />
<div
v-else-if="!participants.list.loading"
class="flex flex-col items-center justify-center text-sm text-ink-gray-5 italic mt-48"
v-if="!participants.list.loading && participants.hasNextPage"
class="flex justify-center mt-5"
>
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-lg font-medium mb-1">
{{ __('No participants found') }}
</div>
<div class="leading-5 w-2/5 text-center">
{{ __('There are no participants matching this criteria.') }}
</div>
<Button @click="participants.next()">
{{ __('Load More') }}
</Button>
</div>
</div>
</template>
@@ -99,33 +105,44 @@ import {
Avatar,
Breadcrumbs,
Button,
call,
createListResource,
FormControl,
Select,
usePageMeta,
} from 'frappe-ui'
import { computed, onMounted, ref } from 'vue'
import { updateDocumentTitle } from '@/utils'
import { BookOpen, GraduationCap } from 'lucide-vue-next'
import { computed, inject, onMounted, ref } from 'vue'
import { GraduationCap } from 'lucide-vue-next'
import { sessionStore } from '../stores/session'
import EmptyState from '@/components/EmptyState.vue'
const currentCategory = ref('')
const filters = ref({})
const nameFilter = ref('')
const { brand } = sessionStore()
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 getMemberCount = () => {
call('lms.lms.api.get_count_of_certified_members', {
filters: filters.value,
}).then((data) => {
memberCount.value = data
})
}
const categories = createListResource({
doctype: 'LMS Certificate',
url: 'lms.lms.api.get_certification_categories',
@@ -139,6 +156,7 @@ const categories = createListResource({
const updateParticipants = () => {
updateFilters()
getMemberCount()
participants.update({
filters: filters.value,
})
@@ -161,14 +179,14 @@ const updateFilters = () => {
const breadcrumbs = computed(() => [
{
label: __('Certified Participants'),
label: __('Certified Members'),
route: { name: 'CertifiedParticipants' },
},
])
usePageMeta(() => {
return {
title: __('Certified Participants'),
title: __('Certified Members'),
icon: brand.favicon,
}
})

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>
@@ -56,7 +56,7 @@
<CourseInstructors :instructors="course.data.instructors" />
</div>
</div>
<div v-if="course.data.tags" class="flex mt-4 w-fit">
<div v-if="course.data.tags" class="flex my-4 w-fit">
<Badge
theme="gray"
size="lg"
@@ -69,7 +69,7 @@
<CourseCardOverlay :course="course" class="md:hidden mb-4" />
<div
v-html="course.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-4"
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"
></div>
<div class="mt-10">
<CourseOutline

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,23 +350,30 @@ const course = reactive({
evaluator: '',
})
const meta = reactive({
description: '',
keywords: '',
})
onMounted(() => {
if (
props.courseName == 'new' &&
!user.data?.is_moderator &&
!user.data?.is_instructor
) {
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' &&
@@ -339,6 +387,7 @@ const keyboardShortcut = (e) => {
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
stopRecording()
})
const courseCreationResource = createResource({
@@ -427,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({
@@ -471,7 +530,7 @@ const deleteCourse = createResource({
}
},
onSuccess() {
showToast(__('Success'), __('Course deleted successfully'), 'check')
toast.success(__('Course deleted successfully'))
router.push({ name: 'Courses' })
},
})
@@ -500,7 +559,7 @@ watch(
() => props.courseName !== 'new',
(newVal) => {
if (newVal) {
courseResource.reload()
fetchCourseInfo()
}
}
)
@@ -535,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

@@ -57,7 +57,7 @@
</div>
<div
v-if="courses.data?.length"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-5"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-4"
>
<router-link
v-for="course in courses.data"
@@ -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"
@@ -96,6 +81,7 @@
import {
Breadcrumbs,
Button,
call,
createListResource,
FormControl,
Select,
@@ -103,10 +89,12 @@ 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')
const dayjs = inject('$dayjs')
@@ -119,10 +107,12 @@ const certification = ref(false)
const filters = ref({})
const currentTab = ref('Live')
const { brand } = sessionStore()
const courseCount = ref(0)
onMounted(() => {
setFiltersFromQuery()
updateCourses()
getCourseCount()
categories.value = [
{
label: '',
@@ -145,16 +135,51 @@ const courses = createListResource({
pageLength: pageLength.value,
start: start.value,
onSuccess(data) {
let allCategories = data.map((course) => course.category)
allCategories = allCategories.filter(
(category, index) => allCategories.indexOf(category) === index && category
)
if (categories.value.length <= allCategories.length) {
updateCategories(data)
}
setCategories(data)
},
})
const setCategories = (data) => {
let allCategories = data.map((course) => course.category)
allCategories = allCategories.filter(
(category, index) => allCategories.indexOf(category) === index && category
)
if (categories.value.length <= allCategories.length) {
updateCategories(data)
}
}
const isPersonaCaptured = async () => {
let persona = await call('frappe.client.get_single_value', {
doctype: 'LMS Settings',
field: 'persona_captured',
})
return persona
}
const identifyUserPersona = async () => {
if (user.data?.is_system_manager && !user.data?.developer_mode) {
let personaCaptured = await isPersonaCaptured()
if (personaCaptured) return
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({
@@ -215,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'] = [
@@ -224,6 +248,8 @@ const updateTabFilter = () => {
]
} else if (currentTab.value == 'Created') {
filters.value['created'] = 1
} else if (currentTab.value == 'Unpublished') {
filters.value['published'] = 0
}
}
}
@@ -293,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

@@ -16,11 +16,14 @@
},
]"
/>
<div v-if="user.data?.name" class="flex space-x-2">
<div
v-if="user.data?.name && !readOnlyMode"
class="flex items-center space-x-2"
>
<router-link
v-if="user.data.name == job.data?.owner"
:to="{
name: 'JobCreation',
name: 'JobForm',
params: { jobName: job.data?.name },
}"
>
@@ -47,8 +50,14 @@
</template>
{{ __('Apply') }}
</Button>
<Badge v-else variant="subtle" theme="green" size="lg">
<template #prefix>
<Check class="h-4 w-4" />
</template>
{{ __('You have applied') }}
</Badge>
</div>
<div v-else>
<div v-else-if="!readOnlyMode">
<Button @click="redirectToLogin(job.data?.name)">
<span>
{{ __('Login to apply') }}
@@ -56,88 +65,63 @@
</Button>
</div>
</header>
<div v-if="job.data" class="max-w-3xl mx-auto">
<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="w-16 h-16 rounded-lg object-contain cursor-pointer mr-4"
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="h-4 w-4 text-ink-green-2" />
<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 text-ink-red-3" />
<div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs font-medium uppercase">
{{ __('Location') }}
</span>
<span class="text-sm font-semibold">
{{ job.data.location }}
</span>
</div>
</div>
<div class="flex items-center space-x-4">
<ClipboardType class="h-4 w-4 text-yellow-500" />
<div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs 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="h-4 w-4 text-ink-blue-2" />
<div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs 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="h-4 w-4 text-purple-500" />
<div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs 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
@@ -149,25 +133,32 @@
</div>
</template>
<script setup>
import { Button, Breadcrumbs, createResource, usePageMeta } from 'frappe-ui'
import {
Badge,
Button,
Breadcrumbs,
createResource,
usePageMeta,
} from 'frappe-ui'
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')
const dayjs = inject('$dayjs')
const { brand } = sessionStore()
const showApplicationModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({
job: {
@@ -235,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,34 +9,39 @@
</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>
<div class="grid grid-cols-2 gap-4">
<div>
<div class="grid grid-cols-2 gap-5">
<div class="space-y-4">
<FormControl
v-model="job.job_title"
:label="__('Title')"
class="mb-4"
:required="true"
/>
<FormControl
v-model="job.location"
:label="__('Location')"
:required="true"
/>
</div>
<div>
<FormControl
v-model="job.type"
:label="__('Type')"
type="select"
:options="jobTypes"
class="mb-4"
:required="true"
/>
</div>
<div class="space-y-4">
<FormControl
v-model="job.location"
:label="__('City')"
:required="true"
/>
<Link
v-model="job.country"
doctype="Country"
:label="__('Country')"
:required="true"
/>
<FormControl
v-if="jobName != 'new'"
v-model="job.status"
:label="__('Status')"
type="select"
@@ -45,25 +50,12 @@
/>
</div>
</div>
<div class="mt-4">
<label class="block text-ink-gray-5 text-xs mb-1">
{{ __('Description') }}
<span class="text-ink-red-3">*</span>
</label>
<TextEditor
:content="job.description"
@change="(val) => (job.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] mb-4"
/>
</div>
</div>
<div class="container mb-4 pb-4">
<div class="container border-b mb-4 pb-5">
<div class="text-lg font-semibold mb-4">
{{ __('Company Details') }}
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-2 gap-5">
<div>
<FormControl
v-model="job.company_name"
@@ -128,6 +120,19 @@
</div>
</div>
</div>
<div class="container mt-4">
<label class="block text-ink-gray-5 text-xs mb-1">
{{ __('Description') }}
<span class="text-ink-red-3">*</span>
</label>
<TextEditor
:content="job.description"
@change="(val) => (job.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] mb-4"
/>
</div>
</div>
</div>
</template>
@@ -140,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()
@@ -217,6 +223,7 @@ const imageResource = createResource({
const job = reactive({
job_title: '',
location: '',
country: '',
type: 'Full Time',
status: 'Open',
company_name: '',
@@ -253,7 +260,7 @@ const createNewJob = () => {
})
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)
@@ -272,7 +279,7 @@ const editJobDetails = () => {
})
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)
@@ -317,7 +324,7 @@ const breadcrumbs = computed(() => {
},
{
label: props.jobName == 'new' ? 'New Job' : 'Edit Job',
route: { name: 'JobCreation' },
route: { name: 'JobForm' },
},
]
return crumbs

View File

@@ -10,13 +10,13 @@
<router-link
v-if="user.data?.name"
:to="{
name: 'JobCreation',
name: 'JobForm',
params: {
jobName: 'new',
},
}"
>
<Button variant="solid">
<Button v-if="!readOnlyMode" variant="solid">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
@@ -25,40 +25,50 @@
</router-link>
</header>
<div>
<div v-if="jobs.data?.length" class="p-5">
<div
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
>
<div class="text-xl text-ink-gray-9 font-semibold">
{{ __('Find the perfect job for you') }}
</div>
<div class="grid grid-cols-2 gap-2">
<FormControl
type="text"
:placeholder="__('Search')"
v-model="searchQuery"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
@input="updateJobs"
>
<template #prefix>
<Search
class="w-4 h-4 stroke-1.5 text-ink-gray-5"
name="search"
/>
</template>
</FormControl>
<FormControl
v-model="jobType"
type="select"
:options="jobTypes"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
:placeholder="__('Type')"
@change="updateJobs"
/>
</div>
<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 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 lg:grid-cols-3 gap-5">
<div
class="grid grid-cols-1 gap-2"
:class="user.data ? 'md:grid-cols-3' : 'md:grid-cols-2'"
>
<FormControl
type="text"
:placeholder="__('Search')"
v-model="searchQuery"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
@input="updateJobs"
>
<template #prefix>
<Search
class="w-4 h-4 stroke-1.5 text-ink-gray-5"
name="search"
/>
</template>
</FormControl>
<Link
v-if="user.data"
doctype="Country"
v-model="country"
:placeholder="__('Country')"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
/>
<FormControl
v-model="jobType"
type="select"
:options="jobTypes"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
:placeholder="__('Type')"
@change="updateJobs"
/>
</div>
</div>
<div v-if="jobs.data?.length" class="w-full md:w-4/5 mx-auto p-5 pt-0">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<router-link
v-for="job in jobs.data"
:to="{
@@ -71,22 +81,7 @@
</router-link>
</div>
</div>
<div
v-else
class="flex flex-col items-center justify-center text-sm text-ink-gray-5 mt-48"
>
<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. Open a job opportunity or check here again later.'
)
}}
</div>
</div>
<EmptyState v-else type="Job Openings" />
</div>
</div>
</template>
@@ -94,21 +89,27 @@
import {
Button,
Breadcrumbs,
call,
createResource,
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 } from 'vue'
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)
const { brand } = sessionStore()
const searchQuery = ref('')
const country = ref(null)
const filters = ref({})
const orFilters = ref({})
const jobCount = ref(0)
const readOnlyMode = window.read_only_mode
onMounted(() => {
let queries = new URLSearchParams(location.search)
@@ -121,6 +122,9 @@ onMounted(() => {
const jobs = createResource({
url: 'lms.lms.api.get_job_opportunities',
cache: ['jobs'],
onSuccess(data) {
jobCount.value = data.length
},
})
const updateJobs = () => {
@@ -153,8 +157,18 @@ const updateFilters = () => {
} else {
orFilters.value = {}
}
if (country.value) {
filters.value.country = country.value
} else {
delete filters.value.country
}
}
watch(country, (val) => {
updateJobs()
})
const jobTypes = computed(() => {
return [
'',

View File

@@ -4,166 +4,237 @@
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 class="h-7" :items="breadcrumbs" />
<CertificationLinks :courseName="courseName" />
<div class="flex items-center space-x-2">
<Tooltip v-if="canGoZen()" :text="__('Zen Mode')">
<Button @click="goFullScreen()">
<template #icon>
<Focus class="w-4 h-4 stroke-2" />
</template>
</Button>
</Tooltip>
<CertificationLinks :courseName="courseName" />
</div>
</header>
<div class="grid md:grid-cols-[70%,30%] h-screen">
<div
v-if="lesson.data.no_preview"
class="border-r text-center pt-10 px-5 md:px-0 pb-10"
>
<p class="mb-4">
{{
__(
'This lesson is not available for preview. Please enroll in the course to access it.'
)
}}
</p>
<Button v-if="user.data" @click="enrollStudent()" variant="solid">
{{ __('Start Learning') }}
</Button>
<Button v-else @click="redirectToLogin()">
{{ __('Login') }}
</Button>
</div>
<div v-else class="border-r container pt-5 pb-10 px-5">
<div class="flex flex-col md:flex-row md:items-center justify-between">
<div class="text-3xl font-semibold text-ink-gray-9">
{{ lesson.data.title }}
<div v-if="lesson.data.no_preview" class="border-r">
<div class="shadow rounded-md w-3/4 mt-10 mx-auto text-center p-4">
<div class="flex items-center justify-center mt-4 space-x-2">
<LockKeyholeIcon class="size-4 stroke-2 text-ink-gray-5" />
<div class="text-lg font-semibold text-ink-gray-7">
{{ __('This lesson is locked') }}
</div>
</div>
<div class="flex items-center mt-2 md:mt-0">
<router-link
v-if="lesson.data.prev"
:to="{
name: 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.data.prev.split('.')[0],
lessonNumber: lesson.data.prev.split('.')[1],
},
}"
>
<Button class="mr-2">
<template #prefix>
<ChevronLeft class="w-4 h-4 stroke-1" />
</template>
<span>
{{ __('Previous') }}
</span>
</Button>
</router-link>
<router-link
v-if="allowEdit()"
:to="{
name: 'LessonForm',
params: {
courseName: courseName,
chapterNumber: props.chapterNumber,
lessonNumber: props.lessonNumber,
},
}"
>
<Button class="mr-2">
{{ __('Edit') }}
</Button>
</router-link>
<router-link
v-if="lesson.data.next"
:to="{
name: 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.data.next.split('.')[0],
lessonNumber: lesson.data.next.split('.')[1],
},
}"
>
<Button>
<template #suffix>
<ChevronRight class="w-4 h-4 stroke-1" />
</template>
<span>
{{ __('Next') }}
</span>
</Button>
</router-link>
<router-link
v-else
:to="{
name: 'CourseDetail',
params: { courseName: courseName },
}"
>
<Button>
{{ __('Back to Course') }}
</Button>
</router-link>
<div class="mt-1 mb-4 text-ink-gray-7">
{{
__(
'This lesson is not available for preview. Please enroll in the course to access it.'
)
}}
</div>
</div>
<div class="flex items-center mt-2">
<span
class="h-6 mr-1"
:class="{
'avatar-group overlap': lesson.data.instructors?.length > 1,
}"
<Button
v-if="user.data && !lesson.data.disable_self_learning"
@click="enrollStudent()"
variant="solid"
>
<UserAvatar
v-for="instructor in lesson.data.instructors"
:user="instructor"
/>
</span>
<CourseInstructors
v-if="lesson.data?.instructors"
:instructors="lesson.data.instructors"
/>
{{ __('Start Learning') }}
</Button>
<Badge
theme="blue"
size="lg"
v-else-if="lesson.data.disable_self_learning"
class="mt-2"
>
{{ __('Contact the Administrator to enroll for this course.') }}
</Badge>
<Button v-else @click="redirectToLogin()">
<template #prefix>
<LogIn class="w-4 h-4 stroke-1" />
</template>
{{ __('Login') }}
</Button>
</div>
</div>
<div
v-else
ref="lessonContainer"
class="bg-surface-white"
:class="{
'overflow-y-auto': zenModeEnabled,
}"
>
<div
v-if="
lesson.data.instructor_content &&
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 &&
allowInstructorContent()
"
class="bg-surface-gray-2 p-3 rounded-md mt-6"
class="border-r container pt-5 pb-10 px-5 h-full"
:class="{
'w-full md:w-3/4 mx-auto border-none !pt-10': zenModeEnabled,
}"
>
<div class="text-ink-gray-5 font-medium">
{{ __('Instructor Notes') }}
<div
class="flex flex-col md:flex-row md:items-center justify-between"
>
<div class="flex flex-col">
<div class="text-3xl font-semibold text-ink-gray-9">
{{ lesson.data.title }}
</div>
<div
v-if="zenModeEnabled"
class="relative flex items-center space-x-2 text-sm mt-1 text-ink-gray-7 group w-fit mt-2"
>
<span>
{{ lesson.data.chapter_title }} -
{{ lesson.data.course_title }}
</span>
<Info class="size-3" />
<div
class="hidden group-hover:block rounded bg-gray-900 px-2 py-1 text-xs text-white shadow-xl absolute left-0 top-full mt-2"
>
{{ Math.ceil(lesson.data.membership.progress) }}%
{{ __('completed') }}
</div>
</div>
</div>
<div class="flex items-center space-x-2 mt-2 md:mt-0">
<Button v-if="zenModeEnabled" @click="showDiscussionsInZenMode()">
<template #icon>
<MessageCircleQuestion class="w-4 h-4 stroke-1.5" />
</template>
</Button>
<router-link
v-if="lesson.data.prev"
:to="{
name: 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.data.prev.split('.')[0],
lessonNumber: lesson.data.prev.split('.')[1],
},
}"
>
<Button>
<template #prefix>
<ChevronLeft class="w-4 h-4 stroke-1" />
</template>
<span>
{{ __('Previous') }}
</span>
</Button>
</router-link>
<router-link
v-if="allowEdit()"
:to="{
name: 'LessonForm',
params: {
courseName: courseName,
chapterNumber: props.chapterNumber,
lessonNumber: props.lessonNumber,
},
}"
>
<Button>
{{ __('Edit') }}
</Button>
</router-link>
<router-link
v-if="lesson.data.next"
:to="{
name: 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.data.next.split('.')[0],
lessonNumber: lesson.data.next.split('.')[1],
},
}"
>
<Button>
<template #suffix>
<ChevronRight class="w-4 h-4 stroke-1" />
</template>
<span>
{{ __('Next') }}
</span>
</Button>
</router-link>
<router-link
v-else
:to="{
name: 'CourseDetail',
params: { courseName: courseName },
}"
>
<Button>
{{ __('Back to Course') }}
</Button>
</router-link>
</div>
</div>
<div v-if="!zenModeEnabled" class="flex items-center mt-2">
<span
class="h-6 mr-1"
:class="{
'avatar-group overlap': lesson.data.instructors?.length > 1,
}"
>
<UserAvatar
v-for="instructor in lesson.data.instructors"
:user="instructor"
/>
</span>
<CourseInstructors
v-if="lesson.data?.instructors"
:instructors="lesson.data.instructors"
/>
</div>
<div
v-if="
lesson.data.instructor_content &&
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 &&
allowInstructorContent()
"
class="bg-surface-gray-2 p-3 rounded-md mt-6"
>
<div class="text-ink-gray-5 font-medium">
{{ __('Instructor Notes') }}
</div>
<div
id="instructor-content"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-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"
></div>
</div>
<div
id="instructor-content"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-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"
></div>
</div>
<div
v-else-if="lesson.data.instructor_notes"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-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"
>
<LessonContent :content="lesson.data.instructor_notes" />
</div>
<div
v-if="lesson.data.content"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-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-5"
>
<div id="editor"></div>
</div>
<div
v-else
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-5"
>
<LessonContent
v-if="lesson.data?.body"
:content="lesson.data.body"
:youtube="lesson.data.youtube"
:quizId="lesson.data.quiz_id"
/>
</div>
<div class="mt-20">
<Discussions
v-if="allowDiscussions"
:title="'Questions'"
:doctype="'Course Lesson'"
:docname="lesson.data.name"
:key="lesson.data.name"
/>
v-else-if="lesson.data.instructor_notes"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-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-8"
>
<LessonContent :content="lesson.data.instructor_notes" />
</div>
<div
v-if="lesson.data.content"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-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-8"
>
<div id="editor"></div>
</div>
<div
v-else
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-8"
>
<LessonContent
v-if="lesson.data?.body"
:content="lesson.data.body"
:youtube="lesson.data.youtube"
:quizId="lesson.data.quiz_id"
/>
</div>
<div class="mt-20" ref="discussionsContainer">
<Discussions
v-if="allowDiscussions"
:title="'Questions'"
:doctype="'Course Lesson'"
:docname="lesson.data.name"
:key="lesson.data.name"
/>
</div>
</div>
</div>
<div class="sticky top-10">
@@ -193,14 +264,37 @@
</div>
</template>
<script setup>
import { createResource, Breadcrumbs, Button, usePageMeta } from 'frappe-ui'
import { computed, watch, inject, ref, onMounted, onBeforeUnmount } from 'vue'
import {
createResource,
Badge,
Breadcrumbs,
Button,
Tooltip,
usePageMeta,
} from 'frappe-ui'
import {
computed,
watch,
inject,
ref,
onMounted,
onBeforeUnmount,
nextTick,
} from 'vue'
import CourseOutline from '@/components/CourseOutline.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { useRouter, useRoute } from 'vue-router'
import { ChevronLeft, ChevronRight, GraduationCap } from 'lucide-vue-next'
import {
ChevronLeft,
ChevronRight,
LockKeyholeIcon,
LogIn,
Focus,
Info,
MessageCircleQuestion,
} from 'lucide-vue-next'
import Discussions from '@/components/Discussions.vue'
import { getEditorTools } from '../utils'
import { getEditorTools, enablePlyr } from '@/utils'
import { sessionStore } from '@/stores/session'
import EditorJS from '@editorjs/editorjs'
import LessonContent from '@/components/LessonContent.vue'
@@ -209,12 +303,17 @@ 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)
const editor = ref(null)
const instructorEditor = ref(null)
const lessonProgress = ref(0)
const lessonContainer = ref(null)
const zenModeEnabled = ref(false)
const hasQuiz = ref(false)
const discussionsContainer = ref(null)
const timer = ref(0)
const { brand } = sessionStore()
let timerInterval
@@ -236,11 +335,32 @@ const props = defineProps({
onMounted(() => {
startTimer()
document.addEventListener('fullscreenchange', attachFullscreenEvent)
socket.on('update_lesson_progress', (data) => {
if (data.course === props.courseName) {
lessonProgress.value = data.progress
}
})
})
const attachFullscreenEvent = () => {
if (document.fullscreenElement) {
zenModeEnabled.value = true
allowDiscussions.value = false
} else {
zenModeEnabled.value = false
if (!hasQuiz.value) {
allowDiscussions.value = true
}
}
}
onBeforeUnmount(() => {
document.removeEventListener('fullscreenchange', attachFullscreenEvent)
})
const lesson = createResource({
url: 'lms.lms.utils.get_lesson',
cache: ['lesson', props.courseName, props.chapterNumber, props.lessonNumber],
makeParams(values) {
return {
course: props.courseName,
@@ -249,36 +369,37 @@ const lesson = createResource({
}
},
auto: true,
onSuccess(data) {
if (Object.keys(data).length === 0) {
router.push({
name: 'CourseDetail',
params: { courseName: props.courseName },
})
return
}
lessonProgress.value = data.membership?.progress
if (data.content) editor.value = renderEditor('editor', data.content)
if (
data.instructor_content &&
JSON.parse(data.instructor_content)?.blocks?.length > 1
)
instructorEditor.value = renderEditor(
'instructor-content',
data.instructor_content
)
editor.value?.isReady.then(() => {
checkIfDiscussionsAllowed()
})
if (!editor.value && data.body) {
const quizRegex = /\{\{ Quiz\(".*"\) \}\}/
const hasQuiz = quizRegex.test(data.body)
if (!hasQuiz) allowDiscussions.value = true
}
},
})
const setupLesson = (data) => {
if (Object.keys(data).length === 0) {
router.push({
name: 'CourseDetail',
params: { courseName: props.courseName },
})
return
}
lessonProgress.value = data.membership?.progress
if (data.content) editor.value = renderEditor('editor', data.content)
if (
data.instructor_content &&
JSON.parse(data.instructor_content)?.blocks?.length > 1
)
instructorEditor.value = renderEditor(
'instructor-content',
data.instructor_content
)
editor.value?.isReady.then(() => {
checkIfDiscussionsAllowed()
})
if (!editor.value && data.body) {
const quizRegex = /\{\{ Quiz\(".*"\) \}\}/
hasQuiz.value = quizRegex.test(data.body)
if (!hasQuiz.value && !zenModeEnabled) allowDiscussions.value = true
}
}
const renderEditor = (holder, content) => {
// empty the holder
if (document.getElementById(holder))
@@ -348,10 +469,19 @@ watch(
clearInterval(timerInterval)
timer.value = 0
startTimer()
enablePlyr()
}
}
)
watch(
() => lesson.data,
(data) => {
setupLesson(data)
enablePlyr()
}
)
const startTimer = () => {
timerInterval = setInterval(() => {
timer.value++
@@ -367,13 +497,13 @@ onBeforeUnmount(() => {
})
const checkIfDiscussionsAllowed = () => {
let quizPresent = false
JSON.parse(lesson.data?.content)?.blocks?.forEach((block) => {
if (block.type === 'quiz') quizPresent = true
if (block.type === 'quiz') hasQuiz.value = true
})
if (
!quizPresent &&
!hasQuiz.value &&
!zenModeEnabled.value &&
(lesson.data?.membership ||
user.data?.is_moderator ||
user.data?.is_instructor)
@@ -382,6 +512,7 @@ const checkIfDiscussionsAllowed = () => {
}
const allowEdit = () => {
if (window.read_only_mode) return false
if (user.data?.is_moderator) return true
if (lesson.data?.instructors?.includes(user.data?.name)) return true
return false
@@ -417,6 +548,48 @@ const enrollStudent = () => {
)
}
const canGoZen = () => {
if (
user.data?.is_moderator ||
user.data?.is_instructor ||
user.data?.is_evaluator
)
return false
if (lesson.data?.membership) return true
return false
}
const goFullScreen = () => {
if (lessonContainer.value.requestFullscreen) {
lessonContainer.value.requestFullscreen()
} else if (lessonContainer.value.mozRequestFullScreen) {
lessonContainer.value.mozRequestFullScreen()
} else if (lessonContainer.value.webkitRequestFullscreen) {
lessonContainer.value.webkitRequestFullscreen()
} else if (lessonContainer.value.msRequestFullscreen) {
lessonContainer.value.msRequestFullscreen()
}
}
const showDiscussionsInZenMode = () => {
if (allowDiscussions.value) {
allowDiscussions.value = false
} else {
allowDiscussions.value = true
scrollDiscussionsIntoView()
}
}
const scrollDiscussionsIntoView = () => {
nextTick(() => {
discussionsContainer.value?.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest',
})
})
}
const redirectToLogin = () => {
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
}
@@ -590,4 +763,30 @@ usePageMeta(() => {
.tc-table {
border-left: 1px solid #e8e8eb;
}
.plyr__volume input[type='range'] {
display: none;
}
.plyr__control--overlaid {
background: radial-gradient(
circle,
rgba(0, 0, 0, 0.4) 0%,
rgba(0, 0, 0, 0.5) 50%
);
}
.plyr__control:hover {
background: none;
}
.plyr--video {
border: 1px solid theme('colors.gray.200');
border-radius: 8px;
}
:root {
--plyr-range-fill-background: white;
--plyr-video-control-background-hover: transparent;
}
</style>

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 } 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,9 +131,11 @@ onMounted(() => {
window.location.href = '/login'
}
capture('lesson_form_opened')
startRecording()
editor.value = renderEditor('content')
instructorEditor.value = renderEditor('instructor-notes')
window.addEventListener('keydown', keyboardShortcut)
enablePlyr()
})
const renderEditor = (holder) => {
@@ -141,6 +144,9 @@ const renderEditor = (holder) => {
tools: getEditorTools(true),
autofocus: true,
defaultBlock: 'markdown',
onChange: async (api, event) => {
enablePlyr()
},
})
}
@@ -204,7 +210,7 @@ const addInstructorNotes = (data) => {
const enableAutoSave = () => {
autoSaveInterval = setInterval(() => {
saveLesson({ showSuccessMessage: false })
}, 10000)
}, 5000)
}
const keyboardShortcut = (e) => {
@@ -221,6 +227,7 @@ const keyboardShortcut = (e) => {
onBeforeUnmount(() => {
clearInterval(autoSaveInterval)
window.removeEventListener('keydown', keyboardShortcut)
stopRecording()
})
const newLessonResource = createResource({
@@ -406,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)
},
}
)
@@ -430,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)
},
}
)
@@ -449,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 = [
{
@@ -624,8 +617,7 @@ usePageMeta(() => {
}
iframe {
border-top: 3px solid theme('colors.gray.700');
border-bottom: 3px solid theme('colors.gray.700');
border: none !important;
}
.tc-table {
@@ -639,4 +631,30 @@ iframe {
.ce-popover-item[data-item-name='markdown'] {
display: none !important;
}
.plyr__volume input[type='range'] {
display: none;
}
.plyr__control--overlaid {
background: radial-gradient(
circle,
rgba(0, 0, 0, 0.4) 0%,
rgba(0, 0, 0, 0.5) 50%
);
}
.plyr__control:hover {
background: none;
}
.plyr--video {
border: 1px solid theme('colors.gray.200');
border-radius: 8px;
}
:root {
--plyr-range-fill-background: white;
--plyr-video-control-background-hover: transparent;
}
</style>

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

@@ -0,0 +1,154 @@
<template>
<div class="flex h-screen overflow-hidden sm:bg-gray-50">
<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
class="select-none text-xl font-semibold tracking-tight text-gray-900"
>
Learning
</span>
</div>
<div
class="mx-auto w-full h-fit bg-white py-8 sm:mt-6 sm:w-96 sm:rounded-lg sm:px-8 sm:shadow-xl"
>
<div class="font-medium text-center mb-8">
{{ __('Help us understand your needs') }}
</div>
<div class="mb-5">
<div class="text-sm text-gray-700 mb-2">
{{ __('What is your use case for Frappe Learning?') }}
</div>
<FormControl
v-model="persona.useCase"
type="select"
:options="useCaseOptions"
/>
</div>
<div class="mb-5">
<div class="text-sm text-gray-700 mb-2">
{{ __('What best describes your role?') }}
</div>
<FormControl
v-model="persona.role"
type="select"
:options="roleOptions"
/>
</div>
<div class="flex w-full">
<Button variant="solid" class="mx-auto" @click="submitPersona()">
{{ __('Submit and Continue') }}
</Button>
</div>
</div>
<div
class="text-center absolute bottom-0 right-0 left-0 mx-auto cursor-pointer text-sm pb-4"
@click="skipPersonaForm()"
>
{{ __('Skip') }}
</div>
</div>
</div>
</template>
<script setup>
import LMSLogo from '@/components/Icons/LMSLogo.vue'
import { Button, call, FormControl, usePageMeta } from 'frappe-ui'
import { computed, inject, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { sessionStore } from '@/stores/session'
const user = inject('$user')
const router = useRouter()
const { brand } = sessionStore()
const persona = reactive({
role: null,
useCase: null,
})
const submitPersona = () => {
let responses = {
site: user.data?.sitename,
no_of_students: persona.noOfStudents,
use_case: persona.useCase,
}
call('lms.lms.api.capture_user_persona', {
responses: JSON.stringify(responses),
}).then(() => {
router.push({
name: 'Courses',
})
})
}
const skipPersonaForm = () => {
call('frappe.client.set_value', {
doctype: 'LMS Settings',
name: null,
fieldname: 'persona_captured',
value: 1,
}).then(() => {
router.push({
name: 'Courses',
})
})
}
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',
'50-200',
'200-1000',
'1000+',
'Not sure yet',
]
return options.map((option) => ({
label: option,
value: option,
}))
})
const useCaseOptions = computed(() => {
const options = [
'Teaching students in a school/university',
'Training employees in my company',
'Onboarding and educating my users/community',
'Selling courses and earning income',
'Other',
]
return options.map((option) => ({
label: option,
value: option,
}))
})
usePageMeta(() => {
return {
title: 'Persona',
icon: brand.favicon,
}
})
</script>

View File

@@ -25,7 +25,11 @@
@select="(imageUrl) => coverImage.submit({ url: imageUrl })"
>
<template v-slot="{ togglePopover }">
<Button variant="outline" @click="togglePopover()">
<Button
v-if="!readOnlyMode"
variant="outline"
@click="togglePopover()"
>
<template #prefix>
<Edit class="w-4 h-4 stroke-1.5 text-ink-gray-7" />
</template>
@@ -58,7 +62,7 @@
</div>
</div>
<Button
v-if="isSessionUser()"
v-if="isSessionUser() && !readOnlyMode"
class="mt-3 sm:mt-0 md:ml-auto"
@click="editProfile()"
>
@@ -95,7 +99,7 @@ import {
} from 'frappe-ui'
import { computed, inject, watch, ref, onMounted, watchEffect } from 'vue'
import { sessionStore } from '@/stores/session'
import { Edit, icons } from 'lucide-vue-next'
import { Edit } from 'lucide-vue-next'
import UserAvatar from '@/components/UserAvatar.vue'
import { useRoute, useRouter } from 'vue-router'
import NoPermission from '@/components/NoPermission.vue'
@@ -109,6 +113,7 @@ const route = useRoute()
const router = useRouter()
const activeTab = ref('')
const showProfileModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({
username: {

View File

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

View File

@@ -4,134 +4,150 @@
{{ __('My availability') }}
</h2>
<div class="">
<div
class="grid grid-cols-3 md:grid-cols-4 gap-4 text-sm text-ink-gray-7 mb-4"
>
<div>
{{ __('Day') }}
</div>
<div>
{{ __('Start Time') }}
</div>
<div>
{{ __('End Time') }}
</div>
</div>
<div
v-if="evaluator.data"
v-for="slot in evaluator.data.slots.schedule"
class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4 group"
>
<FormControl
type="select"
:options="days"
v-model="slot.day"
@focusout.stop="update(slot.name, 'day', slot.day)"
/>
<FormControl
type="time"
v-model="slot.start_time"
@focusout.stop="update(slot.name, 'start_time', slot.start_time)"
/>
<FormControl
type="time"
v-model="slot.end_time"
@focusout.stop="update(slot.name, 'end_time', slot.end_time)"
/>
<X
@click="deleteRow(slot.name)"
class="w-6 h-auto stroke-1.5 text-red-900 rounded-md cursor-pointer p-1 bg-surface-red-2 hidden group-hover:block"
/>
</div>
<div
class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4"
v-show="showSlotsTemplate"
>
<FormControl
type="select"
:options="days"
v-model="newSlot.day"
@focusout.stop="add()"
/>
<FormControl
type="time"
v-model="newSlot.start_time"
@focusout.stop="add()"
/>
<FormControl
type="time"
v-model="newSlot.end_time"
@focusout.stop="add()"
/>
</div>
<Button @click="showSlotsTemplate = 1">
<template #prefix>
<Plus class="w-4 h-4 stroke-1.5 text-ink-gray-7" />
</template>
{{ __('Add Slot') }}
</Button>
<div
v-if="readOnlyMode"
class="flex items-center space-x-2 text-sm text-ink-gray-7 bg-surface-gray-1 px-3 py-2 rounded-md w-full text-center"
>
<CircleAlert class="size-4 stroke-1.5" />
<span>
{{
__(
'You cannot change the availability when the site is being updated.'
)
}}
</span>
</div>
<div class="my-10">
<h2 class="mb-4 text-lg font-semibold text-ink-gray-9">
{{ __('I am unavailable') }}
</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<FormControl
type="date"
:label="__('From')"
v-model="from"
@blur="
() => {
updateUnavailability.submit({
field: 'unavailable_from',
value: from,
})
}
"
/>
<FormControl
type="date"
:label="__('To')"
v-model="to"
@blur="
() => {
updateUnavailability.submit({
field: 'unavailable_to',
value: to,
})
}
"
/>
<div v-else>
<div>
<div
class="grid grid-cols-3 md:grid-cols-4 gap-4 text-sm text-ink-gray-7 mb-4"
>
<div>
{{ __('Day') }}
</div>
<div>
{{ __('Start Time') }}
</div>
<div>
{{ __('End Time') }}
</div>
</div>
<div
v-if="evaluator.data"
v-for="slot in evaluator.data.slots.schedule"
class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4 group"
>
<FormControl
type="select"
:options="days"
v-model="slot.day"
@focusout.stop="update(slot.name, 'day', slot.day)"
/>
<FormControl
type="time"
v-model="slot.start_time"
@focusout.stop="update(slot.name, 'start_time', slot.start_time)"
/>
<FormControl
type="time"
v-model="slot.end_time"
@focusout.stop="update(slot.name, 'end_time', slot.end_time)"
/>
<X
@click="deleteRow(slot.name)"
class="w-6 h-auto stroke-1.5 text-red-900 rounded-md cursor-pointer p-1 bg-surface-red-2 hidden group-hover:block"
/>
</div>
<div
class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4"
v-show="showSlotsTemplate"
>
<FormControl
type="select"
:options="days"
v-model="newSlot.day"
@focusout.stop="add()"
/>
<FormControl
type="time"
v-model="newSlot.start_time"
@focusout.stop="add()"
/>
<FormControl
type="time"
v-model="newSlot.end_time"
@focusout.stop="add()"
/>
</div>
<Button @click="showSlotsTemplate = 1">
<template #prefix>
<Plus class="w-4 h-4 stroke-1.5 text-ink-gray-7" />
</template>
{{ __('Add Slot') }}
</Button>
</div>
</div>
<div>
<h2 class="mb-4 text-lg font-semibold text-ink-gray-9">
{{ __('My calendar') }}
</h2>
<div
v-if="evaluator.data?.calendar && evaluator.data?.is_authorized"
class="flex items-center bg-surface-green-2 text-green-900 text-sm p-1 rounded-md mb-4 w-fit"
>
<Check class="h-4 w-4 stroke-1.5 mr-2" />
{{ __('Your calendar is set.') }}
<div class="my-10">
<h2 class="mb-4 text-lg font-semibold text-ink-gray-9">
{{ __('I am unavailable') }}
</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<FormControl
type="date"
:label="__('From')"
v-model="from"
@blur="
() => {
updateUnavailability.submit({
field: 'unavailable_from',
value: from,
})
}
"
/>
<FormControl
type="date"
:label="__('To')"
v-model="to"
@blur="
() => {
updateUnavailability.submit({
field: 'unavailable_to',
value: to,
})
}
"
/>
</div>
</div>
<div>
<h2 class="mb-4 text-lg font-semibold text-ink-gray-9">
{{ __('My calendar') }}
</h2>
<div
v-if="evaluator.data?.calendar && evaluator.data?.is_authorized"
class="flex items-center bg-surface-green-2 text-green-900 text-sm p-1 rounded-md mb-4 w-fit"
>
<Check class="h-4 w-4 stroke-1.5 mr-2" />
{{ __('Your calendar is set.') }}
</div>
<Button @click="() => authorizeCalendar.submit()">
{{ __('Authorize Google Calendar Access') }}
</Button>
</div>
<Button @click="() => authorizeCalendar.submit()">
{{ __('Authorize Google Calendar Access') }}
</Button>
</div>
</div>
</template>
<script setup>
import { createResource, FormControl, Button } 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 { Plus, X, Check } from 'lucide-vue-next'
import { convertToTitleCase } from '@/utils'
import { Plus, X, Check, CircleAlert } from 'lucide-vue-next'
const user = inject('$user')
const readOnlyMode = window.read_only_mode
const props = defineProps({
profile: {
@@ -182,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 = ''
@@ -190,7 +206,7 @@ const createSlot = createResource({
newSlot.end_time = ''
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
})
@@ -205,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)
},
})
@@ -221,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)
},
})
@@ -240,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

@@ -4,6 +4,16 @@
{{ __('Settings') }}
</h2>
<div
v-if="readOnlyMode"
class="flex items-center space-x-2 text-sm text-ink-gray-7 bg-surface-gray-1 px-3 py-2 rounded-md w-full text-center"
>
<CircleAlert class="size-4 stroke-1.5" />
<span>
{{ __('You cannot change the roles in read-only mode.') }}
</span>
</div>
<div
v-else
class="flex flex-col md:flex-row gap-4 md:gap-0 justify-between w-3/4 mt-5"
>
<FormControl
@@ -34,14 +44,16 @@
</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)
const course_creator = ref(false)
const batch_evaluator = ref(false)
const lms_student = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({
profile: {
@@ -90,7 +102,7 @@ const changeRole = (role) => {
},
{
onSuccess(data) {
showToast('Success', 'Role updated successfully', 'check')
toast.success(__('Role updated successfully'))
},
}
)

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